增强版v2-初始化仓库,验证好了ocr部分,先备份一次

This commit is contained in:
侯欢 2025-05-02 17:25:47 +08:00
commit 0035cd1893
88 changed files with 9031 additions and 0 deletions

191
README.md Normal file
View File

@ -0,0 +1,191 @@
# OCR订单处理系统 v2.0
基于百度OCR API的订单处理系统用于识别采购订单图片并生成Excel采购单。
## 功能特点
- **图像OCR识别**支持对采购单图片进行OCR识别并生成Excel文件
- **Excel数据处理**读取OCR识别的Excel文件并提取商品信息
- **采购单生成**按照模板格式生成标准采购单Excel文件
- **采购单合并**:支持多个采购单合并为一个总单
- **批量处理**:支持批量处理多张图片
- **图形界面**:提供简洁直观的图形界面,方便操作
- **命令行支持**:支持命令行方式调用,便于自动化处理
## 系统架构
### 目录结构
```
orc-order-v2/
├── app/ # 应用主目录
│ ├── config/ # 配置目录
│ │ ├── settings.py # 配置管理
│ │ └── defaults.py # 默认配置值
│ │
│ ├── core/ # 核心功能
│ │ ├── ocr/ # OCR相关功能
│ │ │ ├── baidu_ocr.py # 百度OCR接口
│ │ │ └── table_ocr.py # 表格OCR处理
│ │ │
│ │ ├── excel/ # Excel处理
│ │ │ ├── processor.py # Excel处理核心
│ │ │ ├── merger.py # 订单合并功能
│ │ │ └── converter.py # 单位转换与规格处理
│ │ │
│ │ └── utils/ # 工具函数
│ │ ├── file_utils.py # 文件操作工具
│ │ ├── log_utils.py # 日志工具
│ │ └── string_utils.py # 字符串处理工具
│ │
│ └── services/ # 业务服务
│ ├── ocr_service.py # OCR服务
│ └── order_service.py # 订单处理服务
├── data/ # 数据目录
│ ├── input/ # 输入文件
│ ├── output/ # 输出文件
│ └── temp/ # 临时文件
├── logs/ # 日志目录
├── templates/ # 模板文件
│ └── 银豹-采购单模板.xls # 采购单模板
├── 启动器.py # 图形界面启动器
├── run.py # 命令行入口
├── config.ini # 配置文件
└── requirements.txt # 依赖包列表
```
### 主要模块说明
- **配置模块**:统一管理系统配置,支持默认值和配置文件读取
- **OCR模块**调用百度OCR API进行表格识别生成Excel文件
- **Excel处理模块**读取OCR生成的Excel文件提取商品信息
- **单位转换模块**:处理商品规格和单位转换
- **订单合并模块**:合并多个采购单为一个总单
- **文件工具模块**:处理文件读写、路径管理等
- **启动器**:提供图形界面操作
## 使用方法
### 环境准备
1. 安装Python 3.6+
2. 安装依赖包:
```
pip install -r requirements.txt
```
3. 配置百度OCR API密钥
- 在`config.ini`中填写您的API密钥和Secret密钥
### 图形界面使用
1. 运行启动器:
```
python 启动器.py
```
2. 使用界面上的功能按钮进行操作:
- **OCR图像识别**:批量处理`data/input`目录下的图片
- **处理单个图片**:选择并处理单个图片
- **处理Excel文件**处理OCR识别后的Excel文件生成采购单
- **合并采购单**:合并所有生成的采购单
- **完整处理流程**:按顺序执行所有处理步骤
- **整理项目文件**:整理文件到规范目录结构
### 命令行使用
系统提供命令行方式调用,便于集成到自动化流程中:
```bash
# OCR识别
python run.py ocr [--input 图片路径] [--batch]
# Excel处理
python run.py excel [--input Excel文件路径]
# 订单合并
python run.py merge [--input 采购单文件路径列表]
# 完整流程
python run.py pipeline
```
## 文件处理流程
1. **OCR识别处理**
- 读取`data/input`目录下的图片文件
- 调用百度OCR API进行表格识别
- 保存识别结果为Excel文件到`data/output`目录
2. **Excel处理**
- 读取OCR识别生成的Excel文件
- 提取商品信息(条码、名称、规格、单价、数量等)
- 按照采购单模板格式生成标准采购单Excel文件
- 输出文件命名为"采购单_原文件名.xls"
3. **采购单合并**
- 读取所有采购单Excel文件
- 合并相同商品的数量
- 生成总采购单
## 配置说明
系统配置文件`config.ini`包含以下主要配置:
```ini
[API]
api_key = 您的百度API Key
secret_key = 您的百度Secret Key
timeout = 30
max_retries = 3
retry_delay = 2
api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table
[Paths]
input_folder = data/input
output_folder = data/output
temp_folder = data/temp
[Performance]
max_workers = 4
batch_size = 5
skip_existing = true
[File]
allowed_extensions = .jpg,.jpeg,.png,.bmp
excel_extension = .xlsx
max_file_size_mb = 4
```
## 注意事项
1. 系统依赖百度OCR API使用前请确保已配置正确的API密钥
2. 图片质量会影响OCR识别结果建议使用清晰的原始图片
3. 处理大量图片时可能会受到API调用频率限制
4. 所有处理好的文件会保存在`data/output`目录中
## 错误排查
- **OCR识别失败**检查API密钥是否正确图片是否符合要求
- **Excel处理失败**检查OCR识别结果是否包含必要的列条码、数量、单价等
- **模板填充错误**:确保模板文件存在且格式正确
## 开发说明
如需进行二次开发或扩展功能,请参考以下说明:
1. 核心逻辑位于`app/core`目录
2. 添加新功能建议遵循已有的模块化结构
3. 使用`app/services`目录中的服务类调用核心功能
4. 日志记录已集成到各模块,便于调试
## 许可证
MIT License
## 联系方式
如有问题请提交Issue或联系开发者。

8
app/__init__.py Normal file
View File

@ -0,0 +1,8 @@
"""
OCR订单处理系统
---------------
用于自动识别和处理Excel格式的订单文件的系统
支持多种格式的订单处理包括普通订单和赠品订单的处理
"""
__version__ = '2.0.0'

Binary file not shown.

5
app/cli/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""
OCR订单处理系统 - 命令行接口
-------------------------
提供命令行工具便于用户使用系统功能
"""

138
app/cli/excel_cli.py Normal file
View File

@ -0,0 +1,138 @@
"""
Excel处理命令行工具
---------------
提供Excel处理相关的命令行接口
"""
import os
import sys
import argparse
from typing import List, Optional
from ..config.settings import ConfigManager
from ..core.utils.log_utils import get_logger, close_logger
from ..services.order_service import OrderService
logger = get_logger(__name__)
def create_parser() -> argparse.ArgumentParser:
"""
创建命令行参数解析器
Returns:
参数解析器
"""
parser = argparse.ArgumentParser(description='Excel处理工具')
# 通用选项
parser.add_argument('--config', type=str, help='配置文件路径')
# 子命令
subparsers = parser.add_subparsers(dest='command', help='子命令')
# 处理Excel命令
process_parser = subparsers.add_parser('process', help='处理Excel文件')
process_parser.add_argument('--input', type=str, help='输入Excel文件路径如果不指定则处理最新的文件')
# 查看命令
list_parser = subparsers.add_parser('list', help='获取最新的Excel文件')
return parser
def process_excel(order_service: OrderService, input_file: Optional[str] = None) -> bool:
"""
处理Excel文件
Args:
order_service: 订单服务
input_file: 输入文件路径如果为None则处理最新的文件
Returns:
处理是否成功
"""
if input_file:
if not os.path.exists(input_file):
logger.error(f"输入文件不存在: {input_file}")
return False
result = order_service.process_excel(input_file)
else:
latest_file = order_service.get_latest_excel()
if not latest_file:
logger.warning("未找到可处理的Excel文件")
return False
logger.info(f"处理最新的Excel文件: {latest_file}")
result = order_service.process_excel(latest_file)
if result:
logger.info(f"处理成功,输出文件: {result}")
return True
else:
logger.error("处理失败")
return False
def list_latest_excel(order_service: OrderService) -> bool:
"""
获取最新的Excel文件
Args:
order_service: 订单服务
Returns:
是否找到Excel文件
"""
latest_file = order_service.get_latest_excel()
if latest_file:
logger.info(f"最新的Excel文件: {latest_file}")
return True
else:
logger.info("未找到Excel文件")
return False
def main(args: Optional[List[str]] = None) -> int:
"""
Excel处理命令行主函数
Args:
args: 命令行参数如果为None则使用sys.argv
Returns:
退出状态码
"""
parser = create_parser()
parsed_args = parser.parse_args(args)
if parsed_args.command is None:
parser.print_help()
return 1
try:
# 创建配置管理器
config = ConfigManager(parsed_args.config) if parsed_args.config else ConfigManager()
# 创建订单服务
order_service = OrderService(config)
# 根据命令执行不同功能
if parsed_args.command == 'process':
success = process_excel(order_service, parsed_args.input)
elif parsed_args.command == 'list':
success = list_latest_excel(order_service)
else:
parser.print_help()
return 1
return 0 if success else 1
except Exception as e:
logger.error(f"执行过程中发生错误: {e}")
return 1
finally:
# 关闭日志
close_logger(__name__)
if __name__ == '__main__':
sys.exit(main())

147
app/cli/merge_cli.py Normal file
View File

@ -0,0 +1,147 @@
"""
订单合并命令行工具
--------------
提供订单合并相关的命令行接口
"""
import os
import sys
import argparse
from typing import List, Optional
from ..config.settings import ConfigManager
from ..core.utils.log_utils import get_logger, close_logger
from ..services.order_service import OrderService
logger = get_logger(__name__)
def create_parser() -> argparse.ArgumentParser:
"""
创建命令行参数解析器
Returns:
参数解析器
"""
parser = argparse.ArgumentParser(description='订单合并工具')
# 通用选项
parser.add_argument('--config', type=str, help='配置文件路径')
# 子命令
subparsers = parser.add_subparsers(dest='command', help='子命令')
# 合并命令
merge_parser = subparsers.add_parser('merge', help='合并采购单')
merge_parser.add_argument('--input', type=str, help='输入采购单文件路径列表,以逗号分隔,如果不指定则合并所有采购单')
# 列出采购单命令
list_parser = subparsers.add_parser('list', help='列出采购单文件')
return parser
def merge_orders(order_service: OrderService, input_files: Optional[str] = None) -> bool:
"""
合并采购单
Args:
order_service: 订单服务
input_files: 输入文件路径列表以逗号分隔如果为None则合并所有采购单
Returns:
合并是否成功
"""
if input_files:
# 分割输入文件列表
file_paths = [path.strip() for path in input_files.split(',')]
# 检查文件是否存在
for path in file_paths:
if not os.path.exists(path):
logger.error(f"输入文件不存在: {path}")
return False
result = order_service.merge_orders(file_paths)
else:
# 获取所有采购单文件
file_paths = order_service.get_purchase_orders()
if not file_paths:
logger.warning("未找到采购单文件")
return False
logger.info(f"合并 {len(file_paths)} 个采购单文件")
result = order_service.merge_orders()
if result:
logger.info(f"合并成功,输出文件: {result}")
return True
else:
logger.error("合并失败")
return False
def list_purchase_orders(order_service: OrderService) -> bool:
"""
列出采购单文件
Args:
order_service: 订单服务
Returns:
是否有采购单文件
"""
files = order_service.get_purchase_orders()
if not files:
logger.info("未找到采购单文件")
return False
logger.info(f"采购单文件 ({len(files)}):")
for file in files:
logger.info(f" {file}")
return True
def main(args: Optional[List[str]] = None) -> int:
"""
订单合并命令行主函数
Args:
args: 命令行参数如果为None则使用sys.argv
Returns:
退出状态码
"""
parser = create_parser()
parsed_args = parser.parse_args(args)
if parsed_args.command is None:
parser.print_help()
return 1
try:
# 创建配置管理器
config = ConfigManager(parsed_args.config) if parsed_args.config else ConfigManager()
# 创建订单服务
order_service = OrderService(config)
# 根据命令执行不同功能
if parsed_args.command == 'merge':
success = merge_orders(order_service, parsed_args.input)
elif parsed_args.command == 'list':
success = list_purchase_orders(order_service)
else:
parser.print_help()
return 1
return 0 if success else 1
except Exception as e:
logger.error(f"执行过程中发生错误: {e}")
return 1
finally:
# 关闭日志
close_logger(__name__)
if __name__ == '__main__':
sys.exit(main())

164
app/cli/ocr_cli.py Normal file
View File

@ -0,0 +1,164 @@
"""
OCR命令行工具
----------
提供OCR识别相关的命令行接口
"""
import os
import sys
import argparse
from typing import List, Optional
from ..config.settings import ConfigManager
from ..core.utils.log_utils import get_logger, close_logger
from ..services.ocr_service import OCRService
logger = get_logger(__name__)
def create_parser() -> argparse.ArgumentParser:
"""
创建命令行参数解析器
Returns:
参数解析器
"""
parser = argparse.ArgumentParser(description='OCR识别工具')
# 通用选项
parser.add_argument('--config', type=str, help='配置文件路径')
# 子命令
subparsers = parser.add_subparsers(dest='command', help='子命令')
# 单文件处理命令
process_parser = subparsers.add_parser('process', help='处理单个文件')
process_parser.add_argument('--input', type=str, required=True, help='输入图片文件路径')
# 批量处理命令
batch_parser = subparsers.add_parser('batch', help='批量处理文件')
batch_parser.add_argument('--batch-size', type=int, help='批处理大小')
batch_parser.add_argument('--max-workers', type=int, help='最大线程数')
# 查看未处理文件命令
list_parser = subparsers.add_parser('list', help='列出未处理的文件')
return parser
def process_file(ocr_service: OCRService, input_file: str) -> bool:
"""
处理单个文件
Args:
ocr_service: OCR服务
input_file: 输入文件路径
Returns:
处理是否成功
"""
if not os.path.exists(input_file):
logger.error(f"输入文件不存在: {input_file}")
return False
if not ocr_service.validate_image(input_file):
logger.error(f"输入文件无效: {input_file}")
return False
result = ocr_service.process_image(input_file)
if result:
logger.info(f"处理成功,输出文件: {result}")
return True
else:
logger.error("处理失败")
return False
def process_batch(ocr_service: OCRService, batch_size: Optional[int] = None, max_workers: Optional[int] = None) -> bool:
"""
批量处理文件
Args:
ocr_service: OCR服务
batch_size: 批处理大小
max_workers: 最大线程数
Returns:
处理是否成功
"""
total, success = ocr_service.process_images_batch(batch_size, max_workers)
if total == 0:
logger.warning("没有找到需要处理的文件")
return False
logger.info(f"批量处理完成,总计: {total},成功: {success}")
return success > 0
def list_unprocessed(ocr_service: OCRService) -> bool:
"""
列出未处理的文件
Args:
ocr_service: OCR服务
Returns:
是否有未处理的文件
"""
files = ocr_service.get_unprocessed_images()
if not files:
logger.info("没有未处理的文件")
return False
logger.info(f"未处理的文件 ({len(files)}):")
for file in files:
logger.info(f" {file}")
return True
def main(args: Optional[List[str]] = None) -> int:
"""
OCR命令行主函数
Args:
args: 命令行参数如果为None则使用sys.argv
Returns:
退出状态码
"""
parser = create_parser()
parsed_args = parser.parse_args(args)
if parsed_args.command is None:
parser.print_help()
return 1
try:
# 创建配置管理器
config = ConfigManager(parsed_args.config) if parsed_args.config else ConfigManager()
# 创建OCR服务
ocr_service = OCRService(config)
# 根据命令执行不同功能
if parsed_args.command == 'process':
success = process_file(ocr_service, parsed_args.input)
elif parsed_args.command == 'batch':
success = process_batch(ocr_service, parsed_args.batch_size, parsed_args.max_workers)
elif parsed_args.command == 'list':
success = list_unprocessed(ocr_service)
else:
parser.print_help()
return 1
return 0 if success else 1
except Exception as e:
logger.error(f"执行过程中发生错误: {e}")
return 1
finally:
# 关闭日志
close_logger(__name__)
if __name__ == '__main__':
sys.exit(main())

5
app/config/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""
OCR订单处理系统 - 配置模块
------------------------
负责管理系统配置包括API密钥路径和处理选项
"""

Binary file not shown.

Binary file not shown.

Binary file not shown.

37
app/config/defaults.py Normal file
View File

@ -0,0 +1,37 @@
"""
默认配置
-------
包含系统的默认配置值
"""
# 默认配置
DEFAULT_CONFIG = {
'API': {
'api_key': '', # 将从配置文件中读取
'secret_key': '', # 将从配置文件中读取
'timeout': '30',
'max_retries': '3',
'retry_delay': '2',
'api_url': 'https://aip.baidubce.com/rest/2.0/ocr/v1/table'
},
'Paths': {
'input_folder': 'data/input',
'output_folder': 'data/output',
'temp_folder': 'data/temp',
'template_folder': 'templates',
'processed_record': 'data/processed_files.json'
},
'Performance': {
'max_workers': '4',
'batch_size': '5',
'skip_existing': 'true'
},
'File': {
'allowed_extensions': '.jpg,.jpeg,.png,.bmp',
'excel_extension': '.xlsx',
'max_file_size_mb': '4'
},
'Templates': {
'purchase_order': '银豹-采购单模板.xls'
}
}

128
app/config/settings.py Normal file
View File

@ -0,0 +1,128 @@
"""
配置管理模块
-----------
提供统一的配置加载访问和保存功能
"""
import os
import configparser
import logging
from typing import Dict, List, Optional, Any
from .defaults import DEFAULT_CONFIG
logger = logging.getLogger(__name__)
class ConfigManager:
"""
配置管理类负责加载和保存配置
单例模式确保全局只有一个配置实例
"""
_instance = None
def __new__(cls, config_file=None):
"""单例模式实现"""
if cls._instance is None:
cls._instance = super(ConfigManager, cls).__new__(cls)
cls._instance._init(config_file)
return cls._instance
def _init(self, config_file):
"""初始化配置管理器"""
self.config_file = config_file or 'config.ini'
self.config = configparser.ConfigParser()
self.load_config()
def load_config(self) -> None:
"""
加载配置文件如果不存在则创建默认配置
"""
if not os.path.exists(self.config_file):
self.create_default_config()
try:
self.config.read(self.config_file, encoding='utf-8')
logger.info(f"已加载配置文件: {self.config_file}")
except Exception as e:
logger.error(f"加载配置文件时出错: {e}")
logger.info("使用默认配置")
self.create_default_config(save=False)
def create_default_config(self, save: bool = True) -> None:
"""创建默认配置"""
for section, options in DEFAULT_CONFIG.items():
if not self.config.has_section(section):
self.config.add_section(section)
for option, value in options.items():
self.config.set(section, option, value)
if save:
self.save_config()
logger.info(f"已创建默认配置文件: {self.config_file}")
def save_config(self) -> None:
"""保存配置到文件"""
try:
with open(self.config_file, 'w', encoding='utf-8') as f:
self.config.write(f)
logger.info(f"配置已保存到: {self.config_file}")
except Exception as e:
logger.error(f"保存配置文件时出错: {e}")
def get(self, section: str, option: str, fallback: Any = None) -> Any:
"""获取配置值"""
return self.config.get(section, option, fallback=fallback)
def getint(self, section: str, option: str, fallback: int = 0) -> int:
"""获取整数配置值"""
return self.config.getint(section, option, fallback=fallback)
def getfloat(self, section: str, option: str, fallback: float = 0.0) -> float:
"""获取浮点数配置值"""
return self.config.getfloat(section, option, fallback=fallback)
def getboolean(self, section: str, option: str, fallback: bool = False) -> bool:
"""获取布尔配置值"""
return self.config.getboolean(section, option, fallback=fallback)
def get_list(self, section: str, option: str, fallback: str = "", delimiter: str = ",") -> List[str]:
"""获取列表配置值(逗号分隔的字符串转为列表)"""
value = self.get(section, option, fallback)
return [item.strip() for item in value.split(delimiter) if item.strip()]
def update(self, section: str, option: str, value: Any) -> None:
"""更新配置选项"""
if not self.config.has_section(section):
self.config.add_section(section)
self.config.set(section, option, str(value))
logger.debug(f"更新配置: [{section}] {option} = {value}")
def get_path(self, section: str, option: str, fallback: str = "", create: bool = False) -> str:
"""
获取路径配置并确保它是一个有效的绝对路径
如果create为True则自动创建该目录
"""
path = self.get(section, option, fallback)
if not os.path.isabs(path):
# 相对路径,转为绝对路径
path = os.path.abspath(path)
if create and not os.path.exists(path):
try:
# 如果是文件路径,创建其父目录
if '.' in os.path.basename(path):
directory = os.path.dirname(path)
if directory and not os.path.exists(directory):
os.makedirs(directory, exist_ok=True)
logger.info(f"已创建目录: {directory}")
else:
# 否则认为是目录路径
os.makedirs(path, exist_ok=True)
logger.info(f"已创建目录: {path}")
except Exception as e:
logger.error(f"创建目录失败: {path}, 错误: {e}")
return path

View File

@ -0,0 +1,5 @@
"""
OCR订单处理系统 - Excel处理模块
----------------------------
提供Excel文件处理数据提取和转换功能
"""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

213
app/core/excel/converter.py Normal file
View File

@ -0,0 +1,213 @@
"""
单位转换处理模块
-------------
提供规格和单位的处理和转换功能
"""
import re
from typing import Dict, List, Optional, Tuple, Any
from ..utils.log_utils import get_logger
from ..utils.string_utils import (
clean_string,
extract_number,
extract_unit,
extract_number_and_unit,
parse_specification
)
logger = get_logger(__name__)
class UnitConverter:
"""
单位转换器处理商品规格和单位转换
"""
def __init__(self):
"""初始化单位转换器"""
# 特殊条码配置
self.special_barcodes = {
'6925019900087': {
'multiplier': 10, # 数量乘以10
'target_unit': '', # 目标单位
'description': '特殊处理:数量*10单位转换为瓶'
}
# 可以在这里添加更多特殊条码的配置
}
# 有效的单位列表
self.valid_units = ['', '', '', '', '', '', '', '', '', '', '', '', 'L', 'l', '']
# 需要特殊处理的单位
self.special_units = ['', '', '', '']
logger.info("单位转换器初始化完成")
def add_special_barcode(self, barcode: str, multiplier: int, target_unit: str, description: str = "") -> None:
"""
添加特殊条码处理配置
Args:
barcode: 条码
multiplier: 数量乘数
target_unit: 目标单位
description: 处理描述
"""
self.special_barcodes[barcode] = {
'multiplier': multiplier,
'target_unit': target_unit,
'description': description or f'特殊处理:数量*{multiplier},单位转换为{target_unit}'
}
logger.info(f"添加特殊条码配置: {barcode}, {description}")
def infer_specification_from_name(self, product_name: str) -> Optional[str]:
"""
从商品名称推断规格
Args:
product_name: 商品名称
Returns:
推断的规格如果无法推断则返回None
"""
if not product_name or not isinstance(product_name, str):
return None
try:
# 清理商品名称
name = clean_string(product_name)
# 1. 匹配 XX入纸箱 格式
match = re.search(r'(\d+)入纸箱', name)
if match:
return f"1*{match.group(1)}"
# 2. 匹配 绿茶1*15-纸箱装 格式
match = re.search(r'(\d+)[*×xX](\d+)[-\s]?纸箱', name)
if match:
return f"{match.group(1)}*{match.group(2)}"
# 3. 匹配 12.9L桶装水 格式
match = re.search(r'([\d\.]+)[Ll升](?!.*[*×xX])', name)
if match:
return f"{match.group(1)}L*1"
# 4. 匹配 商品12入纸箱 格式(数字在中间)
match = re.search(r'\D(\d+)入\w*箱', name)
if match:
return f"1*{match.group(1)}"
# 5. 匹配 商品15纸箱 格式(数字在中间)
match = re.search(r'\D(\d+)\w*箱', name)
if match:
return f"1*{match.group(1)}"
# 6. 匹配 商品1*30 格式
match = re.search(r'(\d+)[*×xX](\d+)', name)
if match:
return f"{match.group(1)}*{match.group(2)}"
logger.debug(f"无法从商品名称推断规格: {name}")
return None
except Exception as e:
logger.error(f"从商品名称推断规格时出错: {e}")
return None
def extract_unit_from_quantity(self, quantity_str: str) -> Tuple[Optional[float], Optional[str]]:
"""
从数量字符串提取单位
Args:
quantity_str: 数量字符串
Returns:
(数量, 单位)元组
"""
if not quantity_str or not isinstance(quantity_str, str):
return None, None
try:
# 清理数量字符串
quantity_str = clean_string(quantity_str)
# 提取数字和单位
return extract_number_and_unit(quantity_str)
except Exception as e:
logger.error(f"从数量字符串提取单位时出错: {quantity_str}, 错误: {e}")
return None, None
def process_unit_conversion(self, product: Dict[str, Any]) -> Dict[str, Any]:
"""
处理单位转换根据单位和规格转换数量和单价
Args:
product: 商品字典包含条码单位规格数量和单价等字段
Returns:
处理后的商品字典
"""
# 复制商品信息,避免修改原始数据
result = product.copy()
try:
# 获取条码、单位、规格、数量和单价
barcode = product.get('barcode', '')
unit = product.get('unit', '')
specification = product.get('specification', '')
quantity = product.get('quantity', 0)
price = product.get('price', 0)
# 如果缺少关键信息,无法进行转换
if not barcode or quantity == 0:
return result
# 1. 首先检查是否是特殊条码
if barcode in self.special_barcodes:
special_config = self.special_barcodes[barcode]
logger.info(f"应用特殊条码配置: {barcode}, {special_config['description']}")
# 应用乘数和单位转换
result['quantity'] = quantity * special_config['multiplier']
result['unit'] = special_config['target_unit']
# 如果有单价,进行单价转换
if price != 0:
result['price'] = price / special_config['multiplier']
return result
# 2. 提取规格包装数量
package_quantity = None
if specification:
package_quantity = parse_specification(specification)
# 3. 处理单位转换
if unit and unit in self.special_units and package_quantity:
# 判断是否是三级规格1*5*12格式
is_three_level = bool(re.search(r'\d+[\*xX×]\d+[\*xX×]\d+', str(specification)))
# 对于"提"和"盒"单位的特殊处理
if (unit in ['', '']) and not is_three_level:
# 二级规格:保持原数量不变
logger.info(f"二级规格的提/盒单位,保持原状: {unit}, 规格={specification}")
return result
# 标准处理:数量×包装数量,单价÷包装数量
logger.info(f"标准单位转换: {unit}->瓶, 规格={specification}, 包装数量={package_quantity}")
result['quantity'] = quantity * package_quantity
result['unit'] = ''
if price != 0:
result['price'] = price / package_quantity
return result
# 4. 默认返回原始数据
return result
except Exception as e:
logger.error(f"单位转换处理出错: {e}")
# 发生错误时,返回原始数据
return result

375
app/core/excel/merger.py Normal file
View File

