v1.0正式版

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

After

Width:  |  Height:  |  Size: 5.4 MiB

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