增强版v2-初始化仓库,验证好了ocr部分,先备份一次
This commit is contained in:
commit
0035cd1893
191
README.md
Normal file
191
README.md
Normal 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
8
app/__init__.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
OCR订单处理系统
|
||||||
|
---------------
|
||||||
|
用于自动识别和处理Excel格式的订单文件的系统。
|
||||||
|
支持多种格式的订单处理,包括普通订单和赠品订单的处理。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = '2.0.0'
|
||||||
BIN
app/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
5
app/cli/__init__.py
Normal file
5
app/cli/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
OCR订单处理系统 - 命令行接口
|
||||||
|
-------------------------
|
||||||
|
提供命令行工具,便于用户使用系统功能。
|
||||||
|
"""
|
||||||
138
app/cli/excel_cli.py
Normal file
138
app/cli/excel_cli.py
Normal 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
147
app/cli/merge_cli.py
Normal 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
164
app/cli/ocr_cli.py
Normal 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
5
app/config/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
OCR订单处理系统 - 配置模块
|
||||||
|
------------------------
|
||||||
|
负责管理系统配置,包括API密钥、路径和处理选项。
|
||||||
|
"""
|
||||||
BIN
app/config/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
app/config/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/config/__pycache__/defaults.cpython-39.pyc
Normal file
BIN
app/config/__pycache__/defaults.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/config/__pycache__/settings.cpython-39.pyc
Normal file
BIN
app/config/__pycache__/settings.cpython-39.pyc
Normal file
Binary file not shown.
37
app/config/defaults.py
Normal file
37
app/config/defaults.py
Normal 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
128
app/config/settings.py
Normal 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
|
||||||
5
app/core/excel/__init__.py
Normal file
5
app/core/excel/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
OCR订单处理系统 - Excel处理模块
|
||||||
|
----------------------------
|
||||||
|
提供Excel文件处理、数据提取和转换功能。
|
||||||
|
"""
|
||||||
BIN
app/core/excel/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
app/core/excel/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/core/excel/__pycache__/converter.cpython-39.pyc
Normal file
BIN
app/core/excel/__pycache__/converter.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/core/excel/__pycache__/merger.cpython-39.pyc
Normal file
BIN
app/core/excel/__pycache__/merger.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/core/excel/__pycache__/processor.cpython-39.pyc
Normal file
BIN
app/core/excel/__pycache__/processor.cpython-39.pyc
Normal file
Binary file not shown.
213
app/core/excel/converter.py
Normal file
213
app/core/excel/converter.py
Normal 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
375
app/core/excel/merger.py
Normal 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
393
app/core/excel/processor.py
Normal 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
5
app/core/ocr/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
OCR订单处理系统 - OCR核心模块
|
||||||
|
---------------------------
|
||||||
|
提供OCR识别相关功能,包括图片预处理、文字识别和表格识别。
|
||||||
|
"""
|
||||||
BIN
app/core/ocr/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
app/core/ocr/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/core/ocr/__pycache__/baidu_ocr.cpython-39.pyc
Normal file
BIN
app/core/ocr/__pycache__/baidu_ocr.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/core/ocr/__pycache__/table_ocr.cpython-39.pyc
Normal file
BIN
app/core/ocr/__pycache__/table_ocr.cpython-39.pyc
Normal file
Binary file not shown.
344
app/core/ocr/baidu_ocr.py
Normal file
344
app/core/ocr/baidu_ocr.py
Normal 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
334
app/core/ocr/table_ocr.py
Normal 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
|
||||||
5
app/core/utils/__init__.py
Normal file
5
app/core/utils/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
OCR订单处理系统 - 工具模块
|
||||||
|
------------------------
|
||||||
|
提供系统通用工具和辅助函数。
|
||||||
|
"""
|
||||||
BIN
app/core/utils/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
app/core/utils/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/core/utils/__pycache__/file_utils.cpython-39.pyc
Normal file
BIN
app/core/utils/__pycache__/file_utils.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/core/utils/__pycache__/log_utils.cpython-39.pyc
Normal file
BIN
app/core/utils/__pycache__/log_utils.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/core/utils/__pycache__/string_utils.cpython-39.pyc
Normal file
BIN
app/core/utils/__pycache__/string_utils.cpython-39.pyc
Normal file
Binary file not shown.
251
app/core/utils/file_utils.py
Normal file
251
app/core/utils/file_utils.py
Normal 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
129
app/core/utils/log_utils.py
Normal 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}")
|
||||||
207
app/core/utils/string_utils.py
Normal file
207
app/core/utils/string_utils.py
Normal 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
5
app/services/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""
|
||||||
|
OCR订单处理系统 - 服务模块
|
||||||
|
-----------------------
|
||||||
|
提供业务逻辑服务,协调各个核心组件完成业务功能。
|
||||||
|
"""
|
||||||
BIN
app/services/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
app/services/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/ocr_service.cpython-39.pyc
Normal file
BIN
app/services/__pycache__/ocr_service.cpython-39.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/order_service.cpython-39.pyc
Normal file
BIN
app/services/__pycache__/order_service.cpython-39.pyc
Normal file
Binary file not shown.
88
app/services/ocr_service.py
Normal file
88
app/services/ocr_service.py
Normal 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)
|
||||||
87
app/services/order_service.py
Normal file
87
app/services/order_service.py
Normal 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
24
config.ini
Normal 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
|
||||||
|
|
||||||
BIN
data/input/微信图片_20250227193150(1).jpg
Normal file
BIN
data/input/微信图片_20250227193150(1).jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 892 KiB |
3
data/output/processed_files.json
Normal file
3
data/output/processed_files.json
Normal 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"
|
||||||
|
}
|
||||||
BIN
data/output/微信图片_20250227193150(1).xlsx
Normal file
BIN
data/output/微信图片_20250227193150(1).xlsx
Normal file
Binary file not shown.
BIN
data/output/采购单_微信图片_20250227193150(1)_20250502171625.xls
Normal file
BIN
data/output/采购单_微信图片_20250227193150(1)_20250502171625.xls
Normal file
Binary file not shown.
1
logs/__main__.active
Normal file
1
logs/__main__.active
Normal file
@ -0,0 +1 @@
|
|||||||
|
Active since: 2025-05-02 17:16:24
|
||||||
25
logs/__main__.log
Normal file
25
logs/__main__.log
Normal 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
|
||||||
1
logs/app.core.excel.converter.active
Normal file
1
logs/app.core.excel.converter.active
Normal file
@ -0,0 +1 @@
|
|||||||
|
Active since: 2025-05-02 17:16:24
|
||||||
30
logs/app.core.excel.converter.log
Normal file
30
logs/app.core.excel.converter.log
Normal 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
|
||||||
1
logs/app.core.excel.merger.active
Normal file
1
logs/app.core.excel.merger.active
Normal file
@ -0,0 +1 @@
|
|||||||
|
Active since: 2025-05-02 17:16:24
|
||||||
28
logs/app.core.excel.merger.log
Normal file
28
logs/app.core.excel.merger.log
Normal 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
|
||||||
1
logs/app.core.excel.processor.active
Normal file
1
logs/app.core.excel.processor.active
Normal file
@ -0,0 +1 @@
|
|||||||
|
Active since: 2025-05-02 17:16:24
|
||||||
35
logs/app.core.excel.processor.log
Normal file
35
logs/app.core.excel.processor.log
Normal 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
|
||||||
1
logs/app.core.ocr.baidu_ocr.active
Normal file
1
logs/app.core.ocr.baidu_ocr.active
Normal file
@ -0,0 +1 @@
|
|||||||
|
Active since: 2025-05-02 17:16:23
|
||||||
6
logs/app.core.ocr.baidu_ocr.log
Normal file
6
logs/app.core.ocr.baidu_ocr.log
Normal file
File diff suppressed because one or more lines are too long
1
logs/app.core.ocr.table_ocr.active
Normal file
1
logs/app.core.ocr.table_ocr.active
Normal file
@ -0,0 +1 @@
|
|||||||
|
Active since: 2025-05-02 17:16:23
|
||||||
53
logs/app.core.ocr.table_ocr.log
Normal file
53
logs/app.core.ocr.table_ocr.log
Normal 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
|
||||||
1
logs/app.core.utils.file_utils.active
Normal file
1
logs/app.core.utils.file_utils.active
Normal file
@ -0,0 +1 @@
|
|||||||
|
Active since: 2025-05-02 17:16:23
|
||||||
3
logs/app.core.utils.file_utils.log
Normal file
3
logs/app.core.utils.file_utils.log
Normal 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] 系统找不到指定的路径。: ''
|
||||||
1
logs/app.services.ocr_service.active
Normal file
1
logs/app.services.ocr_service.active
Normal file
@ -0,0 +1 @@
|
|||||||
|
Active since: 2025-05-02 17:16:23
|
||||||
27
logs/app.services.ocr_service.log
Normal file
27
logs/app.services.ocr_service.log
Normal 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初始化完成
|
||||||
1
logs/app.services.order_service.active
Normal file
1
logs/app.services.order_service.active
Normal file
@ -0,0 +1 @@
|
|||||||
|
Active since: 2025-05-02 17:16:24
|
||||||
22
logs/app.services.order_service.log
Normal file
22
logs/app.services.order_service.log
Normal 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
8
logs/ocr_processor.log
Normal 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
9
requirements.txt
Normal 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
320
run.py
Normal 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())
|
||||||
BIN
templates/银豹-采购单模板.xls
Normal file
BIN
templates/银豹-采购单模板.xls
Normal file
Binary file not shown.
52
v1/.gitignore
vendored
Normal file
52
v1/.gitignore
vendored
Normal 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
332
v1/README.md
Normal 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
469
v1/baidu_ocr.py
Normal 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
639
v1/baidu_table_ocr.py
Normal 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
587
v1/clean_files.py
Normal 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
223
v1/clean_logs.py
Normal 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
1364
v1/excel_processor_step2.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
v1/input/微信图片_20250227193150.jpg
Normal file
BIN
v1/input/微信图片_20250227193150.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 MiB |
BIN
v1/logs/.keep
Normal file
BIN
v1/logs/.keep
Normal file
Binary file not shown.
1
v1/logs/clean_logs.active
Normal file
1
v1/logs/clean_logs.active
Normal file
@ -0,0 +1 @@
|
|||||||
|
Active since: 2025-05-01 19:46:44
|
||||||
1
v1/logs/ocr_processor.active
Normal file
1
v1/logs/ocr_processor.active
Normal file
@ -0,0 +1 @@
|
|||||||
|
Active since: 2025-05-01 19:49:19
|
||||||
420
v1/merge_purchase_orders.py
Normal file
420
v1/merge_purchase_orders.py
Normal 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)
|
||||||
4
v1/output/merged_files.json
Normal file
4
v1/output/merged_files.json
Normal 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
9
v1/requirements.txt
Normal 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
235
v1/run_ocr.py
Normal 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()
|
||||||
BIN
v1/templets/银豹-采购单模板.xls
Normal file
BIN
v1/templets/银豹-采购单模板.xls
Normal file
Binary file not shown.
66
v1/test_ocr_log.py
Normal file
66
v1/test_ocr_log.py
Normal 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
141
v1/test_unit_conversion.py
Normal 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
413
启动器.py
Normal 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
209
项目结构优化方案.md
Normal 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. **并行处理优化**:改进并行处理机制,提高性能
|
||||||
Loading…
Reference in New Issue
Block a user