@ -0,0 +1,375 @@
"""
订单合并模块
----------
提供采购单合并功能将多个采购单合并为一个
"""
import os
import re
import pandas as pd
import numpy as np
import xlrd
import xlwt
from xlutils.copy import copy as xlcopy
from typing import Dict, List, Optional, Tuple, Union, Any
from datetime import datetime
from ...config.settings import ConfigManager
from ..utils.log_utils import get_logger
from ..utils.file_utils import (
ensure_dir,
get_file_extension,
get_files_by_extensions,
load_json,
save_json
)
from ..utils.string_utils import (
clean_string,
clean_barcode,
format_barcode
)
logger = get_logger(__name__)
class PurchaseOrderMerger:
"""
采购单合并器将多个采购单Excel文件合并成一个文件
"""
def __init__(self, config: Optional[ConfigManager] = None):
"""
初始化采购单合并器
Args:
config: 配置管理器如果为None则创建新的
"""
logger.info("初始化PurchaseOrderMerger")
self.config = config or ConfigManager()
# 获取配置
self.output_dir = self.config.get_path('Paths', 'output_folder', 'data/output', create=True)
# 获取模板文件路径
template_folder = self.config.get('Paths', 'template_folder', 'templates')
template_name = self.config.get('Templates', 'purchase_order', '银豹-采购单模板.xls')
self.template_path = os.path.join(template_folder, template_name)
# 检查模板文件是否存在
if not os.path.exists(self.template_path):
logger.error(f"模板文件不存在: {self.template_path}")
raise FileNotFoundError(f"模板文件不存在: {self.template_path}")
# 用于记录已合并的文件
self.cache_file = os.path.join(self.output_dir, "merged_files.json")
self.merged_files = self._load_merged_files()
logger.info(f"初始化完成,模板文件: {self.template_path}")
def _load_merged_files(self) -> Dict[str, str]:
"""
加载已合并文件的缓存
Returns:
合并记录字典
"""
return load_json(self.cache_file, {})
def _save_merged_files(self) -> None:
"""保存已合并文件的缓存"""
save_json(self.merged_files, self.cache_file)
def get_purchase_orders(self) -> List[str]:
"""
获取output目录下的采购单Excel文件
Returns:
采购单文件路径列表
"""
logger.info(f"搜索目录 {self.output_dir} 中的采购单Excel文件")
# 获取所有Excel文件
all_files = get_files_by_extensions(self.output_dir, ['.xls', '.xlsx'])
# 筛选采购单文件
purchase_orders = [
file for file in all_files
if os.path.basename(file).startswith('采购单_')
]
if not purchase_orders:
logger.warning(f"未在 {self.output_dir} 目录下找到采购单Excel文件")
return []
# 按修改时间排序,最新的在前
purchase_orders.sort(key=lambda x: os.path.getmtime(x), reverse=True)
logger.info(f"找到 {len(purchase_orders)} 个采购单Excel文件")
return purchase_orders
def read_purchase_order(self, file_path: str) -> Optional[pd.DataFrame]:
"""
读取采购单Excel文件
Args:
file_path: 采购单文件路径
Returns:
数据帧如果读取失败则返回None
"""
try:
# 读取Excel文件
df = pd.read_excel(file_path)
logger.info(f"成功读取采购单文件: {file_path}")
# 打印列名,用于调试
logger.debug(f"Excel文件的列名: {df.columns.tolist()}")
# 检查是否有特殊表头结构如在第3行
special_header = False
if len(df) > 3: # 确保有足够的行
row3 = df.iloc[3].astype(str)
header_keywords = ['行号', '条形码', '条码', '商品名称', '规格', '单价', '数量', '金额', '单位']
# 计算匹配的关键词数量
matches = sum(1 for keyword in header_keywords if any(keyword in str(val) for val in row3.values))
# 如果匹配了至少3个关键词认为第3行是表头
if matches >= 3:
logger.info(f"检测到特殊表头结构使用第3行作为列名")
# 创建新的数据帧使用第3行作为列名数据从第4行开始
header_row = df.iloc[3]
data_rows = df.iloc[4:].reset_index(drop=True)
# 为每一列分配一个名称(避免重复的列名)
new_columns = []
for i, col in enumerate(header_row):
col_str = str(col)
if col_str == 'nan' or col_str == 'None' or pd.isna(col):
new_columns.append(f"Col_{i}")
else:
new_columns.append(col_str)
# 使用新列名创建新的DataFrame
data_rows.columns = new_columns
df = data_rows
special_header = True
logger.debug(f"重新构建的数据帧列名: {df.columns.tolist()}")
# 定义可能的列名映射
column_mapping = {
'条码': ['条码', '条形码', '商品条码', 'barcode', '商品条形码', '条形码', '商品条码', '商品编码', '商品编号', '条形码', '条码(必填)'],
'采购量': ['数量', '采购数量', '购买数量', '采购数量', '订单数量', '采购数量', '采购量(必填)'],
'采购单价': ['单价', '价格', '采购单价', '销售价', '采购单价(必填)'],
'赠送量': ['赠送量', '赠品数量', '赠送数量', '赠品']
}
# 映射实际的列名
mapped_columns = {}
for target_col, possible_names in column_mapping.items():
for col in df.columns:
# 移除列名中的空白字符和括号内容以进行比较
clean_col = re.sub(r'\s+', '', str(col))
clean_col = re.sub(r'.*?', '', clean_col) # 移除括号内容
for name in possible_names:
clean_name = re.sub(r'\s+', '', name)
clean_name = re.sub(r'.*?', '', clean_name) # 移除括号内容
if clean_col == clean_name:
mapped_columns[target_col] = col
break
if target_col in mapped_columns:
break
# 如果找到了必要的列,重命名列
if mapped_columns:
# 如果没有找到条码列,无法继续处理
if '条码' not in mapped_columns:
logger.error(f"未找到条码列: {file_path}")
return None
df = df.rename(columns=mapped_columns)
logger.info(f"列名映射结果: {mapped_columns}")
return df
except Exception as e:
logger.error(f"读取采购单文件失败: {file_path}, 错误: {str(e)}")
return None
def merge_purchase_orders(self, file_paths: List[str]) -> Optional[pd.DataFrame]:
"""
合并多个采购单文件
Args:
file_paths: 采购单文件路径列表
Returns:
合并后的数据帧如果合并失败则返回None
"""
if not file_paths:
logger.warning("没有需要合并的采购单文件")
return None
# 读取所有采购单文件
dfs = []
for file_path in file_paths:
df = self.read_purchase_order(file_path)
if df is not None:
dfs.append(df)
if not dfs:
logger.warning("没有成功读取的采购单文件")
return None
# 合并数据
logger.info(f"开始合并 {len(dfs)} 个采购单文件")
# 首先,整理每个数据帧以确保它们有相同的结构
processed_dfs = []
for i, df in enumerate(dfs):
# 确保必要的列存在
required_columns = ['条码', '采购量', '采购单价']
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
logger.warning(f"数据帧 {i} 缺少必要的列: {missing_columns}")
continue
# 处理赠送量列不存在的情况
if '赠送量' not in df.columns:
df['赠送量'] = pd.NA
# 选择需要的列
selected_df = df[['条码', '采购量', '采购单价', '赠送量']].copy()
# 清理和转换数据
selected_df['条码'] = selected_df['条码'].apply(lambda x: format_barcode(x) if pd.notna(x) else x)
selected_df['采购量'] = pd.to_numeric(selected_df['采购量'], errors='coerce')
selected_df['采购单价'] = pd.to_numeric(selected_df['采购单价'], errors='coerce')
selected_df['赠送量'] = pd.to_numeric(selected_df['赠送量'], errors='coerce')
# 过滤无效行
valid_df = selected_df.dropna(subset=['条码', '采购量'])
processed_dfs.append(valid_df)
if not processed_dfs:
logger.warning("没有有效的数据帧用于合并")
return None
# 将所有数据帧合并
merged_df = pd.concat(processed_dfs, ignore_index=True)
# 按条码和单价分组,合并相同商品
merged_df['采购单价'] = merged_df['采购单价'].round(4) # 四舍五入到4位小数避免浮点误差
# 对于同一条码和单价的商品,合并数量和赠送量
grouped = merged_df.groupby(['条码', '采购单价'], as_index=False).agg({
'采购量': 'sum',
'赠送量': lambda x: sum(x.dropna()) if len(x.dropna()) > 0 else pd.NA
})
# 计算其他信息
grouped['采购金额'] = grouped['采购量'] * grouped['采购单价']
# 排序,按条码升序
result = grouped.sort_values('条码').reset_index(drop=True)
logger.info(f"合并完成,共 {len(result)} 条商品记录")
return result
def create_merged_purchase_order(self, df: pd.DataFrame) -> Optional[str]:
"""
创建合并的采购单文件
Args:
df: 合并后的数据帧
Returns:
输出文件路径如果创建失败则返回None
"""
try:
# 打开模板文件
template_workbook = xlrd.open_workbook(self.template_path, formatting_info=True)
template_sheet = template_workbook.sheet_by_index(0)
# 创建可写的副本
output_workbook = xlcopy(template_workbook)
output_sheet = output_workbook.get_sheet(0)
# 填充商品信息
start_row = 4 # 从第5行开始填充数据索引从0开始
for i, (_, row) in enumerate(df.iterrows()):
r = start_row + i
# 序号
output_sheet.write(r, 0, i + 1)
# 商品编码(条码)
output_sheet.write(r, 1, row['条码'])
# 商品名称(合并单没有名称信息,留空)
output_sheet.write(r, 2, "")
# 规格(合并单没有规格信息,留空)
output_sheet.write(r, 3, "")
# 单位(合并单没有单位信息,留空)
output_sheet.write(r, 4, "")
# 单价
output_sheet.write(r, 5, row['采购单价'])
# 采购数量
output_sheet.write(r, 6, row['采购量'])
# 采购金额
output_sheet.write(r, 7, row['采购金额'])
# 税率
output_sheet.write(r, 8, 0)
# 赠送量
if pd.notna(row['赠送量']):
output_sheet.write(r, 9, row['赠送量'])
else:
output_sheet.write(r, 9, "")
# 生成输出文件名
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
output_file = os.path.join(self.output_dir, f"合并采购单_{timestamp}.xls")
# 保存文件
output_workbook.save(output_file)
logger.info(f"合并采购单已保存到: {output_file}")
return output_file
except Exception as e:
logger.error(f"创建合并采购单时出错: {e}")
return None
def process(self, file_paths: Optional[List[str]] = None) -> Optional[str]:
"""
处理采购单合并
Args:
file_paths: 指定要合并的文件路径列表如果为None则自动获取
Returns:
合并后的文件路径如果合并失败则返回None
"""
# 如果未指定文件路径,则获取所有采购单文件
if file_paths is None:
file_paths = self.get_purchase_orders()
# 检查是否有文件需要合并
if not file_paths:
logger.warning("没有找到可合并的采购单文件")
return None
# 合并采购单
merged_df = self.merge_purchase_orders(file_paths)
if merged_df is None:
logger.error("合并采购单失败")
return None
# 创建合并的采购单文件
output_file = self.create_merged_purchase_order(merged_df)
if output_file is None:
logger.error("创建合并采购单文件失败")
return None
# 记录已合并文件
for file_path in file_paths:
self.merged_files[file_path] = output_file
self._save_merged_files()
return output_file

393
app/core/excel/processor.py Normal file
View File

@ -0,0 +1,393 @@
"""
Excel处理核心模块
--------------
提供Excel文件处理功能包括表格解析数据提取和处理
"""
import os
import re
import pandas as pd
import numpy as np
import xlrd
import xlwt
from xlutils.copy import copy as xlcopy
from typing import Dict, List, Optional, Tuple, Union, Any
from datetime import datetime
from ...config.settings import ConfigManager
from ..utils.log_utils import get_logger
from ..utils.file_utils import (
ensure_dir,
get_file_extension,
get_latest_file,
load_json,
save_json
)
from ..utils.string_utils import (
clean_string,
clean_barcode,
extract_number,
format_barcode
)
from .converter import UnitConverter
logger = get_logger(__name__)
class ExcelProcessor:
"""
Excel处理器处理OCR识别后的Excel文件
提取条码单价和数量并按照采购单模板的格式填充
"""
def __init__(self, config: Optional[ConfigManager] = None):
"""
初始化Excel处理器
Args:
config: 配置管理器如果为None则创建新的
"""
logger.info("初始化ExcelProcessor")
self.config = config or ConfigManager()
# 获取配置
self.output_dir = self.config.get_path('Paths', 'output_folder', 'data/output', create=True)
self.temp_dir = self.config.get_path('Paths', 'temp_folder', 'data/temp', create=True)
# 获取模板文件路径
template_folder = self.config.get('Paths', 'template_folder', 'templates')
template_name = self.config.get('Templates', 'purchase_order', '银豹-采购单模板.xls')
self.template_path = os.path.join(template_folder, template_name)
# 检查模板文件是否存在
if not os.path.exists(self.template_path):
logger.error(f"模板文件不存在: {self.template_path}")
raise FileNotFoundError(f"模板文件不存在: {self.template_path}")
# 用于记录已处理的文件
self.cache_file = os.path.join(self.output_dir, "processed_files.json")
self.processed_files = self._load_processed_files()
# 创建单位转换器
self.unit_converter = UnitConverter()
logger.info(f"初始化完成,模板文件: {self.template_path}")
def _load_processed_files(self) -> Dict[str, str]:
"""
加载已处理文件的缓存
Returns:
处理记录字典
"""
return load_json(self.cache_file, {})
def _save_processed_files(self) -> None:
"""保存已处理文件的缓存"""
save_json(self.processed_files, self.cache_file)
def get_latest_excel(self) -> Optional[str]:
"""
获取output目录下最新的Excel文件排除采购单文件
Returns:
最新Excel文件的路径如果未找到则返回None
"""
logger.info(f"搜索目录 {self.output_dir} 中的Excel文件")
# 使用文件工具获取最新文件
latest_file = get_latest_file(
self.output_dir,
pattern="", # 不限制文件名
extensions=['.xlsx', '.xls'] # 限制为Excel文件
)
# 如果没有找到文件
if not latest_file:
logger.warning(f"未在 {self.output_dir} 目录下找到未处理的Excel文件")
return None
# 检查是否是采购单(以"采购单_"开头的文件)
file_name = os.path.basename(latest_file)
if file_name.startswith('采购单_'):
logger.warning(f"找到的最新文件是采购单,不作处理: {latest_file}")
return None
logger.info(f"找到最新的Excel文件: {latest_file}")
return latest_file
def validate_barcode(self, barcode: Any) -> bool:
"""
验证条码是否有效
新增功能如果条码是"仓库"则返回False以避免误认为有效条码
Args:
barcode: 条码值
Returns:
条码是否有效
"""
# 处理"仓库"特殊情况
if isinstance(barcode, str) and barcode.strip() in ["仓库", "仓库全名"]:
logger.warning(f"条码为仓库标识: {barcode}")
return False
# 清理条码格式
barcode_clean = clean_barcode(barcode)
# 对特定的错误条码进行修正开头改6开头
if len(barcode_clean) > 8 and barcode_clean.startswith('5') and not barcode_clean.startswith('53'):
barcode_clean = '6' + barcode_clean[1:]
logger.info(f"修正条码前缀 5->6: {barcode} -> {barcode_clean}")
# 验证条码长度
if len(barcode_clean) < 8 or len(barcode_clean) > 13:
logger.warning(f"条码长度异常: {barcode_clean}, 长度={len(barcode_clean)}")
return False
# 验证条码是否全为数字
if not barcode_clean.isdigit():
logger.warning(f"条码包含非数字字符: {barcode_clean}")
return False
# 对于序号9的特殊情况允许其条码格式
if barcode_clean == "5321545613":
logger.info(f"特殊条码验证通过: {barcode_clean}")
return True
logger.debug(f"条码验证通过: {barcode_clean}")
return True
def extract_barcode(self, df: pd.DataFrame) -> List[str]:
"""
从数据帧中提取条码列名
Args:
df: 数据帧
Returns:
可能的条码列名列表
"""
possible_barcode_columns = [
'条码', '条形码', '商品条码', '商品条形码',
'商品编码', '商品编号', '条形码', '条码(必填)',
'barcode', 'Barcode', '编码', '条形码'
]
found_columns = []
for col in df.columns:
col_str = str(col).strip()
if col_str in possible_barcode_columns:
found_columns.append(col)
return found_columns
def extract_product_info(self, df: pd.DataFrame) -> List[Dict]:
"""
从数据帧中提取商品信息
Args:
df: 数据帧
Returns:
商品信息列表
"""
# 提取有用的列
barcode_cols = self.extract_barcode(df)
# 如果没有找到条码列,无法继续处理
if not barcode_cols:
logger.error("未找到条码列,无法处理")
return []
# 定义列名映射
column_mapping = {
'name': ['商品名称', '名称', '品名', '商品', '商品名', '商品或服务名称', '品项名'],
'specification': ['规格', '规格型号', '型号', '商品规格'],
'quantity': ['数量', '采购数量', '购买数量', '采购数量', '订单数量', '数量(必填)'],
'unit': ['单位', '采购单位', '计量单位', '单位(必填)'],
'price': ['单价', '价格', '采购单价', '销售价', '进货价', '单价(必填)']
}
# 映射列名到标准名称
mapped_columns = {'barcode': barcode_cols[0]} # 使用第一个找到的条码列
for target, possible_names in column_mapping.items():
for col in df.columns:
col_str = str(col).strip()
for name in possible_names:
if col_str == name:
mapped_columns[target] = col
break
if target in mapped_columns:
break
logger.info(f"列名映射结果: {mapped_columns}")
# 提取商品信息
products = []
for _, row in df.iterrows():
barcode = row.get(mapped_columns['barcode'])
# 跳过空行或无效条码
if pd.isna(barcode) or not self.validate_barcode(barcode):
continue
# 创建商品信息字典
product = {
'barcode': format_barcode(barcode),
'name': row.get(mapped_columns.get('name', ''), ''),
'specification': row.get(mapped_columns.get('specification', ''), ''),
'quantity': extract_number(str(row.get(mapped_columns.get('quantity', ''), 0))) or 0,
'unit': str(row.get(mapped_columns.get('unit', ''), '')),
'price': extract_number(str(row.get(mapped_columns.get('price', ''), 0))) or 0
}
# 如果商品名称为空但商品条码不为空,则使用条码作为名称
if not product['name'] and product['barcode']:
product['name'] = f"商品 ({product['barcode']})"
# 推断规格
if not product['specification'] and product['name']:
inferred_spec = self.unit_converter.infer_specification_from_name(product['name'])
if inferred_spec:
product['specification'] = inferred_spec
logger.info(f"从商品名称推断规格: {product['name']} -> {inferred_spec}")
# 单位处理:如果单位为空但数量包含单位信息
quantity_str = str(row.get(mapped_columns.get('quantity', ''), ''))
if not product['unit'] and '数量' in mapped_columns:
num, unit = self.unit_converter.extract_unit_from_quantity(quantity_str)
if unit:
product['unit'] = unit
logger.info(f"从数量提取单位: {quantity_str} -> {unit}")
# 如果数量被提取出来,更新数量
if num is not None:
product['quantity'] = num
# 应用单位转换规则
product = self.unit_converter.process_unit_conversion(product)
products.append(product)
logger.info(f"提取到 {len(products)} 个商品信息")
return products
def fill_template(self, products: List[Dict], output_file_path: str) -> bool:
"""
填充采购单模板
Args:
products: 商品信息列表
output_file_path: 输出文件路径
Returns:
是否成功填充
"""
try:
# 打开模板文件
template_workbook = xlrd.open_workbook(self.template_path, formatting_info=True)
template_sheet = template_workbook.sheet_by_index(0)
# 创建可写的副本
output_workbook = xlcopy(template_workbook)
output_sheet = output_workbook.get_sheet(0)
# 填充商品信息
start_row = 1 # 从第2行开始填充数据索引从0开始
for i, product in enumerate(products):
row = start_row + i
# 序号
output_sheet.write(row, 0, i + 1)
# 商品编码(条码)
output_sheet.write(row, 1, product['barcode'])
# 商品名称
output_sheet.write(row, 2, product['name'])
# 规格
output_sheet.write(row, 3, product['specification'])
# 单位
output_sheet.write(row, 4, product['unit'])
# 单价
output_sheet.write(row, 5, product['price'])
# 采购数量
output_sheet.write(row, 6, product['quantity'])
# 采购金额(单价 × 数量)
amount = product['price'] * product['quantity']
output_sheet.write(row, 7, amount)
# 税率
output_sheet.write(row, 8, 0)
# 赠送量默认为0
output_sheet.write(row, 9, 0)
# 保存文件
output_workbook.save(output_file_path)
logger.info(f"采购单已保存到: {output_file_path}")
return True
except Exception as e:
logger.error(f"填充模板时出错: {e}")
return False
def process_specific_file(self, file_path: str) -> Optional[str]:
"""
处理指定的Excel文件
Args:
file_path: Excel文件路径
Returns:
输出文件路径如果处理失败则返回None
"""
logger.info(f"开始处理Excel文件: {file_path}")
if not os.path.exists(file_path):
logger.error(f"文件不存在: {file_path}")
return None
try:
# 读取Excel文件
df = pd.read_excel(file_path)
logger.info(f"成功读取Excel文件: {file_path}, 共 {len(df)}")
# 提取商品信息
products = self.extract_product_info(df)
if not products:
logger.warning("未提取到有效商品信息")
return None
# 生成输出文件名
file_name = os.path.splitext(os.path.basename(file_path))[0]
output_file = os.path.join(self.output_dir, f"采购单_{file_name}.xls")
# 填充模板并保存
if self.fill_template(products, output_file):
# 记录已处理文件
self.processed_files[file_path] = output_file
self._save_processed_files()
return output_file
return None
except Exception as e:
logger.error(f"处理Excel文件时出错: {file_path}, 错误: {e}")
return None
def process_latest_file(self) -> Optional[str]:
"""
处理最新的Excel文件
Returns:
输出文件路径如果处理失败则返回None
"""
# 获取最新的Excel文件
latest_file = self.get_latest_excel()
if not latest_file:
logger.warning("未找到可处理的Excel文件")
return None
# 处理文件
return self.process_specific_file(latest_file)

