commit 0035cd18934b33eaa1120f96ccf9802b79de7386 Author: houhuan Date: Fri May 2 17:25:47 2025 +0800 增强版v2-初始化仓库,验证好了ocr部分,先备份一次 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4715a28 --- /dev/null +++ b/README.md @@ -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或联系开发者。 \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..1c563d5 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,8 @@ +""" +OCR订单处理系统 +--------------- +用于自动识别和处理Excel格式的订单文件的系统。 +支持多种格式的订单处理,包括普通订单和赠品订单的处理。 +""" + +__version__ = '2.0.0' \ No newline at end of file diff --git a/app/__pycache__/__init__.cpython-39.pyc b/app/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..f7c0d14 Binary files /dev/null and b/app/__pycache__/__init__.cpython-39.pyc differ diff --git a/app/cli/__init__.py b/app/cli/__init__.py new file mode 100644 index 0000000..dad6c01 --- /dev/null +++ b/app/cli/__init__.py @@ -0,0 +1,5 @@ +""" +OCR订单处理系统 - 命令行接口 +------------------------- +提供命令行工具,便于用户使用系统功能。 +""" \ No newline at end of file diff --git a/app/cli/excel_cli.py b/app/cli/excel_cli.py new file mode 100644 index 0000000..9aa8104 --- /dev/null +++ b/app/cli/excel_cli.py @@ -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()) \ No newline at end of file diff --git a/app/cli/merge_cli.py b/app/cli/merge_cli.py new file mode 100644 index 0000000..242c4e5 --- /dev/null +++ b/app/cli/merge_cli.py @@ -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()) \ No newline at end of file diff --git a/app/cli/ocr_cli.py b/app/cli/ocr_cli.py new file mode 100644 index 0000000..dbc91c5 --- /dev/null +++ b/app/cli/ocr_cli.py @@ -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()) \ No newline at end of file diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 0000000..9331d71 --- /dev/null +++ b/app/config/__init__.py @@ -0,0 +1,5 @@ +""" +OCR订单处理系统 - 配置模块 +------------------------ +负责管理系统配置,包括API密钥、路径和处理选项。 +""" \ No newline at end of file diff --git a/app/config/__pycache__/__init__.cpython-39.pyc b/app/config/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..595189a Binary files /dev/null and b/app/config/__pycache__/__init__.cpython-39.pyc differ diff --git a/app/config/__pycache__/defaults.cpython-39.pyc b/app/config/__pycache__/defaults.cpython-39.pyc new file mode 100644 index 0000000..116bcea Binary files /dev/null and b/app/config/__pycache__/defaults.cpython-39.pyc differ diff --git a/app/config/__pycache__/settings.cpython-39.pyc b/app/config/__pycache__/settings.cpython-39.pyc new file mode 100644 index 0000000..f7bf8d2 Binary files /dev/null and b/app/config/__pycache__/settings.cpython-39.pyc differ diff --git a/app/config/defaults.py b/app/config/defaults.py new file mode 100644 index 0000000..270ea02 --- /dev/null +++ b/app/config/defaults.py @@ -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' + } +} \ No newline at end of file diff --git a/app/config/settings.py b/app/config/settings.py new file mode 100644 index 0000000..b16b7bf --- /dev/null +++ b/app/config/settings.py @@ -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 \ No newline at end of file diff --git a/app/core/excel/__init__.py b/app/core/excel/__init__.py new file mode 100644 index 0000000..3d49a8b --- /dev/null +++ b/app/core/excel/__init__.py @@ -0,0 +1,5 @@ +""" +OCR订单处理系统 - Excel处理模块 +---------------------------- +提供Excel文件处理、数据提取和转换功能。 +""" \ No newline at end of file diff --git a/app/core/excel/__pycache__/__init__.cpython-39.pyc b/app/core/excel/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..034c728 Binary files /dev/null and b/app/core/excel/__pycache__/__init__.cpython-39.pyc differ diff --git a/app/core/excel/__pycache__/converter.cpython-39.pyc b/app/core/excel/__pycache__/converter.cpython-39.pyc new file mode 100644 index 0000000..d3e5627 Binary files /dev/null and b/app/core/excel/__pycache__/converter.cpython-39.pyc differ diff --git a/app/core/excel/__pycache__/merger.cpython-39.pyc b/app/core/excel/__pycache__/merger.cpython-39.pyc new file mode 100644 index 0000000..dda2ab0 Binary files /dev/null and b/app/core/excel/__pycache__/merger.cpython-39.pyc differ diff --git a/app/core/excel/__pycache__/processor.cpython-39.pyc b/app/core/excel/__pycache__/processor.cpython-39.pyc new file mode 100644 index 0000000..5f68473 Binary files /dev/null and b/app/core/excel/__pycache__/processor.cpython-39.pyc differ diff --git a/app/core/excel/converter.py b/app/core/excel/converter.py new file mode 100644 index 0000000..0cdc668 --- /dev/null +++ b/app/core/excel/converter.py @@ -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 \ No newline at end of file diff --git a/app/core/excel/merger.py b/app/core/excel/merger.py new file mode 100644 index 0000000..fcc4838 --- /dev/null +++ b/app/core/excel/merger.py @@ -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 \ No newline at end of file diff --git a/app/core/excel/processor.py b/app/core/excel/processor.py new file mode 100644 index 0000000..0f95cc2 --- /dev/null +++ b/app/core/excel/processor.py @@ -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) \ No newline at end of file diff --git a/app/core/ocr/__init__.py b/app/core/ocr/__init__.py new file mode 100644 index 0000000..e3d34ad --- /dev/null +++ b/app/core/ocr/__init__.py @@ -0,0 +1,5 @@ +""" +OCR订单处理系统 - OCR核心模块 +--------------------------- +提供OCR识别相关功能,包括图片预处理、文字识别和表格识别。 +""" \ No newline at end of file diff --git a/app/core/ocr/__pycache__/__init__.cpython-39.pyc b/app/core/ocr/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..ff09ee1 Binary files /dev/null and b/app/core/ocr/__pycache__/__init__.cpython-39.pyc differ diff --git a/app/core/ocr/__pycache__/baidu_ocr.cpython-39.pyc b/app/core/ocr/__pycache__/baidu_ocr.cpython-39.pyc new file mode 100644 index 0000000..abeda2a Binary files /dev/null and b/app/core/ocr/__pycache__/baidu_ocr.cpython-39.pyc differ diff --git a/app/core/ocr/__pycache__/table_ocr.cpython-39.pyc b/app/core/ocr/__pycache__/table_ocr.cpython-39.pyc new file mode 100644 index 0000000..aa67f94 Binary files /dev/null and b/app/core/ocr/__pycache__/table_ocr.cpython-39.pyc differ diff --git a/app/core/ocr/baidu_ocr.py b/app/core/ocr/baidu_ocr.py new file mode 100644 index 0000000..14e2d7a --- /dev/null +++ b/app/core/ocr/baidu_ocr.py @@ -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 \ No newline at end of file diff --git a/app/core/ocr/table_ocr.py b/app/core/ocr/table_ocr.py new file mode 100644 index 0000000..725f3a4 --- /dev/null +++ b/app/core/ocr/table_ocr.py @@ -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 \ No newline at end of file diff --git a/app/core/utils/__init__.py b/app/core/utils/__init__.py new file mode 100644 index 0000000..7931e8d --- /dev/null +++ b/app/core/utils/__init__.py @@ -0,0 +1,5 @@ +""" +OCR订单处理系统 - 工具模块 +------------------------ +提供系统通用工具和辅助函数。 +""" \ No newline at end of file diff --git a/app/core/utils/__pycache__/__init__.cpython-39.pyc b/app/core/utils/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..a25e3f6 Binary files /dev/null and b/app/core/utils/__pycache__/__init__.cpython-39.pyc differ diff --git a/app/core/utils/__pycache__/file_utils.cpython-39.pyc b/app/core/utils/__pycache__/file_utils.cpython-39.pyc new file mode 100644 index 0000000..752f7e0 Binary files /dev/null and b/app/core/utils/__pycache__/file_utils.cpython-39.pyc differ diff --git a/app/core/utils/__pycache__/log_utils.cpython-39.pyc b/app/core/utils/__pycache__/log_utils.cpython-39.pyc new file mode 100644 index 0000000..8771efd Binary files /dev/null and b/app/core/utils/__pycache__/log_utils.cpython-39.pyc differ diff --git a/app/core/utils/__pycache__/string_utils.cpython-39.pyc b/app/core/utils/__pycache__/string_utils.cpython-39.pyc new file mode 100644 index 0000000..f601e9d Binary files /dev/null and b/app/core/utils/__pycache__/string_utils.cpython-39.pyc differ diff --git a/app/core/utils/file_utils.py b/app/core/utils/file_utils.py new file mode 100644 index 0000000..7dc4a6e --- /dev/null +++ b/app/core/utils/file_utils.py @@ -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 \ No newline at end of file diff --git a/app/core/utils/log_utils.py b/app/core/utils/log_utils.py new file mode 100644 index 0000000..099a2ec --- /dev/null +++ b/app/core/utils/log_utils.py @@ -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}") \ No newline at end of file diff --git a/app/core/utils/string_utils.py b/app/core/utils/string_utils.py new file mode 100644 index 0000000..61991af --- /dev/null +++ b/app/core/utils/string_utils.py @@ -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) \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..4ff788e --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,5 @@ +""" +OCR订单处理系统 - 服务模块 +----------------------- +提供业务逻辑服务,协调各个核心组件完成业务功能。 +""" \ No newline at end of file diff --git a/app/services/__pycache__/__init__.cpython-39.pyc b/app/services/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..dc03c5c Binary files /dev/null and b/app/services/__pycache__/__init__.cpython-39.pyc differ diff --git a/app/services/__pycache__/ocr_service.cpython-39.pyc b/app/services/__pycache__/ocr_service.cpython-39.pyc new file mode 100644 index 0000000..9edaa89 Binary files /dev/null and b/app/services/__pycache__/ocr_service.cpython-39.pyc differ diff --git a/app/services/__pycache__/order_service.cpython-39.pyc b/app/services/__pycache__/order_service.cpython-39.pyc new file mode 100644 index 0000000..51c321d Binary files /dev/null and b/app/services/__pycache__/order_service.cpython-39.pyc differ diff --git a/app/services/ocr_service.py b/app/services/ocr_service.py new file mode 100644 index 0000000..c965fe4 --- /dev/null +++ b/app/services/ocr_service.py @@ -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) \ No newline at end of file diff --git a/app/services/order_service.py b/app/services/order_service.py new file mode 100644 index 0000000..6d66481 --- /dev/null +++ b/app/services/order_service.py @@ -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) \ No newline at end of file diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..e33b7c2 --- /dev/null +++ b/config.ini @@ -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 + diff --git a/data/input/微信图片_20250227193150(1).jpg b/data/input/微信图片_20250227193150(1).jpg new file mode 100644 index 0000000..4bd7897 Binary files /dev/null and b/data/input/微信图片_20250227193150(1).jpg differ diff --git a/data/output/processed_files.json b/data/output/processed_files.json new file mode 100644 index 0000000..b12e9e4 --- /dev/null +++ b/data/output/processed_files.json @@ -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" +} \ No newline at end of file diff --git a/data/output/微信图片_20250227193150(1).xlsx b/data/output/微信图片_20250227193150(1).xlsx new file mode 100644 index 0000000..29e7733 Binary files /dev/null and b/data/output/微信图片_20250227193150(1).xlsx differ diff --git a/data/output/采购单_微信图片_20250227193150(1)_20250502171625.xls b/data/output/采购单_微信图片_20250227193150(1)_20250502171625.xls new file mode 100644 index 0000000..f068002 Binary files /dev/null and b/data/output/采购单_微信图片_20250227193150(1)_20250502171625.xls differ diff --git a/logs/__main__.active b/logs/__main__.active new file mode 100644 index 0000000..6ef6632 --- /dev/null +++ b/logs/__main__.active @@ -0,0 +1 @@ +Active since: 2025-05-02 17:16:24 \ No newline at end of file diff --git a/logs/__main__.log b/logs/__main__.log new file mode 100644 index 0000000..9ccf117 --- /dev/null +++ b/logs/__main__.log @@ -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 diff --git a/logs/app.core.excel.converter.active b/logs/app.core.excel.converter.active new file mode 100644 index 0000000..6ef6632 --- /dev/null +++ b/logs/app.core.excel.converter.active @@ -0,0 +1 @@ +Active since: 2025-05-02 17:16:24 \ No newline at end of file diff --git a/logs/app.core.excel.converter.log b/logs/app.core.excel.converter.log new file mode 100644 index 0000000..5e93887 --- /dev/null +++ b/logs/app.core.excel.converter.log @@ -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 diff --git a/logs/app.core.excel.merger.active b/logs/app.core.excel.merger.active new file mode 100644 index 0000000..6ef6632 --- /dev/null +++ b/logs/app.core.excel.merger.active @@ -0,0 +1 @@ +Active since: 2025-05-02 17:16:24 \ No newline at end of file diff --git a/logs/app.core.excel.merger.log b/logs/app.core.excel.merger.log new file mode 100644 index 0000000..ca4a335 --- /dev/null +++ b/logs/app.core.excel.merger.log @@ -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 diff --git a/logs/app.core.excel.processor.active b/logs/app.core.excel.processor.active new file mode 100644 index 0000000..6ef6632 --- /dev/null +++ b/logs/app.core.excel.processor.active @@ -0,0 +1 @@ +Active since: 2025-05-02 17:16:24 \ No newline at end of file diff --git a/logs/app.core.excel.processor.log b/logs/app.core.excel.processor.log new file mode 100644 index 0000000..c97556a --- /dev/null +++ b/logs/app.core.excel.processor.log @@ -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 diff --git a/logs/app.core.ocr.baidu_ocr.active b/logs/app.core.ocr.baidu_ocr.active new file mode 100644 index 0000000..eb74f98 --- /dev/null +++ b/logs/app.core.ocr.baidu_ocr.active @@ -0,0 +1 @@ +Active since: 2025-05-02 17:16:23 \ No newline at end of file diff --git a/logs/app.core.ocr.baidu_ocr.log b/logs/app.core.ocr.baidu_ocr.log new file mode 100644 index 0000000..f2a1e55 --- /dev/null +++ b/logs/app.core.ocr.baidu_ocr.log @@ -0,0 +1,6 @@ +2025-05-02 16:15:14,856 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌 +2025-05-02 16:24:57,881 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌 +2025-05-02 16:25:00,366 - app.core.ocr.baidu_ocr - ERROR - 无法从结果中提取有效的request_id: {'tables_result': [{'header': [{'location': [{'x': 2017, 'y': 356}, {'x': 2372, 'y': 355}, {'x': 2372, 'y': 432}, {'x': 2017, 'y': 433}], 'words': '2025-02-26'}, {'location': [{'x': 1617, 'y': 351}, {'x': 1919, 'y': 351}, {'x': 1919, 'y': 440}, {'x': 1617, 'y': 440}], 'words': '录单日期:'}], 'body': [{'col_end': 1, 'row_end': 1, 'cell_location': [{'x': 241, 'y': 534}, {'x': 418, 'y': 534}, {'x': 413, 'y': 646}, {'x': 235, 'y': 647}], 'row_start': 0, 'col_start': 0, 'words': '序号'}, {'col_end': 2, 'row_end': 1, 'cell_location': [{'x': 418, 'y': 534}, {'x': 1008, 'y': 532}, {'x': 1004, 'y': 644}, {'x': 413, 'y': 646}], 'row_start': 0, 'col_start': 1, 'words': '条码'}, {'col_end': 3, 'row_end': 1, 'cell_location': [{'x': 1008, 'y': 532}, {'x': 1865, 'y': 531}, {'x': 1863, 'y': 643}, {'x': 1004, 'y': 644}], 'row_start': 0, 'col_start': 2, 'words': '商品全名'}, {'col_end': 4, 'row_end': 1, 'cell_location': [{'x': 1865, 'y': 531}, {'x': 2156, 'y': 530}, {'x': 2155, 'y': 642}, {'x': 1863, 'y': 643}], 'row_start': 0, 'col_start': 3, 'words': '规格'}, {'col_end': 5, 'row_end': 1, 'cell_location': [{'x': 2156, 'y': 530}, {'x': 2397, 'y': 529}, {'x': 2396, 'y': 641}, {'x': 2155, 'y': 642}], 'row_start': 0, 'col_start': 4, 'words': '单位'}, {'col_end': 6, 'row_end': 1, 'cell_location': [{'x': 2397, 'y': 529}, {'x': 2682, 'y': 529}, {'x': 2682, 'y': 640}, {'x': 2396, 'y': 641}], 'row_start': 0, 'col_start': 5, 'words': '数量'}, {'col_end': 7, 'row_end': 1, 'cell_location': [{'x': 2682, 'y': 529}, {'x': 3098, 'y': 528}, {'x': 3099, 'y': 639}, {'x': 2682, 'y': 640}], 'row_start': 0, 'col_start': 6, 'words': '单价'}, {'col_end': 8, 'row_end': 1, 'cell_location': [{'x': 3098, 'y': 528}, {'x': 3436, 'y': 527}, {'x': 3437, 'y': 639}, {'x': 3099, 'y': 639}], 'row_start': 0, 'col_start': 7, 'words': '金额'}, {'col_end': 9, 'row_end': 1, 'cell_location': [{'x': 3436, 'y': 527}, {'x': 3834, 'y': 527}, {'x': 3836, 'y': 638}, {'x': 3437, 'y': 639}], 'row_start': 0, 'col_start': 8, 'words': '备注'}, {'col_end': 1, 'row_end': 2, 'cell_location': [{'x': 235, 'y': 647}, {'x': 413, 'y': 646}, {'x': 407, 'y': 758}, {'x': 230, 'y': 758}], 'row_start': 1, 'col_start': 0, 'words': ''}, {'col_end': 2, 'row_end': 2, 'cell_location': [{'x': 413, 'y': 646}, {'x': 1004, 'y': 644}, {'x': 1000, 'y': 758}, {'x': 407, 'y': 758}], 'row_start': 1, 'col_start': 1, 'words': '6973497202346'}, {'col_end': 3, 'row_end': 2, 'cell_location': [{'x': 1004, 'y': 644}, {'x': 1863, 'y': 643}, {'x': 1861, 'y': 758}, {'x': 1000, 'y': 758}], 'row_start': 1, 'col_start': 2, 'words': '无糖茶栀栀乌龙'}, {'col_end': 4, 'row_end': 2, 'cell_location': [{'x': 1863, 'y': 643}, {'x': 2155, 'y': 642}, {'x': 2154, 'y': 757}, {'x': 1861, 'y': 758}], 'row_start': 1, 'col_start': 3, 'words': '1*15'}, {'col_end': 5, 'row_end': 2, 'cell_location': [{'x': 2155, 'y': 642}, {'x': 2396, 'y': 641}, {'x': 2395, 'y': 757}, {'x': 2154, 'y': 757}], 'row_start': 1, 'col_start': 4, 'words': '件'}, {'col_end': 6, 'row_end': 2, 'cell_location': [{'x': 2396, 'y': 641}, {'x': 2682, 'y': 640}, {'x': 2682, 'y': 757}, {'x': 2395, 'y': 757}], 'row_start': 1, 'col_start': 5, 'words': '1'}, {'col_end': 7, 'row_end': 2, 'cell_location': [{'x': 2682, 'y': 640}, {'x': 3099, 'y': 639}, {'x': 3100, 'y': 757}, {'x': 2682, 'y': 757}], 'row_start': 1, 'col_start': 6, 'words': '55'}, {'col_end': 8, 'row_end': 2, 'cell_location': [{'x': 3099, 'y': 639}, {'x': 3437, 'y': 639}, {'x': 3439, 'y': 756}, {'x': 3100, 'y': 757}], 'row_start': 1, 'col_start': 7, 'words': '55'}, {'col_end': 9, 'row_end': 2, 'cell_location': [{'x': 3437, 'y': 639}, {'x': 3836, 'y': 638}, {'x': 3839, 'y': 756}, {'x': 3439, 'y': 756}], 'row_start': 1, 'col_start': 8, 'words': ''}, {'col_end': 1, 'row_end': 3, 'cell_location': [{'x': 230, 'y': 758}, {'x': 407, 'y': 758}, {'x': 402, 'y': 871}, {'x': 224, 'y': 871}], 'row_start': 2, 'col_start': 0, 'words': '2'}, {'col_end': 2, 'row_end': 3, 'cell_location': [{'x': 407, 'y': 758}, {'x': 1000, 'y': 758}, {'x': 996, 'y': 872}, {'x': 402, 'y': 871}], 'row_start': 2, 'col_start': 1, 'words': '6973497202940'}, {'col_end': 3, 'row_end': 3, 'cell_location': [{'x': 1000, 'y': 758}, {'x': 1861, 'y': 758}, {'x': 1858, 'y': 874}, {'x': 996, 'y': 872}], 'row_start': 2, 'col_start': 2, 'words': '无糖茶茉莉龙井'}, {'col_end': 4, 'row_end': 3, 'cell_location': [{'x': 1861, 'y': 758}, {'x': 2154, 'y': 757}, {'x': 2152, 'y': 874}, {'x': 1858, 'y': 874}], 'row_start': 2, 'col_start': 3, 'words': '1*15'}, {'col_end': 5, 'row_end': 3, 'cell_location': [{'x': 2154, 'y': 757}, {'x': 2395, 'y': 757}, {'x': 2394, 'y': 874}, {'x': 2152, 'y': 874}], 'row_start': 2, 'col_start': 4, 'words': '件'}, {'col_end': 6, 'row_end': 3, 'cell_location': [{'x': 2395, 'y': 757}, {'x': 2682, 'y': 757}, {'x': 2682, 'y': 875}, {'x': 2394, 'y': 874}], 'row_start': 2, 'col_start': 5, 'words': '1'}, {'col_end': 7, 'row_end': 3, 'cell_location': [{'x': 2682, 'y': 757}, {'x': 3100, 'y': 757}, {'x': 3100, 'y': 875}, {'x': 2682, 'y': 875}], 'row_start': 2, 'col_start': 6, 'words': '55'}, {'col_end': 8, 'row_end': 3, 'cell_location': [{'x': 3100, 'y': 757}, {'x': 3439, 'y': 756}, {'x': 3440, 'y': 876}, {'x': 3100, 'y': 875}], 'row_start': 2, 'col_start': 7, 'words': '55'}, {'col_end': 9, 'row_end': 3, 'cell_location': [{'x': 3439, 'y': 756}, {'x': 3839, 'y': 756}, {'x': 3841, 'y': 877}, {'x': 3440, 'y': 876}], 'row_start': 2, 'col_start': 8, 'words': ''}, {'col_end': 1, 'row_end': 4, 'cell_location': [{'x': 224, 'y': 871}, {'x': 402, 'y': 871}, {'x': 396, 'y': 985}, {'x': 218, 'y': 984}], 'row_start': 3, 'col_start': 0, 'words': '3'}, {'col_end': 2, 'row_end': 4, 'cell_location': [{'x': 402, 'y': 871}, {'x': 996, 'y': 872}, {'x': 992, 'y': 986}, {'x': 396, 'y': 985}], 'row_start': 3, 'col_start': 1, 'words': '6973497200267'}, {'col_end': 3, 'row_end': 4, 'cell_location': [{'x': 996, 'y': 872}, {'x': 1858, 'y': 874}, {'x': 1856, 'y': 989}, {'x': 992, 'y': 986}], 'row_start': 3, 'col_start': 2, 'words': '活力水平衡香水柠檬味'}, {'col_end': 4, 'row_end': 4, 'cell_location': [{'x': 1858, 'y': 874}, {'x': 2152, 'y': 874}, {'x': 2151, 'y': 989}, {'x': 1856, 'y': 989}], 'row_start': 3, 'col_start': 3, 'words': '1*15'}, {'col_end': 5, 'row_end': 4, 'cell_location': [{'x': 2152, 'y': 874}, {'x': 2394, 'y': 874}, {'x': 2394, 'y': 990}, {'x': 2151, 'y': 989}], 'row_start': 3, 'col_start': 4, 'words': '件'}, {'col_end': 6, 'row_end': 4, 'cell_location': [{'x': 2394, 'y': 874}, {'x': 2682, 'y': 875}, {'x': 2682, 'y': 991}, {'x': 2394, 'y': 990}], 'row_start': 3, 'col_start': 5, 'words': '1'}, {'col_end': 7, 'row_end': 4, 'cell_location': [{'x': 2682, 'y': 875}, {'x': 3100, 'y': 875}, {'x': 3101, 'y': 992}, {'x': 2682, 'y': 991}], 'row_start': 3, 'col_start': 6, 'words': '55'}, {'col_end': 8, 'row_end': 4, 'cell_location': [{'x': 3100, 'y': 875}, {'x': 3440, 'y': 876}, {'x': 3442, 'y': 993}, {'x': 3101, 'y': 992}], 'row_start': 3, 'col_start': 7, 'words': '55'}, {'col_end': 9, 'row_end': 4, 'cell_location': [{'x': 3440, 'y': 876}, {'x': 3841, 'y': 877}, {'x': 3844, 'y': 994}, {'x': 3442, 'y': 993}], 'row_start': 3, 'col_start': 8, 'words': ''}, {'col_end': 1, 'row_end': 5, 'cell_location': [{'x': 218, 'y': 984}, {'x': 396, 'y': 985}, {'x': 391, 'y': 1099}, {'x': 213, 'y': 1099}], 'row_start': 4, 'col_start': 0, 'words': '4'}, {'col_end': 2, 'row_end': 5, 'cell_location': [{'x': 396, 'y': 985}, {'x': 992, 'y': 986}, {'x': 988, 'y': 1102}, {'x': 391, 'y': 1099}], 'row_start': 4, 'col_start': 1, 'words': '6973497200403'}, {'col_end': 3, 'row_end': 5, 'cell_location': [{'x': 992, 'y': 986}, {'x': 1856, 'y': 989}, {'x': 1854, 'y': 1105}, {'x': 988, 'y': 1102}], 'row_start': 4, 'col_start': 2, 'words': '活力水平衡红提味'}, {'col_end': 4, 'row_end': 5, 'cell_location': [{'x': 1856, 'y': 989}, {'x': 2151, 'y': 989}, {'x': 2149, 'y': 1106}, {'x': 1854, 'y': 1105}], 'row_start': 4, 'col_start': 3, 'words': '1*15'}, {'col_end': 5, 'row_end': 5, 'cell_location': [{'x': 2151, 'y': 989}, {'x': 2394, 'y': 990}, {'x': 2393, 'y': 1107}, {'x': 2149, 'y': 1106}], 'row_start': 4, 'col_start': 4, 'words': '件'}, {'col_end': 6, 'row_end': 5, 'cell_location': [{'x': 2394, 'y': 990}, {'x': 2682, 'y': 991}, {'x': 2682, 'y': 1108}, {'x': 2393, 'y': 1107}], 'row_start': 4, 'col_start': 5, 'words': '1'}, {'col_end': 7, 'row_end': 5, 'cell_location': [{'x': 2682, 'y': 991}, {'x': 3101, 'y': 992}, {'x': 3102, 'y': 1109}, {'x': 2682, 'y': 1108}], 'row_start': 4, 'col_start': 6, 'words': '55'}, {'col_end': 8, 'row_end': 5, 'cell_location': [{'x': 3101, 'y': 992}, {'x': 3442, 'y': 993}, {'x': 3444, 'y': 1111}, {'x': 3102, 'y': 1109}], 'row_start': 4, 'col_start': 7, 'words': '55'}, {'col_end': 9, 'row_end': 5, 'cell_location': [{'x': 3442, 'y': 993}, {'x': 3844, 'y': 994}, {'x': 3847, 'y': 1112}, {'x': 3444, 'y': 1111}], 'row_start': 4, 'col_start': 8, 'words': ''}, {'col_end': 1, 'row_end': 6, 'cell_location': [{'x': 213, 'y': 1099}, {'x': 391, 'y': 1099}, {'x': 385, 'y': 1214}, {'x': 207, 'y': 1213}], 'row_start': 5, 'col_start': 0, 'words': '5'}, {'col_end': 2, 'row_end': 6, 'cell_location': [{'x': 391, 'y': 1099}, {'x': 988, 'y': 1102}, {'x': 984, 'y': 1217}, {'x': 385, 'y': 1214}], 'row_start': 5, 'col_start': 1, 'words': '6873497204449'}, {'col_end': 3, 'row_end': 6, 'cell_location': [{'x': 988, 'y': 1102}, {'x': 1854, 'y': 1105}, {'x': 1852, 'y': 1221}, {'x': 984, 'y': 1217}], 'row_start': 5, 'col_start': 2, 'words': '450ml轻乳茶桂花乌龙'}, {'col_end': 4, 'row_end': 6, 'cell_location': [{'x': 1854, 'y': 1105}, {'x': 2149, 'y': 1106}, {'x': 2148, 'y': 1222}, {'x': 1852, 'y': 1221}], 'row_start': 5, 'col_start': 3, 'words': '1*15'}, {'col_end': 5, 'row_end': 6, 'cell_location': [{'x': 2149, 'y': 1106}, {'x': 2393, 'y': 1107}, {'x': 2392, 'y': 1223}, {'x': 2148, 'y': 1222}], 'row_start': 5, 'col_start': 4, 'words': '件'}, {'col_end': 6, 'row_end': 6, 'cell_location': [{'x': 2393, 'y': 1107}, {'x': 2682, 'y': 1108}, {'x': 2681, 'y': 1225}, {'x': 2392, 'y': 1223}], 'row_start': 5, 'col_start': 5, 'words': ''}, {'col_end': 7, 'row_end': 6, 'cell_location': [{'x': 2682, 'y': 1108}, {'x': 3102, 'y': 1109}, {'x': 3103, 'y': 1227}, {'x': 2681, 'y': 1225}], 'row_start': 5, 'col_start': 6, 'words': '65'}, {'col_end': 8, 'row_end': 6, 'cell_location': [{'x': 3102, 'y': 1109}, {'x': 3444, 'y': 1111}, {'x': 3445, 'y': 1228}, {'x': 3103, 'y': 1227}], 'row_start': 5, 'col_start': 7, 'words': '65'}, {'col_end': 9, 'row_end': 6, 'cell_location': [{'x': 3444, 'y': 1111}, {'x': 3847, 'y': 1112}, {'x': 3849, 'y': 1230}, {'x': 3445, 'y': 1228}], 'row_start': 5, 'col_start': 8, 'words': ''}, {'col_end': 1, 'row_end': 7, 'cell_location': [{'x': 207, 'y': 1213}, {'x': 385, 'y': 1214}, {'x': 380, 'y': 1327}, {'x': 202, 'y': 1326}], 'row_start': 6, 'col_start': 0, 'words': '6'}, {'col_end': 2, 'row_end': 7, 'cell_location': [{'x': 385, 'y': 1214}, {'x': 984, 'y': 1217}, {'x': 980, 'y': 1330}, {'x': 380, 'y': 1327}], 'row_start': 6, 'col_start': 1, 'words': '6973497204432'}, {'col_end': 3, 'row_end': 7, 'cell_location': [{'x': 984, 'y': 1217}, {'x': 1852, 'y': 1221}, {'x': 1850, 'y': 1336}, {'x': 980, 'y': 1330}], 'row_start': 6, 'col_start': 2, 'words': '450ml轻乳茶大红袍乌龙'}, {'col_end': 4, 'row_end': 7, 'cell_location': [{'x': 1852, 'y': 1221}, {'x': 2148, 'y': 1222}, {'x': 2146, 'y': 1337}, {'x': 1850, 'y': 1336}], 'row_start': 6, 'col_start': 3, 'words': '1*15'}, {'col_end': 5, 'row_end': 7, 'cell_location': [{'x': 2148, 'y': 1222}, {'x': 2392, 'y': 1223}, {'x': 2391, 'y': 1339}, {'x': 2146, 'y': 1337}], 'row_start': 6, 'col_start': 4, 'words': '件'}, {'col_end': 6, 'row_end': 7, 'cell_location': [{'x': 2392, 'y': 1223}, {'x': 2681, 'y': 1225}, {'x': 2681, 'y': 1340}, {'x': 2391, 'y': 1339}], 'row_start': 6, 'col_start': 5, 'words': '1'}, {'col_end': 7, 'row_end': 7, 'cell_location': [{'x': 2681, 'y': 1225}, {'x': 3103, 'y': 1227}, {'x': 3104, 'y': 1343}, {'x': 2681, 'y': 1340}], 'row_start': 6, 'col_start': 6, 'words': '65'}, {'col_end': 8, 'row_end': 7, 'cell_location': [{'x': 3103, 'y': 1227}, {'x': 3445, 'y': 1228}, {'x': 3447, 'y': 1345}, {'x': 3104, 'y': 1343}], 'row_start': 6, 'col_start': 7, 'words': '65'}, {'col_end': 9, 'row_end': 7, 'cell_location': [{'x': 3445, 'y': 1228}, {'x': 3849, 'y': 1230}, {'x': 3852, 'y': 1347}, {'x': 3447, 'y': 1345}], 'row_start': 6, 'col_start': 8, 'words': ''}, {'col_end': 1, 'row_end': 8, 'cell_location': [{'x': 202, 'y': 1326}, {'x': 380, 'y': 1327}, {'x': 374, 'y': 1446}, {'x': 196, 'y': 1444}], 'row_start': 7, 'col_start': 0, 'words': '7'}, {'col_end': 2, 'row_end': 8, 'cell_location': [{'x': 380, 'y': 1327}, {'x': 980, 'y': 1330}, {'x': 975, 'y': 1450}, {'x': 374, 'y': 1446}], 'row_start': 7, 'col_start': 1, 'words': '6973497202360'}, {'col_end': 3, 'row_end': 8, 'cell_location': [{'x': 980, 'y': 1330}, {'x': 1850, 'y': 1336}, {'x': 1848, 'y': 1456}, {'x': 975, 'y': 1450}], 'row_start': 7, 'col_start': 2, 'words': '无糖茶\n金桂乌龙'}, {'col_end': 4, 'row_end': 8, 'cell_location': [{'x': 1850, 'y': 1336}, {'x': 2146, 'y': 1337}, {'x': 2145, 'y': 1458}, {'x': 1848, 'y': 1456}], 'row_start': 7, 'col_start': 3, 'words': '1*15'}, {'col_end': 5, 'row_end': 8, 'cell_location': [{'x': 2146, 'y': 1337}, {'x': 2391, 'y': 1339}, {'x': 2390, 'y': 1460}, {'x': 2145, 'y': 1458}], 'row_start': 7, 'col_start': 4, 'words': '件'}, {'col_end': 6, 'row_end': 8, 'cell_location': [{'x': 2391, 'y': 1339}, {'x': 2681, 'y': 1340}, {'x': 2681, 'y': 1462}, {'x': 2390, 'y': 1460}], 'row_start': 7, 'col_start': 5, 'words': '1'}, {'col_end': 7, 'row_end': 8, 'cell_location': [{'x': 2681, 'y': 1340}, {'x': 3104, 'y': 1343}, {'x': 3105, 'y': 1465}, {'x': 2681, 'y': 1462}], 'row_start': 7, 'col_start': 6, 'words': ''}, {'col_end': 8, 'row_end': 8, 'cell_location': [{'x': 3104, 'y': 1343}, {'x': 3447, 'y': 1345}, {'x': 3449, 'y': 1467}, {'x': 3105, 'y': 1465}], 'row_start': 7, 'col_start': 7, 'words': ''}, {'col_end': 9, 'row_end': 8, 'cell_location': [{'x': 3447, 'y': 1345}, {'x': 3852, 'y': 1347}, {'x': 3855, 'y': 1470}, {'x': 3449, 'y': 1467}], 'row_start': 7, 'col_start': 8, 'words': '陈列'}, {'col_end': 1, 'row_end': 9, 'cell_location': [{'x': 196, 'y': 1444}, {'x': 374, 'y': 1446}, {'x': 369, 'y': 1563}, {'x': 190, 'y': 1562}], 'row_start': 8, 'col_start': 0, 'words': '8'}, {'col_end': 2, 'row_end': 9, 'cell_location': [{'x': 374, 'y': 1446}, {'x': 975, 'y': 1450}, {'x': 971, 'y': 1568}, {'x': 369, 'y': 1563}], 'row_start': 8, 'col_start': 1, 'words': '6973497202889'}, {'col_end': 3, 'row_end': 9, 'cell_location': [{'x': 975, 'y': 1450}, {'x': 1848, 'y': 1456}, {'x': 1846, 'y': 1575}, {'x': 971, 'y': 1568}], 'row_start': 8, 'col_start': 2, 'words': '无糖茶青柑乌龙'}, {'col_end': 4, 'row_end': 9, 'cell_location': [{'x': 1848, 'y': 1456}, {'x': 2145, 'y': 1458}, {'x': 2143, 'y': 1577}, {'x': 1846, 'y': 1575}], 'row_start': 8, 'col_start': 3, 'words': '1*15'}, {'col_end': 5, 'row_end': 9, 'cell_location': [{'x': 2145, 'y': 1458}, {'x': 2390, 'y': 1460}, {'x': 2390, 'y': 1580}, {'x': 2143, 'y': 1577}], 'row_start': 8, 'col_start': 4, 'words': '件'}, {'col_end': 6, 'row_end': 9, 'cell_location': [{'x': 2390, 'y': 1460}, {'x': 2681, 'y': 1462}, {'x': 2681, 'y': 1582}, {'x': 2390, 'y': 1580}], 'row_start': 8, 'col_start': 5, 'words': '1'}, {'col_end': 7, 'row_end': 9, 'cell_location': [{'x': 2681, 'y': 1462}, {'x': 3105, 'y': 1465}, {'x': 3105, 'y': 1585}, {'x': 2681, 'y': 1582}], 'row_start': 8, 'col_start': 6, 'words': ''}, {'col_end': 8, 'row_end': 9, 'cell_location': [{'x': 3105, 'y': 1465}, {'x': 3449, 'y': 1467}, {'x': 3450, 'y': 1588}, {'x': 3105, 'y': 1585}], 'row_start': 8, 'col_start': 7, 'words': ''}, {'col_end': 9, 'row_end': 9, 'cell_location': [{'x': 3449, 'y': 1467}, {'x': 3855, 'y': 1470}, {'x': 3858, 'y': 1591}, {'x': 3450, 'y': 1588}], 'row_start': 8, 'col_start': 8, 'words': '陈列'}, {'col_end': 1, 'row_end': 10, 'cell_location': [{'x': 190, 'y': 1562}, {'x': 369, 'y': 1563}, {'x': 363, 'y': 1683}, {'x': 184, 'y': 1682}], 'row_start': 9, 'col_start': 0, 'words': '9'}, {'col_end': 2, 'row_end': 10, 'cell_location': [{'x': 369, 'y': 1563}, {'x': 971, 'y': 1568}, {'x': 967, 'y': 1689}, {'x': 363, 'y': 1683}], 'row_start': 9, 'col_start': 1, 'words': ''}, {'col_end': 3, 'row_end': 10, 'cell_location': [{'x': 971, 'y': 1568}, {'x': 1846, 'y': 1575}, {'x': 1843, 'y': 1697}, {'x': 967, 'y': 1689}], 'row_start': 9, 'col_start': 2, 'words': '无糖茶(单瓶)'}, {'col_end': 4, 'row_end': 10, 'cell_location': [{'x': 1846, 'y': 1575}, {'x': 2143, 'y': 1577}, {'x': 2142, 'y': 1700}, {'x': 1843, 'y': 1697}], 'row_start': 9, 'col_start': 3, 'words': '1'}, {'col_end': 5, 'row_end': 10, 'cell_location': [{'x': 2143, 'y': 1577}, {'x': 2390, 'y': 1580}, {'x': 2389, 'y': 1702}, {'x': 2142, 'y': 1700}], 'row_start': 9, 'col_start': 4, 'words': '瓶'}, {'col_end': 6, 'row_end': 10, 'cell_location': [{'x': 2390, 'y': 1580}, {'x': 2681, 'y': 1582}, {'x': 2681, 'y': 1705}, {'x': 2389, 'y': 1702}], 'row_start': 9, 'col_start': 5, 'words': '13'}, {'col_end': 7, 'row_end': 10, 'cell_location': [{'x': 2681, 'y': 1582}, {'x': 3105, 'y': 1585}, {'x': 3106, 'y': 1709}, {'x': 2681, 'y': 1705}], 'row_start': 9, 'col_start': 6, 'words': ''}, {'col_end': 8, 'row_end': 10, 'cell_location': [{'x': 3105, 'y': 1585}, {'x': 3450, 'y': 1588}, {'x': 3452, 'y': 1712}, {'x': 3106, 'y': 1709}], 'row_start': 9, 'col_start': 7, 'words': ''}, {'col_end': 9, 'row_end': 10, 'cell_location': [{'x': 3450, 'y': 1588}, {'x': 3858, 'y': 1591}, {'x': 3860, 'y': 1716}, {'x': 3452, 'y': 1712}], 'row_start': 9, 'col_start': 8, 'words': '换陈货'}, {'col_end': 3, 'row_end': 11, 'cell_location': [{'x': 184, 'y': 1681}, {'x': 1843, 'y': 1696}, {'x': 1841, 'y': 1819}, {'x': 177, 'y': 1800}], 'row_start': 10, 'col_start': 0, 'words': '总计大写\n叁佰伍拾元整'}, {'col_end': 5, 'row_end': 11, 'cell_location': [{'x': 1843, 'y': 1696}, {'x': 2388, 'y': 1701}, {'x': 2387, 'y': 1824}, {'x': 1841, 'y': 1819}], 'row_start': 10, 'col_start': 3, 'words': '总计件数:'}, {'col_end': 6, 'row_end': 11, 'cell_location': [{'x': 2389, 'y': 1702}, {'x': 2681, 'y': 1705}, {'x': 2681, 'y': 1827}, {'x': 2388, 'y': 1824}], 'row_start': 10, 'col_start': 5, 'words': '81'}, {'col_end': 7, 'row_end': 11, 'cell_location': [{'x': 2681, 'y': 1705}, {'x': 3106, 'y': 1709}, {'x': 3107, 'y': 1832}, {'x': 2681, 'y': 1827}], 'row_start': 10, 'col_start': 6, 'words': '总计金额'}, {'col_end': 9, 'row_end': 11, 'cell_location': [{'x': 3106, 'y': 1708}, {'x': 3859, 'y': 1715}, {'x': 3862, 'y': 1840}, {'x': 3107, 'y': 1832}], 'row_start': 10, 'col_start': 7, 'words': '350元'}, {'col_end': 3, 'row_end': 12, 'cell_location': [{'x': 177, 'y': 1800}, {'x': 1841, 'y': 1819}, {'x': 1839, 'y': 1916}, {'x': 173, 'y': 1897}], 'row_start': 11, 'col_start': 0, 'words': '仓库地址:华府机动车检测站内'}, {'col_end': 9, 'row_end': 12, 'cell_location': [{'x': 1841, 'y': 1819}, {'x': 3862, 'y': 1840}, {'x': 3865, 'y': 1939}, {'x': 1839, 'y': 1916}], 'row_start': 11, 'col_start': 3, 'words': '联系电话:13980567256'}], 'table_location': [{'x': 173, 'y': 527}, {'x': 3865, 'y': 527}, {'x': 3865, 'y': 1939}, {'x': 173, 'y': 1939}], 'footer': [{'location': [{'x': 2497, 'y': 1914}, {'x': 2895, 'y': 1920}, {'x': 2894, 'y': 2023}, {'x': 2496, 'y': 2016}], 'words': '未付款签字:'}]}], 'table_num': 1, 'log_id': 1918220161761126784} +2025-05-02 16:34:26,260 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌 +2025-05-02 16:55:26,799 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌 +2025-05-02 17:08:58,897 - app.core.ocr.baidu_ocr - INFO - 成功获取访问令牌 diff --git a/logs/app.core.ocr.table_ocr.active b/logs/app.core.ocr.table_ocr.active new file mode 100644 index 0000000..eb74f98 --- /dev/null +++ b/logs/app.core.ocr.table_ocr.active @@ -0,0 +1 @@ +Active since: 2025-05-02 17:16:23 \ No newline at end of file diff --git a/logs/app.core.ocr.table_ocr.log b/logs/app.core.ocr.table_ocr.log new file mode 100644 index 0000000..072e77c --- /dev/null +++ b/logs/app.core.ocr.table_ocr.log @@ -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 diff --git a/logs/app.core.utils.file_utils.active b/logs/app.core.utils.file_utils.active new file mode 100644 index 0000000..eb74f98 --- /dev/null +++ b/logs/app.core.utils.file_utils.active @@ -0,0 +1 @@ +Active since: 2025-05-02 17:16:23 \ No newline at end of file diff --git a/logs/app.core.utils.file_utils.log b/logs/app.core.utils.file_utils.log new file mode 100644 index 0000000..42fd2aa --- /dev/null +++ b/logs/app.core.utils.file_utils.log @@ -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] 系统找不到指定的路径。: '' diff --git a/logs/app.services.ocr_service.active b/logs/app.services.ocr_service.active new file mode 100644 index 0000000..eb74f98 --- /dev/null +++ b/logs/app.services.ocr_service.active @@ -0,0 +1 @@ +Active since: 2025-05-02 17:16:23 \ No newline at end of file diff --git a/logs/app.services.ocr_service.log b/logs/app.services.ocr_service.log new file mode 100644 index 0000000..68fa34a --- /dev/null +++ b/logs/app.services.ocr_service.log @@ -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初始化完成 diff --git a/logs/app.services.order_service.active b/logs/app.services.order_service.active new file mode 100644 index 0000000..6ef6632 --- /dev/null +++ b/logs/app.services.order_service.active @@ -0,0 +1 @@ +Active since: 2025-05-02 17:16:24 \ No newline at end of file diff --git a/logs/app.services.order_service.log b/logs/app.services.order_service.log new file mode 100644 index 0000000..8e2cabb --- /dev/null +++ b/logs/app.services.order_service.log @@ -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 diff --git a/logs/ocr_processor.log b/logs/ocr_processor.log new file mode 100644 index 0000000..ed89d59 --- /dev/null +++ b/logs/ocr_processor.log @@ -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秒 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6b54bae --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..d32ef91 --- /dev/null +++ b/run.py @@ -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()) \ No newline at end of file diff --git a/templates/银豹-采购单模板.xls b/templates/银豹-采购单模板.xls new file mode 100644 index 0000000..a8fb1bc Binary files /dev/null and b/templates/银豹-采购单模板.xls differ diff --git a/v1/.gitignore b/v1/.gitignore new file mode 100644 index 0000000..d9a04cd --- /dev/null +++ b/v1/.gitignore @@ -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 \ No newline at end of file diff --git a/v1/README.md b/v1/README.md new file mode 100644 index 0000000..e436acb --- /dev/null +++ b/v1/README.md @@ -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 标准单位处理 + +| 单位 | 处理规则 | 示例 | +|------|----------|------| +| 件 | 数量×包装数量
单价÷包装数量
单位转换为"瓶" | 1件(规格1*12) → 12瓶
单价108元/件 → 9元/瓶 | +| 箱 | 数量×包装数量
单价÷包装数量
单位转换为"瓶" | 2箱(规格1*24) → 48瓶
单价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 \ No newline at end of file diff --git a/v1/baidu_ocr.py b/v1/baidu_ocr.py new file mode 100644 index 0000000..10c0142 --- /dev/null +++ b/v1/baidu_ocr.py @@ -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()) \ No newline at end of file diff --git a/v1/baidu_table_ocr.py b/v1/baidu_table_ocr.py new file mode 100644 index 0000000..0c0c4a5 --- /dev/null +++ b/v1/baidu_table_ocr.py @@ -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) \ No newline at end of file diff --git a/v1/clean_files.py b/v1/clean_files.py new file mode 100644 index 0000000..bfd7b34 --- /dev/null +++ b/v1/clean_files.py @@ -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) \ No newline at end of file diff --git a/v1/clean_logs.py b/v1/clean_logs.py new file mode 100644 index 0000000..0d9a9b9 --- /dev/null +++ b/v1/clean_logs.py @@ -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() \ No newline at end of file diff --git a/v1/excel_processor_step2.py b/v1/excel_processor_step2.py new file mode 100644 index 0000000..6282490 --- /dev/null +++ b/v1/excel_processor_step2.py @@ -0,0 +1,1364 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Excel处理程序 - 第二步 +------------------- +读取OCR识别后的Excel文件,提取条码、单价和数量, +并创建采购单Excel文件。 +""" + +import os +import sys +import re +import logging +import pandas as pd +import numpy as np +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 + +# 配置日志 +logger = logging.getLogger(__name__) +if not logger.handlers: + log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs', 'excel_processor.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 ExcelProcessorStep2: + """ + Excel处理器第二步:处理OCR识别后的Excel文件, + 提取条码、单价和数量,并按照银豹采购单模板的格式填充 + """ + + def __init__(self, output_dir="output"): + """ + 初始化Excel处理器,并设置输出目录 + """ + logger.info("初始化ExcelProcessorStep2") + 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, "processed_files.json") + self.processed_files = {} # 清空已处理文件记录 + + # 特殊条码配置 + self.special_barcodes = { + '6925019900087': { + 'multiplier': 10, # 数量乘以10 + 'target_unit': '瓶', # 目标单位 + 'description': '特殊处理:数量*10,单位转换为瓶' + } + # 可以在这里添加更多特殊条码的配置 + } + + logger.info(f"初始化完成,模板文件: {self.template_path}") + + def _load_processed_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_processed_files(self): + """保存已处理文件的缓存""" + try: + with open(self.cache_file, 'w', encoding='utf-8') as f: + json.dump(self.processed_files, f, ensure_ascii=False, indent=2) + logger.info(f"已更新处理文件缓存,共{len(self.processed_files)} 条记录") + except Exception as e: + logger.warning(f"保存缓存文件失败: {e}") + + def get_latest_excel(self): + """ + 获取output目录下最新的Excel文件 + """ + logger.info(f"搜索目录 {self.output_dir} 中的Excel文件") + excel_files = [] + + for file in os.listdir(self.output_dir): + # 忽略临时文件(以~$开头的文件)和已处理的文件(以"采购单_"开头的文件) + if file.lower().endswith('.xlsx') and not file.startswith('~$') and not 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 None + + # 按修改时间排序,获取最新的文件 + latest_file = sorted(excel_files, key=lambda x: x[1], reverse=True)[0][0] + logger.info(f"找到最新的Excel文件: {latest_file}") + return latest_file + + def validate_barcode(self, barcode): + """ + 验证条码是否有效 + 新增功能:如果条码是"仓库",则返回False以避免误认为有效条码 + """ + # 处理"仓库"特殊情况 + if isinstance(barcode, str) and barcode.strip() in ["仓库", "仓库全名"]: + logger.warning(f"条码为仓库标识: {barcode}") + return False + + # 处理科学计数法 + 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) # 只保留数字 + + # 对特定的错误条码进行修正(开头改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.info(f"条码验证通过: {barcode_clean}") + return True + + def parse_specification(self, spec_str): + """ + 解析规格字符串,提取包装数量 + 支持格式1*15 1x15 格式 + + 新增支持*5*10 格式,其中最后的数字表示包装数量(例如:1袋) + """ + if not spec_str or not isinstance(spec_str, str): + logger.warning(f"无效的规格字符串: {spec_str}") + return None + + try: + # 清理规格字符串 + spec_str = spec_str.strip() + + # 新增:匹配1*5*10 格式的三级规格 + match = re.search(r'(\d+)[\*xX×](\d+)[\*xX×](\d+)', spec_str) + if match: + # 取最后一个数字作为袋数量 + return int(match.group(3)) + + # 1. 匹配 1*15 1x15 格式 + match = re.search(r'(\d+)[\*xX×](\d+)', spec_str) + if match: + # 取第二个数字作为包装数量 + return int(match.group(2)) + + # 2. 匹配 24瓶个支袋格式 + match = re.search(r'(\d+)[瓶个支袋][//](件|箱)', spec_str) + if match: + return int(match.group(1)) + + # 3. 匹配 500ml*15 格式 + match = re.search(r'\d+(?:ml|ML|毫升)[\*xX×](\d+)', spec_str) + if match: + return int(match.group(1)) + + # 4. 提取最后一个数字作为包装数量(兜底方案) + numbers = re.findall(r'\d+', spec_str) + if numbers: + # 对于类似 "330ml*24" 的规格,最后一个数字通常是包装数量 + return int(numbers[-1]) + + except (ValueError, IndexError) as e: + logger.warning(f"解析规格'{spec_str}'时出错: {e}") + + return None + + def infer_specification_from_name(self, product_name): + """ + 从商品名称推断规格 + 根据特定的命名规则匹配规格信息 + + 示例 + - 445水溶C血5入纸-> 1*15 + - 500-东方树叶-绿茶1*15-纸箱开盖活动装 -> 1*15 + - 12.9L桶装-> 12.9L*1 + - 900树叶茉莉花茶12入纸-> 1*12 + - 500茶π蜜桃乌龙15纸箱 -> 1*15 + """ + if not product_name or not isinstance(product_name, str): + logger.warning(f"无效的商品名: {product_name}") + return None, None + + product_name = product_name.strip() + logger.info(f"从商品名称推断规格: {product_name}") + + # 特定商品规则匹配 + spec_rules = [ + # 445水溶C系列 + (r'445水溶C.*?(\d+)[入个]纸箱', lambda m: f"1*{m.group(1)}"), + + # 东方树叶系列 + (r'东方树叶.*?(\d+\*\d+).*纸箱', lambda m: m.group(1)), + (r'东方树叶.*?纸箱.*?(\d+\*\d+)', lambda m: m.group(1)), + + # 桶装 + (r'(\d+\.?\d*L)桶装', lambda m: f"{m.group(1)}*1"), + + # 树叶茶系 + (r'树叶.*?(\d+)[入个]纸箱', lambda m: f"1*{m.group(1)}"), + (r'(\d+)树叶.*?(\d+)[入个]纸箱', lambda m: f"1*{m.group(2)}"), + + # 茶m系列 + (r'茶m.*?(\d+)纸箱', lambda m: f"1*{m.group(1)}"), + (r'(\d+)茶m.*?(\d+)纸箱', lambda m: f"1*{m.group(2)}"), + + # 茶π系列 + (r'茶[πΠπ].*?(\d+)纸箱', lambda m: f"1*{m.group(1)}"), + (r'(\d+)茶[πΠπ].*?(\d+)纸箱', lambda m: f"1*{m.group(2)}"), + + # 通用入数匹配 + (r'.*?(\d+)[入个](?:纸箱|箱装)', lambda m: f"1*{m.group(1)}"), + (r'.*?箱装.*?(\d+)[入个]', lambda m: f"1*{m.group(1)}"), + + # 通用数字+纸箱格式,如"500茶π蜜桃乌龙15纸箱" + (r'.*?(\d+)纸箱', lambda m: f"1*{m.group(1)}") + ] + + # 尝试所有规则 + for pattern, formatter in spec_rules: + match = re.search(pattern, product_name) + if match: + spec = formatter(match) + logger.info(f"根据名称 '{product_name}' 推断规格: {spec}") + + # 提取包装数量 + package_quantity = self.parse_specification(spec) + if package_quantity: + return spec, package_quantity + + # 尝试直接从名称中提取数字*数字格式 + match = re.search(r'(\d+\*\d+)', product_name) + if match: + spec = match.group(1) + package_quantity = self.parse_specification(spec) + if package_quantity: + logger.info(f"从名称中直接提取规格: {spec}, 包装数量={package_quantity}") + return spec, package_quantity + + # 尝试从名称中提取末尾数字 + match = re.search(r'(\d+)[入个]$', product_name) + if match: + qty = match.group(1) + spec = f"1*{qty}" + logger.info(f"从名称末尾提取入数: {spec}") + return spec, int(qty) + + # 最后尝试提取任何位置的数字,默认如果有数字15,很可能5件装 + numbers = re.findall(r'\d+', product_name) + if numbers: + for num in numbers: + # 检查是否为典型的件装数(12/15/24/30) + if num in ['12', '15', '24', '30']: + spec = f"1*{num}" + logger.info(f"从名称中提取可能的件装数: {spec}") + return spec, int(num) + + logger.warning(f"无法从商品名'{product_name}' 推断规格") + return None, None + + def extract_unit_from_quantity(self, quantity_str): + """ + 从数量字符串中提取单位 + 例如 + - '2' -> (2, '') + - '5' -> (5, '') + - '3' -> (3, '') + - '10' -> (10, '') + """ + if not quantity_str: + return None, None + + # 如果是数字,直接返回数字和None + if isinstance(quantity_str, (int, float)): + return float(quantity_str), None + + # 转为字符串并清理 + quantity_str = str(quantity_str).strip() + logger.info(f"从数量字符串提取单位: {quantity_str}") + + # 匹配数字+单位格式 + match = re.match(r'^([\d\.]+)\s*([^\d\s\.]+)$', quantity_str) + if match: + try: + value = float(match.group(1)) + unit = match.group(2) + logger.info(f"提取到数字: {value}, 单位: {unit}") + return value, unit + except ValueError: + logger.warning(f"无法解析数量: {match.group(1)}") + + # 如果只有数字,直接返回数字 + if re.match(r'^[\d\.]+$', quantity_str): + try: + value = float(quantity_str) + logger.info(f"提取到数字: {value}, 无单位") + return value, None + except ValueError: + logger.warning(f"无法解析纯数字数字: {quantity_str}") + + # 如果只有单位,尝试查找其他可能包含数字的部分 + match = re.match(r'^([^\d\s\.]+)$', quantity_str) + if match: + unit = match.group(1) + logger.info(f"仅提取到单位: {unit}, 无数值") + return None, unit + + logger.warning(f"无法提取数量和单位: {quantity_str}") + return None, None + + def extract_barcode(self, df: pd.DataFrame) -> List[str]: + """从数据框中提取条码""" + barcodes = [] + + # 遍历数据框查找条码 + for _, row in df.iterrows(): + for col_name, value in row.items(): + # 转换为字符串并处理 + if value is not None and not pd.isna(value): + value_str = str(value).strip() + # 特殊处理特定条码 + if value_str == "5321545613": + barcodes.append(value_str) + logger.info(f"特殊条码提取: {value_str}") + continue + + if self.validate_barcode(value_str): + # 提取数字部分 + barcode = re.sub(r'\D', '', value_str) + barcodes.append(barcode) + + logger.info(f"提取到{len(barcodes)} 个条码") + return barcodes + + def extract_product_info(self, df: pd.DataFrame) -> List[Dict]: + """ + 提取产品信息,包括条码、单价、数量、金额等 + 增加识别赠品功能:金额为0或为空的产品视为赠品 + 修改后的功能:当没有有效条码时,使用行号作为临时条码 + """ + logger.info(f"正在从数据帧中提取产品信息") + product_info = [] + + try: + # 打印列名,用于调试 + 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()}") + + # 检查是否有商品条码 + if '商品条码' in df.columns: + # 遍历数据框的每一行 + for index, row in df.iterrows(): + # 打印当前行的所有值,用于调试 + logger.info(f"处理行{index+1}: {row.to_dict()}") + + # 跳过空行 + if row.isna().all(): + logger.info(f"跳过空行: {index+1}") + continue + + # 跳过小计行 + if any('小计' in str(val) for val in row.values if isinstance(val, str)): + logger.info(f"跳过小计行: {index+1}") + continue + + # 获取条码(直接从商品条码列获取) + barcode_value = row['商品条码'] + if pd.isna(barcode_value): + logger.info(f"跳过无条码行: {index+1}") + continue + + # 处理条码 + barcode = str(int(barcode_value)) if isinstance(barcode_value, (int, float)) else str(barcode_value) + if not self.validate_barcode(barcode): + logger.warning(f"无效条码: {barcode}") + continue + + # 提取其他信息 + product = { + 'barcode': barcode, + 'name': row.get('商品全名', ''), + 'specification': row.get('规格', ''), + 'unit': row.get('单位', ''), + 'quantity': 0, + 'unit_price': 0, + 'amount': 0, + 'is_gift': False, + 'package_quantity': 1 # 默认包装数量 + } + + # 提取规格并解析包装数量 + if '规格' in df.columns and not pd.isna(row['规格']): + product['specification'] = str(row['规格']) + package_quantity = self.parse_specification(product['specification']) + if package_quantity: + product['package_quantity'] = package_quantity + logger.info(f"解析规格: {product['specification']} -> 包装数量={package_quantity}") + else: + # 逻辑1: 如果规格为空,尝试从商品名称推断规格 + if product['name']: + inferred_spec, inferred_qty = self.infer_specification_from_name(product['name']) + if inferred_spec: + product['specification'] = inferred_spec + product['package_quantity'] = inferred_qty + logger.info(f"从商品名称推断规格: {product['name']} -> {inferred_spec}, 包装数量={inferred_qty}") + + # 提取数量和可能的单位 + if '数量' in df.columns and not pd.isna(row['数量']): + try: + # 尝试从数量中提取单位和数量 + extracted_qty, extracted_unit = self.extract_unit_from_quantity(row['数量']) + + # 处理提取到的数量 + if extracted_qty is not None: + product['quantity'] = extracted_qty + logger.info(f"提取数量: {product['quantity']}") + + # 处理提取到的单位 + if extracted_unit and (not product['unit'] or product['unit'] == ''): + product['unit'] = extracted_unit + logger.info(f"从数量中提取单位: {extracted_unit}") + else: + # 如果没有提取到数量,使用原始方法 + product['quantity'] = float(row['数量']) + logger.info(f"使用原始数量: {product['quantity']}") + except (ValueError, TypeError) as e: + logger.warning(f"无效的数量: {row['数量']}, 错误: {str(e)}") + + # 提取单位(如果还没有单位) + if (not product['unit'] or product['unit'] == '') and '单位' in df.columns and not pd.isna(row['单位']): + product['unit'] = str(row['单位']) + logger.info(f"从单位列提取单位: {product['unit']}") + + # 提取单价 + if '单价' in df.columns: + if pd.isna(row['单价']): + # 单价为空,视为赠品 + is_gift = True + logger.info(f"单价为空,视为赠品") + else: + try: + # 如果单价是字符串且不是数字,视为赠品 + if isinstance(row['单价'], str) and not row['单价'].replace('.', '').isdigit(): + is_gift = True + logger.info(f"单价不是有效数字({row['单价']}),视为赠品") + else: + product['unit_price'] = float(row['单价']) + logger.info(f"提取单价: {product['unit_price']}") + except (ValueError, TypeError): + is_gift = True + logger.warning(f"无效的单价: {row['单价']}") + + # 提取金额 + if '金额' in df.columns: + if amount_col and not pd.isna(row[amount_col]): + try: + # 清理金额字符串,处理可能的范围值(如"40-44") + amount_str = str(row[amount_col]) + if '-' in amount_str: + # 如果是范围,取第一个值 + amount_str = amount_str.split('-')[0] + logger.info(f"金额为范围值({row[amount_col]}),取第一个值: {amount_str}") + + # 尝试转换为浮点数 + product['amount'] = float(amount_str) + logger.info(f"提取金额: {product['amount']}") + except (ValueError, TypeError) as e: + logger.warning(f"无效的金额: {row[amount_col]}, 错误: {e}") + # 金额无效时,设为0 + product['amount'] = 0 + logger.warning(f"设置金额为0") + else: + # 如果没有金额,计算金额 + product['amount'] = product['quantity'] * product['unit_price'] + + # 判断是否为赠品 + is_gift = False + + # 赠品识别规则,根据README要求 + # 1. 商品单价为0或为空 + if product['unit_price'] == 0: + is_gift = True + logger.info(f"单价为空,视为赠品") + + # 2. 商品金额为0或为空 + if not is_gift and amount_col: + try: + if pd.isna(row[amount_col]): + is_gift = True + logger.info(f"金额为空,视为赠品") + else: + # 清理金额字符串,处理可能的范围值(如"40-44") + amount_str = str(row[amount_col]) + if '-' in amount_str: + # 如果是范围,取第一个值 + amount_str = amount_str.split('-')[0] + logger.info(f"金额为范围值({row[amount_col]}),取第一个值: {amount_str}") + + # 转换为浮点数并检查是否为0 + amount_val = float(amount_str) + if amount_val == 0: + is_gift = True + logger.info(f"金额为0,视为赠品") + except (ValueError, TypeError) as e: + logger.warning(f"无法解析金额: {row[amount_col]}, 错误: {e}") + # 金额无效时,不视为赠品,继续处理 + + # 从赠送量列提取赠品数量 + gift_quantity = 0 + if '赠送量' in df.columns and not pd.isna(row['赠送量']): + try: + gift_quantity = float(row['赠送量']) + if gift_quantity > 0: + # 如果有明确的赠送量,总是创建赠品记录 + logger.info(f"提取赠送量: {gift_quantity}") + except (ValueError, TypeError): + logger.warning(f"无效的赠送量: {row['赠送量']}") + + # 处理单位转换 + self.process_unit_conversion(product) + + # 如果单价为0但有金额和数量,计算单价(非赠品情况) + if not is_gift and product['unit_price'] == 0 and product['amount'] > 0 and product['quantity'] > 0: + product['unit_price'] = product['amount'] / product['quantity'] + logger.info(f"计算单价: {product['amount']} / {product['quantity']} = {product['unit_price']}") + + # 处理产品添加逻辑 + product['is_gift'] = is_gift + + if is_gift: + # 如果是赠品且数量>0,使用商品本身的数量 + if product['quantity'] > 0: + logger.info(f"添加赠品商品: 条码={barcode}, 数量={product['quantity']}") + product_info.append(product) + else: + # 正常商品 + if product['quantity'] > 0: + logger.info(f"添加正常商品: 条码={barcode}, 数量={product['quantity']}, 单价={product['unit_price']}") + product_info.append(product) + + # 如果有额外的赠送量,添加专门的赠品记录 + if gift_quantity > 0 and not is_gift: + gift_product = product.copy() + gift_product['is_gift'] = True + gift_product['quantity'] = gift_quantity + gift_product['unit_price'] = 0 + gift_product['amount'] = 0 + product_info.append(gift_product) + logger.info(f"添加额外赠品: 条码={barcode}, 数量={gift_quantity}") + + logger.info(f"提取到{len(product_info)} 个产品信息") + return product_info + + # 如果没有直接匹配的列名,尝试使用更复杂的匹配逻辑 + logger.info("未找到直接匹配的列名或未提取到产品,尝试使用更复杂的匹配逻辑") + # 定义可能的列名 + expected_columns = { + '序号': ['序号', '行号', 'NO', 'NO.', '行号', '行号', '行号'], + '条码': ['条码', '条形码', '商品条码', 'barcode', '商品条形码', '条形码', '商品条码', '商品编码', '商品编号', '条形码', '基本条码'], + '名称': ['名称', '品名', '产品名称', '商品名称', '货物名称'], + '规格': ['规格', '包装规格', '包装', '商品规格', '规格型号'], + '采购单价': ['单价', '价格', '采购单价', '销售价'], + '单位': ['单位', '采购单位'], + '数量': ['数量', '采购数量', '购买数量', '采购数量', '订单数量', '采购数量'], + '金额': ['金额', '订单金额', '总金额', '总价金额', '小计(元)'], + '赠送量': ['赠送量', '赠品数量', '赠送数量', '赠品'], + } + + # 如果是特殊表头处理后的数据,尝试直接从列名匹配 + if special_header: + logger.info("使用特殊表头处理后的列名进行匹配") + direct_map = { + '行号': '序号', + '条形码': '条码', + '商品名称': '名称', + '规格': '规格', + '单价': '采购单价', + '单位': '单位', + '数量': '数量', + '金额': '金额', + '箱数': '箱数', # 可能包含单位信息 + } + + column_mapping = {} + for target_key, source_key in direct_map.items(): + if target_key in df.columns: + column_mapping[source_key] = target_key + logger.info(f"特殊表头匹配: {source_key} -> {target_key}") + + # 如果特殊表头处理没有找到足够的列,或者不是特殊表头,使用原有的映射逻辑 + if not special_header or len(column_mapping) < 3: + # 检查第一行的内容,尝试判断是否是特殊格式的Excel + if len(df) > 0: # 确保DataFrame不为空 + first_row = df.iloc[0].astype(str) + # 检查是否包含"商品全名"、"基本条码"、"仓库全名"等特定字段 + if any("商品全名" in str(val) for val in first_row.values) and any("基本条码" in str(val) for val in first_row.values): + logger.info("检测到特殊格式Excel,使用特定的列映射") + + # 找出各列的索引 + name_idx = None + barcode_idx = None + spec_idx = None + unit_idx = None + qty_idx = None + price_idx = None + amount_idx = None + + for idx, val in enumerate(first_row): + val_str = str(val).strip() + if val_str == "商品全名": + name_idx = df.columns[idx] + elif val_str == "基本条码": + barcode_idx = df.columns[idx] + elif val_str == "规格": + spec_idx = df.columns[idx] + elif val_str == "数量": + qty_idx = df.columns[idx] + elif val_str == "单位": + unit_idx = df.columns[idx] + elif val_str == "单价": + price_idx = df.columns[idx] + elif val_str == "金额": + amount_idx = df.columns[idx] + + # 使用找到的索引创建列映射 + if name_idx and barcode_idx: + column_mapping = { + '名称': name_idx, + '条码': barcode_idx + } + + if spec_idx: + column_mapping['规格'] = spec_idx + if unit_idx: + column_mapping['单位'] = unit_idx + if qty_idx: + column_mapping['数量'] = qty_idx + if price_idx: + column_mapping['采购单价'] = price_idx + if amount_idx: + column_mapping['金额'] = amount_idx + + logger.info(f"特殊格式Excel的列映射: {column_mapping}") + + # 跳过第一行(表头) + df = df.iloc[1:].reset_index(drop=True) + logger.info("已跳过第一行(表头)") + else: + logger.warning("无法在特殊格式Excel中找到必要的列") + else: + # 映射实际的列名 + column_mapping = {} + + # 检查是否有表头 + has_header = False + for col in df.columns: + if not str(col).startswith('Unnamed:'): + has_header = True + break + + if has_header: + # 有表头的情况,使用原有的映射逻辑 + for key, patterns in expected_columns.items(): + for col in df.columns: + # 移除列名中的空白字符以进行比较 + clean_col = re.sub(r'\s+', '', str(col)) + for pattern in patterns: + clean_pattern = re.sub(r'\s+', '', pattern) + if clean_col == clean_pattern: + column_mapping[key] = col + break + if key in column_mapping: + break + else: + # 无表头的情况,根据列的位置进行映射 + # 假设列的顺序是:空列、序号、条码、名称、规格、单价、单位、数量、金额 + if len(df.columns) >= 9: + column_mapping = { + '序号': df.columns[1], # Unnamed: 1 + '条码': df.columns[2], # Unnamed: 2 + '名称': df.columns[3], # Unnamed: 3 + '规格': df.columns[4], # Unnamed: 4 + '采购单价': df.columns[7], # Unnamed: 7 + '单位': df.columns[5], # Unnamed: 5 + '数量': df.columns[6], # Unnamed: 6 + '金额': df.columns[8] # Unnamed: 8 + } + else: + logger.warning(f"列数不足,无法进行映射。当前列数: {len(df.columns)}") + return [] + + logger.info(f"列映射结果: {column_mapping}") + + # 如果找到了必要的列,直接从DataFrame提取数据 + if '条码' in column_mapping: + barcode_col = column_mapping['条码'] + quantity_col = column_mapping.get('数量') + price_col = column_mapping.get('采购单价') + amount_col = column_mapping.get('金额') + unit_col = column_mapping.get('单位') + spec_col = column_mapping.get('规格') + gift_col = column_mapping.get('赠送量') + + # 详细打印各行的关键数据 + logger.info("逐行显示数据内容:") + for idx, row in df.iterrows(): + # 获取关键字段数据 + barcode_val = row[barcode_col] if barcode_col and not pd.isna(row[barcode_col]) else "" + quantity_val = row[quantity_col] if quantity_col and not pd.isna(row[quantity_col]) else "" + unit_val = row[unit_col] if unit_col and not pd.isna(row[unit_col]) else "" + price_val = row[price_col] if price_col and not pd.isna(row[price_col]) else "" + spec_val = row[spec_col] if spec_col and not pd.isna(row[spec_col]) else "" + gift_val = row[gift_col] if gift_col and not pd.isna(row[gift_col]) else "" + + logger.info(f"行{idx}, 条码:{barcode_val}, 数量:{quantity_val}, 单位:{unit_val}, " + + f"单价:{price_val}, 规格:{spec_val}, 赠送量:{gift_val}") + + # 逐行处理数据 + for idx, row in df.iterrows(): + try: + # 跳过表头和汇总行 + skip_row = False + for col in row.index: + if pd.notna(row[col]) and isinstance(row[col], str): + # 检查是否为表头、页脚或汇总行 + if any(keyword in str(row[col]).lower() for keyword in ['序号', '小计', '合计', '总计', '页码', '行号', '页小计']): + skip_row = True + logger.info(f"跳过非商品行: {row[col]}") + break + + if skip_row: + continue + + # 检查是否有有效的数量和单价 + has_valid_data = False + if quantity_col and not pd.isna(row[quantity_col]): + try: + qty = float(row[quantity_col]) + if qty > 0: + has_valid_data = True + except (ValueError, TypeError): + pass + + if not has_valid_data: + logger.info(f"行{idx}没有有效数量,跳过") + continue + + # 提取或生成条码 + barcode_value = row[barcode_col] if not pd.isna(row[barcode_col]) else None + + # 检查条码是否有效,如果是"仓库"或无效条码,跳过该行 + barcode = None + if barcode_value is not None: + barcode_str = str(int(barcode_value)) if isinstance(barcode_value, (int, float)) else str(barcode_value) + if barcode_str not in ["仓库", "仓库全名"] and self.validate_barcode(barcode_str): + barcode = barcode_str + + # 如果没有有效条码,跳过该行 + if barcode is None: + logger.info(f"行{idx}无有效条码,跳过该行") + continue + + # 创建产品信息 + product = { + 'barcode': barcode, + 'name': row[column_mapping['名称']] if '名称' in column_mapping and not pd.isna(row[column_mapping['名称']]) else '', + 'specification': row[spec_col] if spec_col and not pd.isna(row[spec_col]) else '', + 'unit': row[unit_col] if unit_col and not pd.isna(row[unit_col]) else '', + 'quantity': 0, + 'unit_price': 0, + 'amount': 0, + 'is_gift': False, + 'package_quantity': 1 # 默认包装数量 + } + + # 提取规格并解析包装数量 + if spec_col and not pd.isna(row[spec_col]): + product['specification'] = str(row[spec_col]) + package_quantity = self.parse_specification(product['specification']) + if package_quantity: + product['package_quantity'] = package_quantity + logger.info(f"解析规格: {product['specification']} -> 包装数量={package_quantity}") + else: + # 逻辑1: 如果规格为空,尝试从商品名称推断规格 + if '名称' in column_mapping and not pd.isna(row[column_mapping['名称']]): + product_name = str(row[column_mapping['名称']]) + inferred_spec, inferred_qty = self.infer_specification_from_name(product_name) + if inferred_spec: + product['specification'] = inferred_spec + product['package_quantity'] = inferred_qty + logger.info(f"从商品名称推断规格: {product_name} -> {inferred_spec}, 包装数量={inferred_qty}") + + # 提取数量和可能的单位 + if quantity_col and not pd.isna(row[quantity_col]): + try: + # 尝试从数量中提取单位和数量 + extracted_qty, extracted_unit = self.extract_unit_from_quantity(row[quantity_col]) + + # 处理提取到的数量 + if extracted_qty is not None: + product['quantity'] = extracted_qty + logger.info(f"提取数量: {product['quantity']}") + + # 处理提取到的单位 + if extracted_unit and (not product['unit'] or product['unit'] == ''): + product['unit'] = extracted_unit + logger.info(f"从数量中提取单位: {extracted_unit}") + else: + # 如果没有提取到数量,使用原始方法 + product['quantity'] = float(row[quantity_col]) + logger.info(f"使用原始数量: {product['quantity']}") + except (ValueError, TypeError) as e: + logger.warning(f"无效的数量: {row[quantity_col]}, 错误: {str(e)}") + continue # 如果数量无效,跳过此行 + else: + # 如果没有数量,跳过此行 + logger.warning(f"行{idx}缺少数量,跳过") + continue + + # 提取单价 + if price_col and not pd.isna(row[price_col]): + try: + product['unit_price'] = float(row[price_col]) + logger.info(f"提取单价: {product['unit_price']}") + except (ValueError, TypeError) as e: + logger.warning(f"无效的单价: {row[price_col]}, 错误: {e}") + # 单价无效时,可能是赠品 + is_gift = True + + # 初始化赠品标志 + is_gift = False + + # 提取金额 + # 忽略金额栏中可能存在的备注信息 + if amount_col and not pd.isna(row[amount_col]): + amount_value = row[amount_col] + if isinstance(amount_value, (int, float)): + # 如果是数字类型,直接使用 + product['amount'] = float(amount_value) + logger.info(f"提取金额: {product['amount']}") + if product['amount'] == 0: + is_gift = True + logger.info(f"金额为0,视为赠品") + else: + # 如果不是数字类型,尝试从字符串中提取数字 + try: + # 尝试转换为浮点数 + amount_str = str(amount_value) + if amount_str.replace('.', '', 1).isdigit(): + product['amount'] = float(amount_str) + logger.info(f"从字符串提取金额: {product['amount']}") + if product['amount'] == 0: + is_gift = True + logger.info(f"金额为0,视为赠品") + else: + # 金额栏含有非数字内容,可能是备注,此时使用单价*数量计算金额 + logger.warning(f"金额栏包含非数字内容: {amount_value},将被视为备注,金额计算为单价*数量") + product['amount'] = product['unit_price'] * product['quantity'] + logger.info(f"计算金额: {product['unit_price']} * {product['quantity']} = {product['amount']}") + except (ValueError, TypeError) as e: + logger.warning(f"无法解析金额: {amount_value}, 错误: {e}") + # 计算金额 + product['amount'] = product['unit_price'] * product['quantity'] + logger.info(f"计算金额: {product['unit_price']} * {product['quantity']} = {product['amount']}") + else: + # 如果金额为空,可能是赠品,或需要计算金额 + if product['unit_price'] > 0: + product['amount'] = product['unit_price'] * product['quantity'] + logger.info(f"计算金额: {product['unit_price']} * {product['quantity']} = {product['amount']}") + else: + is_gift = True + logger.info(f"单价或金额为空,视为赠品") + + # 处理单位转换 + self.process_unit_conversion(product) + + # 处理产品添加逻辑 + product['is_gift'] = is_gift + + if is_gift: + # 如果是赠品且数量>0,使用商品本身的数量 + if product['quantity'] > 0: + logger.info(f"添加赠品商品: 条码={barcode}, 数量={product['quantity']}") + product_info.append(product) + else: + # 正常商品 + if product['quantity'] > 0: + logger.info(f"添加正常商品: 条码={barcode}, 数量={product['quantity']}, 单价={product['unit_price']}") + product_info.append(product) + + # 如果有额外的赠送量,添加专门的赠品记录 + if gift_col and not pd.isna(row[gift_col]): + try: + gift_quantity = float(row[gift_col]) + if gift_quantity > 0: + gift_product = product.copy() + gift_product['is_gift'] = True + gift_product['quantity'] = gift_quantity + gift_product['unit_price'] = 0 + gift_product['amount'] = 0 + product_info.append(gift_product) + logger.info(f"添加额外赠品: 条码={barcode}, 数量={gift_quantity}") + except (ValueError, TypeError) as e: + logger.warning(f"无效的赠送量: {row[gift_col]}, 错误: {e}") + + except Exception as e: + logger.warning(f"处理行{idx}时出错: {e}") + continue # 跳过有错误的行,继续处理下一行 + + logger.info(f"提取到{len(product_info)} 个产品信息") + return product_info + + except Exception as e: + logger.error(f"提取产品信息时出错: {e}", exc_info=True) + return [] + + def fill_template(self, template_file_path, products, output_file_path): + """ + 填充采购单模板并保存为新文件 + 按照模板格式填充(银豹采购单模板) + - 列B(1): 条码(必填) + - 列C(2): 采购量(必填) 对于只有赠品的商品,此列为空 + - 列D(3): 赠送量 - 同一条码的赠品数量 + - 列E(4): 采购单价(必填)- 保留4位小数 + + 特殊处理 + - 同一条码既有正常商品又有赠品时,保持正常商品的采购量不变,将赠品数量填写到赠送量栏位 + - 只有赠品没有正常商品的情况,采购量列填写0,赠送量填写赠品数量 + - 赠品的判断依据:is_gift标记为True + """ + logger.info(f"开始填充模板: {template_file_path}") + + try: + # 打开模板文件 + workbook = xlrd.open_workbook(template_file_path) + workbook = xlcopy(workbook) + worksheet = workbook.get_sheet(0) # 默认第一个工作表 + + # 从第2行开始填充数据(索引从0开始,对应Excel中的行号) + row_index = 1 # Excel的行号从0开始,对应Excel中的行号 + + # 先对产品按条码进行分组,识别赠品和普通商品 + barcode_groups = {} + + # 遍历所有产品,按条码分组 + logger.info(f"开始处理{len(products)} 个产品信息") + for product in products: + barcode = product.get('barcode', '') + if not barcode: + logger.warning(f"跳过无条码商品") + continue + + # 使用产品中的is_gift标记来判断是否为赠品 + is_gift = product.get('is_gift', False) + + # 获取数量和单位 + quantity = product.get('quantity', 0) + unit_price = product.get('unit_price', 0) + + logger.info(f"处理商品: 条码={barcode}, 数量={quantity}, 单价={unit_price}, 是否赠品={is_gift}") + + if barcode not in barcode_groups: + barcode_groups[barcode] = { + 'normal': None, # 正常商品信息 + 'gift_quantity': 0 # 赠品数量 + } + + if is_gift: + # 是赠品,累加赠品数量 + barcode_groups[barcode]['gift_quantity'] += quantity + logger.info(f"发现赠品:条码{barcode}, 数量={quantity}") + else: + # 是正常商品 + if barcode_groups[barcode]['normal'] is None: + barcode_groups[barcode]['normal'] = { + 'product': product, + 'quantity': quantity, + 'price': unit_price + } + logger.info(f"发现正常商品:条码{barcode}, 数量={quantity}, 单价={unit_price}") + else: + # 如果有多个正常商品记录,累加数量 + barcode_groups[barcode]['normal']['quantity'] += quantity + logger.info(f"累加正常商品数量:条码{barcode}, 新增={quantity}, 累计={barcode_groups[barcode]['normal']['quantity']}") + + # 如果单价不同,取平均值 + if unit_price != barcode_groups[barcode]['normal']['price']: + avg_price = (barcode_groups[barcode]['normal']['price'] + unit_price) / 2 + barcode_groups[barcode]['normal']['price'] = avg_price + logger.info(f"调整单价(取平均值):条码{barcode}, 原价={barcode_groups[barcode]['normal']['price']}, 新价={unit_price}, 平均={avg_price}") + + # 输出调试信息 + logger.info(f"分组后共{len(barcode_groups)} 个不同条码的商品") + for barcode, group in barcode_groups.items(): + if group['normal'] is not None: + logger.info(f"条码 {barcode} 处理结果:正常商品数量{group['normal']['quantity']},单价{group['normal']['price']},赠品数量{group['gift_quantity']}") + else: + logger.info(f"条码 {barcode} 处理结果:只有赠品,数量={group['gift_quantity']}") + + # 准备填充数据 + for barcode, group in barcode_groups.items(): + # 1. 列B(1): 条码(必填) + worksheet.write(row_index, 1, barcode) + + if group['normal'] is not None: + # 有正常商品 + product = group['normal']['product'] + + # 2. 列C(2): 采购量(必填) 使用正常商品的采购量 + normal_quantity = group['normal']['quantity'] + worksheet.write(row_index, 2, normal_quantity) + + # 3. 列D(3): 赠送量 - 添加赠品数量 + if group['gift_quantity'] > 0: + worksheet.write(row_index, 3, group['gift_quantity']) + logger.info(f"条码 {barcode} 填充:采购量={normal_quantity},赠品数量{group['gift_quantity']}") + + # 4. 列E(4): 采购单价(必填) + purchase_price = group['normal']['price'] + style = xlwt.XFStyle() + style.num_format_str = '0.0000' + worksheet.write(row_index, 4, round(purchase_price, 4), style) + + elif group['gift_quantity'] > 0: + # 只有赠品,没有正常商品 + logger.info(f"条码 {barcode} 只有赠品,数量{group['gift_quantity']},采购量=0,赠送量={group['gift_quantity']}") + + # 2. 列C(2): 采购量(必填) 对于只有赠品的条目,采购量填写为0 + worksheet.write(row_index, 2, 0) + + # 3. 列D(3): 赠送量 - 填写赠品数量 + worksheet.write(row_index, 3, group['gift_quantity']) + + # 4. 列E(4): 采购单价(必填) - 对于只有赠品的条目,采购单价为0 + style = xlwt.XFStyle() + style.num_format_str = '0.0000' + worksheet.write(row_index, 4, 0, style) + + row_index += 1 + + # 保存文件 + workbook.save(output_file_path) + logger.info(f"采购单已保存: {output_file_path}") + return True + + except Exception as e: + logger.error(f"填充模板时出错: {str(e)}", exc_info=True) + return False + + def create_new_xls(self, input_file_path, products): + """ + 根据输入的Excel文件创建新的采购单 + """ + try: + # 获取输入文件的文件名(不带扩展名) + input_filename = os.path.basename(input_file_path) + name_without_ext = os.path.splitext(input_filename)[0] + + # 创建基本输出文件路径 + base_output_path = os.path.join("output", f"采购单_{name_without_ext}.xls") + + # 如果文件已存在,自动添加时间戳避免覆盖 + output_file_path = base_output_path + if os.path.exists(base_output_path): + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + name_parts = os.path.splitext(base_output_path) + output_file_path = f"{name_parts[0]}_{timestamp}{name_parts[1]}" + logger.info(f"文件 {base_output_path} 已存在,重命名为 {output_file_path}") + + # 填充模板 + result = self.fill_template(self.template_path, products, output_file_path) + + if result: + logger.info(f"成功创建采购单: {output_file_path}") + return output_file_path + else: + logger.error("创建采购单失败") + return None + + except Exception as e: + logger.error(f"创建采购单时出错: {str(e)}") + return None + + def process_specific_file(self, file_path): + """ + 处理指定的Excel文件 + """ + if not os.path.exists(file_path): + logger.error(f"文件不存在: {file_path}") + return False + + # 检查文件是否已处理 + file_stat = os.stat(file_path) + file_key = f"{os.path.basename(file_path)}_{file_stat.st_size}_{file_stat.st_mtime}" + + if file_key in self.processed_files: + output_file = self.processed_files[file_key] + if os.path.exists(output_file): + logger.info(f"文件已处理过,采购单文件: {output_file}") + return True + + logger.info(f"开始处理Excel文件: {file_path}") + try: + # 读取Excel文件 + df = pd.read_excel(file_path) + + # 删除行号列(如果存在) + if '行号' in df.columns: + df = df.drop('行号', axis=1) + logger.info("已删除行号列") + + # 提取商品信息 + products = self.extract_product_info(df) + + if not products: + logger.warning("未从Excel文件中提取到有效的商品信息") + return False + + # 获取文件名(不含扩展名) + file_name = os.path.splitext(os.path.basename(file_path))[0] + + # 基本输出文件路径 + base_output_file = os.path.join(self.output_dir, f"采购单_{file_name}.xls") + + # 如果文件已存在,自动添加时间戳避免覆盖 + output_file = base_output_file + if os.path.exists(base_output_file): + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + name_parts = os.path.splitext(base_output_file) + output_file = f"{name_parts[0]}_{timestamp}{name_parts[1]}" + logger.info(f"文件 {base_output_file} 已存在,重命名为 {output_file}") + + # 填充模板 + result = self.fill_template(self.template_path, products, output_file) + + if result: + # 记录已处理文件 + self.processed_files[file_key] = output_file + self._save_processed_files() + + logger.info(f"Excel处理成功,采购单已保存至: {output_file}") + return True + else: + logger.error("填充模板失败") + return False + + except Exception as e: + logger.error(f"处理Excel文件时出错: {str(e)}") + return False + + def process_latest_file(self): + """ + 处理最新的Excel文件 + """ + latest_file = self.get_latest_excel() + if not latest_file: + logger.error("未找到可处理的Excel文件") + return False + + return self.process_specific_file(latest_file) + + def process(self): + """ + 处理最新的Excel文件 + """ + return self.process_latest_file() + + def process_unit_conversion(self, product): + """ + 处理单位转换 + """ + if product['unit'] in ['提', '盒']: + # 检查是否是特殊条码 + if product['barcode'] in self.special_barcodes: + special_config = self.special_barcodes[product['barcode']] + # 特殊条码处理 + actual_quantity = product['quantity'] * special_config['multiplier'] + logger.info(f"特殊条码处理: {product['quantity']}{product['unit']} -> {actual_quantity}{special_config['target_unit']}") + + # 更新产品信息 + product['original_quantity'] = product['quantity'] + product['quantity'] = actual_quantity + product['original_unit'] = product['unit'] + product['unit'] = special_config['target_unit'] + + # 如果有单价,计算转换后的单价 + if product['unit_price'] > 0: + product['original_unit_price'] = product['unit_price'] + product['unit_price'] = product['unit_price'] / special_config['multiplier'] + logger.info(f"单价转换: {product['original_unit_price']}/{product['original_unit']} -> {product['unit_price']}/{special_config['target_unit']}") + else: + # 提取规格中的数字 + spec_parts = re.findall(r'\d+', product['specification']) + + # 检查是否是1*5*12这样的三级格式 + if len(spec_parts) >= 3: + # 三级规格:按件处理 + actual_quantity = product['quantity'] * product['package_quantity'] + logger.info(f"{product['unit']}单位三级规格转换: {product['quantity']}{product['unit']} -> {actual_quantity}瓶") + + # 更新产品信息 + product['original_quantity'] = product['quantity'] + product['quantity'] = actual_quantity + product['original_unit'] = product['unit'] + product['unit'] = '瓶' + + # 如果有单价,计算转换后的单价 + if product['unit_price'] > 0: + product['original_unit_price'] = product['unit_price'] + product['unit_price'] = product['unit_price'] / product['package_quantity'] + logger.info(f"单价转换: {product['original_unit_price']}/{product['original_unit']} -> {product['unit_price']}/瓶") + else: + # 二级规格:保持原数量不变 + logger.info(f"{product['unit']}单位二级规格保持原数量: {product['quantity']}{product['unit']}") + # 对于"件"单位或其他特殊条码的处理 + elif product['barcode'] in self.special_barcodes: + special_config = self.special_barcodes[product['barcode']] + # 特殊条码处理 + actual_quantity = product['quantity'] * special_config['multiplier'] + logger.info(f"特殊条码处理: {product['quantity']}{product['unit']} -> {actual_quantity}{special_config['target_unit']}") + + # 更新产品信息 + product['original_quantity'] = product['quantity'] + product['quantity'] = actual_quantity + product['original_unit'] = product['unit'] + product['unit'] = special_config['target_unit'] + + # 如果有单价,计算转换后的单价 + if product['unit_price'] > 0: + product['original_unit_price'] = product['unit_price'] + product['unit_price'] = product['unit_price'] / special_config['multiplier'] + logger.info(f"单价转换: {product['original_unit_price']}/{product['original_unit']} -> {product['unit_price']}/{special_config['target_unit']}") + elif product['unit'] == '件': + # 标准件处理:数量×包装数量 + if product['package_quantity'] and product['package_quantity'] > 1: + actual_quantity = product['quantity'] * product['package_quantity'] + logger.info(f"件单位转换: {product['quantity']}件 -> {actual_quantity}瓶") + + # 更新产品信息 + product['original_quantity'] = product['quantity'] + product['quantity'] = actual_quantity + product['original_unit'] = product['unit'] + product['unit'] = '瓶' + + # 如果有单价,计算转换后的单价 + if product['unit_price'] > 0: + product['original_unit_price'] = product['unit_price'] + product['unit_price'] = product['unit_price'] / product['package_quantity'] + logger.info(f"单价转换: {product['original_unit_price']}/件 -> {product['unit_price']}/瓶") + +def main(): + """主程序""" + import argparse + + # 解析命令行参数 + parser = argparse.ArgumentParser(description='Excel处理程序 - 第二步') + parser.add_argument('--input', type=str, help='指定输入Excel文件路径,默认使用output目录中最新的Excel文件') + parser.add_argument('--output', type=str, help='指定输出文件路径,默认使用模板文件路径加时间') + args = parser.parse_args() + + processor = ExcelProcessorStep2() + + # 处理Excel文件 + try: + # 根据是否指定输入文件选择处理方式 + if args.input: + # 使用指定文件处理 + result = processor.process_specific_file(args.input) + else: + # 使用默认处理流程(查找最新文件) + result = processor.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) diff --git a/v1/input/微信图片_20250227193150.jpg b/v1/input/微信图片_20250227193150.jpg new file mode 100644 index 0000000..5b2702a Binary files /dev/null and b/v1/input/微信图片_20250227193150.jpg differ diff --git a/v1/logs/.keep b/v1/logs/.keep new file mode 100644 index 0000000..49cc8ef Binary files /dev/null and b/v1/logs/.keep differ diff --git a/v1/logs/clean_logs.active b/v1/logs/clean_logs.active new file mode 100644 index 0000000..c1180a1 --- /dev/null +++ b/v1/logs/clean_logs.active @@ -0,0 +1 @@ +Active since: 2025-05-01 19:46:44 \ No newline at end of file diff --git a/v1/logs/ocr_processor.active b/v1/logs/ocr_processor.active new file mode 100644 index 0000000..1335c3c --- /dev/null +++ b/v1/logs/ocr_processor.active @@ -0,0 +1 @@ +Active since: 2025-05-01 19:49:19 \ No newline at end of file diff --git a/v1/merge_purchase_orders.py b/v1/merge_purchase_orders.py new file mode 100644 index 0000000..eb7f79a --- /dev/null +++ b/v1/merge_purchase_orders.py @@ -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) \ No newline at end of file diff --git a/v1/output/merged_files.json b/v1/output/merged_files.json new file mode 100644 index 0000000..f44be11 --- /dev/null +++ b/v1/output/merged_files.json @@ -0,0 +1,4 @@ +{ + "采购单_武侯环球乐百惠便利店849.xls_5632_1746098172.9159887": "output\\合并采购单_20250501193931.xls", + "采购单_武侯环球乐百惠便利店3333.xls_9728_1746097892.1829922": "output\\合并采购单_20250501193931.xls" +} \ No newline at end of file diff --git a/v1/requirements.txt b/v1/requirements.txt new file mode 100644 index 0000000..6b54bae --- /dev/null +++ b/v1/requirements.txt @@ -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 \ No newline at end of file diff --git a/v1/run_ocr.py b/v1/run_ocr.py new file mode 100644 index 0000000..7c9f43a --- /dev/null +++ b/v1/run_ocr.py @@ -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() \ No newline at end of file diff --git a/v1/templets/银豹-采购单模板.xls b/v1/templets/银豹-采购单模板.xls new file mode 100644 index 0000000..a8fb1bc Binary files /dev/null and b/v1/templets/银豹-采购单模板.xls differ diff --git a/v1/test_ocr_log.py b/v1/test_ocr_log.py new file mode 100644 index 0000000..b9ec6ee --- /dev/null +++ b/v1/test_ocr_log.py @@ -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("测试完成") \ No newline at end of file diff --git a/v1/test_unit_conversion.py b/v1/test_unit_conversion.py new file mode 100644 index 0000000..e0db03c --- /dev/null +++ b/v1/test_unit_conversion.py @@ -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("单位转换处理规则测试完成") \ No newline at end of file diff --git a/启动器.py b/启动器.py new file mode 100644 index 0000000..0f3287d --- /dev/null +++ b/启动器.py @@ -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() \ No newline at end of file diff --git a/项目结构优化方案.md b/项目结构优化方案.md new file mode 100644 index 0000000..dce208a --- /dev/null +++ b/项目结构优化方案.md @@ -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. **并行处理优化**:改进并行处理机制,提高性能 \ No newline at end of file