5
app/core/ocr/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""
OCR订单处理系统 - OCR核心模块
---------------------------
提供OCR识别相关功能包括图片预处理文字识别和表格识别
"""

Binary file not shown.

Binary file not shown.

Binary file not shown.

344
app/core/ocr/baidu_ocr.py Normal file
View File

@ -0,0 +1,344 @@
"""
百度OCR客户端模块
---------------
提供百度OCR API的访问和调用功能
"""
import os
import time
import base64
import requests
import logging
from typing import Dict, Optional, Any, Union
from ...config.settings import ConfigManager
from ..utils.log_utils import get_logger
logger = get_logger(__name__)
class TokenManager:
"""
令牌管理类负责获取和刷新百度API访问令牌
"""
def __init__(self, api_key: str, secret_key: str, max_retries: int = 3, retry_delay: int = 2):
"""
初始化令牌管理器
Args:
api_key: 百度API Key
secret_key: 百度Secret Key
max_retries: 最大重试次数
retry_delay: 重试延迟
"""
self.api_key = api_key
self.secret_key = secret_key
self.max_retries = max_retries
self.retry_delay = retry_delay
self.access_token = None
self.token_expiry = 0
def get_token(self) -> Optional[str]:
"""
获取访问令牌如果令牌已过期则刷新
Returns:
访问令牌如果获取失败则返回None
"""
if self.is_token_valid():
return self.access_token
return self.refresh_token()
def is_token_valid(self) -> bool:
"""
检查令牌是否有效
Returns:
令牌是否有效
"""
return (
self.access_token is not None and
self.token_expiry > time.time() + 60 # 提前1分钟刷新
)
def refresh_token(self) -> Optional[str]:
"""
刷新访问令牌
Returns:
新的访问令牌如果获取失败则返回None
"""
url = "https://aip.baidubce.com/oauth/2.0/token"
params = {
"grant_type": "client_credentials",
"client_id": self.api_key,
"client_secret": self.secret_key
}
for attempt in range(self.max_retries):
try:
response = requests.post(url, params=params, timeout=10)
if response.status_code == 200:
result = response.json()
if "access_token" in result:
self.access_token = result["access_token"]
# 设置令牌过期时间默认30天提前1小时过期以确保安全
self.token_expiry = time.time() + result.get("expires_in", 2592000) - 3600
logger.info("成功获取访问令牌")
return self.access_token
logger.warning(f"获取访问令牌失败 (尝试 {attempt+1}/{self.max_retries}): {response.text}")
except Exception as e:
logger.warning(f"获取访问令牌时发生错误 (尝试 {attempt+1}/{self.max_retries}): {e}")
# 如果不是最后一次尝试,则等待后重试
if attempt < self.max_retries - 1:
time.sleep(self.retry_delay * (attempt + 1)) # 指数退避
logger.error("无法获取访问令牌")
return None
class BaiduOCRClient:
"""
百度OCR API客户端
"""
def __init__(self, config: Optional[ConfigManager] = None):
"""
初始化百度OCR客户端
Args:
config: 配置管理器如果为None则创建新的
"""
self.config = config or ConfigManager()
# 获取配置
self.api_key = self.config.get('API', 'api_key')
self.secret_key = self.config.get('API', 'secret_key')
self.timeout = self.config.getint('API', 'timeout', 30)
self.max_retries = self.config.getint('API', 'max_retries', 3)
self.retry_delay = self.config.getint('API', 'retry_delay', 2)
self.api_url = self.config.get('API', 'api_url', 'https://aip.baidubce.com/rest/2.0/ocr/v1/table')
# 创建令牌管理器
self.token_manager = TokenManager(
self.api_key,
self.secret_key,
self.max_retries,
self.retry_delay
)
# 验证API配置
if not self.api_key or not self.secret_key:
logger.warning("API密钥未设置请在配置文件中设置API密钥")
def read_image(self, image_path: str) -> Optional[bytes]:
"""
读取图片文件为二进制数据
Args:
image_path: 图片文件路径
Returns:
图片二进制数据如果读取失败则返回None
"""
try:
with open(image_path, 'rb') as f:
return f.read()
except Exception as e:
logger.error(f"读取图片文件失败: {image_path}, 错误: {e}")
return None
def recognize_table(self, image_data: Union[str, bytes]) -> Optional[Dict]:
"""
识别表格
Args:
image_data: 图片数据可以是文件路径或二进制数据
Returns:
识别结果字典如果识别失败则返回None
"""
# 获取访问令牌
access_token = self.token_manager.get_token()
if not access_token:
logger.error("无法获取访问令牌,无法进行表格识别")
return None
# 如果是文件路径,读取图片数据
if isinstance(image_data, str):
image_data = self.read_image(image_data)
if image_data is None:
return None
# 准备请求参数
url = f"{self.api_url}?access_token={access_token}"
image_base64 = base64.b64encode(image_data).decode('utf-8')
# 请求参数 - 添加return_excel参数与v1版本保持一致
payload = {
'image': image_base64,
'is_sync': 'true', # 同步请求
'request_type': 'excel', # 输出为Excel
'return_excel': 'true' # 直接返回Excel数据
}
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
}
# 发送请求
for attempt in range(self.max_retries):
try:
response = requests.post(
url,
data=payload,
headers=headers,
timeout=self.timeout
)
if response.status_code == 200:
result = response.json()
# 打印返回结果以便调试
logger.debug(f"百度OCR API返回结果: {result}")
if 'error_code' in result:
error_msg = result.get('error_msg', '未知错误')
logger.error(f"百度OCR API错误: {error_msg}")
# 如果是授权错误,尝试刷新令牌
if result.get('error_code') in [110, 111]: # 授权相关错误码
logger.info("尝试刷新访问令牌...")
self.token_manager.refresh_token()
return None
# 兼容不同的返回结构
# 这是最关键的修改部分: 直接返回整个结果,不强制要求特定结构
return result
else:
logger.warning(f"表格识别请求失败 (尝试 {attempt+1}/{self.max_retries}): {response.text}")
except Exception as e:
logger.warning(f"表格识别时发生错误 (尝试 {attempt+1}/{self.max_retries}): {e}")
# 如果不是最后一次尝试,则等待后重试
if attempt < self.max_retries - 1:
wait_time = self.retry_delay * (2 ** attempt) # 指数退避
logger.info(f"将在 {wait_time} 秒后重试...")
time.sleep(wait_time)
logger.error("表格识别失败")
return None
def get_excel_result(self, request_id_or_result: Union[str, Dict]) -> Optional[bytes]:
"""
获取Excel结果
Args:
request_id_or_result: 请求ID或完整的识别结果
Returns:
Excel二进制数据如果获取失败则返回None
"""
# 获取访问令牌
access_token = self.token_manager.get_token()
if not access_token:
logger.error("无法获取访问令牌无法获取Excel结果")
return None
# 处理直接传入结果对象的情况
request_id = request_id_or_result
if isinstance(request_id_or_result, dict):
# v1版本兼容处理如果结果中直接包含Excel数据
if 'result' in request_id_or_result:
# 如果是同步返回的Excel结果某些API版本会直接返回
if 'result_data' in request_id_or_result['result']:
excel_content = request_id_or_result['result']['result_data']
if excel_content:
try:
return base64.b64decode(excel_content)
except Exception as e:
logger.error(f"解析Excel数据失败: {e}")
# 提取request_id
if 'request_id' in request_id_or_result['result']:
request_id = request_id_or_result['result']['request_id']
logger.debug(f"从result子对象中提取request_id: {request_id}")
elif 'tables_result' in request_id_or_result['result'] and len(request_id_or_result['result']['tables_result']) > 0:
# 某些版本API可能直接返回表格内容此时可能没有request_id
logger.info("检测到API直接返回了表格内容但没有request_id")
return None
# 有些版本可能request_id在顶层
elif 'request_id' in request_id_or_result:
request_id = request_id_or_result['request_id']
logger.debug(f"从顶层对象中提取request_id: {request_id}")
# 如果没有有效的request_id无法获取结果
if not isinstance(request_id, str):
logger.error(f"无法从结果中提取有效的request_id: {request_id_or_result}")
return None
url = f"https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result?access_token={access_token}"
payload = {
'request_id': request_id,
'result_type': 'excel'
}
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
}
for attempt in range(self.max_retries):
try:
response = requests.post(
url,
data=payload,
headers=headers,
timeout=self.timeout
)
if response.status_code == 200:
try:
result = response.json()
logger.debug(f"获取Excel结果返回: {result}")
# 检查是否还在处理中
if result.get('result', {}).get('ret_code') == 3:
logger.info(f"Excel结果正在处理中等待后重试 (尝试 {attempt+1}/{self.max_retries})")
time.sleep(2)
continue
# 检查是否有错误
if 'error_code' in result or result.get('result', {}).get('ret_code') != 0:
error_msg = result.get('error_msg') or result.get('result', {}).get('ret_msg', '未知错误')
logger.error(f"获取Excel结果失败: {error_msg}")
return None
# 获取Excel内容
excel_content = result.get('result', {}).get('result_data')
if excel_content:
return base64.b64decode(excel_content)
else:
logger.error("Excel结果为空")
return None
except Exception as e:
logger.error(f"解析Excel结果时出错: {e}")
return None
else:
logger.warning(f"获取Excel结果请求失败 (尝试 {attempt+1}/{self.max_retries}): {response.text}")
except Exception as e:
logger.warning(f"获取Excel结果时发生错误 (尝试 {attempt+1}/{self.max_retries}): {e}")
# 如果不是最后一次尝试,则等待后重试
if attempt < self.max_retries - 1:
time.sleep(self.retry_delay * (attempt + 1))
logger.error("获取Excel结果失败")
return None

334
app/core/ocr/table_ocr.py Normal file
View File

@ -0,0 +1,334 @@
"""
表格OCR处理模块
-------------
处理图片并提取表格内容保存为Excel文件
"""
import os
import sys
import time
import json
import base64
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional, Tuple, Union, Any
from ...config.settings import ConfigManager
from ..utils.log_utils import get_logger
from ..utils.file_utils import (
ensure_dir,
get_file_extension,
get_files_by_extensions,
generate_timestamp_filename,
is_file_size_valid,
load_json,
save_json
)
from .baidu_ocr import BaiduOCRClient
logger = get_logger(__name__)
class ProcessedRecordManager:
"""处理记录管理器,用于跟踪已处理的文件"""
def __init__(self, record_file: str):
"""
初始化处理记录管理器
Args:
record_file: 记录文件路径
"""
self.record_file = record_file
self.processed_files = self._load_record()
def _load_record(self) -> Dict[str, str]:
"""
加载处理记录
Returns:
处理记录字典键为输入文件路径值为输出文件路径
"""
return load_json(self.record_file, {})
def save_record(self) -> None:
"""保存处理记录"""
save_json(self.processed_files, self.record_file)
def is_processed(self, image_file: str) -> bool:
"""
检查图片是否已处理
Args:
image_file: 图片文件路径
Returns:
是否已处理
"""
return image_file in self.processed_files
def mark_as_processed(self, image_file: str, output_file: str) -> None:
"""
标记图片为已处理
Args:
image_file: 图片文件路径
output_file: 输出文件路径
"""
self.processed_files[image_file] = output_file
self.save_record()
def get_output_file(self, image_file: str) -> Optional[str]:
"""
获取图片的输出文件路径
Args:
image_file: 图片文件路径
Returns:
输出文件路径如果不存在则返回None
"""
return self.processed_files.get(image_file)
def get_unprocessed_files(self, files: List[str]) -> List[str]:
"""
获取未处理的文件列表
Args:
files: 文件列表
Returns:
未处理的文件列表
"""
return [file for file in files if not self.is_processed(file)]
class OCRProcessor:
"""
OCR处理器用于表格识别与处理
"""
def __init__(self, config: Optional[ConfigManager] = None):
"""
初始化OCR处理器
Args:
config: 配置管理器如果为None则创建新的
"""
self.config = config or ConfigManager()
# 创建百度OCR客户端
self.ocr_client = BaiduOCRClient(self.config)
# 获取配置
self.input_folder = self.config.get_path('Paths', 'input_folder', 'data/input', create=True)
self.output_folder = self.config.get_path('Paths', 'output_folder', 'data/output', create=True)
self.temp_folder = self.config.get_path('Paths', 'temp_folder', 'data/temp', create=True)
# 确保目录结构正确
for folder in [self.input_folder, self.output_folder, self.temp_folder]:
if not os.path.exists(folder):
os.makedirs(folder, exist_ok=True)
logger.info(f"创建目录: {folder}")
# 记录实际路径
logger.info(f"使用输入目录: {os.path.abspath(self.input_folder)}")
logger.info(f"使用输出目录: {os.path.abspath(self.output_folder)}")
logger.info(f"使用临时目录: {os.path.abspath(self.temp_folder)}")
self.allowed_extensions = self.config.get_list('File', 'allowed_extensions', '.jpg,.jpeg,.png,.bmp')
self.max_file_size_mb = self.config.getfloat('File', 'max_file_size_mb', 4.0)
self.excel_extension = self.config.get('File', 'excel_extension', '.xlsx')
# 处理性能配置
self.max_workers = self.config.getint('Performance', 'max_workers', 4)
self.batch_size = self.config.getint('Performance', 'batch_size', 5)
self.skip_existing = self.config.getboolean('Performance', 'skip_existing', True)
# 初始化处理记录管理器
record_file = self.config.get('Paths', 'processed_record', 'data/processed_files.json')
self.record_manager = ProcessedRecordManager(record_file)
logger.info(f"OCR处理器初始化完成输入目录: {self.input_folder}, 输出目录: {self.output_folder}")
def get_unprocessed_images(self) -> List[str]:
"""
获取未处理的图片列表
Returns:
未处理的图片文件路径列表
"""
# 获取所有图片文件
image_files = get_files_by_extensions(self.input_folder, self.allowed_extensions)
# 如果需要跳过已存在的文件
if self.skip_existing:
# 过滤已处理的文件
unprocessed_files = self.record_manager.get_unprocessed_files(image_files)
logger.info(f"找到 {len(image_files)} 个图片文件,其中 {len(unprocessed_files)} 个未处理")
return unprocessed_files
logger.info(f"找到 {len(image_files)} 个图片文件(不跳过已处理的文件)")
return image_files
def validate_image(self, image_path: str) -> bool:
"""
验证图片是否有效
Args:
image_path: 图片文件路径
Returns:
图片是否有效
"""
# 检查文件是否存在
if not os.path.exists(image_path):
logger.warning(f"图片文件不存在: {image_path}")
return False
# 检查文件扩展名
ext = get_file_extension(image_path)
if ext not in self.allowed_extensions:
logger.warning(f"不支持的文件类型: {ext}, 文件: {image_path}")
return False
# 检查文件大小
if not is_file_size_valid(image_path, self.max_file_size_mb):
logger.warning(f"文件大小超过限制 ({self.max_file_size_mb}MB): {image_path}")
return False
return True
def process_image(self, image_path: str) -> Optional[str]:
"""
处理单个图片
Args:
image_path: 图片文件路径
Returns:
输出Excel文件路径如果处理失败则返回None
"""
# 验证图片
if not self.validate_image(image_path):
return None
# 如果需要跳过已处理的文件
if self.skip_existing and self.record_manager.is_processed(image_path):
output_file = self.record_manager.get_output_file(image_path)
logger.info(f"图片已处理,跳过: {image_path}, 输出文件: {output_file}")
return output_file
logger.info(f"开始处理图片: {image_path}")
try:
# 生成输出文件路径
file_name = os.path.splitext(os.path.basename(image_path))[0]
output_file = os.path.join(self.output_folder, f"{file_name}{self.excel_extension}")
# 检查是否已存在对应的Excel文件
if os.path.exists(output_file) and self.skip_existing:
logger.info(f"已存在对应的Excel文件跳过处理: {os.path.basename(image_path)} -> {os.path.basename(output_file)}")
# 记录处理结果
self.record_manager.mark_as_processed(image_path, output_file)
return output_file
# 进行OCR识别
ocr_result = self.ocr_client.recognize_table(image_path)
if not ocr_result:
logger.error(f"OCR识别失败: {image_path}")
return None
# 保存Excel文件 - 按照v1版本逻辑提取Excel数据
excel_base64 = None
# 从不同可能的字段中尝试获取Excel数据
if 'excel_file' in ocr_result:
excel_base64 = ocr_result['excel_file']
logger.debug("从excel_file字段获取Excel数据")
elif 'result' in ocr_result:
if 'result_data' in ocr_result['result']:
excel_base64 = ocr_result['result']['result_data']
logger.debug("从result.result_data字段获取Excel数据")
elif 'excel_file' in ocr_result['result']:
excel_base64 = ocr_result['result']['excel_file']
logger.debug("从result.excel_file字段获取Excel数据")
elif 'tables_result' in ocr_result['result'] and ocr_result['result']['tables_result']:
for table in ocr_result['result']['tables_result']:
if 'excel_file' in table:
excel_base64 = table['excel_file']
logger.debug("从tables_result中获取Excel数据")
break
# 如果还是没有找到Excel数据尝试通过get_excel_result获取
if not excel_base64:
logger.info("无法从直接返回中获取Excel数据尝试通过API获取...")
excel_data = self.ocr_client.get_excel_result(ocr_result)
if not excel_data:
logger.error(f"获取Excel结果失败: {image_path}")
return None
# 保存Excel文件
os.makedirs(os.path.dirname(output_file), exist_ok=True)
with open(output_file, 'wb') as f:
f.write(excel_data)
else:
# 解码并保存Excel文件
try:
excel_data = base64.b64decode(excel_base64)
os.makedirs(os.path.dirname(output_file), exist_ok=True)
with open(output_file, 'wb') as f:
f.write(excel_data)
except Exception as e:
logger.error(f"解码或保存Excel数据时出错: {e}")
return None
logger.info(f"图片处理成功: {image_path}, 输出文件: {output_file}")
# 标记为已处理
self.record_manager.mark_as_processed(image_path, output_file)
return output_file
except Exception as e:
logger.error(f"处理图片时出错: {image_path}, 错误: {e}")
return None
def process_images_batch(self, batch_size: int = None, max_workers: int = None) -> Tuple[int, int]:
"""
批量处理图片
Args:
batch_size: 批处理大小如果为None则使用配置值
max_workers: 最大线程数如果为None则使用配置值
Returns:
(总处理数, 成功处理数)元组
"""
# 使用配置值或参数值
batch_size = batch_size or self.batch_size
max_workers = max_workers or self.max_workers
# 获取未处理的图片
unprocessed_images = self.get_unprocessed_images()
if not unprocessed_images:
logger.warning("没有需要处理的图片")
return 0, 0
total = len(unprocessed_images)
success = 0
# 按批次处理
for i in range(0, total, batch_size):
batch = unprocessed_images[i:i + batch_size]
logger.info(f"处理批次 {i//batch_size + 1}/{(total-1)//batch_size + 1}, 大小: {len(batch)}")
# 使用线程池并行处理
with ThreadPoolExecutor(max_workers=max_workers) as executor:
results = list(executor.map(self.process_image, batch))
# 统计成功数
success += sum(1 for result in results if result is not None)
logger.info(f"批次处理完成, 成功: {sum(1 for result in results if result is not None)}/{len(batch)}")
logger.info(f"所有图片处理完成, 总计: {total}, 成功: {success}")
return total, success

View File

@ -0,0 +1,5 @@
"""
OCR订单处理系统 - 工具模块
------------------------
提供系统通用工具和辅助函数
"""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,251 @@
"""
文件操作工具模块
--------------
提供文件处理查找和管理功能
"""
import os
import sys
import shutil
import json
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Union, Any
from .log_utils import get_logger
logger = get_logger(__name__)
def ensure_dir(directory: str) -> bool:
"""
确保目录存在如果不存在则创建
Args:
directory: 目录路径
Returns:
是否成功创建或目录已存在
"""
try:
os.makedirs(directory, exist_ok=True)
return True
except Exception as e:
logger.error(f"创建目录失败: {directory}, 错误: {e}")
return False
def get_file_extension(file_path: str) -> str:
"""
获取文件扩展名小写
Args:
file_path: 文件路径
Returns:
文件扩展名包含点例如 .jpg
"""
return os.path.splitext(file_path)[1].lower()
def is_valid_extension(file_path: str, allowed_extensions: List[str]) -> bool:
"""
检查文件扩展名是否在允许的列表中
Args:
file_path: 文件路径
allowed_extensions: 允许的扩展名列表例如 ['.jpg', '.png']
Returns:
文件扩展名是否有效
"""
ext = get_file_extension(file_path)
return ext in allowed_extensions
def get_files_by_extensions(directory: str, extensions: List[str], exclude_patterns: List[str] = None) -> List[str]:
"""
获取指定目录下所有符合扩展名的文件路径
Args:
directory: 目录路径
extensions: 扩展名列表例如 ['.jpg', '.png']
exclude_patterns: 排除的文件名模式例如 ['~$', '.tmp']
Returns:
文件路径列表
"""
if exclude_patterns is None:
exclude_patterns = ['~$', '.tmp']
files = []
for file in os.listdir(directory):
file_path = os.path.join(directory, file)
# 检查是否是文件
if not os.path.isfile(file_path):
continue
# 检查扩展名
if not is_valid_extension(file_path, extensions):
continue
# 检查排除模式
exclude = False
for pattern in exclude_patterns:
if pattern in file:
exclude = True
break
if not exclude:
files.append(file_path)
return files
def get_latest_file(directory: str, pattern: str = "", extensions: List[str] = None) -> Optional[str]:
"""
获取指定目录下最新的文件
Args:
directory: 目录路径
pattern: 文件名包含的字符串模式
extensions: 限制的文件扩展名列表
Returns:
最新文件的路径如果没有找到则返回None
"""
if not os.path.exists(directory):
logger.warning(f"目录不存在: {directory}")
return None
files = []
for file in os.listdir(directory):
# 检查模式和扩展名
if (pattern and pattern not in file) or \
(extensions and not is_valid_extension(file, extensions)):
continue
file_path = os.path.join(directory, file)
if os.path.isfile(file_path):
files.append((file_path, os.path.getmtime(file_path)))
if not files:
logger.warning(f"未在目录 {directory} 中找到符合条件的文件")
return None
# 按修改时间排序,返回最新的
sorted_files = sorted(files, key=lambda x: x[1], reverse=True)
return sorted_files[0][0]
def generate_timestamp_filename(original_path: str) -> str:
"""
生成基于时间戳的文件名
Args:
original_path: 原始文件路径
Returns:
带时间戳的新文件路径
"""
dir_path = os.path.dirname(original_path)
ext = os.path.splitext(original_path)[1]
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
return os.path.join(dir_path, f"{timestamp}{ext}")
def rename_file(source_path: str, target_path: str) -> bool:
"""
重命名文件
Args:
source_path: 源文件路径
target_path: 目标文件路径
Returns:
是否成功重命名
"""
try:
# 确保目标目录存在
target_dir = os.path.dirname(target_path)
ensure_dir(target_dir)
# 重命名文件
os.rename(source_path, target_path)
logger.info(f"文件已重命名: {os.path.basename(source_path)} -> {os.path.basename(target_path)}")
return True
except Exception as e:
logger.error(f"重命名文件失败: {e}")
return False
def load_json(file_path: str, default: Any = None) -> Any:
"""
加载JSON文件
Args:
file_path: JSON文件路径
default: 如果文件不存在或加载失败时返回的默认值
Returns:
JSON内容或者默认值
"""
if not os.path.exists(file_path):
return default
try:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"加载JSON文件失败: {file_path}, 错误: {e}")
return default
def save_json(data: Any, file_path: str, ensure_ascii: bool = False, indent: int = 2) -> bool:
"""
保存数据到JSON文件
Args:
data: 要保存的数据
file_path: JSON文件路径
ensure_ascii: 是否确保ASCII编码
indent: 缩进空格数
Returns:
是否成功保存
"""
try:
# 确保目录存在
directory = os.path.dirname(file_path)
ensure_dir(directory)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=ensure_ascii, indent=indent)
logger.debug(f"JSON数据已保存到: {file_path}")
return True
except Exception as e:
logger.error(f"保存JSON文件失败: {file_path}, 错误: {e}")
return False
def get_file_size(file_path: str) -> int:
"""
获取文件大小字节
Args:
file_path: 文件路径
Returns:
文件大小字节
"""
try:
return os.path.getsize(file_path)
except Exception as e:
logger.error(f"获取文件大小失败: {file_path}, 错误: {e}")
return 0
def is_file_size_valid(file_path: str, max_size_mb: float) -> bool:
"""
检查文件大小是否在允许范围内
Args:
file_path: 文件路径
max_size_mb: 最大允许大小MB
Returns:
文件大小是否有效
"""
size_bytes = get_file_size(file_path)
max_size_bytes = max_size_mb * 1024 * 1024
return size_bytes <= max_size_bytes

129
app/core/utils/log_utils.py Normal file
View File

@ -0,0 +1,129 @@
"""
日志工具模块
----------
提供统一的日志配置和管理功能
"""
import os
import sys
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict
# 日志处理器字典,用于跟踪已创建的处理器
_handlers: Dict[str, logging.Handler] = {}
def setup_logger(name: str,
log_file: Optional[str] = None,
level=logging.INFO,
console_output: bool = True,
file_output: bool = True,
log_format: str = '%(asctime)s - %(name)s - %(levelname)s - %(message)s') -> logging.Logger:
"""
配置并返回日志记录器
Args:
name: 日志记录器的名称
log_file: 日志文件路径如果为None则使用默认路径
level: 日志级别
console_output: 是否输出到控制台
file_output: 是否输出到文件
log_format: 日志格式
Returns:
配置好的日志记录器
"""
# 获取或创建日志记录器
logger = logging.getLogger(name)
# 如果已经配置过处理器,不重复配置
if logger.handlers:
return logger
# 设置日志级别
logger.setLevel(level)
# 创建格式化器
formatter = logging.Formatter(log_format)
# 如果需要输出到文件
if file_output:
# 如果没有指定日志文件,使用默认路径
if log_file is None:
log_dir = os.path.abspath('logs')
# 确保日志目录存在
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, f"{name}.log")
# 创建文件处理器
try:
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setFormatter(formatter)
file_handler.setLevel(level)
logger.addHandler(file_handler)
_handlers[f"{name}_file"] = file_handler
# 记录活跃标记,避免被日志清理工具删除
active_marker = os.path.join(os.path.dirname(log_file), f"{name}.active")
with open(active_marker, 'w', encoding='utf-8') as f:
f.write(f"Active since: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
except Exception as e:
print(f"无法创建日志文件处理器: {e}")
# 如果需要输出到控制台
if console_output:
# 创建控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
console_handler.setLevel(level)
logger.addHandler(console_handler)
_handlers[f"{name}_console"] = console_handler
return logger
def get_logger(name: str) -> logging.Logger:
"""
获取已配置的日志记录器如果不存在则创建一个新的
Args:
name: 日志记录器的名称
Returns:
日志记录器
"""
logger = logging.getLogger(name)
if not logger.handlers:
return setup_logger(name)
return logger
def close_logger(name: str) -> None:
"""
关闭日志记录器的所有处理器
Args:
name: 日志记录器的名称
"""
logger = logging.getLogger(name)
for handler in logger.handlers[:]:
handler.close()
logger.removeHandler(handler)
# 清除处理器缓存
_handlers.pop(f"{name}_file", None)
_handlers.pop(f"{name}_console", None)
def cleanup_active_marker(name: str) -> None:
"""
清理日志活跃标记
Args:
name: 日志记录器的名称
"""
try:
log_dir = os.path.abspath('logs')
active_marker = os.path.join(log_dir, f"{name}.active")
if os.path.exists(active_marker):
os.remove(active_marker)
except Exception as e:
print(f"无法清理日志活跃标记: {e}")

View File

@ -0,0 +1,207 @@
"""
字符串处理工具模块
---------------
提供字符串处理正则表达式匹配等功能
"""
import re
from typing import Dict, List, Optional, Tuple, Any, Match, Pattern
def clean_string(text: str) -> str:
"""
清理字符串移除多余空白
Args:
text: 源字符串
Returns:
清理后的字符串
"""
if not isinstance(text, str):
return ""
# 移除首尾空白
text = text.strip()
# 移除多余空白
text = re.sub(r'\s+', ' ', text)
return text
def remove_non_digits(text: str) -> str:
"""
移除字符串中的非数字字符
Args:
text: 源字符串
Returns:
只包含数字的字符串
"""
if not isinstance(text, str):
return ""
return re.sub(r'\D', '', text)
def extract_number(text: str) -> Optional[float]:
"""
从字符串中提取数字
Args:
text: 源字符串
Returns:
提取的数字如果没有则返回None
"""
if not isinstance(text, str):
return None
# 匹配数字(可以包含小数点和负号)
match = re.search(r'-?\d+(\.\d+)?', text)
if match:
return float(match.group())
return None
def extract_unit(text: str, units: List[str] = None) -> Optional[str]:
"""
从字符串中提取单位
Args:
text: 源字符串
units: 有效单位列表如果为None则自动识别
Returns:
提取的单位如果没有则返回None
"""
if not isinstance(text, str):
return None
# 如果提供了单位列表,检查字符串中是否包含
if units:
for unit in units:
if unit in text:
return unit
return None
# 否则,尝试自动识别常见单位
# 正则表达式:匹配数字后面的非数字部分作为单位
match = re.search(r'\d+\s*([^\d\s]+)', text)
if match:
return match.group(1)
return None
def extract_number_and_unit(text: str) -> Tuple[Optional[float], Optional[str]]:
"""
从字符串中同时提取数字和单位
Args:
text: 源字符串
Returns:
(数字, 单位)元组如果没有则对应返回None
"""
if not isinstance(text, str):
return None, None
# 匹配数字和单位的组合
match = re.search(r'(-?\d+(?:\.\d+)?)\s*([^\d\s]+)?', text)
if match:
number = float(match.group(1))
unit = match.group(2) if match.group(2) else None
return number, unit
return None, None
def parse_specification(spec_str: str) -> Optional[int]:
"""
解析规格字符串提取包装数量
支持格式1*15, 1x15, 1*5*10
Args:
spec_str: 规格字符串
Returns:
包装数量如果无法解析则返回None
"""
if not spec_str or not isinstance(spec_str, str):
return None
try:
# 清理规格字符串
spec_str = clean_string(spec_str)
# 匹配1*5*10 格式的三级规格
match = re.search(r'(\d+)[\*xX×](\d+)[\*xX×](\d+)', spec_str)
if match:
# 取最后一个数字作为袋数量
return int(match.group(3))
# 匹配1*15, 1x15 格式
match = re.search(r'(\d+)[\*xX×](\d+)', spec_str)
if match:
# 取第二个数字作为包装数量
return int(match.group(2))
# 匹配24瓶/件等格式
match = re.search(r'(\d+)[瓶个支袋][/](件|箱)', spec_str)
if match:
return int(match.group(1))
# 匹配4L格式
match = re.search(r'(\d+(?:\.\d+)?)\s*[Ll升][*×]?(\d+)?', spec_str)
if match:
# 如果有第二个数字返回它否则返回1
return int(match.group(2)) if match.group(2) else 1
except Exception:
pass
return None
def clean_barcode(barcode: Any) -> str:
"""
清理条码格式
Args:
barcode: 条码可以是字符串整数或浮点数
Returns:
清理后的条码字符串
"""
if isinstance(barcode, (int, float)):
barcode = f"{barcode:.0f}"
# 清理条码格式,移除可能的非数字字符(包括小数点)
barcode_clean = re.sub(r'\.0+$', '', str(barcode)) # 移除末尾0
barcode_clean = re.sub(r'\D', '', barcode_clean) # 只保留数字
return barcode_clean
def is_scientific_notation(value: str) -> bool:
"""
检查字符串是否是科学计数法表示
Args:
value: 字符串值
Returns:
是否是科学计数法
"""
return bool(re.match(r'^-?\d+(\.\d+)?[eE][+-]?\d+$', str(value)))
def format_barcode(barcode: Any) -> str:
"""
格式化条码处理科学计数法
Args:
barcode: 条码值
Returns:
格式化后的条码字符串
"""
if isinstance(barcode, (int, float)) or is_scientific_notation(str(barcode)):
try:
# 转换为整数并格式化为字符串
return f"{int(float(barcode))}"
except (ValueError, TypeError):
pass
# 如果不是数字或转换失败,返回原始字符串
return str(barcode)

5
app/services/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""
OCR订单处理系统 - 服务模块
-----------------------
提供业务逻辑服务协调各个核心组件完成业务功能
"""

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,88 @@
"""
OCR服务模块
---------
提供OCR识别服务协调OCR流程
"""
from typing import Dict, List, Optional, Tuple, Union, Any
from ..config.settings import ConfigManager
from ..core.utils.log_utils import get_logger
from ..core.ocr.table_ocr import OCRProcessor
logger = get_logger(__name__)
class OCRService:
"""
OCR识别服务协调OCR流程
"""
def __init__(self, config: Optional[ConfigManager] = None):
"""
初始化OCR服务
Args:
config: 配置管理器如果为None则创建新的
"""
logger.info("初始化OCRService")
self.config = config or ConfigManager()
# 创建OCR处理器
self.ocr_processor = OCRProcessor(self.config)
logger.info("OCRService初始化完成")
def get_unprocessed_images(self) -> List[str]:
"""
获取待处理的图片列表
Returns:
待处理图片路径列表
"""
return self.ocr_processor.get_unprocessed_images()
def process_image(self, image_path: str) -> Optional[str]:
"""
处理单张图片
Args:
image_path: 图片路径
Returns:
输出Excel文件路径如果处理失败则返回None
"""
logger.info(f"OCRService开始处理图片: {image_path}")
result = self.ocr_processor.process_image(image_path)
if result:
logger.info(f"OCRService处理图片成功: {image_path} -> {result}")
else:
logger.error(f"OCRService处理图片失败: {image_path}")
return result
def process_images_batch(self, batch_size: int = None, max_workers: int = None) -> Tuple[int, int]:
"""
批量处理图片
Args:
batch_size: 批处理大小
max_workers: 最大线程数
Returns:
(总处理数, 成功处理数)元组
"""
logger.info(f"OCRService开始批量处理图片, batch_size={batch_size}, max_workers={max_workers}")
return self.ocr_processor.process_images_batch(batch_size, max_workers)
def validate_image(self, image_path: str) -> bool:
"""
验证图片是否有效
Args:
image_path: 图片路径
Returns:
图片是否有效
"""
return self.ocr_processor.validate_image(image_path)

View File

@ -0,0 +1,87 @@
"""
订单服务模块
---------
提供订单处理服务协调Excel处理和订单合并流程
"""
from typing import Dict, List, Optional, Tuple, Union, Any
from ..config.settings import ConfigManager
from ..core.utils.log_utils import get_logger
from ..core.excel.processor import ExcelProcessor
from ..core.excel.merger import PurchaseOrderMerger
logger = get_logger(__name__)
class OrderService:
"""
订单服务协调Excel处理和订单合并流程
"""
def __init__(self, config: Optional[ConfigManager] = None):
"""
初始化订单服务
Args:
config: 配置管理器如果为None则创建新的
"""
logger.info("初始化OrderService")
self.config = config or ConfigManager()
# 创建Excel处理器和采购单合并器
self.excel_processor = ExcelProcessor(self.config)
self.order_merger = PurchaseOrderMerger(self.config)
logger.info("OrderService初始化完成")
def get_latest_excel(self) -> Optional[str]:
"""
获取最新的Excel文件
Returns:
最新Excel文件路径如果未找到则返回None
"""
return self.excel_processor.get_latest_excel()
def process_excel(self, file_path: Optional[str] = None) -> Optional[str]:
"""
处理Excel文件生成采购单
Args:
file_path: Excel文件路径如果为None则处理最新的文件
Returns:
输出采购单文件路径如果处理失败则返回None
"""
if file_path:
logger.info(f"OrderService开始处理指定Excel文件: {file_path}")
return self.excel_processor.process_specific_file(file_path)
else:
logger.info("OrderService开始处理最新Excel文件")
return self.excel_processor.process_latest_file()
def get_purchase_orders(self) -> List[str]:
"""
获取采购单文件列表
Returns:
采购单文件路径列表
"""
return self.order_merger.get_purchase_orders()
def merge_orders(self, file_paths: Optional[List[str]] = None) -> Optional[str]:
"""
合并采购单
Args:
file_paths: 采购单文件路径列表如果为None则处理所有采购单
Returns:
合并后的采购单文件路径如果合并失败则返回None
"""
if file_paths:
logger.info(f"OrderService开始合并指定采购单: {file_paths}")
else:
logger.info("OrderService开始合并所有采购单")
return self.order_merger.process(file_paths)

24
config.ini Normal file
View File

@ -0,0 +1,24 @@
[API]
api_key = O0Fgk3o69RWJ86eAX8BTHRaB
secret_key = VyZD5lzcIMgsup1uuD6Cw0pfzS20IGPZ
timeout = 30
max_retries = 3
retry_delay = 2
api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table
[Paths]
input_folder = data/input
output_folder = data/output
temp_folder = data/temp
processed_record = data/processed_files.json
[Performance]
max_workers = 4
batch_size = 5
skip_existing = true
[File]
allowed_extensions = .jpg,.jpeg,.png,.bmp
excel_extension = .xlsx
max_file_size_mb = 4

Binary file not shown.

After

Width:  |  Height:  |  Size: 892 KiB

View File

@ -0,0 +1,3 @@
{
"D:\\My Documents\\python\\orc-order-v2\\data\\output\\微信图片_20250227193150(1).xlsx": "D:\\My Documents\\python\\orc-order-v2\\data\\output\\采购单_微信图片_20250227193150(1)_20250502171625.xls"
}

Binary file not shown.

1
logs/__main__.active Normal file
View File

@ -0,0 +1 @@
Active since: 2025-05-02 17:16:24

25
logs/__main__.log Normal file
View File

@ -0,0 +1,25 @@
2025-05-02 16:10:30,807 - __main__ - INFO - 批量处理模式
2025-05-02 16:10:30,814 - __main__ - WARNING - 没有找到需要处理的文件
2025-05-02 16:11:05,083 - __main__ - INFO - 批量处理模式
2025-05-02 16:11:05,090 - __main__ - INFO - 批量处理完成,总计: 1成功: 0
2025-05-02 16:15:14,543 - __main__ - INFO - 批量处理模式
2025-05-02 16:15:17,347 - __main__ - INFO - 批量处理完成,总计: 1成功: 0
2025-05-02 16:24:57,651 - __main__ - INFO - 批量处理模式
2025-05-02 16:25:00,387 - __main__ - INFO - 批量处理完成,总计: 1成功: 0
2025-05-02 16:34:26,014 - __main__ - INFO - === 流程步骤 1: OCR识别 ===
2025-05-02 16:34:26,014 - __main__ - INFO - 批量处理所有图片
2025-05-02 16:34:28,701 - __main__ - INFO - OCR处理完成总计: 1成功: 1
2025-05-02 16:34:28,702 - __main__ - INFO - === 流程步骤 2: Excel处理 ===
2025-05-02 16:34:28,703 - __main__ - INFO - 处理最新的Excel文件: D:\My Documents\python\orc-order-v2\output\微信图片_20250227193150(1).xlsx
2025-05-02 16:34:29,401 - __main__ - INFO - Excel处理成功输出文件: D:\My Documents\python\orc-order-v2\output\采购单_微信图片_20250227193150(1)_20250502163429.xls
2025-05-02 16:34:29,402 - __main__ - INFO - === 流程步骤 3: 订单合并 ===
2025-05-02 16:34:29,403 - __main__ - INFO - 合并所有采购单文件: 1 个
2025-05-02 16:34:29,411 - __main__ - ERROR - 订单合并失败
2025-05-02 16:55:26,481 - __main__ - INFO - 处理单个图片: D:\My Documents\python\orc-order-v2\data\input\微信图片_20250227193150(1).jpg
2025-05-02 16:55:29,254 - __main__ - INFO - OCR处理成功输出文件: D:\My Documents\python\orc-order-v2\output\微信图片_20250227193150(1).xlsx
2025-05-02 17:08:58,657 - __main__ - INFO - 处理单个图片: D:\My Documents\python\orc-order-v2\data\input\微信图片_20250227193150(1).jpg
2025-05-02 17:09:01,349 - __main__ - INFO - OCR处理成功输出文件: D:\My Documents\python\orc-order-v2\output\微信图片_20250227193150(1).xlsx
2025-05-02 17:10:09,224 - __main__ - INFO - 处理Excel文件: D:\My Documents\python\orc-order-v2\data\output\微信图片_20250227193150(1).xlsx
2025-05-02 17:10:09,825 - __main__ - INFO - Excel处理成功输出文件: D:\My Documents\python\orc-order-v2\output\采购单_微信图片_20250227193150(1)_20250502171009.xls
2025-05-02 17:16:24,478 - __main__ - INFO - 处理Excel文件: D:\My Documents\python\orc-order-v2\data\output\微信图片_20250227193150(1).xlsx
2025-05-02 17:16:25,037 - __main__ - INFO - Excel处理成功输出文件: D:\My Documents\python\orc-order-v2\data\output\采购单_微信图片_20250227193150(1)_20250502171625.xls

View File

@ -0,0 +1 @@
Active since: 2025-05-02 17:16:24

View File

@ -0,0 +1,30 @@
2025-05-02 16:10:30,802 - app.core.excel.converter - INFO - 单位转换器初始化完成
2025-05-02 16:11:05,079 - app.core.excel.converter - INFO - 单位转换器初始化完成
2025-05-02 16:15:14,539 - app.core.excel.converter - INFO - 单位转换器初始化完成
2025-05-02 16:24:57,644 - app.core.excel.converter - INFO - 单位转换器初始化完成
2025-05-02 16:34:26,013 - app.core.excel.converter - INFO - 单位转换器初始化完成
2025-05-02 16:34:29,377 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 16:34:29,378 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 16:34:29,378 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 16:34:29,379 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 16:34:29,380 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 16:34:29,380 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 16:34:29,380 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 16:55:26,480 - app.core.excel.converter - INFO - 单位转换器初始化完成
2025-05-02 17:08:58,655 - app.core.excel.converter - INFO - 单位转换器初始化完成
2025-05-02 17:10:09,223 - app.core.excel.converter - INFO - 单位转换器初始化完成
2025-05-02 17:10:09,805 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:10:09,805 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:10:09,806 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:10:09,806 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:10:09,807 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:10:09,807 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:10:09,807 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:16:24,477 - app.core.excel.converter - INFO - 单位转换器初始化完成
2025-05-02 17:16:25,023 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:16:25,024 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:16:25,024 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:16:25,024 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:16:25,025 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:16:25,025 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15
2025-05-02 17:16:25,025 - app.core.excel.converter - INFO - 标准单位转换: 件->瓶, 规格=1*15, 包装数量=15

View File

@ -0,0 +1 @@
Active since: 2025-05-02 17:16:24

View File

@ -0,0 +1,28 @@
2025-05-02 16:10:30,803 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger
2025-05-02 16:10:30,805 - app.core.excel.merger - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 16:11:05,080 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger
2025-05-02 16:11:05,082 - app.core.excel.merger - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 16:15:14,540 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger
2025-05-02 16:15:14,541 - app.core.excel.merger - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 16:24:57,646 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger
2025-05-02 16:24:57,648 - app.core.excel.merger - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 16:34:26,013 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger
2025-05-02 16:34:26,014 - app.core.excel.merger - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 16:34:29,402 - app.core.excel.merger - INFO - 搜索目录 D:\My Documents\python\orc-order-v2\output 中的采购单Excel文件
2025-05-02 16:34:29,403 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
2025-05-02 16:34:29,403 - app.core.excel.merger - INFO - 搜索目录 D:\My Documents\python\orc-order-v2\output 中的采购单Excel文件
2025-05-02 16:34:29,404 - app.core.excel.merger - INFO - 找到 1 个采购单Excel文件
2025-05-02 16:34:29,408 - app.core.excel.merger - INFO - 成功读取采购单文件: D:\My Documents\python\orc-order-v2\output\采购单_微信图片_20250227193150(1)_20250502163429.xls
2025-05-02 16:34:29,410 - app.core.excel.merger - INFO - 列名映射结果: {'条码': '条码(必填)', '采购量': '采购量(必填)', '采购单价': '采购单价(必填)', '赠送量': '赠送量'}
2025-05-02 16:34:29,410 - app.core.excel.merger - INFO - 开始合并 1 个采购单文件
2025-05-02 16:34:29,411 - app.core.excel.merger - WARNING - 数据帧 0 缺少必要的列: ['条码', '采购量', '采购单价']
2025-05-02 16:34:29,411 - app.core.excel.merger - WARNING - 没有有效的数据帧用于合并
2025-05-02 16:34:29,411 - app.core.excel.merger - ERROR - 合并采购单失败
2025-05-02 16:55:26,480 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger
2025-05-02 16:55:26,481 - app.core.excel.merger - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 17:08:58,655 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger
2025-05-02 17:08:58,656 - app.core.excel.merger - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 17:10:09,223 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger
2025-05-02 17:10:09,224 - app.core.excel.merger - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 17:16:24,477 - app.core.excel.merger - INFO - 初始化PurchaseOrderMerger
2025-05-02 17:16:24,478 - app.core.excel.merger - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls

View File

@ -0,0 +1 @@
Active since: 2025-05-02 17:16:24

View File

@ -0,0 +1,35 @@
2025-05-02 16:10:30,800 - app.core.excel.processor - INFO - 初始化ExcelProcessor
2025-05-02 16:10:30,803 - app.core.excel.processor - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 16:11:05,077 - app.core.excel.processor - INFO - 初始化ExcelProcessor
2025-05-02 16:11:05,079 - app.core.excel.processor - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 16:15:14,538 - app.core.excel.processor - INFO - 初始化ExcelProcessor
2025-05-02 16:15:14,539 - app.core.excel.processor - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 16:24:57,642 - app.core.excel.processor - INFO - 初始化ExcelProcessor
2025-05-02 16:24:57,644 - app.core.excel.processor - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 16:34:26,012 - app.core.excel.processor - INFO - 初始化ExcelProcessor
2025-05-02 16:34:26,013 - app.core.excel.processor - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 16:34:28,702 - app.core.excel.processor - INFO - 搜索目录 D:\My Documents\python\orc-order-v2\output 中的Excel文件
2025-05-02 16:34:28,702 - app.core.excel.processor - INFO - 找到最新的Excel文件: D:\My Documents\python\orc-order-v2\output\微信图片_20250227193150(1).xlsx
2025-05-02 16:34:28,703 - app.core.excel.processor - INFO - 开始处理Excel文件: D:\My Documents\python\orc-order-v2\output\微信图片_20250227193150(1).xlsx
2025-05-02 16:34:29,373 - app.core.excel.processor - INFO - 成功读取Excel文件: D:\My Documents\python\orc-order-v2\output\微信图片_20250227193150(1).xlsx, 共 11 行
2025-05-02 16:34:29,373 - app.core.excel.processor - INFO - 列名映射结果: {'barcode': '条码', 'specification': '规格', 'quantity': '数量', 'unit': '单位', 'price': '单价'}
2025-05-02 16:34:29,380 - app.core.excel.processor - INFO - 提取到 8 个商品信息
2025-05-02 16:34:29,399 - app.core.excel.processor - INFO - 采购单已保存到: D:\My Documents\python\orc-order-v2\output\采购单_微信图片_20250227193150(1)_20250502163429.xls
2025-05-02 16:55:26,479 - app.core.excel.processor - INFO - 初始化ExcelProcessor
2025-05-02 16:55:26,480 - app.core.excel.processor - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 17:08:58,654 - app.core.excel.processor - INFO - 初始化ExcelProcessor
2025-05-02 17:08:58,655 - app.core.excel.processor - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 17:10:09,222 - app.core.excel.processor - INFO - 初始化ExcelProcessor
2025-05-02 17:10:09,223 - app.core.excel.processor - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 17:10:09,225 - app.core.excel.processor - INFO - 开始处理Excel文件: D:\My Documents\python\orc-order-v2\data\output\微信图片_20250227193150(1).xlsx
2025-05-02 17:10:09,803 - app.core.excel.processor - INFO - 成功读取Excel文件: D:\My Documents\python\orc-order-v2\data\output\微信图片_20250227193150(1).xlsx, 共 11 行
2025-05-02 17:10:09,803 - app.core.excel.processor - INFO - 列名映射结果: {'barcode': '条码', 'specification': '规格', 'quantity': '数量', 'unit': '单位', 'price': '单价'}
2025-05-02 17:10:09,808 - app.core.excel.processor - INFO - 提取到 8 个商品信息
2025-05-02 17:10:09,823 - app.core.excel.processor - INFO - 采购单已保存到: D:\My Documents\python\orc-order-v2\output\采购单_微信图片_20250227193150(1)_20250502171009.xls
2025-05-02 17:16:24,476 - app.core.excel.processor - INFO - 初始化ExcelProcessor
2025-05-02 17:16:24,477 - app.core.excel.processor - INFO - 初始化完成,模板文件: templates\银豹-采购单模板.xls
2025-05-02 17:16:24,478 - app.core.excel.processor - INFO - 开始处理Excel文件: D:\My Documents\python\orc-order-v2\data\output\微信图片_20250227193150(1).xlsx
2025-05-02 17:16:25,022 - app.core.excel.processor - INFO - 成功读取Excel文件: D:\My Documents\python\orc-order-v2\data\output\微信图片_20250227193150(1).xlsx, 共 11 行
2025-05-02 17:16:25,022 - app.core.excel.processor - INFO - 列名映射结果: {'barcode': '条码', 'specification': '规格', 'quantity': '数量', 'unit': '单位', 'price': '单价'}
2025-05-02 17:16:25,025 - app.core.excel.processor - INFO - 提取到 8 个商品信息
2025-05-02 17:16:25,035 - app.core.excel.processor - INFO - 采购单已保存到: D:\My Documents\python\orc-order-v2\data\output\采购单_微信图片_20250227193150(1)_20250502171625.xls

View File

@ -0,0 +1 @@
Active since: 2025-05-02 17:16:23

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
Active since: 2025-05-02 17:16:23

View File

@ -0,0 +1,53 @@
2025-05-02 16:10:30,794 - app.core.ocr.table_ocr - INFO - OCR处理器初始化完成输入目录: D:\My Documents\python\orc-order-v2\input, 输出目录: D:\My Documents\python\orc-order-v2\output
2025-05-02 16:10:30,809 - app.core.ocr.table_ocr - INFO - 找到 0 个图片文件,其中 0 个未处理
2025-05-02 16:10:30,810 - app.core.ocr.table_ocr - WARNING - 没有需要处理的图片
2025-05-02 16:11:05,075 - app.core.ocr.table_ocr - INFO - OCR处理器初始化完成输入目录: D:\My Documents\python\orc-order-v2\input, 输出目录: D:\My Documents\python\orc-order-v2\output
2025-05-02 16:11:05,085 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
2025-05-02 16:11:05,086 - app.core.ocr.table_ocr - INFO - 处理批次 1/1, 大小: 1
2025-05-02 16:11:05,088 - app.core.ocr.table_ocr - WARNING - 文件大小超过限制 (4.0MB): D:\My Documents\python\orc-order-v2\input\微信图片_20250227193150.jpg
2025-05-02 16:11:05,089 - app.core.ocr.table_ocr - INFO - 批次处理完成, 成功: 0/1
2025-05-02 16:11:05,089 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 0
2025-05-02 16:15:14,536 - app.core.ocr.table_ocr - INFO - OCR处理器初始化完成输入目录: D:\My Documents\python\orc-order-v2\input, 输出目录: D:\My Documents\python\orc-order-v2\output
2025-05-02 16:15:14,547 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
2025-05-02 16:15:14,548 - app.core.ocr.table_ocr - INFO - 处理批次 1/1, 大小: 1
2025-05-02 16:15:14,550 - app.core.ocr.table_ocr - INFO - 开始处理图片: D:\My Documents\python\orc-order-v2\input\微信图片_20250227193150(1).jpg
2025-05-02 16:15:17,344 - app.core.ocr.table_ocr - ERROR - 无法获取请求ID: D:\My Documents\python\orc-order-v2\input\微信图片_20250227193150(1).jpg
2025-05-02 16:15:17,345 - app.core.ocr.table_ocr - INFO - 批次处理完成, 成功: 0/1
2025-05-02 16:15:17,346 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 0
2025-05-02 16:24:57,641 - app.core.ocr.table_ocr - INFO - OCR处理器初始化完成输入目录: D:\My Documents\python\orc-order-v2\input, 输出目录: D:\My Documents\python\orc-order-v2\output
2025-05-02 16:24:57,653 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
2025-05-02 16:24:57,653 - app.core.ocr.table_ocr - INFO - 处理批次 1/1, 大小: 1
2025-05-02 16:24:57,655 - app.core.ocr.table_ocr - INFO - 开始处理图片: D:\My Documents\python\orc-order-v2\input\微信图片_20250227193150(1).jpg
2025-05-02 16:25:00,385 - app.core.ocr.table_ocr - ERROR - 获取Excel结果失败: D:\My Documents\python\orc-order-v2\input\微信图片_20250227193150(1).jpg
2025-05-02 16:25:00,386 - app.core.ocr.table_ocr - INFO - 批次处理完成, 成功: 0/1
2025-05-02 16:25:00,387 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 0
2025-05-02 16:34:26,010 - app.core.ocr.table_ocr - INFO - 使用输入目录: D:\My Documents\python\orc-order-v2\input
2025-05-02 16:34:26,011 - app.core.ocr.table_ocr - INFO - 使用输出目录: D:\My Documents\python\orc-order-v2\output
2025-05-02 16:34:26,011 - app.core.ocr.table_ocr - INFO - 使用临时目录: D:\My Documents\python\orc-order-v2\temp
2025-05-02 16:34:26,011 - app.core.ocr.table_ocr - INFO - OCR处理器初始化完成输入目录: D:\My Documents\python\orc-order-v2\input, 输出目录: D:\My Documents\python\orc-order-v2\output
2025-05-02 16:34:26,015 - app.core.ocr.table_ocr - INFO - 找到 1 个图片文件,其中 1 个未处理
2025-05-02 16:34:26,015 - app.core.ocr.table_ocr - INFO - 处理批次 1/1, 大小: 1
2025-05-02 16:34:26,016 - app.core.ocr.table_ocr - INFO - 开始处理图片: D:\My Documents\python\orc-order-v2\input\微信图片_20250227193150(1).jpg
2025-05-02 16:34:28,698 - app.core.ocr.table_ocr - INFO - 图片处理成功: D:\My Documents\python\orc-order-v2\input\微信图片_20250227193150(1).jpg, 输出文件: D:\My Documents\python\orc-order-v2\output\微信图片_20250227193150(1).xlsx
2025-05-02 16:34:28,701 - app.core.ocr.table_ocr - INFO - 批次处理完成, 成功: 1/1
2025-05-02 16:34:28,701 - app.core.ocr.table_ocr - INFO - 所有图片处理完成, 总计: 1, 成功: 1
2025-05-02 16:55:26,477 - app.core.ocr.table_ocr - INFO - 使用输入目录: D:\My Documents\python\orc-order-v2\input
2025-05-02 16:55:26,478 - app.core.ocr.table_ocr - INFO - 使用输出目录: D:\My Documents\python\orc-order-v2\output
2025-05-02 16:55:26,478 - app.core.ocr.table_ocr - INFO - 使用临时目录: D:\My Documents\python\orc-order-v2\temp
2025-05-02 16:55:26,479 - app.core.ocr.table_ocr - INFO - OCR处理器初始化完成输入目录: D:\My Documents\python\orc-order-v2\input, 输出目录: D:\My Documents\python\orc-order-v2\output
2025-05-02 16:55:26,481 - app.core.ocr.table_ocr - INFO - 开始处理图片: D:\My Documents\python\orc-order-v2\data\input\微信图片_20250227193150(1).jpg
2025-05-02 16:55:29,252 - app.core.ocr.table_ocr - INFO - 图片处理成功: D:\My Documents\python\orc-order-v2\data\input\微信图片_20250227193150(1).jpg, 输出文件: D:\My Documents\python\orc-order-v2\output\微信图片_20250227193150(1).xlsx
2025-05-02 17:08:58,652 - app.core.ocr.table_ocr - INFO - 使用输入目录: D:\My Documents\python\orc-order-v2\input
2025-05-02 17:08:58,653 - app.core.ocr.table_ocr - INFO - 使用输出目录: D:\My Documents\python\orc-order-v2\output
2025-05-02 17:08:58,653 - app.core.ocr.table_ocr - INFO - 使用临时目录: D:\My Documents\python\orc-order-v2\temp
2025-05-02 17:08:58,653 - app.core.ocr.table_ocr - INFO - OCR处理器初始化完成输入目录: D:\My Documents\python\orc-order-v2\input, 输出目录: D:\My Documents\python\orc-order-v2\output
2025-05-02 17:08:58,657 - app.core.ocr.table_ocr - INFO - 开始处理图片: D:\My Documents\python\orc-order-v2\data\input\微信图片_20250227193150(1).jpg
2025-05-02 17:09:01,343 - app.core.ocr.table_ocr - INFO - 图片处理成功: D:\My Documents\python\orc-order-v2\data\input\微信图片_20250227193150(1).jpg, 输出文件: D:\My Documents\python\orc-order-v2\output\微信图片_20250227193150(1).xlsx
2025-05-02 17:10:09,220 - app.core.ocr.table_ocr - INFO - 使用输入目录: D:\My Documents\python\orc-order-v2\input
2025-05-02 17:10:09,221 - app.core.ocr.table_ocr - INFO - 使用输出目录: D:\My Documents\python\orc-order-v2\output
2025-05-02 17:10:09,221 - app.core.ocr.table_ocr - INFO - 使用临时目录: D:\My Documents\python\orc-order-v2\temp
2025-05-02 17:10:09,222 - app.core.ocr.table_ocr - INFO - OCR处理器初始化完成输入目录: D:\My Documents\python\orc-order-v2\input, 输出目录: D:\My Documents\python\orc-order-v2\output
2025-05-02 17:16:24,475 - app.core.ocr.table_ocr - INFO - 使用输入目录: D:\My Documents\python\orc-order-v2\data\input
2025-05-02 17:16:24,475 - app.core.ocr.table_ocr - INFO - 使用输出目录: D:\My Documents\python\orc-order-v2\data\output
2025-05-02 17:16:24,475 - app.core.ocr.table_ocr - INFO - 使用临时目录: D:\My Documents\python\orc-order-v2\data\temp
2025-05-02 17:16:24,475 - app.core.ocr.table_ocr - INFO - OCR处理器初始化完成输入目录: D:\My Documents\python\orc-order-v2\data\input, 输出目录: D:\My Documents\python\orc-order-v2\data\output

View File

@ -0,0 +1 @@
Active since: 2025-05-02 17:16:23

View File

@ -0,0 +1,3 @@
2025-05-02 16:34:28,699 - app.core.utils.file_utils - ERROR - 创建目录失败: , 错误: [WinError 3] 系统找不到指定的路径。: ''
2025-05-02 16:55:29,252 - app.core.utils.file_utils - ERROR - 创建目录失败: , 错误: [WinError 3] 系统找不到指定的路径。: ''
2025-05-02 17:09:01,344 - app.core.utils.file_utils - ERROR - 创建目录失败: , 错误: [WinError 3] 系统找不到指定的路径。: ''

View File

@ -0,0 +1 @@
Active since: 2025-05-02 17:16:23

View File

@ -0,0 +1,27 @@
2025-05-02 16:10:30,792 - app.services.ocr_service - INFO - 初始化OCRService
2025-05-02 16:10:30,795 - app.services.ocr_service - INFO - OCRService初始化完成
2025-05-02 16:10:30,808 - app.services.ocr_service - INFO - OCRService开始批量处理图片, batch_size=None, max_workers=None
2025-05-02 16:11:05,074 - app.services.ocr_service - INFO - 初始化OCRService
2025-05-02 16:11:05,076 - app.services.ocr_service - INFO - OCRService初始化完成
2025-05-02 16:11:05,084 - app.services.ocr_service - INFO - OCRService开始批量处理图片, batch_size=None, max_workers=None
2025-05-02 16:15:14,534 - app.services.ocr_service - INFO - 初始化OCRService
2025-05-02 16:15:14,537 - app.services.ocr_service - INFO - OCRService初始化完成
2025-05-02 16:15:14,543 - app.services.ocr_service - INFO - OCRService开始批量处理图片, batch_size=None, max_workers=None
2025-05-02 16:24:57,640 - app.services.ocr_service - INFO - 初始化OCRService
2025-05-02 16:24:57,641 - app.services.ocr_service - INFO - OCRService初始化完成
2025-05-02 16:24:57,651 - app.services.ocr_service - INFO - OCRService开始批量处理图片, batch_size=None, max_workers=None
2025-05-02 16:34:26,008 - app.services.ocr_service - INFO - 初始化OCRService
2025-05-02 16:34:26,011 - app.services.ocr_service - INFO - OCRService初始化完成
2025-05-02 16:34:26,015 - app.services.ocr_service - INFO - OCRService开始批量处理图片, batch_size=None, max_workers=None
2025-05-02 16:55:26,474 - app.services.ocr_service - INFO - 初始化OCRService
2025-05-02 16:55:26,479 - app.services.ocr_service - INFO - OCRService初始化完成
2025-05-02 16:55:26,481 - app.services.ocr_service - INFO - OCRService开始处理图片: D:\My Documents\python\orc-order-v2\data\input\微信图片_20250227193150(1).jpg
2025-05-02 16:55:29,254 - app.services.ocr_service - INFO - OCRService处理图片成功: D:\My Documents\python\orc-order-v2\data\input\微信图片_20250227193150(1).jpg -> D:\My Documents\python\orc-order-v2\output\微信图片_20250227193150(1).xlsx
2025-05-02 17:08:58,648 - app.services.ocr_service - INFO - 初始化OCRService
2025-05-02 17:08:58,653 - app.services.ocr_service - INFO - OCRService初始化完成
2025-05-02 17:08:58,657 - app.services.ocr_service - INFO - OCRService开始处理图片: D:\My Documents\python\orc-order-v2\data\input\微信图片_20250227193150(1).jpg
2025-05-02 17:09:01,348 - app.services.ocr_service - INFO - OCRService处理图片成功: D:\My Documents\python\orc-order-v2\data\input\微信图片_20250227193150(1).jpg -> D:\My Documents\python\orc-order-v2\output\微信图片_20250227193150(1).xlsx
2025-05-02 17:10:09,219 - app.services.ocr_service - INFO - 初始化OCRService
2025-05-02 17:10:09,222 - app.services.ocr_service - INFO - OCRService初始化完成
2025-05-02 17:16:24,474 - app.services.ocr_service - INFO - 初始化OCRService
2025-05-02 17:16:24,476 - app.services.ocr_service - INFO - OCRService初始化完成

View File

@ -0,0 +1 @@
Active since: 2025-05-02 17:16:24

View File

@ -0,0 +1,22 @@
2025-05-02 16:10:30,796 - app.services.order_service - INFO - 初始化OrderService
2025-05-02 16:10:30,805 - app.services.order_service - INFO - OrderService初始化完成
2025-05-02 16:11:05,077 - app.services.order_service - INFO - 初始化OrderService
2025-05-02 16:11:05,083 - app.services.order_service - INFO - OrderService初始化完成
2025-05-02 16:15:14,537 - app.services.order_service - INFO - 初始化OrderService
2025-05-02 16:15:14,542 - app.services.order_service - INFO - OrderService初始化完成
2025-05-02 16:24:57,642 - app.services.order_service - INFO - 初始化OrderService
2025-05-02 16:24:57,650 - app.services.order_service - INFO - OrderService初始化完成
2025-05-02 16:34:26,012 - app.services.order_service - INFO - 初始化OrderService
2025-05-02 16:34:26,014 - app.services.order_service - INFO - OrderService初始化完成
2025-05-02 16:34:28,703 - app.services.order_service - INFO - OrderService开始处理指定Excel文件: D:\My Documents\python\orc-order-v2\output\微信图片_20250227193150(1).xlsx
2025-05-02 16:34:29,403 - app.services.order_service - INFO - OrderService开始合并所有采购单
2025-05-02 16:55:26,479 - app.services.order_service - INFO - 初始化OrderService
2025-05-02 16:55:26,481 - app.services.order_service - INFO - OrderService初始化完成
2025-05-02 17:08:58,653 - app.services.order_service - INFO - 初始化OrderService
2025-05-02 17:08:58,656 - app.services.order_service - INFO - OrderService初始化完成
2025-05-02 17:10:09,222 - app.services.order_service - INFO - 初始化OrderService
2025-05-02 17:10:09,224 - app.services.order_service - INFO - OrderService初始化完成
2025-05-02 17:10:09,224 - app.services.order_service - INFO - OrderService开始处理指定Excel文件: D:\My Documents\python\orc-order-v2\data\output\微信图片_20250227193150(1).xlsx
2025-05-02 17:16:24,476 - app.services.order_service - INFO - 初始化OrderService
2025-05-02 17:16:24,478 - app.services.order_service - INFO - OrderService初始化完成
2025-05-02 17:16:24,478 - app.services.order_service - INFO - OrderService开始处理指定Excel文件: D:\My Documents\python\orc-order-v2\data\output\微信图片_20250227193150(1).xlsx

8
logs/ocr_processor.log Normal file
View File

@ -0,0 +1,8 @@
2025-05-02 15:58:36,151 - INFO - 开始百度表格OCR识别程序
2025-05-02 15:58:36,165 - INFO - 已创建默认配置文件: config.ini
2025-05-02 15:58:36,168 - INFO - 已加载配置文件: config.ini
2025-05-02 15:58:36,170 - INFO - 已确保文件夹存在: input
2025-05-02 15:58:36,172 - INFO - 已确保文件夹存在: output
2025-05-02 15:58:36,172 - INFO - 已确保文件夹存在: temp
2025-05-02 15:58:36,175 - INFO - 没有需要处理的图像文件
2025-05-02 15:58:36,176 - INFO - 百度表格OCR识别程序已完成耗时: 0.02秒

9
requirements.txt Normal file
View File

@ -0,0 +1,9 @@
configparser>=5.0.0
numpy>=1.19.0
openpyxl>=3.0.0
pandas>=1.3.0
pathlib>=1.0.1
requests>=2.25.0
xlrd>=2.0.0,<2.1.0
xlutils>=2.0.0
xlwt>=1.3.0

320
run.py Normal file
View File

@ -0,0 +1,320 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
OCR订单处理系统 - 主入口
---------------------
提供命令行接口整合OCR识别Excel处理和订单合并功能
"""
import os
import sys
import argparse
from typing import List, Optional
from app.config.settings import ConfigManager
from app.core.utils.log_utils import get_logger, close_logger
from app.services.ocr_service import OCRService
from app.services.order_service import OrderService
logger = get_logger(__name__)
def create_parser() -> argparse.ArgumentParser:
"""
创建命令行参数解析器
Returns:
参数解析器
"""
parser = argparse.ArgumentParser(description='OCR订单处理系统')
# 通用选项
parser.add_argument('--config', type=str, help='配置文件路径')
# 子命令
subparsers = parser.add_subparsers(dest='command', help='子命令')
# OCR识别命令
ocr_parser = subparsers.add_parser('ocr', help='OCR识别')
ocr_parser.add_argument('--input', type=str, help='输入图片文件路径')
ocr_parser.add_argument('--batch', action='store_true', help='批量处理模式')
ocr_parser.add_argument('--batch-size', type=int, help='批处理大小')
ocr_parser.add_argument('--max-workers', type=int, help='最大线程数')
# Excel处理命令
excel_parser = subparsers.add_parser('excel', help='Excel处理')
excel_parser.add_argument('--input', type=str, help='输入Excel文件路径如果不指定则处理最新的文件')
# 订单合并命令
merge_parser = subparsers.add_parser('merge', help='订单合并')
merge_parser.add_argument('--input', type=str, help='输入采购单文件路径列表,以逗号分隔,如果不指定则合并所有采购单')
# 完整流程命令
pipeline_parser = subparsers.add_parser('pipeline', help='完整流程')
pipeline_parser.add_argument('--input', type=str, help='输入图片文件路径,如果不指定则处理所有图片')
return parser
def run_ocr(ocr_service: OCRService, args) -> bool:
"""
运行OCR识别
Args:
ocr_service: OCR服务
args: 命令行参数
Returns:
处理是否成功
"""
if args.input:
if not os.path.exists(args.input):
logger.error(f"输入文件不存在: {args.input}")
return False
if not ocr_service.validate_image(args.input):
logger.error(f"输入文件无效: {args.input}")
return False
logger.info(f"处理单个图片: {args.input}")
result = ocr_service.process_image(args.input)
if result:
logger.info(f"OCR处理成功输出文件: {result}")
return True
else:
logger.error("OCR处理失败")
return False
elif args.batch:
logger.info("批量处理模式")
total, success = ocr_service.process_images_batch(args.batch_size, args.max_workers)
if total == 0:
logger.warning("没有找到需要处理的文件")
return False
logger.info(f"批量处理完成,总计: {total},成功: {success}")
return success > 0
else:
# 列出未处理的文件
files = ocr_service.get_unprocessed_images()
if not files:
logger.info("没有未处理的文件")
return True
logger.info(f"未处理的文件 ({len(files)}):")
for file in files:
logger.info(f" {file}")
return True
def run_excel(order_service: OrderService, args) -> bool:
"""
运行Excel处理
Args:
order_service: 订单服务
args: 命令行参数
Returns:
处理是否成功
"""
if args.input:
if not os.path.exists(args.input):
logger.error(f"输入文件不存在: {args.input}")
return False
logger.info(f"处理Excel文件: {args.input}")
result = order_service.process_excel(args.input)
else:
latest_file = order_service.get_latest_excel()
if not latest_file:
logger.warning("未找到可处理的Excel文件")
return False
logger.info(f"处理最新的Excel文件: {latest_file}")
result = order_service.process_excel(latest_file)
if result:
logger.info(f"Excel处理成功输出文件: {result}")
return True
else:
logger.error("Excel处理失败")
return False
def run_merge(order_service: OrderService, args) -> bool:
"""
运行订单合并
Args:
order_service: 订单服务
args: 命令行参数
Returns:
处理是否成功
"""
if args.input:
# 分割输入文件列表
file_paths = [path.strip() for path in args.input.split(',')]
# 检查文件是否存在
for path in file_paths:
if not os.path.exists(path):
logger.error(f"输入文件不存在: {path}")
return False
logger.info(f"合并指定的采购单文件: {file_paths}")
result = order_service.merge_orders(file_paths)
else:
# 获取所有采购单文件
file_paths = order_service.get_purchase_orders()
if not file_paths:
logger.warning("未找到采购单文件")
return False
logger.info(f"合并所有采购单文件: {len(file_paths)}")
result = order_service.merge_orders()
if result:
logger.info(f"订单合并成功,输出文件: {result}")
return True
else:
logger.error("订单合并失败")
return False
def run_pipeline(ocr_service: OCRService, order_service: OrderService, args) -> bool:
"""
运行完整流程
Args:
ocr_service: OCR服务
order_service: 订单服务
args: 命令行参数
Returns:
处理是否成功
"""
# 1. OCR识别
logger.info("=== 流程步骤 1: OCR识别 ===")
if args.input:
if not os.path.exists(args.input):
logger.error(f"输入文件不存在: {args.input}")
return False
if not ocr_service.validate_image(args.input):
logger.error(f"输入文件无效: {args.input}")
return False
logger.info(f"处理单个图片: {args.input}")
ocr_result = ocr_service.process_image(args.input)
if not ocr_result:
logger.error("OCR处理失败")
return False
logger.info(f"OCR处理成功输出文件: {ocr_result}")
else:
# 批量处理所有图片
logger.info("批量处理所有图片")
total, success = ocr_service.process_images_batch()
if total == 0:
logger.warning("没有找到需要处理的图片")
# 继续下一步因为可能已经有处理好的Excel文件
elif success == 0:
logger.error("OCR处理失败没有成功处理的图片")
return False
else:
logger.info(f"OCR处理完成总计: {total},成功: {success}")
# 2. Excel处理
logger.info("=== 流程步骤 2: Excel处理 ===")
latest_file = order_service.get_latest_excel()
if not latest_file:
logger.warning("未找到可处理的Excel文件")
return False
logger.info(f"处理最新的Excel文件: {latest_file}")
excel_result = order_service.process_excel(latest_file)
if not excel_result:
logger.error("Excel处理失败")
return False
logger.info(f"Excel处理成功输出文件: {excel_result}")
# 3. 订单合并
logger.info("=== 流程步骤 3: 订单合并 ===")
# 获取所有采购单文件
file_paths = order_service.get_purchase_orders()
if not file_paths:
logger.warning("未找到采购单文件")
return False
logger.info(f"合并所有采购单文件: {len(file_paths)}")
merge_result = order_service.merge_orders()
if not merge_result:
logger.error("订单合并失败")
return False
logger.info(f"订单合并成功,输出文件: {merge_result}")
logger.info("=== 完整流程处理成功 ===")
return True
def main(args: Optional[List[str]] = None) -> int:
"""
主函数
Args:
args: 命令行参数如果为None则使用sys.argv
Returns:
退出状态码
"""
parser = create_parser()
parsed_args = parser.parse_args(args)
if parsed_args.command is None:
parser.print_help()
return 1
try:
# 创建配置管理器
config = ConfigManager(parsed_args.config) if parsed_args.config else ConfigManager()
# 创建服务
ocr_service = OCRService(config)
order_service = OrderService(config)
# 根据命令执行不同功能
if parsed_args.command == 'ocr':
success = run_ocr(ocr_service, parsed_args)
elif parsed_args.command == 'excel':
success = run_excel(order_service, parsed_args)
elif parsed_args.command == 'merge':
success = run_merge(order_service, parsed_args)
elif parsed_args.command == 'pipeline':
success = run_pipeline(ocr_service, order_service, parsed_args)
else:
parser.print_help()
return 1
return 0 if success else 1
except Exception as e:
logger.error(f"执行过程中发生错误: {e}")
import traceback
logger.error(traceback.format_exc())
return 1
finally:
# 关闭日志
close_logger(__name__)
if __name__ == '__main__':
sys.exit(main())

Binary file not shown.

52
v1/.gitignore vendored Normal file
View File

@ -0,0 +1,52 @@
# 配置文件(可能包含敏感信息)
config.ini
# 日志文件
*.log
# 临时文件
temp/
~$*
.DS_Store
__pycache__/
# 处理记录(因为通常很大且与具体环境相关)
processed_files.json
# 输入输出数据 (可以根据需要调整)
# input/
# output/
# Python相关
*.py[cod]
*$py.class
*.so
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# 虚拟环境
venv/
ENV/
env/
# IDE相关
.idea/
.vscode/
*.swp
*.swo
.DS_Store

332
v1/README.md Normal file
View File

@ -0,0 +1,332 @@
# OCR订单处理系统
这是一个基于OCR技术的订单处理系统用于自动识别和处理Excel格式的订单文件。系统支持多种格式的订单处理包括普通订单和赠品订单的处理。
## 主要功能
1. **OCR识别**
- 支持图片和PDF文件的文字识别
- 支持表格结构识别
- 支持多种格式的订单识别
2. **Excel处理**
- 自动处理订单数据
- 支持赠品订单处理
- 自动提取商品规格和数量信息
- 从商品名称智能推断规格信息
- 从数量字段提取单位信息
- 支持多种格式的订单合并
3. **日志管理**
- 自动记录处理过程
- 支持日志文件压缩
- 自动清理过期日志
- 日志文件自动重建
- 支持日志大小限制
- 活跃日志文件保护机制
4. **文件管理**
- 自动备份清理的文件
- 支持按时间和模式清理文件
- 文件统计和状态查看
- 支持输入输出目录的独立管理
## 系统要求
- Python 3.8+
- Windows 10/11
## 安装说明
1. 克隆项目到本地:
```bash
git clone [项目地址]
cd orc-order
```
2. 安装依赖:
```bash
pip install -r requirements.txt
```
3. 配置百度OCR API
- 在[百度AI开放平台](https://ai.baidu.com/)注册账号
- 创建OCR应用并获取API Key和Secret Key
- 将密钥信息填入`config.ini`文件
## 使用说明
### 1. OCR处理流程
1. 运行OCR识别
```bash
python run_ocr.py [输入文件路径]
```
2. 使用百度OCR API
```bash
python baidu_ocr.py [输入文件路径]
```
3. 处理表格OCR
```bash
python baidu_table_ocr.py [输入文件路径]
```
### 2. Excel处理
```bash
python excel_processor_step2.py [输入Excel文件路径]
```
或者不指定输入文件自动处理output目录中最新的Excel文件
```bash
python excel_processor_step2.py
```
#### 2.1 Excel处理逻辑说明
1. **列名识别与映射**
- 系统首先检查是否存在直接匹配的列(如"商品条码"列)
- 如果没有,系统会尝试将多种可能的列名映射到标准列名
- 支持特殊表头格式处理(如基本条码、仓库全名等)
2. **条码识别与处理**
- 验证条码格式确保长度在8-13位之间
- 对特定的错误条码进行修正如5开头改为6开头
- 识别特殊条码(如"5321545613"
- 跳过条码为"仓库"或"仓库全名"的行
3. **智能规格推断**
- 当规格信息为空时,从商品名称自动推断规格
- 支持多种商品命名模式:
- 445水溶C血橙15入纸箱 → 规格推断为 1*15
- 500-东方树叶-绿茶1*15-纸箱装 → 规格推断为 1*15
- 12.9L桶装水 → 规格推断为 12.9L*1
- 900树叶茉莉花茶12入纸箱 → 规格推断为 1*12
- 500茶π蜜桃乌龙15纸箱 → 规格推断为 1*15
4. **单位自动提取**
- 当单位信息为空时,从数量字段中自动提取单位
- 支持格式2箱、5桶、3件、10瓶等
- 自动分离数字和单位部分
5. **赠品识别**
- 通过以下条件识别赠品:
- 商品单价为0或为空
- 商品金额为0或为空
- 单价非有效数字
6. **数据合并与处理**
- 对同一条码的多个正常商品记录,累加数量
- 对同一条码的多个赠品记录,累加赠品数量
- 如果同一条码有不同单价,取平均值
### 3. 订单合并
`merge_purchase_orders.py`是专门用来合并多个采购单Excel文件的工具可以高效处理多份采购单并去重。
#### 3.1 基本用法
自动合并output目录下的所有采购单文件以"采购单_"开头的Excel文件
```bash
python merge_purchase_orders.py
```
指定要合并的特定文件:
```bash
python merge_purchase_orders.py --input "output/采购单_1.xls,output/采购单_2.xls"
```
#### 3.2 合并逻辑说明
1. **数据识别与映射**
- 自动识别Excel文件中的列名支持多种表头格式
- 将不同格式的列名映射到标准列名(如"条码"、"条码(必填)"等)
- 支持特殊表头结构的处理如表头在第3行的情况
2. **相同商品的处理**
- 自动检测相同条码和单价的商品
- 对相同商品进行数量累加处理
- 保持商品名称、条码和单价不变
3. **赠送量的处理**
- 自动检测和处理赠送量
- 对相同商品的赠送量进行累加
- 当原始文件中赠送量为空时合并后保持为空不显示为0
4. **数据格式保持**
- 保持条码的原始格式(不转换为小数)
- 单价保持四位小数格式
- 避免"nan"值的显示,空值保持为空
### 4. 单位处理规则(核心规则)
系统支持多种单位的智能处理,能够自动识别和转换不同的计量单位。所有开发必须严格遵循以下规则处理单位转换。
#### 4.1 标准单位处理
| 单位 | 处理规则 | 示例 |
|------|----------|------|
| 件 | 数量×包装数量<br>单价÷包装数量<br>单位转换为"瓶" | 1件(规格1*12) → 12瓶<br>单价108元/件 → 9元/瓶 |
| 箱 | 数量×包装数量<br>单价÷包装数量<br>单位转换为"瓶" | 2箱(规格1*24) → 48瓶<br>单价120元/箱 → 5元/瓶 |
| 包 | 保持原数量和单位不变 | 3包 → 3包 |
| 其他单位 | 保持原数量和单位不变 | 5瓶 → 5瓶 |
#### 4.2 提和盒单位特殊处理
系统对"提"和"盒"单位有特殊的处理逻辑:
1. 当规格是三级格式如1*5*12
- 按照件的计算方式处理
- 数量 = 原始数量 × 包装数量
- 单位转换为"瓶"
- 单价 = 原始单价 ÷ 包装数量
示例3提(规格1*5*12) → 36瓶
2. 当规格是二级格式如1*16
- **保持原数量不变**
- **保持原单位不变**
示例3提(规格1*16) → 仍然是3提
#### 4.3 特殊条码处理
系统支持对特定条码进行特殊处理,这些条码的处理规则会覆盖上述的标准单位处理规则:
1. 特殊条码配置:
```python
special_barcodes = {
'6925019900087': {
'multiplier': 10, # 数量乘以10
'target_unit': '瓶', # 目标单位
'description': '特殊处理:数量*10单位转换为瓶'
}
# 可以添加更多特殊条码的配置
}
```
2. 处理规则:
- 当遇到特殊条码时,无论规格是二级还是三级
- 无论单位是提还是盒还是件
- 都按照特殊条码配置进行处理
- 数量乘以配置的倍数
- 单位转换为配置的目标单位
- 如果有单价,单价除以配置的倍数
3. 添加新的特殊条码的正确方法:
- 在`ExcelProcessorStep2`类的`__init__`方法中的`special_barcodes`字典中添加新的配置
- 每个特殊条码需要配置:
- `multiplier`: 数量乘以的倍数
- `target_unit`: 转换后的目标单位
- `description`: 处理规则的描述
4. 注意事项:
- 特殊条码处理优先级高于标准单位处理
- 添加新的特殊条码前,需要确认该条码是否真的需要特殊处理,或者可以使用现有的标准规则
- 如果商品单位是"件"且有规格信息,应首先考虑使用标准的"件"单位处理规则
5. 示例:
- 条码6925019900087单位为"副"原始数量为2
- 无论规格如何
- 最终数量 = 2 * 10 = 20单位为"瓶"
- 如原单价为50元/副则转换后为5元/瓶
### 5. 开发注意事项
1. **遵循原有处理逻辑**
- 在进行任何修改前,必须理解并遵循现有的单位处理逻辑
- 对于"件"单位,必须按照"数量×包装数量"进行处理
- 对于"提"和"盒"单位,必须检查规格是二级还是三级格式,按相应规则处理
2. **添加特殊条码处理**
- 只有在明确确认某条码无法使用现有规则处理的情况下,才添加特殊处理规则
- 添加特殊条码处理前,先咨询相关负责人确认处理逻辑
3. **代码更改原则**
- 任何代码修改都不应破坏现有的处理逻辑
- 添加新功能时,先确保理解并保留现有功能
- 如需修改核心处理逻辑,必须详细记录变更并更新本文档
4. **文档同步更新**
- 当修改代码逻辑时必须同步更新README文档
- 确保文档准确反映当前系统的处理逻辑
- 记录所有特殊处理规则和条码
## 注意事项
1. 确保输入文件格式正确
2. 定期检查日志文件大小
3. 及时更新百度OCR API密钥
4. 建议定期备份重要数据
5. 清理文件前先查看统计信息
6. 重要文件建议手动备份后再清理
7. 日志文件会自动重建,可以放心清理
8. 规格推断功能适用于特定命名格式的商品
9. 单位提取功能依赖于数量字段的格式
## 常见问题
1. **OCR识别失败**
- 检查图片质量
- 确认API密钥配置正确
- 查看日志文件了解详细错误信息
2. **Excel处理错误**
- 确认Excel文件格式正确
- 检查商品信息是否完整
- 查看处理日志了解具体错误
3. **规格推断失败**
- 检查商品名称是否符合支持的格式
- 尝试手动在Excel中填写规格信息
- 查看日志中的推断过程信息
4. **单位提取失败**
- 检查数量字段格式是否为"数字+单位"格式
- 确认数量字段没有额外的空格或特殊字符
- 尝试手动在Excel中填写单位信息
5. **文件清理问题**
- 清理前使用`--test`参数预览要删除的文件
- 清理前检查文件统计信息`--stats`
- 对于重要文件,先使用`--test`确认后再实际删除
- 对于被占用的文件,尝试关闭占用的程序后再清理
- Windows下某些文件无法删除时可以使用`--force`参数
- 日志文件清理时可以使用`--clean-all-logs`参数
## 更新日志
### v1.2.1 (2024-05-04)
- 新增日志文件活跃标记保护机制
- 修复OCR处理器日志文件被意外删除的问题
- 改进日志清理工具,避免删除活跃日志
- 优化文件清理逻辑
- 更新README文档
### v1.2.0 (2024-05-03)
- 新增对"提"单位的支持1提=10袋
- 新增对三级规格格式1*5*10的解析支持
- 优化单位转换逻辑
- 改进规格解析能力
### v1.1.0 (2024-05-02)
- 新增从商品名称智能推断规格功能
- 新增从数量字段提取单位功能
- 优化赠品处理逻辑
- 修复缩进错误和代码问题
- 改进日志记录内容
### v1.0.0 (2024-05-01)
- 初始版本发布
- 支持基本的OCR识别功能
- 支持Excel订单处理
- 支持日志管理功能
- 添加文件清理工具
- 优化文件命名和目录结构
- 完善日志自动重建功能
## 许可证
MIT License

469
v1/baidu_ocr.py Normal file
View File

@ -0,0 +1,469 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
百度表格OCR识别工具
-----------------
用于将图片中的表格转换为Excel文件的工具
使用百度云OCR API进行识别
"""
import os
import sys
import requests
import base64
import json
import time
import logging
import datetime
import configparser
from pathlib import Path
from typing import Dict, List, Optional, Any
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
# 默认配置
DEFAULT_CONFIG = {
'API': {
'api_key': '', # 将从配置文件中读取
'secret_key': '', # 将从配置文件中读取
'timeout': '30',
'max_retries': '3',
'retry_delay': '2'
},
'Paths': {
'input_folder': 'input',
'output_folder': 'output',
'temp_folder': 'temp'
},
'File': {
'allowed_extensions': '.jpg,.jpeg,.png,.bmp',
'excel_extension': '.xlsx'
}
}
class ConfigManager:
"""配置管理类,负责加载和保存配置"""
def __init__(self, config_file: str = 'config.ini'):
self.config_file = config_file
self.config = configparser.ConfigParser()
self.load_config()
def load_config(self) -> None:
"""加载配置文件,如果不存在则创建默认配置"""
if not os.path.exists(self.config_file):
self.create_default_config()
try:
self.config.read(self.config_file, encoding='utf-8')
logger.info(f"已加载配置文件: {self.config_file}")
except Exception as e:
logger.error(f"加载配置文件时出错: {e}")
logger.info("使用默认配置")
self.create_default_config(save=False)
def create_default_config(self, save: bool = True) -> None:
"""创建默认配置"""
for section, options in DEFAULT_CONFIG.items():
if not self.config.has_section(section):
self.config.add_section(section)
for option, value in options.items():
self.config.set(section, option, value)
if save:
self.save_config()
logger.info(f"已创建默认配置文件: {self.config_file}")
def save_config(self) -> None:
"""保存配置到文件"""
try:
with open(self.config_file, 'w', encoding='utf-8') as f:
self.config.write(f)
except Exception as e:
logger.error(f"保存配置文件时出错: {e}")
def get(self, section: str, option: str, fallback: Any = None) -> Any:
"""获取配置值"""
return self.config.get(section, option, fallback=fallback)
def getint(self, section: str, option: str, fallback: int = 0) -> int:
"""获取整数配置值"""
return self.config.getint(section, option, fallback=fallback)
def getboolean(self, section: str, option: str, fallback: bool = False) -> bool:
"""获取布尔配置值"""
return self.config.getboolean(section, option, fallback=fallback)
def get_list(self, section: str, option: str, fallback: str = "", delimiter: str = ",") -> List[str]:
"""获取列表配置值"""
value = self.get(section, option, fallback)
return [item.strip() for item in value.split(delimiter) if item.strip()]
class OCRProcessor:
"""OCR处理器用于表格识别"""
def __init__(self, config_file: str = 'config.ini'):
"""
初始化OCR处理器
Args:
config_file: 配置文件路径
"""
self.config_manager = ConfigManager(config_file)
# 获取配置
self.api_key = self.config_manager.get('API', 'api_key')
self.secret_key = self.config_manager.get('API', 'secret_key')
self.timeout = self.config_manager.getint('API', 'timeout', 30)
self.max_retries = self.config_manager.getint('API', 'max_retries', 3)
self.retry_delay = self.config_manager.getint('API', 'retry_delay', 2)
# 设置路径
self.input_folder = self.config_manager.get('Paths', 'input_folder', 'input')
self.output_folder = self.config_manager.get('Paths', 'output_folder', 'output')
self.temp_folder = self.config_manager.get('Paths', 'temp_folder', 'temp')
# 确保目录存在
for dir_path in [self.input_folder, self.output_folder, self.temp_folder]:
os.makedirs(dir_path, exist_ok=True)
# 设置允许的文件扩展名
self.allowed_extensions = self.config_manager.get_list('File', 'allowed_extensions')
# 验证API配置
if not self.api_key or not self.secret_key:
logger.warning("API密钥未设置请在配置文件中设置API密钥")
def get_access_token(self) -> Optional[str]:
"""获取百度API访问令牌"""
url = "https://aip.baidubce.com/oauth/2.0/token"
params = {
"grant_type": "client_credentials",
"client_id": self.api_key,
"client_secret": self.secret_key
}
for attempt in range(self.max_retries):
try:
response = requests.post(url, params=params, timeout=10)
if response.status_code == 200:
result = response.json()
if "access_token" in result:
return result["access_token"]
logger.warning(f"获取访问令牌失败 (尝试 {attempt+1}/{self.max_retries}): {response.text}")
except Exception as e:
logger.warning(f"获取访问令牌时发生错误 (尝试 {attempt+1}/{self.max_retries}): {e}")
# 如果不是最后一次尝试,则等待后重试
if attempt < self.max_retries - 1:
time.sleep(self.retry_delay * (attempt + 1))
logger.error("无法获取访问令牌")
return None
def rename_image_to_timestamp(self, image_path: str) -> str:
"""将图片重命名为时间戳格式(如果需要)"""
try:
# 获取当前时间戳
now = datetime.datetime.now()
timestamp = now.strftime("%Y%m%d%H%M%S")
# 构造新文件名
dir_path = os.path.dirname(image_path)
ext = os.path.splitext(image_path)[1]
new_path = os.path.join(dir_path, f"{timestamp}{ext}")
# 如果文件名不同,则重命名
if image_path != new_path:
os.rename(image_path, new_path)
logger.info(f"已将图片重命名为: {os.path.basename(new_path)}")
return new_path
return image_path
except Exception as e:
logger.error(f"重命名图片时出错: {e}")
return image_path
def recognize_table(self, image_path: str) -> Optional[Dict]:
"""
识别图片中的表格
Args:
image_path: 图片文件路径
Returns:
Dict: 识别结果失败返回None
"""
try:
# 获取access_token
access_token = self.get_access_token()
if not access_token:
return None
# 请求URL
url = f"https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/request?access_token={access_token}"
# 读取图片内容
with open(image_path, 'rb') as f:
image_data = f.read()
# Base64编码
image_base64 = base64.b64encode(image_data).decode('utf-8')
# 请求参数
headers = {
'Content-Type': 'application/x-www-form-urlencoded'
}
data = {
'image': image_base64,
'is_sync': 'true',
'request_type': 'excel'
}
# 发送请求
response = requests.post(url, headers=headers, data=data, timeout=self.timeout)
response.raise_for_status()
# 解析结果
result = response.json()
# 检查错误码
if 'error_code' in result:
logger.error(f"识别表格失败: {result.get('error_msg', '未知错误')}")
return None
# 返回识别结果
return result
except Exception as e:
logger.error(f"识别表格时出错: {e}")
return None
def get_excel_result(self, request_id: str, access_token: str) -> Optional[bytes]:
"""
获取Excel结果
Args:
request_id: 请求ID
access_token: 访问令牌
Returns:
bytes: Excel文件内容失败返回None
"""
try:
# 请求URL
url = f"https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result?access_token={access_token}"
# 请求参数
headers = {
'Content-Type': 'application/x-www-form-urlencoded'
}
data = {
'request_id': request_id,
'result_type': 'excel'
}
# 最大重试次数
max_retries = 10
# 循环获取结果
for i in range(max_retries):
# 发送请求
response = requests.post(url, headers=headers, data=data, timeout=self.timeout)
response.raise_for_status()
# 解析结果
result = response.json()
# 检查错误码
if 'error_code' in result:
logger.error(f"获取Excel结果失败: {result.get('error_msg', '未知错误')}")
return None
# 检查处理状态
result_data = result.get('result', {})
status = result_data.get('ret_code')
if status == 3: # 处理完成
# 获取Excel文件URL
excel_url = result_data.get('result_data')
if not excel_url:
logger.error("未获取到Excel结果URL")
return None
# 下载Excel文件
excel_response = requests.get(excel_url)
excel_response.raise_for_status()
# 返回Excel文件内容
return excel_response.content
elif status == 1: # 排队中
logger.info(f"请求排队中 ({i+1}/{max_retries}),等待后重试...")
elif status == 2: # 处理中
logger.info(f"正在处理 ({i+1}/{max_retries}),等待后重试...")
else:
logger.error(f"未知状态码: {status}")
return None
# 等待后重试
time.sleep(2)
logger.error(f"获取Excel结果超时请稍后再试")
return None
except Exception as e:
logger.error(f"获取Excel结果时出错: {e}")
return None
def process_image(self, image_path: str) -> Optional[str]:
"""
处理单个图片
Args:
image_path: 图片文件路径
Returns:
str: 生成的Excel文件路径失败返回None
"""
try:
logger.info(f"开始处理图片: {image_path}")
# 验证文件扩展名
ext = os.path.splitext(image_path)[1].lower()
if self.allowed_extensions and ext not in self.allowed_extensions:
logger.error(f"不支持的文件类型: {ext},支持的类型: {', '.join(self.allowed_extensions)}")
return None
# 重命名图片(可选)
renamed_path = self.rename_image_to_timestamp(image_path)
# 获取文件名(不含扩展名)
basename = os.path.basename(renamed_path)
name_without_ext = os.path.splitext(basename)[0]
# 获取access_token
access_token = self.get_access_token()
if not access_token:
return None
# 识别表格
ocr_result = self.recognize_table(renamed_path)
if not ocr_result:
return None
# 获取请求ID
request_id = ocr_result.get('result', {}).get('request_id')
if not request_id:
logger.error("未获取到请求ID")
return None
# 获取Excel结果
excel_content = self.get_excel_result(request_id, access_token)
if not excel_content:
return None
# 保存Excel文件
output_path = os.path.join(self.output_folder, f"{name_without_ext}.xlsx")
with open(output_path, 'wb') as f:
f.write(excel_content)
logger.info(f"已保存Excel文件: {output_path}")
return output_path
except Exception as e:
logger.error(f"处理图片时出错: {e}")
return None
def process_directory(self) -> List[str]:
"""
处理输入目录中的所有图片
Returns:
List[str]: 生成的Excel文件路径列表
"""
results = []
try:
# 获取输入目录中的所有图片文件
image_files = []
for ext in self.allowed_extensions:
image_files.extend(list(Path(self.input_folder).glob(f"*{ext}")))
image_files.extend(list(Path(self.input_folder).glob(f"*{ext.upper()}")))
if not image_files:
logger.warning(f"输入目录 {self.input_folder} 中没有找到图片文件")
return []
logger.info(f"{self.input_folder} 中找到 {len(image_files)} 个图片文件")
# 处理每个图片
for image_file in image_files:
result = self.process_image(str(image_file))
if result:
results.append(result)
logger.info(f"处理完成,成功生成 {len(results)} 个Excel文件")
return results
except Exception as e:
logger.error(f"处理目录时出错: {e}")
return results
def main():
"""主函数"""
import argparse
# 解析命令行参数
parser = argparse.ArgumentParser(description='百度表格OCR识别工具')
parser.add_argument('--config', type=str, default='config.ini', help='配置文件路径')
parser.add_argument('--input', type=str, help='输入图片路径')
parser.add_argument('--debug', action='store_true', help='启用调试模式')
args = parser.parse_args()
# 设置日志级别
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
# 创建OCR处理器
processor = OCRProcessor(args.config)
# 处理单个图片或目录
if args.input:
if os.path.isfile(args.input):
result = processor.process_image(args.input)
if result:
print(f"处理成功: {result}")
return 0
else:
print("处理失败")
return 1
elif os.path.isdir(args.input):
results = processor.process_directory()
print(f"处理完成,成功生成 {len(results)} 个Excel文件")
return 0
else:
print(f"输入路径不存在: {args.input}")
return 1
else:
# 处理默认输入目录
results = processor.process_directory()
print(f"处理完成,成功生成 {len(results)} 个Excel文件")
return 0
if __name__ == "__main__":
sys.exit(main())

639
v1/baidu_table_ocr.py Normal file
View File

@ -0,0 +1,639 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
百度表格OCR识别工具
-----------------
用于将图片中的表格转换为Excel文件的工具
使用百度云OCR API进行识别支持批量处理
"""
import os
import sys
import requests
import base64
import json
import time
import logging
import datetime
import configparser
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union, Any
from concurrent.futures import ThreadPoolExecutor
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('ocr_processor.log', encoding='utf-8'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
# 默认配置
DEFAULT_CONFIG = {
'API': {
'api_key': '', # 将从配置文件中读取
'secret_key': '', # 将从配置文件中读取
'timeout': '30',
'max_retries': '3',
'retry_delay': '2',
'api_url': 'https://aip.baidubce.com/rest/2.0/ocr/v1/table'
},
'Paths': {
'input_folder': 'input',
'output_folder': 'output',
'temp_folder': 'temp',
'processed_record': 'processed_files.json'
},
'Performance': {
'max_workers': '4',
'batch_size': '5',
'skip_existing': 'true'
},
'File': {
'allowed_extensions': '.jpg,.jpeg,.png,.bmp',
'excel_extension': '.xlsx',
'max_file_size_mb': '4'
}
}
class ConfigManager:
"""配置管理类,负责加载和保存配置"""
def __init__(self, config_file: str = 'config.ini'):
self.config_file = config_file
self.config = configparser.ConfigParser()
self.load_config()
def load_config(self) -> None:
"""加载配置文件,如果不存在则创建默认配置"""
if not os.path.exists(self.config_file):
self.create_default_config()
try:
self.config.read(self.config_file, encoding='utf-8')
logger.info(f"已加载配置文件: {self.config_file}")
except Exception as e:
logger.error(f"加载配置文件时出错: {e}")
logger.info("使用默认配置")
self.create_default_config(save=False)
def create_default_config(self, save: bool = True) -> None:
"""创建默认配置"""
for section, options in DEFAULT_CONFIG.items():
if not self.config.has_section(section):
self.config.add_section(section)
for option, value in options.items():
self.config.set(section, option, value)
if save:
self.save_config()
logger.info(f"已创建默认配置文件: {self.config_file}")
def save_config(self) -> None:
"""保存配置到文件"""
try:
with open(self.config_file, 'w', encoding='utf-8') as f:
self.config.write(f)
except Exception as e:
logger.error(f"保存配置文件时出错: {e}")
def get(self, section: str, option: str, fallback: Any = None) -> Any:
"""获取配置值"""
return self.config.get(section, option, fallback=fallback)
def getint(self, section: str, option: str, fallback: int = 0) -> int:
"""获取整数配置值"""
return self.config.getint(section, option, fallback=fallback)
def getfloat(self, section: str, option: str, fallback: float = 0.0) -> float:
"""获取浮点数配置值"""
return self.config.getfloat(section, option, fallback=fallback)
def getboolean(self, section: str, option: str, fallback: bool = False) -> bool:
"""获取布尔配置值"""
return self.config.getboolean(section, option, fallback=fallback)
def get_list(self, section: str, option: str, fallback: str = "", delimiter: str = ",") -> List[str]:
"""获取列表配置值"""
value = self.get(section, option, fallback)
return [item.strip() for item in value.split(delimiter) if item.strip()]
class TokenManager:
"""令牌管理类负责获取和刷新百度API访问令牌"""
def __init__(self, api_key: str, secret_key: str, max_retries: int = 3, retry_delay: int = 2):
self.api_key = api_key
self.secret_key = secret_key
self.max_retries = max_retries
self.retry_delay = retry_delay
self.access_token = None
self.token_expiry = 0
def get_token(self) -> Optional[str]:
"""获取访问令牌,如果令牌已过期则刷新"""
if self.is_token_valid():
return self.access_token
return self.refresh_token()
def is_token_valid(self) -> bool:
"""检查令牌是否有效"""
return (
self.access_token is not None and
self.token_expiry > time.time() + 60 # 提前1分钟刷新
)
def refresh_token(self) -> Optional[str]:
"""刷新访问令牌"""
url = "https://aip.baidubce.com/oauth/2.0/token"
params = {
"grant_type": "client_credentials",
"client_id": self.api_key,
"client_secret": self.secret_key
}
for attempt in range(self.max_retries):
try:
response = requests.post(url, params=params, timeout=10)
if response.status_code == 200:
result = response.json()
if "access_token" in result:
self.access_token = result["access_token"]
# 设置令牌过期时间默认30天提前1小时过期以确保安全
self.token_expiry = time.time() + result.get("expires_in", 2592000) - 3600
logger.info("成功获取访问令牌")
return self.access_token
logger.warning(f"获取访问令牌失败 (尝试 {attempt+1}/{self.max_retries}): {response.text}")
except Exception as e:
logger.warning(f"获取访问令牌时发生错误 (尝试 {attempt+1}/{self.max_retries}): {e}")
# 如果不是最后一次尝试,则等待后重试
if attempt < self.max_retries - 1:
time.sleep(self.retry_delay * (attempt + 1)) # 指数退避
logger.error("无法获取访问令牌")
return None
class ProcessedRecordManager:
"""处理记录管理器,用于跟踪已处理的文件"""
def __init__(self, record_file: str):
self.record_file = record_file
self.processed_files = self._load_record()
def _load_record(self) -> Dict[str, str]:
"""加载处理记录"""
if os.path.exists(self.record_file):
try:
with open(self.record_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"加载处理记录时出错: {e}")
return {}
def save_record(self) -> None:
"""保存处理记录"""
try:
with open(self.record_file, 'w', encoding='utf-8') as f:
json.dump(self.processed_files, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"保存处理记录时出错: {e}")
def is_processed(self, image_file: str) -> bool:
"""检查文件是否已处理"""
return image_file in self.processed_files
def mark_as_processed(self, image_file: str, output_file: str) -> None:
"""标记文件为已处理"""
self.processed_files[image_file] = output_file
self.save_record()
def get_output_file(self, image_file: str) -> Optional[str]:
"""获取已处理文件对应的输出文件"""
return self.processed_files.get(image_file)
class OCRProcessor:
"""OCR处理器核心类用于识别表格并保存为Excel"""
def __init__(self, config_manager: ConfigManager):
self.config = config_manager
# 路径配置
self.input_folder = self.config.get('Paths', 'input_folder')
self.output_folder = self.config.get('Paths', 'output_folder')
self.temp_folder = self.config.get('Paths', 'temp_folder')
self.processed_record_file = os.path.join(
self.config.get('Paths', 'output_folder'),
self.config.get('Paths', 'processed_record')
)
# API配置
self.api_url = self.config.get('API', 'api_url')
self.timeout = self.config.getint('API', 'timeout')
self.max_retries = self.config.getint('API', 'max_retries')
self.retry_delay = self.config.getint('API', 'retry_delay')
# 文件配置
self.allowed_extensions = self.config.get_list('File', 'allowed_extensions')
self.excel_extension = self.config.get('File', 'excel_extension')
self.max_file_size_mb = self.config.getfloat('File', 'max_file_size_mb')
# 性能配置
self.max_workers = self.config.getint('Performance', 'max_workers')
self.batch_size = self.config.getint('Performance', 'batch_size')
self.skip_existing = self.config.getboolean('Performance', 'skip_existing')
# 初始化其他组件
self.token_manager = TokenManager(
self.config.get('API', 'api_key'),
self.config.get('API', 'secret_key'),
self.max_retries,
self.retry_delay
)
self.record_manager = ProcessedRecordManager(self.processed_record_file)
# 确保文件夹存在
for folder in [self.input_folder, self.output_folder, self.temp_folder]:
os.makedirs(folder, exist_ok=True)
logger.info(f"已确保文件夹存在: {folder}")
def get_unprocessed_images(self) -> List[str]:
"""获取待处理的图像文件列表"""
all_files = []
for ext in self.allowed_extensions:
all_files.extend(Path(self.input_folder).glob(f"*{ext}"))
# 转换为字符串路径
file_paths = [str(file_path) for file_path in all_files]
if self.skip_existing:
# 过滤掉已处理的文件
return [
file_path for file_path in file_paths
if not self.record_manager.is_processed(os.path.basename(file_path))
]
return file_paths
def validate_image(self, image_path: str) -> bool:
"""验证图像文件是否有效且符合大小限制"""
# 检查文件是否存在
if not os.path.exists(image_path):
logger.error(f"文件不存在: {image_path}")
return False
# 检查是否是文件
if not os.path.isfile(image_path):
logger.error(f"路径不是文件: {image_path}")
return False
# 检查文件大小
file_size_mb = os.path.getsize(image_path) / (1024 * 1024)
if file_size_mb > self.max_file_size_mb:
logger.error(f"文件过大 ({file_size_mb:.2f}MB > {self.max_file_size_mb}MB): {image_path}")
return False
# 检查文件扩展名
_, ext = os.path.splitext(image_path)
if ext.lower() not in self.allowed_extensions:
logger.error(f"不支持的文件格式 {ext}: {image_path}")
return False
return True
def rename_image_to_timestamp(self, image_path: str) -> str:
"""将图像文件重命名为时间戳格式"""
try:
# 获取目录和文件扩展名
dir_name = os.path.dirname(image_path)
file_ext = os.path.splitext(image_path)[1]
# 生成时间戳文件名
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
new_filename = f"{timestamp}{file_ext}"
# 构建新路径
new_path = os.path.join(dir_name, new_filename)
# 如果目标文件已存在,添加毫秒级别的后缀
if os.path.exists(new_path):
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S%f")
new_filename = f"{timestamp}{file_ext}"
new_path = os.path.join(dir_name, new_filename)
# 重命名文件
os.rename(image_path, new_path)
logger.info(f"文件已重命名: {os.path.basename(image_path)} -> {new_filename}")
return new_path
except Exception as e:
logger.error(f"重命名文件时出错: {e}")
return image_path
def recognize_table(self, image_path: str) -> Optional[Dict]:
"""使用百度表格OCR API识别图像中的表格"""
# 获取访问令牌
access_token = self.token_manager.get_token()
if not access_token:
logger.error("无法获取访问令牌")
return None
url = f"{self.api_url}?access_token={access_token}"
for attempt in range(self.max_retries):
try:
# 读取图像文件并进行base64编码
with open(image_path, 'rb') as f:
image_data = f.read()
image_base64 = base64.b64encode(image_data).decode('utf-8')
# 设置请求头和请求参数
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
params = {
'image': image_base64,
'return_excel': 'true' # 返回Excel文件编码
}
# 发送请求
response = requests.post(
url,
data=params,
headers=headers,
timeout=self.timeout
)
# 检查响应状态
if response.status_code == 200:
result = response.json()
if 'error_code' in result:
error_msg = result.get('error_msg', '未知错误')
logger.error(f"表格识别失败: {error_msg}")
# 如果是授权错误,尝试刷新令牌
if result.get('error_code') in [110, 111]: # 授权相关错误码
self.token_manager.refresh_token()
else:
return result
else:
logger.error(f"表格识别失败: {response.status_code} - {response.text}")
except Exception as e:
logger.error(f"表格识别过程中发生错误 (尝试 {attempt+1}/{self.max_retries}): {e}")
# 如果不是最后一次尝试,则等待后重试
if attempt < self.max_retries - 1:
wait_time = self.retry_delay * (2 ** attempt) # 指数退避
logger.info(f"将在 {wait_time} 秒后重试...")
time.sleep(wait_time)
return None
def save_to_excel(self, ocr_result: Dict, output_path: str) -> bool:
"""将表格识别结果保存为Excel文件"""
try:
# 检查结果中是否包含表格数据和Excel文件
if not ocr_result:
logger.error("无法保存结果: 识别结果为空")
return False
# 直接从excel_file字段获取Excel文件的base64编码
excel_base64 = None
if 'excel_file' in ocr_result:
excel_base64 = ocr_result['excel_file']
elif 'tables_result' in ocr_result and ocr_result['tables_result']:
for table in ocr_result['tables_result']:
if 'excel_file' in table:
excel_base64 = table['excel_file']
break
if not excel_base64:
logger.error("无法获取Excel文件编码")
logger.debug(f"API返回结果: {json.dumps(ocr_result, ensure_ascii=False, indent=2)}")
return False
# 解码base64并保存Excel文件
try:
excel_data = base64.b64decode(excel_base64)
# 确保输出目录存在
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, 'wb') as f:
f.write(excel_data)
logger.info(f"成功保存表格数据到: {output_path}")
return True
except Exception as e:
logger.error(f"解码Excel数据时出错: {e}")
return False
except Exception as e:
logger.error(f"保存Excel文件时发生错误: {e}")
return False
def process_image(self, image_path: str) -> Optional[str]:
"""处理单个图像文件:验证、重命名、识别和保存"""
try:
# 获取原始图片文件名(不含扩展名)
image_basename = os.path.basename(image_path)
image_name_without_ext = os.path.splitext(image_basename)[0]
# 检查是否已存在对应的Excel文件
excel_filename = f"{image_name_without_ext}{self.excel_extension}"
excel_path = os.path.join(self.output_folder, excel_filename)
if os.path.exists(excel_path):
logger.info(f"已存在对应的Excel文件跳过处理: {image_basename} -> {excel_filename}")
# 记录处理结果(虽然跳过了处理,但仍标记为已处理)
self.record_manager.mark_as_processed(image_basename, excel_path)
return excel_path
# 检查文件是否已经处理过
if self.skip_existing and self.record_manager.is_processed(image_basename):
output_file = self.record_manager.get_output_file(image_basename)
logger.info(f"文件已处理过,跳过: {image_basename} -> {output_file}")
return output_file
# 验证图像文件
if not self.validate_image(image_path):
logger.warning(f"图像验证失败: {image_path}")
return None
# 识别表格(不再重命名图片)
logger.info(f"正在识别表格: {image_basename}")
ocr_result = self.recognize_table(image_path)
if not ocr_result:
logger.error(f"表格识别失败: {image_basename}")
return None
# 保存结果到Excel使用原始图片名
if self.save_to_excel(ocr_result, excel_path):
# 记录处理结果
self.record_manager.mark_as_processed(image_basename, excel_path)
return excel_path
return None
except Exception as e:
logger.error(f"处理图像时发生错误: {e}")
return None
def process_images_batch(self, batch_size: int = None, max_workers: int = None) -> Tuple[int, int]:
"""批量处理图像文件"""
if batch_size is None:
batch_size = self.batch_size
if max_workers is None:
max_workers = self.max_workers
# 获取待处理的图像文件
image_files = self.get_unprocessed_images()
total_files = len(image_files)
if total_files == 0:
logger.info("没有需要处理的图像文件")
return 0, 0
logger.info(f"找到 {total_files} 个待处理图像文件")
# 处理所有文件
processed_count = 0
success_count = 0
# 如果文件数量很少,直接顺序处理
if total_files <= 2 or max_workers <= 1:
for image_path in image_files:
processed_count += 1
logger.info(f"处理文件 ({processed_count}/{total_files}): {os.path.basename(image_path)}")
output_path = self.process_image(image_path)
if output_path:
success_count += 1
logger.info(f"处理成功 ({success_count}/{processed_count}): {os.path.basename(output_path)}")
else:
logger.warning(f"处理失败: {os.path.basename(image_path)}")
else:
# 使用线程池并行处理
with ThreadPoolExecutor(max_workers=max_workers) as executor:
for i in range(0, total_files, batch_size):
batch = image_files[i:i+batch_size]
batch_results = list(executor.map(self.process_image, batch))
for j, result in enumerate(batch_results):
processed_count += 1
if result:
success_count += 1
logger.info(f"处理成功 ({success_count}/{processed_count}): {os.path.basename(result)}")
else:
logger.warning(f"处理失败: {os.path.basename(batch[j])}")
logger.info(f"已处理 {processed_count}/{total_files} 个文件,成功率: {success_count/processed_count*100:.1f}%")
logger.info(f"处理完成。总共处理 {processed_count} 个文件,成功 {success_count} 个,成功率: {success_count/max(processed_count,1)*100:.1f}%")
return processed_count, success_count
def check_processed_status(self) -> Dict[str, List[str]]:
"""检查处理状态,返回已处理和未处理的文件列表"""
# 获取输入文件夹中的所有支持格式的图像文件
all_images = []
for ext in self.allowed_extensions:
all_images.extend([str(file) for file in Path(self.input_folder).glob(f"*{ext}")])
# 获取已处理的文件列表
processed_files = list(self.record_manager.processed_files.keys())
# 对路径进行规范化以便比较
all_image_basenames = [os.path.basename(img) for img in all_images]
# 找出未处理的文件
unprocessed_files = [
img for img, basename in zip(all_images, all_image_basenames)
if basename not in processed_files
]
# 找出已处理的文件及其对应的输出文件
processed_with_output = {
img: self.record_manager.get_output_file(basename)
for img, basename in zip(all_images, all_image_basenames)
if basename in processed_files
}
return {
'all': all_images,
'unprocessed': unprocessed_files,
'processed': processed_with_output
}
def main():
"""主函数: 解析命令行参数并执行相应操作"""
import argparse
parser = argparse.ArgumentParser(description='百度表格OCR识别工具')
parser.add_argument('--config', type=str, default='config.ini', help='配置文件路径')
parser.add_argument('--batch-size', type=int, help='批处理大小')
parser.add_argument('--max-workers', type=int, help='最大工作线程数')
parser.add_argument('--force', action='store_true', help='强制处理所有文件,包括已处理的文件')
parser.add_argument('--check', action='store_true', help='检查处理状态而不执行处理')
args = parser.parse_args()
# 加载配置
config_manager = ConfigManager(args.config)
# 创建处理器
processor = OCRProcessor(config_manager)
# 根据命令行参数调整配置
if args.force:
processor.skip_existing = False
if args.check:
# 检查处理状态
status = processor.check_processed_status()
print("\n=== 处理状态 ===")
print(f"总共 {len(status['all'])} 个图像文件")
print(f"已处理: {len(status['processed'])}")
print(f"未处理: {len(status['unprocessed'])}")
if status['processed']:
print("\n已处理文件:")
for img, output in status['processed'].items():
print(f" {os.path.basename(img)} -> {os.path.basename(output)}")
if status['unprocessed']:
print("\n未处理文件:")
for img in status['unprocessed']:
print(f" {os.path.basename(img)}")
return
# 处理图像
batch_size = args.batch_size if args.batch_size is not None else processor.batch_size
max_workers = args.max_workers if args.max_workers is not None else processor.max_workers
processor.process_images_batch(batch_size, max_workers)
if __name__ == "__main__":
try:
start_time = time.time()
logger.info("开始百度表格OCR识别程序")
main()
elapsed_time = time.time() - start_time
logger.info(f"百度表格OCR识别程序已完成耗时: {elapsed_time:.2f}")
except Exception as e:
logger.error(f"程序执行过程中发生错误: {e}", exc_info=True)
sys.exit(1)

587
v1/clean_files.py Normal file
View File

@ -0,0 +1,587 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
文件清理工具
-----------
用于清理输入/输出目录中的旧文件支持按天数和文件名模式进行清理
默认情况下会清理input目录下的所有图片文件和output目录下的Excel文件
"""
import os
import re
import sys
import logging
import argparse
from datetime import datetime, timedelta
from pathlib import Path
import time
import glob
# 配置日志
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, 'clean_files.log')
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_file, encoding='utf-8'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
class FileCleaner:
"""文件清理工具类"""
def __init__(self, input_dir="input", output_dir="output"):
"""初始化清理工具"""
self.input_dir = input_dir
self.output_dir = output_dir
self.logs_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
# 确保目录存在
for directory in [self.input_dir, self.output_dir, self.logs_dir]:
os.makedirs(directory, exist_ok=True)
logger.info(f"确保目录存在: {directory}")
def get_file_stats(self, directory):
"""获取目录的文件统计信息"""
if not os.path.exists(directory):
logger.warning(f"目录不存在: {directory}")
return {}
stats = {
'total_files': 0,
'total_size': 0,
'oldest_file': None,
'newest_file': None,
'file_types': {},
'files_by_age': {
'1_day': 0,
'7_days': 0,
'30_days': 0,
'older': 0
}
}
now = datetime.now()
one_day_ago = now - timedelta(days=1)
seven_days_ago = now - timedelta(days=7)
thirty_days_ago = now - timedelta(days=30)
for root, _, files in os.walk(directory):
for file in files:
file_path = os.path.join(root, file)
# 跳过临时文件
if file.startswith('~$') or file.startswith('.'):
continue
# 文件信息
try:
file_stats = os.stat(file_path)
file_size = file_stats.st_size
mod_time = datetime.fromtimestamp(file_stats.st_mtime)
# 更新统计信息
stats['total_files'] += 1
stats['total_size'] += file_size
# 更新最旧和最新文件
if stats['oldest_file'] is None or mod_time < stats['oldest_file'][1]:
stats['oldest_file'] = (file_path, mod_time)
if stats['newest_file'] is None or mod_time > stats['newest_file'][1]:
stats['newest_file'] = (file_path, mod_time)
# 按文件类型统计
ext = os.path.splitext(file)[1].lower()
if ext in stats['file_types']:
stats['file_types'][ext]['count'] += 1
stats['file_types'][ext]['size'] += file_size
else:
stats['file_types'][ext] = {'count': 1, 'size': file_size}
# 按年龄统计
if mod_time > one_day_ago:
stats['files_by_age']['1_day'] += 1
elif mod_time > seven_days_ago:
stats['files_by_age']['7_days'] += 1
elif mod_time > thirty_days_ago:
stats['files_by_age']['30_days'] += 1
else:
stats['files_by_age']['older'] += 1
except Exception as e:
logger.error(f"处理文件时出错 {file_path}: {e}")
return stats
def print_stats(self):
"""打印文件统计信息"""
# 输入目录统计
input_stats = self.get_file_stats(self.input_dir)
output_stats = self.get_file_stats(self.output_dir)
print("\n===== 文件统计信息 =====")
# 打印输入目录统计
if input_stats:
print(f"\n输入目录 ({self.input_dir}):")
print(f" 总文件数: {input_stats['total_files']}")
print(f" 总大小: {self._format_size(input_stats['total_size'])}")
if input_stats['oldest_file']:
oldest = input_stats['oldest_file']
print(f" 最旧文件: {os.path.basename(oldest[0])} ({oldest[1].strftime('%Y-%m-%d %H:%M:%S')})")
if input_stats['newest_file']:
newest = input_stats['newest_file']
print(f" 最新文件: {os.path.basename(newest[0])} ({newest[1].strftime('%Y-%m-%d %H:%M:%S')})")
print(" 文件年龄分布:")
print(f" 1天内: {input_stats['files_by_age']['1_day']}个文件")
print(f" 7天内(不含1天内): {input_stats['files_by_age']['7_days']}个文件")
print(f" 30天内(不含7天内): {input_stats['files_by_age']['30_days']}个文件")
print(f" 更旧: {input_stats['files_by_age']['older']}个文件")
print(" 文件类型分布:")
for ext, data in sorted(input_stats['file_types'].items(), key=lambda x: x[1]['count'], reverse=True):
print(f" {ext or '无扩展名'}: {data['count']}个文件, {self._format_size(data['size'])}")
# 打印输出目录统计
if output_stats:
print(f"\n输出目录 ({self.output_dir}):")
print(f" 总文件数: {output_stats['total_files']}")
print(f" 总大小: {self._format_size(output_stats['total_size'])}")
if output_stats['oldest_file']:
oldest = output_stats['oldest_file']
print(f" 最旧文件: {os.path.basename(oldest[0])} ({oldest[1].strftime('%Y-%m-%d %H:%M:%S')})")
if output_stats['newest_file']:
newest = output_stats['newest_file']
print(f" 最新文件: {os.path.basename(newest[0])} ({newest[1].strftime('%Y-%m-%d %H:%M:%S')})")
print(" 文件年龄分布:")
print(f" 1天内: {output_stats['files_by_age']['1_day']}个文件")
print(f" 7天内(不含1天内): {output_stats['files_by_age']['7_days']}个文件")
print(f" 30天内(不含7天内): {output_stats['files_by_age']['30_days']}个文件")
print(f" 更旧: {output_stats['files_by_age']['older']}个文件")
def _format_size(self, size_bytes):
"""格式化文件大小"""
if size_bytes < 1024:
return f"{size_bytes} 字节"
elif size_bytes < 1024 * 1024:
return f"{size_bytes/1024:.2f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes/(1024*1024):.2f} MB"
else:
return f"{size_bytes/(1024*1024*1024):.2f} GB"
def clean_files(self, directory, days=None, pattern=None, extensions=None, exclude_patterns=None, force=False, test_mode=False):
"""
清理指定目录中的文件
参数:
directory (str): 要清理的目录
days (int): 保留的天数超过这个天数的文件将被清理None表示不考虑时间
pattern (str): 文件名匹配模式正则表达式
extensions (list): 要删除的文件扩展名列表['.jpg', '.xlsx']
exclude_patterns (list): 要排除的文件名模式列表
force (bool): 是否强制清理不显示确认提示
test_mode (bool): 测试模式只显示要删除的文件而不实际删除
返回:
tuple: (cleaned_count, cleaned_size) 清理的文件数量和总大小
"""
if not os.path.exists(directory):
logger.warning(f"目录不存在: {directory}")
return 0, 0
cutoff_date = None
if days is not None:
cutoff_date = datetime.now() - timedelta(days=days)
pattern_regex = re.compile(pattern) if pattern else None
files_to_clean = []
logger.info(f"扫描目录: {directory}")
# 查找需要清理的文件
for root, _, files in os.walk(directory):
for file in files:
file_path = os.path.join(root, file)
# 跳过临时文件
if file.startswith('~$') or file.startswith('.'):
continue
# 检查是否在排除列表中
if exclude_patterns and any(pattern in file for pattern in exclude_patterns):
logger.info(f"跳过文件: {file}")
continue
# 检查文件扩展名
if extensions and not any(file.lower().endswith(ext.lower()) for ext in extensions):
continue
# 检查修改时间
if cutoff_date:
try:
mod_time = datetime.fromtimestamp(os.path.getmtime(file_path))
if mod_time >= cutoff_date:
logger.debug(f"文件未超过保留天数: {file} - {mod_time.strftime('%Y-%m-%d %H:%M:%S')}")
continue
except Exception as e:
logger.error(f"检查文件时间时出错 {file_path}: {e}")
continue
# 检查是否匹配模式
if pattern_regex and not pattern_regex.search(file):
continue
try:
file_size = os.path.getsize(file_path)
files_to_clean.append((file_path, file_size))
logger.info(f"找到要清理的文件: {file_path}")
except Exception as e:
logger.error(f"获取文件大小时出错 {file_path}: {e}")
if not files_to_clean:
logger.info(f"没有找到需要清理的文件: {directory}")
return 0, 0
# 显示要清理的文件
total_size = sum(f[1] for f in files_to_clean)
print(f"\n找到 {len(files_to_clean)} 个文件要清理,总大小: {self._format_size(total_size)}")
if len(files_to_clean) > 10:
print("前10个文件:")
for file_path, size in files_to_clean[:10]:
print(f" {os.path.basename(file_path)} ({self._format_size(size)})")
print(f" ...以及其他 {len(files_to_clean) - 10} 个文件")
else:
for file_path, size in files_to_clean:
print(f" {os.path.basename(file_path)} ({self._format_size(size)})")
# 如果是测试模式,就不实际删除
if test_mode:
print("\n测试模式:不会实际删除文件。")
return len(files_to_clean), total_size
# 确认清理
if not force:
confirm = input(f"\n确定要清理这些文件吗?[y/N] ")
if confirm.lower() != 'y':
print("清理操作已取消。")
return 0, 0
# 执行清理
cleaned_count = 0
cleaned_size = 0
for file_path, size in files_to_clean:
try:
# 删除文件
try:
# 尝试检查文件是否被其他进程占用
if os.path.exists(file_path):
# 在Windows系统上可能需要先关闭可能打开的文件句柄
if sys.platform == 'win32':
try:
# 尝试重命名文件,如果被占用通常会失败
temp_path = file_path + '.temp'
os.rename(file_path, temp_path)
os.rename(temp_path, file_path)
except Exception as e:
logger.warning(f"文件可能被占用: {file_path}, 错误: {e}")
# 尝试关闭文件句柄仅Windows
try:
import ctypes
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
handle = kernel32.CreateFileW(file_path, 0x80000000, 0, None, 3, 0x80, None)
if handle != -1:
kernel32.CloseHandle(handle)
except Exception:
pass
# 使用Path对象删除文件
try:
Path(file_path).unlink(missing_ok=True)
logger.info(f"已删除文件: {file_path}")
cleaned_count += 1
cleaned_size += size
except Exception as e1:
# 如果Path.unlink失败尝试使用os.remove
try:
os.remove(file_path)
logger.info(f"使用os.remove删除文件: {file_path}")
cleaned_count += 1
cleaned_size += size
except Exception as e2:
logger.error(f"删除文件失败 {file_path}: {e1}, 再次尝试: {e2}")
else:
logger.warning(f"文件不存在或已被删除: {file_path}")
except Exception as e:
logger.error(f"删除文件时出错 {file_path}: {e}")
except Exception as e:
logger.error(f"处理文件时出错 {file_path}: {e}")
print(f"\n已清理 {cleaned_count} 个文件,总大小: {self._format_size(cleaned_size)}")
return cleaned_count, cleaned_size
def clean_image_files(self, force=False, test_mode=False):
"""清理输入目录中的图片文件"""
print(f"\n===== 清理输入目录图片文件 ({self.input_dir}) =====")
image_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif']
return self.clean_files(
self.input_dir,
days=None, # 不考虑天数,清理所有图片
extensions=image_extensions,
force=force,
test_mode=test_mode
)
def clean_excel_files(self, force=False, test_mode=False):
"""清理输出目录中的Excel文件"""
print(f"\n===== 清理输出目录Excel文件 ({self.output_dir}) =====")
excel_extensions = ['.xlsx', '.xls']
exclude_patterns = ['processed_files.json'] # 保留处理记录文件
return self.clean_files(
self.output_dir,
days=None, # 不考虑天数清理所有Excel
extensions=excel_extensions,
exclude_patterns=exclude_patterns,
force=force,
test_mode=test_mode
)
def clean_log_files(self, days=None, force=False, test_mode=False):
"""清理日志目录中的旧日志文件
参数:
days (int): 保留的天数超过这个天数的日志将被清理None表示清理所有日志
force (bool): 是否强制清理不显示确认提示
test_mode (bool): 测试模式只显示要删除的文件而不实际删除
"""
print(f"\n===== 清理日志文件 ({self.logs_dir}) =====")
log_extensions = ['.log']
# 排除当前正在使用的日志文件
current_log = os.path.basename(log_file)
logger.info(f"当前使用的日志文件: {current_log}")
result = self.clean_files(
self.logs_dir,
days=days, # 如果days=None清理所有日志文件
extensions=log_extensions,
exclude_patterns=[current_log], # 排除当前使用的日志文件
force=force,
test_mode=test_mode
)
return result
def clean_logs(self, days=7, force=False, test=False):
"""清理日志目录中的日志文件"""
try:
logs_dir = self.logs_dir
if not os.path.exists(logs_dir):
logger.warning(f"日志目录不存在: {logs_dir}")
return
cutoff_date = datetime.now() - timedelta(days=days)
files_to_delete = []
# 检查是否有活跃标记文件
active_files = set()
for marker_file in glob.glob(os.path.join(logs_dir, '*.active')):
active_log_name = os.path.basename(marker_file).replace('.active', '.log')
active_files.add(active_log_name)
logger.info(f"检测到活跃日志文件: {active_log_name}")
for file_path in glob.glob(os.path.join(logs_dir, '*.log*')):
file_name = os.path.basename(file_path)
# 跳过活跃的日志文件
if file_name in active_files:
logger.info(f"跳过活跃日志文件: {file_name}")
continue
mtime = os.path.getmtime(file_path)
if datetime.fromtimestamp(mtime) < cutoff_date:
files_to_delete.append(file_path)
if not files_to_delete:
logger.info("没有找到需要清理的日志文件")
return
logger.info(f"找到 {len(files_to_delete)} 个过期的日志文件")
for file_path in files_to_delete:
if test:
logger.info(f"测试模式 - 将删除: {os.path.basename(file_path)}")
else:
if not force:
response = input(f"是否删除日志文件 {os.path.basename(file_path)}? (y/n): ")
if response.lower() != 'y':
logger.info(f"已跳过 {os.path.basename(file_path)}")
continue
try:
os.remove(file_path)
logger.info(f"已删除日志文件: {os.path.basename(file_path)}")
except Exception as e:
logger.error(f"删除文件失败: {file_path}, 错误: {e}")
except Exception as e:
logger.error(f"清理日志文件时出错: {e}")
def clean_all_logs(self, force=False, test=False, except_current=True):
"""清理所有日志文件"""
try:
logs_dir = self.logs_dir
if not os.path.exists(logs_dir):
logger.warning(f"日志目录不存在: {logs_dir}")
return
# 检查是否有活跃标记文件
active_files = set()
for marker_file in glob.glob(os.path.join(logs_dir, '*.active')):
active_log_name = os.path.basename(marker_file).replace('.active', '.log')
active_files.add(active_log_name)
logger.info(f"检测到活跃日志文件: {active_log_name}")
files_to_delete = []
for file_path in glob.glob(os.path.join(logs_dir, '*.log*')):
file_name = os.path.basename(file_path)
# 跳过当前正在使用的日志文件
if except_current and file_name in active_files:
logger.info(f"保留活跃日志文件: {file_name}")
continue
files_to_delete.append(file_path)
if not files_to_delete:
logger.info("没有找到需要清理的日志文件")
return
logger.info(f"找到 {len(files_to_delete)} 个日志文件需要清理")
for file_path in files_to_delete:
if test:
logger.info(f"测试模式 - 将删除: {os.path.basename(file_path)}")
else:
if not force:
response = input(f"是否删除日志文件 {os.path.basename(file_path)}? (y/n): ")
if response.lower() != 'y':
logger.info(f"已跳过 {os.path.basename(file_path)}")
continue
try:
os.remove(file_path)
logger.info(f"已删除日志文件: {os.path.basename(file_path)}")
except Exception as e:
logger.error(f"删除文件失败: {file_path}, 错误: {e}")
except Exception as e:
logger.error(f"清理所有日志文件时出错: {e}")
def main():
"""主程序"""
parser = argparse.ArgumentParser(description='文件清理工具')
parser.add_argument('--stats', action='store_true', help='显示文件统计信息')
parser.add_argument('--clean-input', action='store_true', help='清理输入目录中超过指定天数的文件')
parser.add_argument('--clean-output', action='store_true', help='清理输出目录中超过指定天数的文件')
parser.add_argument('--clean-images', action='store_true', help='清理输入目录中的所有图片文件')
parser.add_argument('--clean-excel', action='store_true', help='清理输出目录中的所有Excel文件')
parser.add_argument('--clean-logs', action='store_true', help='清理日志目录中超过指定天数的日志文件')
parser.add_argument('--clean-all-logs', action='store_true', help='清理所有日志文件(除当前使用的)')
parser.add_argument('--days', type=int, default=30, help='保留的天数默认30天')
parser.add_argument('--log-days', type=int, default=7, help='保留的日志天数默认7天')
parser.add_argument('--pattern', type=str, help='文件名匹配模式(正则表达式)')
parser.add_argument('--force', action='store_true', help='强制清理,不显示确认提示')
parser.add_argument('--test', action='store_true', help='测试模式,只显示要删除的文件而不实际删除')
parser.add_argument('--input-dir', type=str, default='input', help='指定输入目录')
parser.add_argument('--output-dir', type=str, default='output', help='指定输出目录')
parser.add_argument('--help-only', action='store_true', help='只显示帮助信息,不执行任何操作')
parser.add_argument('--all', action='store_true', help='清理所有类型的文件(输入、输出和日志)')
args = parser.parse_args()
cleaner = FileCleaner(args.input_dir, args.output_dir)
# 显示统计信息
if args.stats:
cleaner.print_stats()
# 如果指定了--help-only只显示帮助信息
if args.help_only:
parser.print_help()
return
# 如果指定了--all清理所有类型的文件
if args.all:
cleaner.clean_image_files(args.force, args.test)
cleaner.clean_excel_files(args.force, args.test)
cleaner.clean_log_files(args.log_days, args.force, args.test)
cleaner.clean_all_logs(args.force, args.test)
return
# 清理输入目录中的图片文件
if args.clean_images or not any([args.stats, args.clean_input, args.clean_output,
args.clean_excel, args.clean_logs, args.clean_all_logs, args.help_only]):
cleaner.clean_image_files(args.force, args.test)
# 清理输出目录中的Excel文件
if args.clean_excel or not any([args.stats, args.clean_input, args.clean_output,
args.clean_images, args.clean_logs, args.clean_all_logs, args.help_only]):
cleaner.clean_excel_files(args.force, args.test)
# 清理日志文件(按天数)
if args.clean_logs:
cleaner.clean_log_files(args.log_days, args.force, args.test)
# 清理所有日志文件
if args.clean_all_logs:
cleaner.clean_all_logs(args.force, args.test)
# 清理输入目录(按天数)
if args.clean_input:
print(f"\n===== 清理输入目录 ({args.input_dir}) =====")
cleaner.clean_files(
args.input_dir,
days=args.days,
pattern=args.pattern,
force=args.force,
test_mode=args.test
)
# 清理输出目录(按天数)
if args.clean_output:
print(f"\n===== 清理输出目录 ({args.output_dir}) =====")
cleaner.clean_files(
args.output_dir,
days=args.days,
pattern=args.pattern,
force=args.force,
test_mode=args.test
)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n程序已被用户中断")
except Exception as e:
logger.error(f"程序运行出错: {e}", exc_info=True)
print(f"程序运行出错: {e}")
print("请查看日志文件了解详细信息")
sys.exit(0)

223
v1/clean_logs.py Normal file
View File

@ -0,0 +1,223 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
日志清理脚本
-----------
用于清理和管理日志文件包括
1. 清理指定天数之前的日志文件
2. 保留最新的N个日志文件
3. 清理过大的日志文件
4. 支持压缩旧日志文件
"""
import os
import sys
import time
import shutil
import logging
import argparse
from datetime import datetime, timedelta
import gzip
from pathlib import Path
import glob
import re
# 配置日志
logger = logging.getLogger(__name__)
if not logger.handlers:
log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs', 'clean_logs.log')
os.makedirs(os.path.dirname(log_file), exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_file, encoding='utf-8'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
# 标记该日志文件为活跃
active_marker = os.path.join(os.path.dirname(log_file), 'clean_logs.active')
with open(active_marker, 'w') as f:
f.write(f"Active since: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
def is_log_active(log_file):
"""检查日志文件是否处于活跃状态(正在被使用)"""
# 检查对应的活跃标记文件是否存在
log_name = os.path.basename(log_file)
base_name = os.path.splitext(log_name)[0]
active_marker = os.path.join(os.path.dirname(log_file), f"{base_name}.active")
# 如果活跃标记文件存在,说明日志文件正在被使用
if os.path.exists(active_marker):
logger.info(f"日志文件 {log_name} 正在使用中,不会被删除")
return True
# 检查是否是当前脚本正在使用的日志文件
if log_name == os.path.basename(log_file):
logger.info(f"当前脚本正在使用 {log_name},不会被删除")
return True
return False
def clean_logs(log_dir="logs", max_days=7, max_files=10, max_size=100, force=False):
"""
清理日志文件
参数:
log_dir: 日志目录
max_days: 保留的最大天数
max_files: 保留的最大文件数
max_size: 日志文件大小上限(MB)
force: 是否强制清理
"""
logger.info(f"开始清理日志目录: {log_dir}")
# 确保日志目录存在
if not os.path.exists(log_dir):
logger.warning(f"日志目录不存在: {log_dir}")
return
# 获取所有日志文件
log_files = []
for ext in ['*.log', '*.log.*']:
log_files.extend(glob.glob(os.path.join(log_dir, ext)))
if not log_files:
logger.info(f"没有找到日志文件")
return
logger.info(f"找到 {len(log_files)} 个日志文件")
# 按修改时间排序
log_files.sort(key=lambda x: os.path.getmtime(x), reverse=True)
# 处理大文件
for log_file in log_files:
# 跳过活跃的日志文件
if is_log_active(log_file):
continue
# 检查文件大小
file_size_mb = os.path.getsize(log_file) / (1024 * 1024)
if file_size_mb > max_size:
logger.info(f"日志文件 {os.path.basename(log_file)} 大小为 {file_size_mb:.2f}MB超过限制 {max_size}MB")
# 压缩并重命名大文件
compressed_file = f"{log_file}.{datetime.now().strftime('%Y%m%d%H%M%S')}.zip"
try:
shutil.make_archive(os.path.splitext(compressed_file)[0], 'zip', log_dir, os.path.basename(log_file))
logger.info(f"已压缩日志文件: {compressed_file}")
# 清空原文件内容
if not force:
confirm = input(f"是否清空日志文件 {os.path.basename(log_file)}? (y/n): ")
if confirm.lower() != 'y':
logger.info("已取消清空操作")
continue
with open(log_file, 'w') as f:
f.write(f"日志已于 {datetime.now()} 清空并压缩\n")
logger.info(f"已清空日志文件: {os.path.basename(log_file)}")
except Exception as e:
logger.error(f"压缩日志文件时出错: {e}")
# 清理过期的文件
cutoff_date = datetime.now() - timedelta(days=max_days)
files_to_delete = []
for log_file in log_files[max_files:]:
# 跳过活跃的日志文件
if is_log_active(log_file):
continue
mtime = datetime.fromtimestamp(os.path.getmtime(log_file))
if mtime < cutoff_date:
files_to_delete.append(log_file)
if not files_to_delete:
logger.info("没有需要删除的过期日志文件")
return
logger.info(f"找到 {len(files_to_delete)} 个过期日志文件")
# 确认删除
if not force:
print(f"以下 {len(files_to_delete)} 个文件将被删除:")
for file in files_to_delete:
print(f" - {os.path.basename(file)}")
confirm = input("确认删除? (y/n): ")
if confirm.lower() != 'y':
logger.info("已取消删除操作")
return
# 删除文件
deleted_count = 0
for file in files_to_delete:
try:
os.remove(file)
logger.info(f"已删除日志文件: {os.path.basename(file)}")
deleted_count += 1
except Exception as e:
logger.error(f"删除日志文件时出错: {e}")
logger.info(f"成功删除 {deleted_count} 个日志文件")
def show_stats(log_dir="logs"):
"""显示日志文件统计信息"""
if not os.path.exists(log_dir):
print(f"日志目录不存在: {log_dir}")
return
log_files = []
for ext in ['*.log', '*.log.*']:
log_files.extend(glob.glob(os.path.join(log_dir, ext)))
if not log_files:
print("没有找到日志文件")
return
print(f"\n找到 {len(log_files)} 个日志文件:")
print("=" * 80)
print(f"{'文件名':<30} {'大小':<10} {'最后修改时间':<20} {'状态':<10}")
print("-" * 80)
total_size = 0
for file in sorted(log_files, key=lambda x: os.path.getmtime(x), reverse=True):
size = os.path.getsize(file)
total_size += size
mtime = datetime.fromtimestamp(os.path.getmtime(file))
size_str = f"{size / 1024:.1f} KB" if size < 1024*1024 else f"{size / (1024*1024):.1f} MB"
# 检查是否是活跃日志
status = "活跃" if is_log_active(file) else ""
print(f"{os.path.basename(file):<30} {size_str:<10} {mtime.strftime('%Y-%m-%d %H:%M:%S'):<20} {status:<10}")
print("-" * 80)
total_size_str = f"{total_size / 1024:.1f} KB" if total_size < 1024*1024 else f"{total_size / (1024*1024):.1f} MB"
print(f"总大小: {total_size_str}")
print("=" * 80)
def main():
parser = argparse.ArgumentParser(description="日志文件清理工具")
parser.add_argument("--max-days", type=int, default=7, help="日志保留的最大天数")
parser.add_argument("--max-files", type=int, default=10, help="保留的最大文件数")
parser.add_argument("--max-size", type=float, default=100, help="日志文件大小上限(MB)")
parser.add_argument("--force", action="store_true", help="强制清理,不提示确认")
parser.add_argument("--stats", action="store_true", help="显示日志统计信息")
parser.add_argument("--log-dir", type=str, default="logs", help="日志目录")
args = parser.parse_args()
if args.stats:
show_stats(args.log_dir)
else:
clean_logs(args.log_dir, args.max_days, args.max_files, args.max_size, args.force)
if __name__ == "__main__":
main()

1364
v1/excel_processor_step2.py Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 MiB

BIN
v1/logs/.keep Normal file

Binary file not shown.

View File

@ -0,0 +1 @@
Active since: 2025-05-01 19:46:44

View File

@ -0,0 +1 @@
Active since: 2025-05-01 19:49:19

420
v1/merge_purchase_orders.py Normal file
View File

@ -0,0 +1,420 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
合并采购单程序
-------------------
将多个采购单Excel文件合并成一个文件
"""
import os
import sys
import logging
import pandas as pd
import xlrd
import xlwt
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union, Any
from datetime import datetime
import random
from xlutils.copy import copy as xlcopy
import time
import json
import re
# 配置日志
logger = logging.getLogger(__name__)
if not logger.handlers:
log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs', 'merge_purchase_orders.log')
os.makedirs(os.path.dirname(log_file), exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_file, encoding='utf-8'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
logger.info("初始化日志系统")
class PurchaseOrderMerger:
"""
采购单合并器将多个采购单Excel文件合并成一个文件
"""
def __init__(self, output_dir="output"):
"""
初始化采购单合并器并设置输出目录
"""
logger.info("初始化PurchaseOrderMerger")
self.output_dir = output_dir
# 确保输出目录存在
if not os.path.exists(output_dir):
os.makedirs(output_dir)
logger.info(f"创建输出目录: {output_dir}")
# 设置路径
self.template_path = os.path.join("templets", "银豹-采购单模板.xls")
# 检查模板文件是否存在
if not os.path.exists(self.template_path):
logger.error(f"模板文件不存在: {self.template_path}")
raise FileNotFoundError(f"模板文件不存在: {self.template_path}")
# 用于记录已处理的文件
self.cache_file = os.path.join(output_dir, "merged_files.json")
self.merged_files = self._load_merged_files()
logger.info(f"初始化完成,模板文件: {self.template_path}")
def _load_merged_files(self):
"""加载已合并文件的缓存"""
if os.path.exists(self.cache_file):
try:
with open(self.cache_file, 'r', encoding='utf-8') as f:
cache = json.load(f)
logger.info(f"加载已合并文件缓存,共{len(cache)} 条记录")
return cache
except Exception as e:
logger.warning(f"读取缓存文件失败: {e}")
return {}
def _save_merged_files(self):
"""保存已合并文件的缓存"""
try:
with open(self.cache_file, 'w', encoding='utf-8') as f:
json.dump(self.merged_files, f, ensure_ascii=False, indent=2)
logger.info(f"已更新合并文件缓存,共{len(self.merged_files)} 条记录")
except Exception as e:
logger.warning(f"保存缓存文件失败: {e}")
def get_latest_purchase_orders(self):
"""
获取output目录下最新的采购单Excel文件
"""
logger.info(f"搜索目录 {self.output_dir} 中的采购单Excel文件")
excel_files = []
for file in os.listdir(self.output_dir):
# 只处理以"采购单_"开头的Excel文件
if file.lower().endswith('.xls') and file.startswith('采购单_'):
file_path = os.path.join(self.output_dir, file)
excel_files.append((file_path, os.path.getmtime(file_path)))
if not excel_files:
logger.warning(f"未在 {self.output_dir} 目录下找到采购单Excel文件")
return []
# 按修改时间排序,获取最新的文件
sorted_files = sorted(excel_files, key=lambda x: x[1], reverse=True)
logger.info(f"找到{len(sorted_files)} 个采购单Excel文件")
return [file[0] for file in sorted_files]
def read_purchase_order(self, file_path):
"""
读取采购单Excel文件
"""
try:
# 读取Excel文件
df = pd.read_excel(file_path)
logger.info(f"成功读取采购单文件: {file_path}")
# 打印列名,用于调试
logger.info(f"Excel文件的列名: {df.columns.tolist()}")
# 检查是否有特殊表头结构(如"武侯环球乐百惠便利店3333.xlsx"
# 判断依据检查第3行是否包含常见的商品表头信息
special_header = False
if len(df) > 3: # 确保有足够的行
row3 = df.iloc[3].astype(str)
header_keywords = ['行号', '条形码', '条码', '商品名称', '规格', '单价', '数量', '金额', '单位']
# 计算匹配的关键词数量
matches = sum(1 for keyword in header_keywords if any(keyword in str(val) for val in row3.values))
# 如果匹配了至少3个关键词认为第3行是表头
if matches >= 3:
logger.info(f"检测到特殊表头结构使用第3行作为列名: {row3.values.tolist()}")
# 创建新的数据帧使用第3行作为列名数据从第4行开始
header_row = df.iloc[3]
data_rows = df.iloc[4:].reset_index(drop=True)
# 为每一列分配一个名称(避免重复的列名)
new_columns = []
for i, col in enumerate(header_row):
col_str = str(col)
if col_str == 'nan' or col_str == 'None' or pd.isna(col):
new_columns.append(f"Col_{i}")
else:
new_columns.append(col_str)
# 使用新列名创建新的DataFrame
data_rows.columns = new_columns
df = data_rows
special_header = True
logger.info(f"重新构建的数据帧列名: {df.columns.tolist()}")
# 定义可能的列名映射
column_mapping = {
'条码': ['条码', '条形码', '商品条码', 'barcode', '商品条形码', '条形码', '商品条码', '商品编码', '商品编号', '条形码', '条码(必填)'],
'采购量': ['数量', '采购数量', '购买数量', '采购数量', '订单数量', '采购数量', '采购量(必填)'],
'采购单价': ['单价', '价格', '采购单价', '销售价', '采购单价(必填)'],
'赠送量': ['赠送量', '赠品数量', '赠送数量', '赠品']
}
# 映射实际的列名
mapped_columns = {}
for target_col, possible_names in column_mapping.items():
for col in df.columns:
# 移除列名中的空白字符和括号内容以进行比较
clean_col = re.sub(r'\s+', '', str(col))
clean_col = re.sub(r'.*?', '', clean_col) # 移除括号内容
for name in possible_names:
clean_name = re.sub(r'\s+', '', name)
clean_name = re.sub(r'.*?', '', clean_name) # 移除括号内容
if clean_col == clean_name:
mapped_columns[target_col] = col
break
if target_col in mapped_columns:
break
# 如果找到了必要的列,重命名列
if mapped_columns:
df = df.rename(columns=mapped_columns)
logger.info(f"列名映射结果: {mapped_columns}")
return df
except Exception as e:
logger.error(f"读取采购单文件失败: {file_path}, 错误: {str(e)}")
return None
def merge_purchase_orders(self, file_paths):
"""
合并多个采购单文件
"""
if not file_paths:
logger.warning("没有需要合并的采购单文件")
return None
# 读取所有采购单文件
dfs = []
for file_path in file_paths:
df = self.read_purchase_order(file_path)
if df is not None:
# 确保条码列是字符串类型
df['条码(必填)'] = df['条码(必填)'].astype(str)
# 去除可能的小数点和.0
df['条码(必填)'] = df['条码(必填)'].apply(lambda x: x.split('.')[0] if '.' in x else x)
# 处理NaN值将其转换为空字符串
for col in df.columns:
df[col] = df[col].apply(lambda x: '' if pd.isna(x) else x)
dfs.append(df)
if not dfs:
logger.error("没有成功读取任何采购单文件")
return None
# 合并所有数据框
merged_df = pd.concat(dfs, ignore_index=True)
logger.info(f"合并了{len(dfs)} 个采购单文件,共{len(merged_df)} 条记录")
# 检查并合并相同条码和单价的数据
merged_data = {}
for _, row in merged_df.iterrows():
# 使用映射后的列名访问数据
barcode = str(row['条码(必填)']) # 保持字符串格式
# 移除条码中可能的小数点
barcode = barcode.split('.')[0] if '.' in barcode else barcode
unit_price = float(row['采购单价(必填)'])
quantity = float(row['采购量(必填)'])
# 检查赠送量是否为空
has_gift = '赠送量' in row and row['赠送量'] != '' and not pd.isna(row['赠送量'])
gift_quantity = float(row['赠送量']) if has_gift else ''
# 商品名称处理,确保不会出现"nan"
product_name = row['商品名称']
if pd.isna(product_name) or product_name == 'nan' or product_name == 'None':
product_name = ''
# 创建唯一键:条码+单价
key = f"{barcode}_{unit_price}"
if key in merged_data:
# 如果已存在相同条码和单价的数据,累加数量
merged_data[key]['采购量(必填)'] += quantity
# 如果当前记录有赠送量且之前的记录也有赠送量,则累加赠送量
if has_gift and merged_data[key]['赠送量'] != '':
merged_data[key]['赠送量'] += gift_quantity
# 如果当前记录有赠送量但之前的记录没有,则设置赠送量
elif has_gift:
merged_data[key]['赠送量'] = gift_quantity
# 其他情况保持原样(为空)
logger.info(f"合并相同条码和单价的数据: 条码={barcode}, 单价={unit_price}, 数量={quantity}, 赠送量={gift_quantity}")
# 如果当前商品名称不为空,且原来的为空,则更新商品名称
if product_name and not merged_data[key]['商品名称']:
merged_data[key]['商品名称'] = product_name
else:
# 如果是新数据,直接添加
merged_data[key] = {
'商品名称': product_name,
'条码(必填)': barcode, # 使用处理后的条码
'采购量(必填)': quantity,
'赠送量': gift_quantity,
'采购单价(必填)': unit_price
}
# 将合并后的数据转换回DataFrame
final_df = pd.DataFrame(list(merged_data.values()))
logger.info(f"合并后剩余{len(final_df)} 条唯一记录")
return final_df
def create_merged_purchase_order(self, df):
"""
创建合并后的采购单Excel文件
"""
try:
# 获取当前时间戳
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
# 创建输出文件路径
output_file = os.path.join(self.output_dir, f"合并采购单_{timestamp}.xls")
# 打开模板文件
workbook = xlrd.open_workbook(self.template_path)
workbook = xlcopy(workbook)
worksheet = workbook.get_sheet(0)
# 从第2行开始填充数据
row_index = 1
# 按条码排序
df = df.sort_values('条码(必填)')
# 填充数据
for _, row in df.iterrows():
# 1. 列A(0): 商品名称
product_name = str(row['商品名称'])
# 检查并处理nan值
if product_name == 'nan' or product_name == 'None':
product_name = ''
worksheet.write(row_index, 0, product_name)
# 2. 列B(1): 条码
worksheet.write(row_index, 1, str(row['条码(必填)']))
# 3. 列C(2): 采购量
worksheet.write(row_index, 2, float(row['采购量(必填)']))
# 4. 列D(3): 赠送量
# 只有当赠送量不为空且不为0时才写入
if '赠送量' in row and row['赠送量'] != '' and not pd.isna(row['赠送量']):
# 将赠送量转换为数字
try:
gift_quantity = float(row['赠送量'])
# 只有当赠送量大于0时才写入
if gift_quantity > 0:
worksheet.write(row_index, 3, gift_quantity)
except (ValueError, TypeError):
# 如果转换失败,忽略赠送量
pass
# 5. 列E(4): 采购单价
style = xlwt.XFStyle()
style.num_format_str = '0.0000'
worksheet.write(row_index, 4, float(row['采购单价(必填)']), style)
row_index += 1
# 保存文件
workbook.save(output_file)
logger.info(f"合并采购单已保存: {output_file}")
# 记录已合并文件
for file_path in self.get_latest_purchase_orders():
file_stat = os.stat(file_path)
file_key = f"{os.path.basename(file_path)}_{file_stat.st_size}_{file_stat.st_mtime}"
self.merged_files[file_key] = output_file
self._save_merged_files()
return output_file
except Exception as e:
logger.error(f"创建合并采购单失败: {str(e)}")
return None
def process(self):
"""
处理最新的采购单文件
"""
# 获取最新的采购单文件
file_paths = self.get_latest_purchase_orders()
if not file_paths:
logger.error("未找到可处理的采购单文件")
return False
# 合并采购单
merged_df = self.merge_purchase_orders(file_paths)
if merged_df is None:
logger.error("合并采购单失败")
return False
# 创建合并后的采购单
output_file = self.create_merged_purchase_order(merged_df)
if output_file is None:
logger.error("创建合并采购单失败")
return False
logger.info(f"处理完成,合并采购单已保存至: {output_file}")
return True
def main():
"""主程序"""
import argparse
# 解析命令行参数
parser = argparse.ArgumentParser(description='合并采购单程序')
parser.add_argument('--input', type=str, help='指定输入采购单文件路径,多个文件用逗号分隔')
args = parser.parse_args()
merger = PurchaseOrderMerger()
# 处理采购单文件
try:
if args.input:
# 使用指定文件处理
file_paths = [path.strip() for path in args.input.split(',')]
merged_df = merger.merge_purchase_orders(file_paths)
if merged_df is not None:
output_file = merger.create_merged_purchase_order(merged_df)
if output_file:
print(f"处理成功!合并采购单已保存至: {output_file}")
else:
print("处理失败!请查看日志了解详细信息")
else:
print("处理失败!请查看日志了解详细信息")
else:
# 使用默认处理流程(查找最新文件)
result = merger.process()
if result:
print("处理成功!已将数据合并并保存")
else:
print("处理失败!请查看日志了解详细信息")
except Exception as e:
logger.error(f"处理过程中发生错误: {e}", exc_info=True)
print(f"处理过程中发生错误: {e}")
print("请查看日志文件了解详细信息")
if __name__ == "__main__":
try:
main()
except Exception as e:
logger.error(f"程序执行过程中发生错误: {e}", exc_info=True)
sys.exit(1)

View File

@ -0,0 +1,4 @@
{
"采购单_武侯环球乐百惠便利店849.xls_5632_1746098172.9159887": "output\\合并采购单_20250501193931.xls",
"采购单_武侯环球乐百惠便利店3333.xls_9728_1746097892.1829922": "output\\合并采购单_20250501193931.xls"
}

9
v1/requirements.txt Normal file
View File

@ -0,0 +1,9 @@
configparser>=5.0.0
numpy>=1.19.0
openpyxl>=3.0.0
pandas>=1.3.0
pathlib>=1.0.1
requests>=2.25.0
xlrd>=2.0.0,<2.1.0
xlutils>=2.0.0
xlwt>=1.3.0

235
v1/run_ocr.py Normal file
View File

@ -0,0 +1,235 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
OCR流程运行脚本
-------------
整合百度OCR和Excel处理功能的便捷脚本
"""
import os
import sys
import argparse
import logging
import configparser
from pathlib import Path
from datetime import datetime
# 确保logs目录存在
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
os.makedirs(log_dir, exist_ok=True)
# 设置日志文件路径
log_file = os.path.join(log_dir, 'ocr_processor.log')
# 配置日志
logger = logging.getLogger('ocr_processor')
if not logger.handlers:
# 创建文件处理器
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setLevel(logging.INFO)
# 创建控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
# 设置格式
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
# 添加处理器到日志器
logger.addHandler(file_handler)
logger.addHandler(console_handler)
# 设置日志级别
logger.setLevel(logging.INFO)
logger.info("OCR处理器初始化")
# 标记该日志文件为活跃,避免被清理工具删除
try:
# 创建一个标记文件,表示该日志文件正在使用中
active_marker = os.path.join(log_dir, 'ocr_processor.active')
with open(active_marker, 'w') as f:
f.write(f"Active since: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
except Exception as e:
logger.warning(f"无法创建日志活跃标记: {e}")
def parse_args():
"""解析命令行参数"""
parser = argparse.ArgumentParser(description='OCR流程运行脚本')
parser.add_argument('--step', type=int, default=0, help='运行步骤: 1-OCR识别, 2-Excel处理, 0-全部运行 (默认)')
parser.add_argument('--config', type=str, default='config.ini', help='配置文件路径')
parser.add_argument('--force', action='store_true', help='强制处理所有文件,包括已处理的文件')
parser.add_argument('--input', type=str, help='指定输入文件(仅用于单文件处理)')
parser.add_argument('--output', type=str, help='指定输出文件(仅用于单文件处理)')
return parser.parse_args()
def check_env():
"""检查配置是否有效"""
try:
# 尝试读取配置文件
config = configparser.ConfigParser()
if not config.read('config.ini', encoding='utf-8'):
logger.warning("未找到配置文件config.ini或文件为空")
return
# 检查API密钥是否已配置
if not config.has_section('API'):
logger.warning("配置文件中缺少[API]部分")
return
api_key = config.get('API', 'api_key', fallback='')
secret_key = config.get('API', 'secret_key', fallback='')
if not api_key or not secret_key:
logger.warning("API密钥未设置或为空请在config.ini中配置API密钥")
except Exception as e:
logger.error(f"检查配置时出错: {e}")
def run_ocr(args):
"""运行OCR识别过程"""
logger.info("开始OCR识别过程...")
# 导入模块
try:
from baidu_table_ocr import OCRProcessor, ConfigManager
# 创建配置管理器
config_manager = ConfigManager(args.config)
# 创建处理器
processor = OCRProcessor(config_manager)
# 检查输入目录中是否有图片
input_files = processor.get_unprocessed_images()
if not input_files and not args.input:
logger.warning(f"{processor.input_folder}目录中没有找到未处理的图片文件")
return False
# 单文件处理或批量处理
if args.input:
if not os.path.exists(args.input):
logger.error(f"输入文件不存在: {args.input}")
return False
logger.info(f"处理单个文件: {args.input}")
output_file = processor.process_image(args.input)
if output_file:
logger.info(f"OCR识别成功输出文件: {output_file}")
return True
else:
logger.error("OCR识别失败")
return False
else:
# 批量处理
batch_size = processor.batch_size
max_workers = processor.max_workers
# 如果需要强制处理先设置skip_existing为False
if args.force:
processor.skip_existing = False
logger.info(f"批量处理文件,批量大小: {batch_size}, 最大线程数: {max_workers}")
total, success = processor.process_images_batch(
batch_size=batch_size,
max_workers=max_workers
)
logger.info(f"OCR识别完成总计处理: {total},成功: {success}")
return success > 0
except ImportError as e:
logger.error(f"导入OCR模块失败: {e}")
return False
except Exception as e:
logger.error(f"OCR识别过程出错: {e}")
return False
def run_excel_processing(args):
"""运行Excel处理过程"""
logger.info("开始Excel处理过程...")
# 导入模块
try:
from excel_processor_step2 import ExcelProcessorStep2
# 创建处理器
processor = ExcelProcessorStep2()
# 单文件处理或批量处理
if args.input:
if not os.path.exists(args.input):
logger.error(f"输入文件不存在: {args.input}")
return False
logger.info(f"处理单个Excel文件: {args.input}")
result = processor.process_specific_file(args.input)
if result:
logger.info(f"Excel处理成功")
return True
else:
logger.error("Excel处理失败请查看日志了解详细信息")
return False
else:
# 检查output目录中最新的Excel文件
latest_file = processor.get_latest_excel()
if not latest_file:
logger.error("未找到可处理的Excel文件无法进行处理")
return False
# 处理最新的Excel文件
logger.info(f"处理最新的Excel文件: {latest_file}")
result = processor.process_latest_file()
if result:
logger.info("Excel处理成功")
return True
else:
logger.error("Excel处理失败请查看日志了解详细信息")
return False
except ImportError as e:
logger.error(f"导入Excel处理模块失败: {e}")
return False
except Exception as e:
logger.error(f"Excel处理过程出错: {e}")
return False
def main():
"""主函数"""
# 解析命令行参数
args = parse_args()
# 检查环境变量
check_env()
# 根据步骤运行相应的处理
ocr_success = False
if args.step == 0 or args.step == 1:
ocr_success = run_ocr(args)
if not ocr_success:
if args.step == 1:
logger.error("OCR识别失败请检查input目录是否有图片或检查API配置")
sys.exit(1)
else:
logger.warning("OCR识别未处理任何文件跳过Excel处理步骤")
return
else:
# 如果只运行第二步假设OCR已成功完成
ocr_success = True
# 只有当OCR成功或只运行第二步时才执行Excel处理
if ocr_success and (args.step == 0 or args.step == 2):
excel_result = run_excel_processing(args)
if not excel_result and args.step == 2:
logger.error("Excel处理失败")
sys.exit(1)
logger.info("处理完成")
if __name__ == "__main__":
main()

Binary file not shown.

66
v1/test_ocr_log.py Normal file
View File

@ -0,0 +1,66 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
测试OCR处理器日志文件创建
"""
import os
import sys
import logging
from datetime import datetime
# 确保logs目录存在
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
os.makedirs(log_dir, exist_ok=True)
print(f"日志目录: {log_dir}")
# 设置日志文件路径
log_file = os.path.join(log_dir, 'ocr_processor.log')
print(f"日志文件路径: {log_file}")
# 配置日志
logger = logging.getLogger('ocr_processor')
if not logger.handlers:
# 创建文件处理器
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setLevel(logging.INFO)
# 创建控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
# 设置格式
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
file_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
# 添加处理器到日志器
logger.addHandler(file_handler)
logger.addHandler(console_handler)
# 设置日志级别
logger.setLevel(logging.INFO)
# 写入测试日志
logger.info("这是一条测试日志消息")
logger.info(f"测试时间: {datetime.now()}")
# 标记该日志文件为活跃,避免被清理工具删除
try:
# 创建一个标记文件,表示该日志文件正在使用中
active_marker = os.path.join(log_dir, 'ocr_processor.active')
with open(active_marker, 'w') as f:
f.write(f"Active since: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"活跃标记文件: {active_marker}")
except Exception as e:
print(f"无法创建日志活跃标记: {e}")
# 检查文件是否已创建
if os.path.exists(log_file):
print(f"日志文件已成功创建: {log_file}")
print(f"文件大小: {os.path.getsize(log_file)} 字节")
else:
print(f"错误: 日志文件创建失败: {log_file}")
print("测试完成")

141
v1/test_unit_conversion.py Normal file
View File

@ -0,0 +1,141 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
单位转换处理规则测试
-------------------
这个脚本用于演示excel_processor_step2.py中的单位转换处理规则
包括件盒单位的处理以及特殊条码的处理
"""
import os
import sys
import logging
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
def test_unit_conversion(barcode, unit, quantity, specification, unit_price):
"""
测试单位转换处理逻辑
"""
logger.info(f"测试条码: {barcode}, 单位: {unit}, 数量: {quantity}, 规格: {specification}, 单价: {unit_price}")
# 特殊条码处理
special_barcodes = {
'6925019900087': {
'multiplier': 10, # 数量乘以10
'target_unit': '', # 目标单位
'description': '特殊处理:数量*10单位转换为瓶'
}
}
# 解析规格
package_quantity = None
is_tertiary_spec = False
if specification:
import re
# 三级规格如1*5*12
match = re.search(r'(\d+)[\*xX×](\d+)[\*xX×](\d+)', specification)
if match:
package_quantity = int(match.group(3))
is_tertiary_spec = True
else:
# 二级规格如1*15
match = re.search(r'(\d+)[\*xX×](\d+)', specification)
if match:
package_quantity = int(match.group(2))
# 初始化结果
result_quantity = quantity
result_unit = unit
result_unit_price = unit_price
# 处理单位转换
if barcode in special_barcodes:
# 特殊条码处理
special_config = special_barcodes[barcode]
result_quantity = quantity * special_config['multiplier']
result_unit = special_config['target_unit']
if unit_price:
result_unit_price = unit_price / special_config['multiplier']
logger.info(f"特殊条码处理: {quantity}{unit} -> {result_quantity}{result_unit}")
if unit_price:
logger.info(f"单价转换: {unit_price}/{unit} -> {result_unit_price}/{result_unit}")
elif unit in ['', '']:
# 提和盒单位特殊处理
if is_tertiary_spec and package_quantity:
# 三级规格:按照件的计算方式处理
result_quantity = quantity * package_quantity
result_unit = ''
if unit_price:
result_unit_price = unit_price / package_quantity
logger.info(f"{unit}单位三级规格转换: {quantity}{unit} -> {result_quantity}")
if unit_price:
logger.info(f"单价转换: {unit_price}/{unit} -> {result_unit_price}/瓶")
else:
# 二级规格或无规格:保持原数量不变
logger.info(f"{unit}单位二级规格保持原数量: {quantity}{unit}")
elif unit == '' and package_quantity:
# 件单位处理:数量×包装数量
result_quantity = quantity * package_quantity
result_unit = ''
if unit_price:
result_unit_price = unit_price / package_quantity
logger.info(f"件单位转换: {quantity}件 -> {result_quantity}")
if unit_price:
logger.info(f"单价转换: {unit_price}/件 -> {result_unit_price}/瓶")
else:
# 其他单位保持不变
logger.info(f"保持原单位不变: {quantity}{unit}")
# 输出处理结果
logger.info(f"处理结果 => 数量: {result_quantity}, 单位: {result_unit}, 单价: {result_unit_price}")
logger.info("-" * 50)
return result_quantity, result_unit, result_unit_price
def run_tests():
"""运行一系列测试用例"""
# 标准件单位测试
test_unit_conversion("1234567890123", "", 1, "1*12", 108)
test_unit_conversion("1234567890124", "", 2, "1*24", 120)
# 提和盒单位测试 - 二级规格
test_unit_conversion("1234567890125", "", 3, "1*16", 50)
test_unit_conversion("1234567890126", "", 5, "1*20", 60)
# 提和盒单位测试 - 三级规格
test_unit_conversion("1234567890127", "", 2, "1*5*12", 100)
test_unit_conversion("1234567890128", "", 3, "1*6*8", 120)
# 特殊条码测试
test_unit_conversion("6925019900087", "", 2, "1*10", 50)
test_unit_conversion("6925019900087", "", 1, "1*16", 30)
# 其他单位测试
test_unit_conversion("1234567890129", "", 4, "1*24", 12)
test_unit_conversion("1234567890130", "", 10, "", 5)
if __name__ == "__main__":
logger.info("开始测试单位转换处理规则")
run_tests()
logger.info("单位转换处理规则测试完成")

413
启动器.py Normal file
View File

@ -0,0 +1,413 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
OCR订单处理系统启动器
-----------------
提供简单的图形界面方便用户选择功能
"""
import os
import sys
import time
import subprocess
import shutil
import tkinter as tk
from tkinter import messagebox, filedialog, scrolledtext
from threading import Thread
import datetime
def ensure_directories():
"""确保必要的目录结构存在"""
directories = ["data/input", "data/output", "data/temp", "logs"]
for directory in directories:
if not os.path.exists(directory):
os.makedirs(directory, exist_ok=True)
print(f"创建目录: {directory}")
class LogRedirector:
"""日志重定向器,用于捕获命令输出并显示到界面"""
def __init__(self, text_widget):
self.text_widget = text_widget
self.buffer = ""
def write(self, string):
self.buffer += string
# 在UI线程中更新文本控件
self.text_widget.after(0, self.update_text_widget)
def update_text_widget(self):
self.text_widget.configure(state=tk.NORMAL)
self.text_widget.insert(tk.END, self.buffer)
# 自动滚动到底部
self.text_widget.see(tk.END)
self.text_widget.configure(state=tk.DISABLED)
self.buffer = ""
def flush(self):
pass
def run_command_with_logging(command, log_widget):
"""运行命令并将输出重定向到日志窗口"""
def run_in_thread():
# 记录命令开始执行的时间
start_time = datetime.datetime.now()
log_widget.configure(state=tk.NORMAL)
log_widget.delete(1.0, tk.END) # 清空之前的日志
log_widget.insert(tk.END, f"执行命令: {' '.join(command)}\n")
log_widget.insert(tk.END, f"开始时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n")
log_widget.insert(tk.END, "=" * 50 + "\n\n")
log_widget.configure(state=tk.DISABLED)
# 设置环境变量强制OCR模块输出到data目录
env = os.environ.copy()
env["OCR_OUTPUT_DIR"] = os.path.abspath("data/output")
env["OCR_INPUT_DIR"] = os.path.abspath("data/input")
env["OCR_LOG_LEVEL"] = "DEBUG" # 设置更详细的日志级别
try:
# 运行命令并捕获输出
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True,
env=env
)
# 读取并显示输出
for line in process.stdout:
log_widget.after(0, lambda l=line: add_to_log(log_widget, l))
# 等待进程结束
process.wait()
# 记录命令结束时间
end_time = datetime.datetime.now()
duration = end_time - start_time
log_widget.after(0, lambda: add_to_log(
log_widget,
f"\n{'=' * 50}\n执行完毕!返回码: {process.returncode}\n"
f"结束时间: {end_time.strftime('%Y-%m-%d %H:%M:%S')}\n"
f"耗时: {duration.total_seconds():.2f}\n"
))
# 如果处理成功,显示成功信息
if process.returncode == 0:
log_widget.after(0, lambda: messagebox.showinfo("操作成功", "处理完成!\n请在data/output目录查看结果。"))
else:
log_widget.after(0, lambda: messagebox.showerror("操作失败", f"处理失败,返回码:{process.returncode}"))
except Exception as e:
log_widget.after(0, lambda: add_to_log(log_widget, f"\n执行出错: {str(e)}\n"))
log_widget.after(0, lambda: messagebox.showerror("执行错误", f"执行命令时出错: {str(e)}"))
# 在新线程中运行避免UI阻塞
Thread(target=run_in_thread).start()
def add_to_log(log_widget, text):
"""向日志窗口添加文本"""
log_widget.configure(state=tk.NORMAL)
log_widget.insert(tk.END, text)
log_widget.see(tk.END) # 自动滚动到底部
log_widget.configure(state=tk.DISABLED)
def select_file(log_widget):
"""选择图片文件并复制到data/input目录"""
# 确保目录存在
ensure_directories()
# 获取输入目录的绝对路径
input_dir = os.path.abspath("data/input")
file_path = filedialog.askopenfilename(
title="选择要处理的图片文件",
initialdir=input_dir, # 默认打开data/input目录
filetypes=[("图片文件", "*.jpg *.jpeg *.png *.bmp")]
)
if not file_path:
return None
# 记录选择文件的信息
add_to_log(log_widget, f"已选择文件: {file_path}\n")
# 计算目标路径始终放在data/input中
output_path = os.path.join("data/input", os.path.basename(file_path))
abs_output_path = os.path.abspath(output_path)
# 检查是否是同一个文件
if os.path.normpath(os.path.abspath(file_path)) != os.path.normpath(abs_output_path):
# 如果是不同的文件,则复制
try:
shutil.copy2(file_path, output_path)
add_to_log(log_widget, f"已复制文件到处理目录: {output_path}\n")
except Exception as e:
add_to_log(log_widget, f"复制文件失败: {e}\n")
messagebox.showerror("错误", f"复制文件失败: {e}")
return None
# 返回绝对路径,确保命令行处理正确
return abs_output_path
def select_excel_file(log_widget):
"""选择Excel文件并复制到data/output目录"""
# 确保目录存在
ensure_directories()
# 获取输出目录的绝对路径
output_dir = os.path.abspath("data/output")
file_path = filedialog.askopenfilename(
title="选择要处理的Excel文件",
initialdir=output_dir, # 默认打开data/output目录
filetypes=[("Excel文件", "*.xlsx *.xls")]
)
if not file_path:
return None
# 记录选择文件的信息
add_to_log(log_widget, f"已选择文件: {file_path}\n")
# 计算目标路径始终放在data/output中
output_path = os.path.join("data/output", os.path.basename(file_path))
abs_output_path = os.path.abspath(output_path)
# 检查是否是同一个文件
if os.path.normpath(os.path.abspath(file_path)) != os.path.normpath(abs_output_path):
# 如果是不同的文件,则复制
try:
shutil.copy2(file_path, output_path)
add_to_log(log_widget, f"已复制文件到处理目录: {output_path}\n")
except Exception as e:
add_to_log(log_widget, f"复制文件失败: {e}\n")
messagebox.showerror("错误", f"复制文件失败: {e}")
return None
# 返回绝对路径,确保命令行处理正确
return abs_output_path
def process_single_image(log_widget):
"""处理单个图片"""
file_path = select_file(log_widget)
if file_path:
# 确保文件存在
if os.path.exists(file_path):
add_to_log(log_widget, f"正在处理图片: {os.path.basename(file_path)}\n")
# 使用绝对路径并指定直接输出到data/output
run_command_with_logging(["python", "run.py", "ocr", "--input", file_path], log_widget)
else:
add_to_log(log_widget, f"文件不存在: {file_path}\n")
messagebox.showerror("错误", f"文件不存在: {file_path}")
else:
add_to_log(log_widget, "未选择文件,操作已取消\n")
def process_excel_file(log_widget):
"""处理Excel文件"""
file_path = select_excel_file(log_widget)
if file_path:
# 确保文件存在
if os.path.exists(file_path):
add_to_log(log_widget, f"正在处理Excel文件: {os.path.basename(file_path)}\n")
# 使用绝对路径
run_command_with_logging(["python", "run.py", "excel", "--input", file_path], log_widget)
else:
add_to_log(log_widget, f"文件不存在: {file_path}\n")
messagebox.showerror("错误", f"文件不存在: {file_path}")
else:
# 如果未选择文件尝试处理最新的Excel
add_to_log(log_widget, "未选择文件尝试处理最新的Excel文件\n")
run_command_with_logging(["python", "run.py", "excel"], log_widget)
def organize_project_files(log_widget):
"""整理项目中的文件到正确位置"""
# 确保目录存在
ensure_directories()
add_to_log(log_widget, "开始整理项目文件...\n")
# 转移根目录文件
files_moved = 0
# 处理日志文件
log_files = [f for f in os.listdir('.') if f.endswith('.log')]
for log_file in log_files:
try:
src_path = os.path.join('.', log_file)
dst_path = os.path.join('logs', log_file)
if not os.path.exists(dst_path) or os.path.getmtime(src_path) > os.path.getmtime(dst_path):
shutil.copy2(src_path, dst_path)
add_to_log(log_widget, f"已移动日志文件: {src_path} -> {dst_path}\n")
files_moved += 1
except Exception as e:
add_to_log(log_widget, f"移动日志文件出错: {e}\n")
# 处理JSON文件
json_files = [f for f in os.listdir('.') if f.endswith('.json')]
for json_file in json_files:
try:
src_path = os.path.join('.', json_file)
dst_path = os.path.join('data', json_file)
if not os.path.exists(dst_path) or os.path.getmtime(src_path) > os.path.getmtime(dst_path):
shutil.copy2(src_path, dst_path)
add_to_log(log_widget, f"已移动记录文件: {src_path} -> {dst_path}\n")
files_moved += 1
except Exception as e:
add_to_log(log_widget, f"移动记录文件出错: {e}\n")
# 处理input和output目录
for old_dir, new_dir in {"input": "data/input", "output": "data/output"}.items():
if os.path.exists(old_dir) and os.path.isdir(old_dir):
for file in os.listdir(old_dir):
src_path = os.path.join(old_dir, file)
dst_path = os.path.join(new_dir, file)
try:
if os.path.isfile(src_path):
if not os.path.exists(dst_path) or os.path.getmtime(src_path) > os.path.getmtime(dst_path):
shutil.copy2(src_path, dst_path)
add_to_log(log_widget, f"已转移文件: {src_path} -> {dst_path}\n")
files_moved += 1
except Exception as e:
add_to_log(log_widget, f"移动文件出错: {e}\n")
# 显示结果
if files_moved > 0:
add_to_log(log_widget, f"整理完成,共整理 {files_moved} 个文件\n")
messagebox.showinfo("整理完成", f"已整理 {files_moved} 个文件到正确位置。\n"
"原始文件保留在原位置,以确保数据安全。")
else:
add_to_log(log_widget, "没有需要整理的文件\n")
messagebox.showinfo("整理完成", "没有需要整理的文件。")
def main():
"""主函数"""
# 确保必要的目录结构存在并转移旧目录内容
ensure_directories()
# 创建窗口
root = tk.Tk()
root.title("OCR订单处理系统 v2.0")
root.geometry("800x600") # 增加窗口宽度以容纳日志
# 创建主区域分割
main_pane = tk.PanedWindow(root, orient=tk.HORIZONTAL)
main_pane.pack(fill=tk.BOTH, expand=1, padx=5, pady=5)
# 左侧操作区域
left_frame = tk.Frame(main_pane, width=300)
main_pane.add(left_frame)
# 标题
tk.Label(left_frame, text="OCR订单处理系统", font=("Arial", 16)).pack(pady=10)
# 功能按钮区域
buttons_frame = tk.Frame(left_frame)
buttons_frame.pack(pady=10, fill=tk.Y)
# 创建日志显示区域
log_frame = tk.Frame(main_pane)
main_pane.add(log_frame)
# 日志标题
tk.Label(log_frame, text="处理日志", font=("Arial", 12)).pack(pady=5)
# 日志文本区域
log_text = scrolledtext.ScrolledText(log_frame, wrap=tk.WORD, height=30, width=60)
log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
log_text.configure(state=tk.DISABLED) # 设置为只读
# 日志初始内容
add_to_log(log_text, "OCR订单处理系统启动器 v2.0\n")
add_to_log(log_text, f"当前工作目录: {os.getcwd()}\n")
add_to_log(log_text, "系统已准备就绪,请选择要执行的操作。\n")
# OCR识别按钮
tk.Button(
buttons_frame,
text="OCR图像识别 (批量)",
width=20,
height=2,
command=lambda: run_command_with_logging(["python", "run.py", "ocr", "--batch"], log_text)
).pack(pady=5)
# 单个图片处理
tk.Button(
buttons_frame,
text="处理单个图片",
width=20,
height=2,
command=lambda: process_single_image(log_text)
).pack(pady=5)
# Excel处理按钮
tk.Button(
buttons_frame,
text="处理Excel文件",
width=20,
height=2,
command=lambda: process_excel_file(log_text)
).pack(pady=5)
# 订单合并按钮
tk.Button(
buttons_frame,
text="合并采购单",
width=20,
height=2,
command=lambda: run_command_with_logging(["python", "run.py", "merge"], log_text)
).pack(pady=5)
# 完整流程按钮
tk.Button(
buttons_frame,
text="完整处理流程",
width=20,
height=2,
command=lambda: run_command_with_logging(["python", "run.py", "pipeline"], log_text)
).pack(pady=5)
# 整理文件按钮
tk.Button(
buttons_frame,
text="整理项目文件",
width=20,
height=2,
command=lambda: organize_project_files(log_text)
).pack(pady=5)
# 打开输入目录
tk.Button(
buttons_frame,
text="打开输入目录",
width=20,
command=lambda: os.startfile(os.path.abspath("data/input"))
).pack(pady=5)
# 打开输出目录
tk.Button(
buttons_frame,
text="打开输出目录",
width=20,
command=lambda: os.startfile(os.path.abspath("data/output"))
).pack(pady=5)
# 清空日志按钮
tk.Button(
buttons_frame,
text="清空日志",
width=20,
command=lambda: log_text.delete(1.0, tk.END)
).pack(pady=5)
# 底部说明
tk.Label(left_frame, text="© 2025 OCR订单处理系统", font=("Arial", 10)).pack(side=tk.BOTTOM, pady=10)
# 启动主循环
root.mainloop()
if __name__ == "__main__":
main()

209
项目结构优化方案.md Normal file
View File

@ -0,0 +1,209 @@
# OCR订单处理系统 - 项目结构优化方案
根据对v1目录项目的分析提出以下项目结构优化方案。本方案旨在提高代码的可维护性、可扩展性和可读性。
## 主要优化目标
1. **模块化设计**:将功能拆分为独立模块,降低耦合度
2. **统一配置管理**:简化配置处理,避免重复代码
3. **标准化日志系统**:统一日志管理,便于调试和问题追踪
4. **清晰的项目结构**采用现代Python项目结构
5. **规范化开发流程**:添加单元测试,代码质量检查
## 项目新结构
```
orc-order-v2/ # 项目根目录
├── app/ # 应用主目录
│ ├── __init__.py # 包初始化
│ ├── config/ # 配置目录
│ │ ├── __init__.py
│ │ ├── settings.py # 基础配置
│ │ └── defaults.py # 默认配置值
│ │
│ ├── core/ # 核心功能
│ │ ├── __init__.py
│ │ ├── ocr/ # OCR相关功能
│ │ │ ├── __init__.py
│ │ │ ├── baidu_ocr.py # 百度OCR基本功能
│ │ │ └── table_ocr.py # 表格OCR处理
│ │ │
│ │ ├── excel/ # Excel处理相关功能
│ │ │ ├── __init__.py
│ │ │ ├── processor.py # Excel处理核心
│ │ │ ├── merger.py # 订单合并功能
│ │ │ └── converter.py # 单位转换与规格处理
│ │ │
│ │ └── utils/ # 工具函数
│ │ ├── __init__.py
│ │ ├── file_utils.py # 文件操作工具
│ │ ├── log_utils.py # 日志工具
│ │ └── string_utils.py # 字符串处理工具
│ │
│ ├── services/ # 业务服务
│ │ ├── __init__.py
│ │ ├── ocr_service.py # OCR服务
│ │ └── order_service.py # 订单处理服务
│ │
│ └── cli/ # 命令行接口
│ ├── __init__.py
│ ├── ocr_cli.py # OCR命令行工具
│ ├── excel_cli.py # Excel处理命令行工具
│ └── merge_cli.py # 订单合并命令行工具
├── templates/ # 模板文件
│ └── 银豹-采购单模板.xls # 订单模板
├── data/ # 数据目录
│ ├── input/ # 输入文件
│ ├── output/ # 输出文件
│ └── temp/ # 临时文件
├── logs/ # 日志目录
├── tests/ # 测试目录
│ ├── __init__.py
│ ├── test_ocr.py
│ ├── test_excel.py
│ └── test_merger.py
├── pyproject.toml # 项目配置
├── setup.py # 安装配置
├── requirements.txt # 依赖管理
├── config.ini.example # 配置示例
├── .gitignore # Git忽略文件
├── README.md # 项目说明
└── run.py # 主入口脚本
```
## 功能优化
### 1. 配置管理优化
创建统一的配置管理系统,避免多个模块各自实现配置处理:
```python
# app/config/settings.py
import os
import configparser
from typing import Dict, List, Any
from .defaults import DEFAULT_CONFIG
class ConfigManager:
"""统一配置管理"""
_instance = None
def __new__(cls, config_file=None):
if cls._instance is None:
cls._instance = super(ConfigManager, cls).__new__(cls)
cls._instance._init(config_file)
return cls._instance
def _init(self, config_file):
self.config_file = config_file or 'config.ini'
self.config = configparser.ConfigParser()
self.load_config()
def load_config(self):
# 配置加载实现...
```
### 2. 日志系统优化
创建统一的日志管理系统:
```python
# app/core/utils/log_utils.py
import os
import sys
import logging
from datetime import datetime
from typing import Optional
def setup_logger(name: str, log_file: Optional[str] = None, level=logging.INFO):
"""配置并返回日志记录器"""
# 日志配置实现...
```
### 3. 核心业务逻辑优化
#### OCR处理优化
将百度OCR API调用与业务逻辑分离
```python
# app/core/ocr/baidu_ocr.py
class BaiduOCRClient:
"""百度OCR API客户端"""
# API调用实现...
# app/services/ocr_service.py
class OCRService:
"""OCR处理服务"""
# 业务逻辑实现...
```
#### Excel处理优化
将Excel处理逻辑模块化
```python
# app/core/excel/processor.py
class ExcelProcessor:
"""Excel处理核心"""
# Excel处理实现...
# app/core/excel/converter.py
class UnitConverter:
"""单位转换处理"""
# 单位转换实现...
```
### 4. 命令行接口优化
使用标准的命令行接口设计:
```python
# app/cli/ocr_cli.py
import argparse
import sys
from app.services.ocr_service import OCRService
def create_parser():
"""创建命令行参数解析器"""
# 参数配置实现...
def main():
"""OCR处理命令行入口"""
# 命令实现...
if __name__ == "__main__":
main()
```
## 代码优化方向
1. **类型提示**使用Python类型注解提高代码可读性
2. **异常处理**:优化异常处理流程,便于调试
3. **代码复用**:减少重复代码,提取公共功能
4. **单元测试**:为核心功能编写测试用例
## 迁移路径
1. 创建新的项目结构
2. 迁移配置管理模块
3. 迁移日志系统
4. 迁移OCR核心功能
5. 迁移Excel处理功能
6. 迁移命令行接口
7. 编写单元测试
8. 完善文档
## 后续优化建议
1. **Web界面**考虑添加简单的Web界面便于操作
2. **多OCR引擎支持**增加更多OCR引擎选择
3. **进度报告**:添加处理进度报告功能
4. **并行处理优化**:改进并行处理机制,提高性能