From 97d9d98b4c0d2d585469f51e1faf357752a3f8f2 Mon Sep 17 00:00:00 2001 From: houhuan Date: Tue, 21 Jul 2026 10:25:29 +0800 Subject: [PATCH] Update: Refactor to absolute imports, fix QPS limits, and enhance Gitea sync with SQLite support --- app/config/settings.py | 4 +- app/core/__init__.py | 0 app/core/db/product_db.py | 65 ++++- app/core/excel/converter.py | 8 +- app/core/excel/handlers/__init__.py | 4 +- app/core/excel/handlers/barcode_mapper.py | 2 +- .../excel/handlers/unit_converter_handlers.py | 2 +- app/core/excel/merger.py | 10 +- app/core/excel/processor.py | 48 +++- app/core/excel/validators.py | 4 +- app/core/handlers/__init__.py | 6 +- app/core/handlers/calculator.py | 2 +- app/core/handlers/column_mapper.py | 6 +- app/core/handlers/data_cleaner.py | 2 +- app/core/ocr/baidu_ocr.py | 131 ++++++++- app/core/ocr/metadata_extractor.py | 163 ++++++++++- app/core/ocr/table_ocr.py | 18 +- app/core/processors/__init__.py | 6 +- app/core/processors/base.py | 2 +- app/core/processors/ocr_processor.py | 8 +- .../supplier_processors/__init__.py | 2 +- .../generic_supplier_processor.py | 12 +- app/core/processors/tobacco_processor.py | 8 +- app/core/utils/cloud_sync.py | 2 +- app/core/utils/dialog_utils.py | 12 +- app/core/utils/file_utils.py | 2 +- app/services/batch_service.py | 9 +- app/services/ocr_service.py | 48 ++-- app/services/order_service.py | 266 +++++++++++++++--- app/services/processor_service.py | 10 +- app/services/special_suppliers_service.py | 2 +- app/services/tobacco_service.py | 2 +- app/ui/action_handlers.py | 142 +++------- app/ui/barcode_editor.py | 2 +- app/ui/command_runner.py | 4 +- app/ui/config_dialog.py | 4 +- app/ui/db_viewer.py | 264 +++++++++++++++++ app/ui/file_operations.py | 12 +- app/ui/main_window.py | 29 +- app/ui/memory_editor.py | 2 +- app/ui/result_previews.py | 4 +- app/ui/shortcuts.py | 6 +- app/ui/ui_widgets.py | 30 ++ build_exe.py | 219 +++++++------- check_db.py | 73 +++++ config.ini | 15 +- config/config.ini | 8 +- debug_ocr.py | 57 ++++ debug_table_v2.py | 73 +++++ docs/数据库增强/ALIGNMENT_数据库增强.md | 35 +++ docs/数据库增强/DESIGN_数据库增强.md | 60 ++++ docs/数据库增强/TASK_数据库增强.md | 27 ++ scripts/manual_sync.py | 63 +++++ verify_workflow.py | 81 ++++++ web/backend/auth/dependencies.py | 2 +- web/backend/auth/jwt_handler.py | 2 +- web/backend/auth/router.py | 4 +- web/backend/main.py | 30 +- web/backend/routers/barcodes.py | 2 +- web/backend/routers/config_api.py | 2 +- web/backend/routers/files.py | 8 +- web/backend/routers/logs.py | 4 +- web/backend/routers/memory.py | 2 +- web/backend/routers/processing.py | 6 +- web/backend/routers/sync.py | 4 +- web/backend/routers/tasks.py | 4 +- web/backend/routers/websocket.py | 2 +- 67 files changed, 1691 insertions(+), 457 deletions(-) create mode 100644 app/core/__init__.py create mode 100644 app/ui/db_viewer.py create mode 100644 check_db.py create mode 100644 debug_ocr.py create mode 100644 debug_table_v2.py create mode 100644 docs/数据库增强/ALIGNMENT_数据库增强.md create mode 100644 docs/数据库增强/DESIGN_数据库增强.md create mode 100644 docs/数据库增强/TASK_数据库增强.md create mode 100644 scripts/manual_sync.py create mode 100644 verify_workflow.py diff --git a/app/config/settings.py b/app/config/settings.py index e158147..f9c9078 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -9,8 +9,8 @@ import configparser from typing import Dict, List, Optional, Any from dotenv import load_dotenv -from ..core.utils.log_utils import get_logger -from .defaults import DEFAULT_CONFIG +from app.core.utils.log_utils import get_logger +from app.config.defaults import DEFAULT_CONFIG # 加载 .env 文件 load_dotenv() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/db/product_db.py b/app/core/db/product_db.py index 95c50ed..d7c2528 100644 --- a/app/core/db/product_db.py +++ b/app/core/db/product_db.py @@ -16,9 +16,9 @@ from typing import Dict, List, Optional, Tuple, Callable import pandas as pd -from ..utils.log_utils import get_logger -from ..utils.file_utils import smart_read_excel -from ...core.handlers.column_mapper import ColumnMapper +from app.core.utils.log_utils import get_logger +from app.core.utils.file_utils import smart_read_excel +from app.core.handlers.column_mapper import ColumnMapper logger = get_logger(__name__) @@ -43,6 +43,13 @@ class ProductDatabase: max_price REAL DEFAULT 0.0, price_count INTEGER DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS missing_barcodes ( + barcode TEXT PRIMARY KEY, + name TEXT DEFAULT '', + last_seen TEXT, + source_file TEXT, + count INTEGER DEFAULT 1 + ); """ _NEW_COLUMNS = { @@ -90,16 +97,68 @@ class ProductDatabase: def _migrate_schema(self): conn = self._connect() try: + # 迁移 products 表 cursor = conn.execute("PRAGMA table_info(products)") existing_cols = {row[1] for row in cursor.fetchall()} for col_name, col_type in self._NEW_COLUMNS.items(): if col_name not in existing_cols: conn.execute(f"ALTER TABLE products ADD COLUMN {col_name} {col_type}") logger.info(f"数据库迁移: 添加列 {col_name}") + + # 确保 missing_barcodes 表存在 + conn.execute(""" + CREATE TABLE IF NOT EXISTS missing_barcodes ( + barcode TEXT PRIMARY KEY, + name TEXT DEFAULT '', + last_seen TEXT, + source_file TEXT, + count INTEGER DEFAULT 1 + ) + """) conn.commit() finally: conn.close() + # ══════════════════════════════════════════════════════════════ + # 缺失条码记录 + # ══════════════════════════════════════════════════════════════ + + def record_missing_barcode(self, barcode: str, name: str = '', source_file: str = ''): + """记录缺失条码。""" + barcode = str(barcode).strip() + if not barcode: + return + now = datetime.now().isoformat(timespec='seconds') + conn = self._connect() + try: + conn.execute(""" + INSERT INTO missing_barcodes (barcode, name, last_seen, source_file, count) + VALUES (?, ?, ?, ?, 1) + ON CONFLICT(barcode) DO UPDATE SET + name = CASE WHEN excluded.name != '' THEN excluded.name ELSE name END, + last_seen = excluded.last_seen, + source_file = excluded.source_file, + count = count + 1 + """, (barcode, name, now, os.path.basename(source_file))) + conn.commit() + logger.info(f"已记录缺失条码: {barcode} ({name})") + except Exception as e: + logger.error(f"记录缺失条码失败: {e}") + finally: + conn.close() + + def get_missing_barcodes(self, limit: int = 100) -> List[Dict]: + """获取缺失条码列表。""" + conn = self._connect() + conn.row_factory = sqlite3.Row + try: + rows = conn.execute( + "SELECT * FROM missing_barcodes ORDER BY last_seen DESC LIMIT ?", + (limit,)).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + # ══════════════════════════════════════════════════════════════ # 导入 # ══════════════════════════════════════════════════════════════ diff --git a/app/core/excel/converter.py b/app/core/excel/converter.py index 3de8b8f..ec1c683 100644 --- a/app/core/excel/converter.py +++ b/app/core/excel/converter.py @@ -10,13 +10,13 @@ import os import json from typing import Dict, Tuple, Optional, Any, List, Union -from ..utils.log_utils import get_logger -from .handlers.barcode_mapper import BarcodeMapper -from .handlers.unit_converter_handlers import ( +from app.core.utils.log_utils import get_logger +from app.core.excel.handlers.barcode_mapper import BarcodeMapper +from app.core.excel.handlers.unit_converter_handlers import ( JianUnitHandler, BoxUnitHandler, TiHeUnitHandler, GiftUnitHandler, UnitHandler ) -from .validators import ProductValidator +from app.core.excel.validators import ProductValidator logger = get_logger(__name__) diff --git a/app/core/excel/handlers/__init__.py b/app/core/excel/handlers/__init__.py index fe768ae..27c985b 100644 --- a/app/core/excel/handlers/__init__.py +++ b/app/core/excel/handlers/__init__.py @@ -7,5 +7,5 @@ from typing import Dict, Any # 导出所有处理程序类 -from .barcode_mapper import BarcodeMapper -from .unit_converter_handlers import JianUnitHandler, BoxUnitHandler, TiHeUnitHandler, GiftUnitHandler, UnitHandler \ No newline at end of file +from app.core.excel.handlers.barcode_mapper import BarcodeMapper +from app.core.excel.handlers.unit_converter_handlers import JianUnitHandler, BoxUnitHandler, TiHeUnitHandler, GiftUnitHandler, UnitHandler \ No newline at end of file diff --git a/app/core/excel/handlers/barcode_mapper.py b/app/core/excel/handlers/barcode_mapper.py index 979cded..689335b 100644 --- a/app/core/excel/handlers/barcode_mapper.py +++ b/app/core/excel/handlers/barcode_mapper.py @@ -7,7 +7,7 @@ import logging from typing import Dict, Optional, Any -from ...utils.log_utils import get_logger +from app.core.utils.log_utils import get_logger logger = get_logger(__name__) diff --git a/app/core/excel/handlers/unit_converter_handlers.py b/app/core/excel/handlers/unit_converter_handlers.py index 9cce4e9..60e3bd6 100644 --- a/app/core/excel/handlers/unit_converter_handlers.py +++ b/app/core/excel/handlers/unit_converter_handlers.py @@ -8,7 +8,7 @@ import logging from typing import Dict, Optional, Any, Tuple, Protocol from abc import ABC, abstractmethod -from ...utils.log_utils import get_logger +from app.core.utils.log_utils import get_logger logger = get_logger(__name__) diff --git a/app/core/excel/merger.py b/app/core/excel/merger.py index 53efc6e..0fcf5a9 100644 --- a/app/core/excel/merger.py +++ b/app/core/excel/merger.py @@ -14,17 +14,17 @@ from xlutils.copy import copy as xlcopy from typing import Dict, List, Optional, Tuple, Union, Any, Callable from datetime import datetime -from ...config.settings import ConfigManager -from ..utils.log_utils import get_logger -from ..handlers.column_mapper import ColumnMapper -from ..utils.file_utils import ( +from app.config.settings import ConfigManager +from app.core.utils.log_utils import get_logger +from app.core.handlers.column_mapper import ColumnMapper +from app.core.utils.file_utils import ( ensure_dir, get_file_extension, get_files_by_extensions, load_json, save_json ) -from ..utils.string_utils import ( +from app.core.utils.string_utils import ( clean_string, clean_barcode, format_barcode diff --git a/app/core/excel/processor.py b/app/core/excel/processor.py index 400143c..f3ddd71 100644 --- a/app/core/excel/processor.py +++ b/app/core/excel/processor.py @@ -14,23 +14,23 @@ from xlutils.copy import copy as xlcopy from typing import Dict, List, Optional, Tuple, Union, Any, Callable from datetime import datetime -from ...config.settings import ConfigManager -from ..utils.log_utils import get_logger -from ..utils.file_utils import ( +from app.config.settings import ConfigManager +from app.core.utils.log_utils import get_logger +from app.core.utils.file_utils import ( ensure_dir, get_file_extension, get_latest_file, load_json, save_json ) -from ..utils.string_utils import ( +from app.core.utils.string_utils import ( clean_string, extract_number, format_barcode, parse_monetary_string ) -from .converter import UnitConverter -from ..handlers.column_mapper import ColumnMapper +from app.core.excel.converter import UnitConverter +from app.core.handlers.column_mapper import ColumnMapper logger = get_logger(__name__) @@ -40,15 +40,19 @@ class ExcelProcessor: 提取条码、单价和数量,并按照采购单模板的格式填充 """ - def __init__(self, config, product_db=None): + def __init__(self, config, product_db=None, missing_barcodes_cb: Optional[Callable[[List[str]], None]] = None): """ 初始化Excel处理器 Args: config: 配置信息 product_db: 商品数据库实例(可选,由外部传入以共享) + missing_barcodes_cb: 缺失条码的回调函数,接收条码列表 """ self.config = config + self.missing_barcodes_cb = missing_barcodes_cb + self.current_missing_barcodes = [] # 记录当前文件处理中缺失的条码 + self.current_file_path = "" # 记录当前处理的文件路径 # 修复ConfigParser对象没有get_path方法的问题 try: @@ -62,7 +66,7 @@ class ExcelProcessor: logger.warning(f"模板文件不存在: {self.template_path}") # 设置缓存文件路径 - self.cache_file = os.path.join(self.output_dir, "processed_files.json") + self.cache_file = os.path.join(self.output_dir, "excel_process_records.json") self.processed_files = self._load_processed_files() # 确保目录存在 @@ -80,7 +84,7 @@ class ExcelProcessor: if product_db is not None: self.product_db = product_db else: - from ..db.product_db import ProductDatabase + from app.core.db.product_db import ProductDatabase db_path = config.get_path('Paths', 'product_db', fallback='data/product_cache.db') if hasattr(config, 'get_path') else 'data/product_cache.db' tpl_folder = config.get('Paths', 'template_folder', fallback='templates') item_data = config.get('Templates', 'item_data', fallback='商品资料.xlsx') @@ -220,6 +224,21 @@ class ExcelProcessor: # 跳过空条码行 if not product['barcode']: continue + + # 检查条码是否存在于数据库(商品资料) + bc = product['barcode'] + mem = self.product_db.get_memory(bc) + if not mem or mem.get('confidence', 0) < 50: + # 如果不存在,或者置信度低(说明不是来自商品资料模板),记录为缺失 + if bc not in self.current_missing_barcodes: + self.current_missing_barcodes.append(bc) + # 记录到数据库 + self.product_db.record_missing_barcode( + bc, + product.get('name', ''), + self.current_file_path + ) + logger.warning(f"条码缺失: {bc} ({product.get('name', '')})") # 检查备注列,过滤换货、退货、作废等非采购行 skip_row = False @@ -606,6 +625,9 @@ class ExcelProcessor: if not os.path.exists(file_path): logger.error(f"文件不存在: {file_path}") return None + + self.current_missing_barcodes = [] # 重置缺失列表 + self.current_file_path = file_path # 设置当前处理文件路径 try: # 读取Excel文件时不立即指定表头 @@ -672,6 +694,14 @@ class ExcelProcessor: # 不再自动打开输出目录 logger.info(f"采购单已保存到: {output_file}") + + # 处理完成,如果有缺失条码,触发回调 + if self.current_missing_barcodes and self.missing_barcodes_cb: + try: + self.missing_barcodes_cb(self.current_missing_barcodes) + except Exception as e: + logger.error(f"触发缺失条码回调失败: {e}") + if progress_cb: try: progress_cb(100) diff --git a/app/core/excel/validators.py b/app/core/excel/validators.py index 4bb1900..d570314 100644 --- a/app/core/excel/validators.py +++ b/app/core/excel/validators.py @@ -8,8 +8,8 @@ import re import logging from typing import Dict, Any, Optional, List, Tuple, Union -from ..utils.log_utils import get_logger -from ..utils.string_utils import parse_monetary_string +from app.core.utils.log_utils import get_logger +from app.core.utils.string_utils import parse_monetary_string logger = get_logger(__name__) diff --git a/app/core/handlers/__init__.py b/app/core/handlers/__init__.py index 3dccd02..57370ed 100644 --- a/app/core/handlers/__init__.py +++ b/app/core/handlers/__init__.py @@ -2,8 +2,8 @@ 数据处理handlers模块初始化文件 """ -from .data_cleaner import DataCleaner -from .column_mapper import ColumnMapper -from .calculator import DataCalculator +from app.core.handlers.data_cleaner import DataCleaner +from app.core.handlers.column_mapper import ColumnMapper +from app.core.handlers.calculator import DataCalculator __all__ = ['DataCleaner', 'ColumnMapper', 'DataCalculator'] \ No newline at end of file diff --git a/app/core/handlers/calculator.py b/app/core/handlers/calculator.py index b274681..492e99a 100644 --- a/app/core/handlers/calculator.py +++ b/app/core/handlers/calculator.py @@ -7,7 +7,7 @@ import pandas as pd import numpy as np from typing import Dict, Any, Optional, List, Union -from ...core.utils.log_utils import get_logger +from app.core.utils.log_utils import get_logger logger = get_logger(__name__) diff --git a/app/core/handlers/column_mapper.py b/app/core/handlers/column_mapper.py index dc58806..c79afbb 100644 --- a/app/core/handlers/column_mapper.py +++ b/app/core/handlers/column_mapper.py @@ -7,7 +7,7 @@ import re import pandas as pd from typing import Dict, Any, Optional, List, Union -from ...core.utils.log_utils import get_logger +from app.core.utils.log_utils import get_logger logger = get_logger(__name__) @@ -22,7 +22,7 @@ class ColumnMapper: STANDARD_COLUMNS = { 'barcode': [ '条码', '条形码', '商品条码', '商品条形码', '产品条码', '商品编码', - '商品编号', '条码(必填)', '电脑条码', '条码ID', + '商品编号', '条码(必填)', '电脑条码', '条码ID', '单品条码', 'barcode', 'Barcode', 'BarCode', 'code', '编码', ], 'name': [ @@ -363,7 +363,7 @@ class ColumnMapper: """ header_keywords = [ '条码', '条形码', '商品条码', '商品名称', '名称', '规格', - '单价', '数量', '金额', '单位', '必填', '编码', + '单价', '数量', '金额', '单位', '必填', '编码', '单品条码', '序号', ] best_row = -1 diff --git a/app/core/handlers/data_cleaner.py b/app/core/handlers/data_cleaner.py index 156255a..c688e54 100644 --- a/app/core/handlers/data_cleaner.py +++ b/app/core/handlers/data_cleaner.py @@ -6,7 +6,7 @@ import pandas as pd from typing import Dict, Any, Optional, List, Union -from ...core.utils.log_utils import get_logger +from app.core.utils.log_utils import get_logger logger = get_logger(__name__) diff --git a/app/core/ocr/baidu_ocr.py b/app/core/ocr/baidu_ocr.py index cf02274..ef2fcbf 100644 --- a/app/core/ocr/baidu_ocr.py +++ b/app/core/ocr/baidu_ocr.py @@ -9,7 +9,7 @@ import base64 import requests from typing import Dict, Optional, Union, List -from ..utils.log_utils import get_logger +from app.core.utils.log_utils import get_logger logger = get_logger(__name__) @@ -229,10 +229,19 @@ class BaiduOCRClient: logger.debug(f"百度OCR API返回结果: {result}") if 'error_code' in result: + error_code = result.get('error_code') error_msg = result.get('error_msg', '未知错误') + + # 如果是 QPS 限制 (18),增加延迟并重试 + if error_code == 18: + wait_time = self.retry_delay * (attempt + 1) + logger.warning(f"触发 QPS 限制,将在 {wait_time} 秒后重试 (尝试 {attempt+1}/{self.max_retries})") + time.sleep(wait_time) + continue + logger.error(f"百度OCR API错误: {error_msg}") # 如果是授权错误,尝试刷新令牌 - if result.get('error_code') in [110, 111]: # 授权相关错误码 + if error_code in [110, 111]: # 授权相关错误码 logger.info("尝试刷新访问令牌...") self.token_manager.refresh_token() return None @@ -292,8 +301,18 @@ class BaiduOCRClient: if response.status_code == 200: result = response.json() if 'error_code' in result: - logger.warning(f"通用识别错误: {result.get('error_msg')}") - if result.get('error_code') in (110, 111): + error_code = result.get('error_code') + error_msg = result.get('error_msg', '未知错误') + + # 如果是 QPS 限制 (18),增加延迟并重试 + if error_code == 18: + wait_time = self.retry_delay * (attempt + 1) + logger.warning(f"触发 QPS 限制 (General),将在 {wait_time} 秒后重试 (尝试 {attempt+1}/{self.max_retries})") + time.sleep(wait_time) + continue + + logger.warning(f"通用识别错误: {error_msg}") + if error_code in (110, 111): self.token_manager.refresh_token() return None words_list = result.get('words_result') or [] @@ -306,6 +325,110 @@ class BaiduOCRClient: time.sleep(self.retry_delay * (2 ** attempt)) logger.error("通用识别失败") return None + + def recognize_ticket(self, image_data: Union[str, bytes]) -> Optional[List[Dict]]: + """通用票务识别:用于精准捕获表头抬头、日期等关键信息。 + 使用用户指定的 URL: https://aip.baidubce.com/rest/2.0/ocr/v1/general_ocr + + Returns: + [{'words': '...'}, ...] + """ + 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 = self.config.get('API', 'ticket_ocr_url', + fallback='https://aip.baidubce.com/rest/2.0/ocr/v1/general_ocr') + url = f"{url}?access_token={access_token}" + image_base64 = base64.b64encode(image_data).decode('utf-8') + + payload = { + 'image': image_base64, + 'detect_direction': 'true', # 开启方向检测,应对旋转的图片 + } + 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() + if 'error_code' in result: + error_code = result.get('error_code') + error_msg = result.get('error_msg', '未知错误') + + # 如果是 QPS 限制 (18),增加延迟并重试 + if error_code == 18: + wait_time = self.retry_delay * (attempt + 1) + logger.warning(f"触发 QPS 限制 (Ticket),将在 {wait_time} 秒后重试 (尝试 {attempt+1}/{self.max_retries})") + time.sleep(wait_time) + continue + + # 如果是权限错误 (6),记录详细日志告知用户 + if error_code == 6: + logger.error("权限错误 (6): 您的百度 API Key 未开启【通用卡证票据识别】(General OCR) 服务。请前往百度云控制台手动开启该服务,否则无法精准识别表头。") + + logger.warning(f"票务识别错误: {error_msg}") + if error_code in (110, 111): + self.token_manager.refresh_token() + return None + + # 1. 优先处理 results 结构 (通用卡证票据识别的新结构) + if 'results' in result and isinstance(result['results'], dict): + extracted = [] + # 通常只有一个结果 "0" + for res_id, res_content in result['results'].items(): + if not isinstance(res_content, dict): continue + for k, v_list in res_content.items(): + if isinstance(v_list, list): + for item in v_list: + if isinstance(item, dict) and 'words' in item: + words_val = item['words'] + if isinstance(words_val, list): + for w in words_val: + extracted.append({'words': f"{k}: {w}"}) + else: + extracted.append({'words': f"{k}: {words_val}"}) + return extracted + + # 2. 通用票据/票务识别通常也返回 words_result + words_list = result.get('words_result') + + # 如果返回的是结构化字典,提取其中的文字行 + if isinstance(words_list, dict): + extracted = [] + for k, v in words_list.items(): + if isinstance(v, dict) and 'words' in v: + words_val = v['words'] + if isinstance(words_val, list): + for w in words_val: + extracted.append({'words': f"{k}: {w}"}) + else: + extracted.append({'words': f"{k}: {words_val}"}) + elif isinstance(v, str): + extracted.append({'words': f"{k}: {v}"}) + return extracted + + return words_list or [] + + logger.warning(f"票务识别请求失败 (尝试 {attempt+1}): {response.text[:200]}") + except Exception as e: + logger.warning(f"票务识别异常 (尝试 {attempt+1}): {e}") + + if attempt < self.max_retries - 1: + time.sleep(self.retry_delay * (2 ** attempt)) + + logger.error("票务识别失败") + return None def get_excel_result(self, request_id_or_result: Union[str, Dict]) -> Optional[bytes]: """ diff --git a/app/core/ocr/metadata_extractor.py b/app/core/ocr/metadata_extractor.py index 31b4cff..4b83b05 100644 --- a/app/core/ocr/metadata_extractor.py +++ b/app/core/ocr/metadata_extractor.py @@ -16,6 +16,14 @@ from typing import List, Optional, Tuple SUPPLIER_KEYWORDS = ( "供货单", "供应商", "供货方", "批发", "酒行", "商行", "经销", "专卖店", "配送单", "送货单", "采购单", "订单", + "销售单", "出库单", "经营部", "商贸", "有限公司", "发证机构", +) + +# 供应商名称中需要剔除的冗余噪声词 +SUPPLIER_NOISE_WORDS = ( + "标题", "采购单", "销售单", "入库单", "出库单", "送货单", + "单据", "订单", "配送单", "清单", "供货单", "预览", "详情", + "打印", "副本", "记账", "联", "存根", ) # 总金额关键词(命中后取该行最近一个金额数字) @@ -24,6 +32,11 @@ AMOUNT_KEYWORDS = ( "合计", "总计", "总计金额", ) +# 日期关键词(用于辅助定位日期) +DATE_KEYWORDS = ( + "单据日期", "下单时间", "日期", "时间", "开单日期", "制单日期", "打印时间", "业务日期", "下单日期", +) + # 日期正则:4 种格式 DATE_PATTERNS = [ # 2026年07月17日 / 2026年7月17日 @@ -52,6 +65,16 @@ class OrderMetadata: return asdict(self) +# 排除关键词:包含这些词的行绝对不是供应商抬头 +EXCLUDE_KEYWORDS = ( + "购货单位", "客户名称", "收货地址", "联系电话", "经手人", + "地址", "电话", "传真", "邮编", "网址", "开户行", "账号", + "税号", "业务员", "联系人", "单据编号", "流水号", "页码", + "四川省", "成都市", "武侯区", "双流区", "高新区", "金牛区", + "成华区", "锦江区", "龙泉驿区", "青羊区", "新都区", "温江区", + "街道", "社区", "楼", "号", "室", "路", "巷", "段", +) + class OrderMetadataExtractor: """单据元信息识别器。""" @@ -61,20 +84,35 @@ class OrderMetadataExtractor: # 供应商名最大长度 MAX_SUPPLIER_LEN = 50 - def extract(self, ocr_text: str, ocr_rows: Optional[List[List[str]]] = None) -> OrderMetadata: + def extract(self, ocr_text: str, ocr_rows: Optional[List[List[str]]] = None, general_text: Optional[str] = None) -> OrderMetadata: """从 OCR 原始文本和/或解析后的二维数组提取三字段。 Args: - ocr_text: OCR 原始字符串全文(带换行) - ocr_rows: 解析后的二维数组(可选),用于 row-level 精确匹配 + ocr_text: OCR 原始字符串全文(从表格 OCR 提取) + ocr_rows: 解析后的二维数组(可选) + general_text: 从通用票据识别接口 (/v1/general_ocr) 提取的文本,优先级最高 Returns: OrderMetadata """ - text = ocr_text or '' - supplier, raw_supplier = self._extract_supplier(text, ocr_rows) - bill_date = self._extract_bill_date(text) - total_amount = self._extract_total_amount(text, ocr_rows) + # 优先使用通用票据识别的文本进行供应商和日期提取 + supplier_source = general_text if general_text else ocr_text + date_source = general_text if general_text else ocr_text + + # 金额通常在表格内,优先使用 ocr_text + amount_source = ocr_text or general_text or '' + + supplier, raw_supplier = self._extract_supplier(supplier_source, ocr_rows) + bill_date = self._extract_bill_date(date_source) + total_amount = self._extract_total_amount(amount_source, ocr_rows) + + # 如果通用票据识别没抓到供应商,尝试用表格 OCR 的文本补位 + if not supplier and general_text and ocr_text: + supplier, raw_supplier = self._extract_supplier(ocr_text, ocr_rows) + + # 如果通用票据识别没抓到日期,尝试用表格 OCR 的文本补位 + if not bill_date and general_text and ocr_text: + bill_date = self._extract_bill_date(ocr_text) return OrderMetadata( supplier=supplier, @@ -95,9 +133,15 @@ class OrderMetadataExtractor: cleaned = self._clean_supplier_line(line) if not cleaned: continue + + # 严格排除:购货单位、地址、电话等干扰项 + if any(k in cleaned for k in EXCLUDE_KEYWORDS): + continue + for kw in SUPPLIER_KEYWORDS: if kw in cleaned: - return self._truncate(cleaned), cleaned + final_name = self._final_cleanup_supplier(cleaned) + return self._truncate(final_name), cleaned # 2) 兜底:取顶部第一个"含中文且无数字行号"且长度 ≥ 4 的非空行 # 但排除"纯日期行"(避免把日期当供应商) @@ -105,6 +149,11 @@ class OrderMetadataExtractor: cleaned = self._clean_supplier_line(line) if not cleaned: continue + + # 兜底也要严格排除地址和电话行 + if any(k in cleaned for k in EXCLUDE_KEYWORDS): + continue + if not (re.search(r'[\u4e00-\u9fa5]', cleaned) and len(cleaned) >= 4): continue # 排除:纯日期(YYYY-MM-DD / YYYY/MM/DD / YYYY年MM月DD日 / YYYYMMDD) @@ -117,16 +166,63 @@ class OrderMetadataExtractor: # 排除:以"单据"开头的行 if re.match(r'^\s*单据[::]?', cleaned): continue - return self._truncate(cleaned), cleaned + + final_name = self._final_cleanup_supplier(cleaned) + if final_name: + return self._truncate(final_name), cleaned return '', '' + @staticmethod + def _final_cleanup_supplier(s: str) -> str: + """最后的供应商名称深度清理:去除“标题”、“采购单”等噪声。""" + if not s: + return '' + + # 1. 统一处理全角和常见符号 + s = s.replace(':', ':').replace('(', '(').replace(')', ')') + + # 2. 去除“标题:”或“名称:”这类前缀 + s = re.sub(r'^(标题|名称|供应商|供货方|单位|商户)[:\s]*', '', s) + + # 3. 循环去除噪声词 + noise_sorted = sorted(SUPPLIER_NOISE_WORDS, key=len, reverse=True) + + changed = True + while changed: + original = s + for noise in noise_sorted: + # 简单替换所有匹配的噪声词 + s = s.replace(noise, '') + + # 去除括号及其中间的噪声词(如 (采购单) ) + s = re.sub(r'\(\s*\)', '', s) + + # 去除首尾残留标点和空白 + s = re.sub(r'^[::\s\-_\|\.\(\)]+', '', s) + s = re.sub(r'[::\s\-_\|\.\(\)]+$', '', s) + + changed = (s != original) + + return s.strip() + @staticmethod def _clean_supplier_line(line: str) -> str: """清理一行文本:去前后空白、去首尾日期/编号/电话/标点。""" s = line.strip() if not s: return '' + + # 统一全角冒号 + s = s.replace(':', ':') + + # 如果是“标题: 新双利采购单”,保留冒号后面的部分进行初步处理 + if ':' in s: + parts = s.split(':', 1) + # 如果冒号前面是“标题”、“名称”等词,则取后面 + if any(k in parts[0] for k in ("标题", "名称", "供应商", "商户")): + s = parts[1].strip() + # 去行首日期/编号前缀 s = re.sub(r'^[\s\d\-\.\/年月日::]+', '', s) # 去行尾标点 @@ -142,13 +238,54 @@ class OrderMetadataExtractor: def _extract_bill_date(self, text: str) -> str: """提取单据日期,标准化为 YYYYMMDD。""" + if not text: + return '' + + lines = text.splitlines() + + # 1. 优先尝试关键词定位逻辑(如用户建议:搜索“时间”或“日期”) + for i, line in enumerate(lines): + line_clean = line.strip().replace(':', ':') + + # 检查是否包含日期相关关键词 + for kw in DATE_KEYWORDS: + if kw in line_clean: + # a) 尝试在当前行找日期 + for pat in DATE_PATTERNS: + m = pat.search(line_clean) + if m: + y, mo, d = m.group(1), m.group(2), m.group(3) + if self._is_valid_date(y, mo, d): + return f"{y}{int(mo):02d}{int(d):02d}" + + # b) 如果当前行没找到,尝试在下一行找(针对表格 OCR 错位情况) + if i + 1 < len(lines): + next_line = lines[i+1].strip() + for pat in DATE_PATTERNS: + m = pat.search(next_line) + if m: + y, mo, d = m.group(1), m.group(2), m.group(3) + if self._is_valid_date(y, mo, d): + return f"{y}{int(mo):02d}{int(d):02d}" + + # 2. 兜底策略:全文正则匹配 for pat in DATE_PATTERNS: - m = pat.search(text) - if m: + # 优先找包含 202 开头的年份(更像当前日期) + matches = pat.finditer(text) + for m in matches: y, mo, d = m.group(1), m.group(2), m.group(3) if self._is_valid_date(y, mo, d): - return f"{y}{int(mo):02d}{int(d):02d}" - return '' + # 如果年份以 202 开头,优先返回 + if y.startswith('202'): + return f"{y}{int(mo):02d}{int(d):02d}" + # 否则记录下来作为候选 + last_valid = f"{y}{int(mo):02d}{int(d):02d}" + + # 如果没有 202 开头的,返回最后一个有效的 + try: + return last_valid + except NameError: + return '' @staticmethod def _is_valid_date(y: str, m: str, d: str) -> bool: diff --git a/app/core/ocr/table_ocr.py b/app/core/ocr/table_ocr.py index 3757293..789d851 100644 --- a/app/core/ocr/table_ocr.py +++ b/app/core/ocr/table_ocr.py @@ -10,8 +10,8 @@ import base64 from concurrent.futures import ThreadPoolExecutor from typing import Dict, List, Optional, Tuple, Callable -from ..utils.log_utils import get_logger -from ..utils.file_utils import ( +from app.core.utils.log_utils import get_logger +from app.core.utils.file_utils import ( ensure_dir, get_file_extension, get_files_by_extensions, @@ -20,6 +20,7 @@ from ..utils.file_utils import ( load_json, save_json ) +from app.config.settings import ConfigManager from .baidu_ocr import BaiduOCRClient logger = get_logger(__name__) @@ -102,14 +103,16 @@ class OCRProcessor: OCR处理器,负责协调OCR识别和结果处理 """ - def __init__(self, config): + def __init__(self, config: Optional[ConfigManager] = None): """ 初始化OCR处理器 Args: - config: 配置信息 + config: 配置管理器 """ - self.config = config + self.config = config or ConfigManager() + self.ocr_client = None + self._ensure_ocr_client() # 修复ConfigParser对象没有get_path方法的问题 try: @@ -348,9 +351,10 @@ class OCRProcessor: if max_workers is None: try: - max_workers = self.config.getint('Performance', 'max_workers', fallback=4) + # 强制设为 1 以严格遵守百度 QPS=2 的限制(一张图调两个接口就满了) + max_workers = self.config.getint('Performance', 'max_workers', fallback=1) except Exception: - max_workers = 4 + max_workers = 1 # 获取未处理的图片 unprocessed_images = self.get_unprocessed_images() diff --git a/app/core/processors/__init__.py b/app/core/processors/__init__.py index 0653b6d..191998b 100644 --- a/app/core/processors/__init__.py +++ b/app/core/processors/__init__.py @@ -2,8 +2,8 @@ 处理器模块初始化文件 """ -from .base import BaseProcessor -from .ocr_processor import OCRProcessor -from .tobacco_processor import TobaccoProcessor +from app.core.processors.base import BaseProcessor +from app.core.processors.ocr_processor import OCRProcessor +from app.core.processors.tobacco_processor import TobaccoProcessor __all__ = ['BaseProcessor', 'OCRProcessor', 'TobaccoProcessor'] \ No newline at end of file diff --git a/app/core/processors/base.py b/app/core/processors/base.py index e988851..70b1919 100644 --- a/app/core/processors/base.py +++ b/app/core/processors/base.py @@ -10,7 +10,7 @@ from pathlib import Path import logging import pandas as pd -from ...core.utils.log_utils import get_logger +from app.core.utils.log_utils import get_logger logger = get_logger(__name__) diff --git a/app/core/processors/ocr_processor.py b/app/core/processors/ocr_processor.py index 1ff94bf..176e8c3 100644 --- a/app/core/processors/ocr_processor.py +++ b/app/core/processors/ocr_processor.py @@ -8,10 +8,10 @@ import os from pathlib import Path from typing import Optional, Dict, Any, List -from .base import BaseProcessor -from ...services.ocr_service import OCRService -from ...services.order_service import OrderService -from ...core.utils.log_utils import get_logger +from app.core.processors.base import BaseProcessor +from app.services.ocr_service import OCRService +from app.services.order_service import OrderService +from app.core.utils.log_utils import get_logger logger = get_logger(__name__) diff --git a/app/core/processors/supplier_processors/__init__.py b/app/core/processors/supplier_processors/__init__.py index d9e1616..3ae17c6 100644 --- a/app/core/processors/supplier_processors/__init__.py +++ b/app/core/processors/supplier_processors/__init__.py @@ -2,6 +2,6 @@ 供应商处理器模块初始化文件 """ -from .generic_supplier_processor import GenericSupplierProcessor +from app.core.processors.supplier_processors.generic_supplier_processor import GenericSupplierProcessor __all__ = ['GenericSupplierProcessor'] \ No newline at end of file diff --git a/app/core/processors/supplier_processors/generic_supplier_processor.py b/app/core/processors/supplier_processors/generic_supplier_processor.py index 38525ca..a90e917 100644 --- a/app/core/processors/supplier_processors/generic_supplier_processor.py +++ b/app/core/processors/supplier_processors/generic_supplier_processor.py @@ -9,12 +9,12 @@ import pandas as pd from typing import Optional, Dict, Any, List from pathlib import Path -from ..base import BaseProcessor -from ...utils.log_utils import get_logger -from ...handlers.rule_engine import apply_rules -from ...handlers.column_mapper import ColumnMapper -from ...handlers.data_cleaner import DataCleaner -from ...handlers.calculator import DataCalculator +from app.core.processors.base import BaseProcessor +from app.core.utils.log_utils import get_logger +from app.core.handlers.rule_engine import apply_rules +from app.core.handlers.column_mapper import ColumnMapper +from app.core.handlers.data_cleaner import DataCleaner +from app.core.handlers.calculator import DataCalculator logger = get_logger(__name__) diff --git a/app/core/processors/tobacco_processor.py b/app/core/processors/tobacco_processor.py index fc8079e..bfaa3e2 100644 --- a/app/core/processors/tobacco_processor.py +++ b/app/core/processors/tobacco_processor.py @@ -14,10 +14,10 @@ from openpyxl import load_workbook from typing import Optional, Dict, Any, List, Tuple from pathlib import Path -from .base import BaseProcessor -from ...core.utils.log_utils import get_logger -from ...core.utils.string_utils import parse_monetary_string -from ...core.utils.dialog_utils import show_custom_dialog +from app.core.processors.base import BaseProcessor +from app.core.utils.log_utils import get_logger +from app.core.utils.string_utils import parse_monetary_string +from app.core.utils.dialog_utils import show_custom_dialog logger = get_logger(__name__) diff --git a/app/core/utils/cloud_sync.py b/app/core/utils/cloud_sync.py index 6de3b9e..5a90028 100644 --- a/app/core/utils/cloud_sync.py +++ b/app/core/utils/cloud_sync.py @@ -6,7 +6,7 @@ from typing import Optional, Tuple import requests -from .log_utils import get_logger +from app.core.utils.log_utils import get_logger logger = get_logger(__name__) diff --git a/app/core/utils/dialog_utils.py b/app/core/utils/dialog_utils.py index 7896bdd..263df2d 100644 --- a/app/core/utils/dialog_utils.py +++ b/app/core/utils/dialog_utils.py @@ -13,7 +13,7 @@ import tkinter as tk from tkinter import messagebox, ttk, simpledialog from datetime import datetime -from .cloud_sync import GiteaSync +from app.core.utils.cloud_sync import GiteaSync from app.config.settings import ConfigManager def create_custom_dialog(title="提示", message="", result_file=None, time_info=None, @@ -82,7 +82,7 @@ def create_custom_dialog(title="提示", message="", result_file=None, time_info file_size = os.path.getsize(result_file) file_time = datetime.fromtimestamp(os.path.getmtime(result_file)) - from .file_utils import format_file_size + from app.core.utils.file_utils import format_file_size size_text = format_file_size(file_size) tk.Label(file_frame, text=f"文件大小: {size_text}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2) @@ -831,10 +831,10 @@ SYNC_FILES = [ "type": "binary", }, { - "name": "商品记忆库", - "remote": "product_memory.json", - "local": "data/product_memory.json", - "type": "json", + "name": "商品记忆库 (DB)", + "remote": "product_cache.db", + "local": "data/product_cache.db", + "type": "binary", }, ] diff --git a/app/core/utils/file_utils.py b/app/core/utils/file_utils.py index 5c91be3..e385711 100644 --- a/app/core/utils/file_utils.py +++ b/app/core/utils/file_utils.py @@ -12,7 +12,7 @@ from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Union, Any -from .log_utils import get_logger +from app.core.utils.log_utils import get_logger logger = get_logger(__name__) diff --git a/app/services/batch_service.py b/app/services/batch_service.py index 18963f5..0f9cdee 100644 --- a/app/services/batch_service.py +++ b/app/services/batch_service.py @@ -11,8 +11,8 @@ import os from pathlib import Path from typing import Any, Callable, Dict, List, Optional -from ..config.settings import ConfigManager -from ..core.utils.log_utils import get_logger +from app.config.settings import ConfigManager +from app.core.utils.log_utils import get_logger from .ocr_service import OCRService from .order_service import OrderService @@ -25,10 +25,11 @@ IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.bmp', '.webp', '.tif', '.tiff'} class BatchService: """批量处理服务。""" - def __init__(self, config: Optional[ConfigManager] = None): + def __init__(self, config: Optional[ConfigManager] = None, missing_barcodes_cb: Optional[Callable[[List[str]], None]] = None): self.config = config or ConfigManager() + self.missing_barcodes_cb = missing_barcodes_cb self.ocr_service = OCRService(self.config) - self.order_service = OrderService(self.config) + self.order_service = OrderService(self.config, missing_barcodes_cb=self.missing_barcodes_cb) self._input_folder = self.config.get_path( 'Paths', 'input_folder', fallback='data/input', create=True ) diff --git a/app/services/ocr_service.py b/app/services/ocr_service.py index 7229285..b7ca78b 100644 --- a/app/services/ocr_service.py +++ b/app/services/ocr_service.py @@ -7,9 +7,9 @@ OCR服务模块 from typing import Dict, List, Optional, Tuple, Union, Any, Callable import os -from ..config.settings import ConfigManager -from ..core.utils.log_utils import get_logger -from ..core.ocr.table_ocr import OCRProcessor +from app.config.settings import ConfigManager +from app.core.utils.log_utils import get_logger +from app.core.ocr.table_ocr import OCRProcessor logger = get_logger(__name__) @@ -62,16 +62,10 @@ class OCRService: if not self._is_valid_image(image_path): logger.error(f"不支持的文件类型: {image_path}") return None - - # 检查是否已处理 - excel_file = self._get_excel_path(image_path) - if os.path.exists(excel_file): - logger.info(f"文件已处理过,跳过OCR识别: {image_path}") - # 即使 xlsx 已存在,仍补写 meta.json(首次 OCR 后可能未生成) - meta_path = str(excel_file).replace('.xlsx', '.meta.json') - if not os.path.exists(meta_path): - self._write_meta_json(str(excel_file), {}, image_path) - return excel_file + + # 不再做 xlsx 已存在就跳过的判断(xlsx 可能被业务层重命名过), + # 跳过逻辑交给 OCRProcessor 内部的 record_manager.is_processed(image_path) + # —— 由它读 processed_files.json(业务层会同步更新 xlsx 路径) # 执行OCR识别 result = self.ocr_processor.process_image(image_path) @@ -212,24 +206,36 @@ class OCRService: base = Path(excel_path) meta_path = base.with_suffix('.meta.json') - # 0) 优先:调百度通用文字识别(/accurate),覆盖全图文字(含手写抬头/日期) + # 0) 优先:调百度通用票务识别(/general_ocr),精准捕获表头(含手写抬头/日期) general_text = '' general_lines = [] try: - # OCRService.ocr_processor = core.ocr.table_ocr.OCRProcessor - # 其 .ocr_client = BaiduOCRClient (process_image 时初始化) client = getattr(self.ocr_processor, 'ocr_client', None) if client is None: - # 显式触发一次 process_image 准备流程 (不会重复 OCR) try: self.ocr_processor._ensure_ocr_client() except Exception: pass client = getattr(self.ocr_processor, 'ocr_client', None) - if client and hasattr(client, 'recognize_general') and image_path and os.path.exists(str(image_path)): + + # 关键:在进行第二次 OCR (票务识别) 前稍微等待,避免触发 QPS 限制 + import time + time.sleep(0.5) + + # 优先使用票务识别 (Ticket OCR) + if client and hasattr(client, 'recognize_ticket') and image_path and os.path.exists(str(image_path)): + logger.info("使用通用票务识别捕获表头元数据...") + words = client.recognize_ticket(str(image_path)) + if words: + general_lines = [(w.get('words') or '').strip() for w in words if (w.get('words') or '').strip()] + general_text = '\n'.join(general_lines) + logger.info(f"票务识别获取 {len(general_lines)} 行文字") + + # 如果票务识别无结果或不可用,则兜底使用高精度文字识别 + if not general_lines and client and hasattr(client, 'recognize_general') and image_path and os.path.exists(str(image_path)): + logger.info("尝试高精度文字识别作为兜底...") words = client.recognize_general(str(image_path)) if words: - # 按 location.top 排序(顶部先),方便后续提取供应商/日期 def _top(w): loc = w.get('location') or {} try: @@ -239,9 +245,9 @@ class OCRService: words_sorted = sorted(words, key=_top) general_lines = [(w.get('words') or '').strip() for w in words_sorted if (w.get('words') or '').strip()] general_text = '\n'.join(general_lines) - logger.info(f"通用识别获取 {len(general_lines)} 行文字") + logger.info(f"通用高精度识别获取 {len(general_lines)} 行文字") except Exception as e: - logger.warning(f"通用识别失败(不影响主流程): {e}") + logger.warning(f"获取元数据识别失败(不影响主流程): {e}") # 1) 表格识别 header/body 拼表内文字(已在前面逻辑处理) ocr_text = '' diff --git a/app/services/order_service.py b/app/services/order_service.py index 6e1c12e..4c32a71 100644 --- a/app/services/order_service.py +++ b/app/services/order_service.py @@ -12,13 +12,13 @@ from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Tuple, Union, Any, Callable -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 -from ..core.db.product_db import ProductDatabase -from ..core.db.order_metadata_db import OrderMetadataDB -from ..core.ocr.metadata_extractor import OrderMetadataExtractor, sanitize_for_filename +from app.config.settings import ConfigManager +from app.core.utils.log_utils import get_logger +from app.core.excel.processor import ExcelProcessor +from app.core.excel.merger import PurchaseOrderMerger +from app.core.db.product_db import ProductDatabase +from app.core.db.order_metadata_db import OrderMetadataDB +from app.core.ocr.metadata_extractor import OrderMetadataExtractor, sanitize_for_filename logger = get_logger(__name__) @@ -27,15 +27,17 @@ class OrderService: 订单服务:协调Excel处理和订单合并流程 """ - def __init__(self, config: Optional[ConfigManager] = None): + def __init__(self, config: Optional[ConfigManager] = None, missing_barcodes_cb: Optional[Callable[[List[str]], None]] = None): """ 初始化订单服务 Args: config: 配置管理器,如果为None则创建新的 + missing_barcodes_cb: 缺失条码的回调函数 """ logger.info("初始化OrderService") self.config = config or ConfigManager() + self.missing_barcodes_cb = missing_barcodes_cb # 创建共享的商品数据库实例 db_path = self.config.get_path('Paths', 'product_db', fallback='data/product_cache.db') if hasattr(self.config, 'get_path') else 'data/product_cache.db' @@ -45,7 +47,11 @@ class OrderService: self.product_db = ProductDatabase(db_path, tpl_path) # 创建Excel处理器和采购单合并器 - self.excel_processor = ExcelProcessor(self.config, product_db=self.product_db) + self.excel_processor = ExcelProcessor( + self.config, + product_db=self.product_db, + missing_barcodes_cb=self.missing_barcodes_cb + ) self.order_merger = PurchaseOrderMerger(self.config) # 元信息识别器 + 单据元信息库 @@ -291,15 +297,16 @@ class OrderService: if meta_path.exists(): try: payload = json.loads(meta_path.read_text(encoding='utf-8')) - # 优先用通用识别文本(含手写抬头/日期) - ocr_text = payload.get('general_text') or payload.get('ocr_text', '') or '' + # 区分表格 OCR 文本和通用识别文本 + ocr_text = payload.get('ocr_text', '') or '' + general_text = payload.get('general_text', '') or '' ocr_rows = payload.get('ocr_rows', []) or [] source_image = payload.get('image_path', '') or '' except Exception as e: logger.warning(f"读 meta.json 失败: {e}") # 兜底:从 xlsx 拼文本(与 OCRService._write_meta_json 的兜底一致) - if not ocr_text: + if not ocr_text and not general_text: try: import xlrd rb = xlrd.open_workbook(str(ocr_excel_path)) @@ -313,7 +320,7 @@ class OrderService: except Exception as e: logger.debug(f"从 xlsx 拼 OCR 文本失败: {e}") - meta = self.extractor.extract(ocr_text, ocr_rows) + meta = self.extractor.extract(ocr_text, ocr_rows, general_text=general_text) self.metadata_db.save( file_hash=file_hash, supplier=meta.supplier, @@ -334,49 +341,77 @@ class OrderService: def _apply_metadata_to_filenames(self, result_path: str, ocr_excel_path: str, meta) -> str: - """应用新文件名规则: - - result: 采购单_{YYYYMMDD}_{供应商}_{hash}.xls - - 原图: {原stem}_{YYYYMMDD}_{供应商}_{hash}.{ext} + """应用新文件名规则(按供应商+日期): + - result (xls):{供应商}_{日期}.xls + - output xlsx: {供应商}_{日期}.xlsx + - 原图: {供应商}_{日期}.{ext} + + 冲突时加 _2 / _3 ...;任一步骤失败不影响整体。 - 任一步骤失败不影响 result 文件本身。 Returns: 新 result 路径(无论重命名是否成功都返回;失败时返回原路径) """ try: - file_hash = Path(ocr_excel_path).stem + # 1. 提取并清理元数据 supplier_clean = sanitize_for_filename(meta.supplier) or '未知供应商' date_part = meta.bill_date or '未知日期' - # ── 1. result 重命名 ── - new_result_name = f"采购单_{date_part}_{supplier_clean}_{file_hash}.xls" - result_dir = Path(result_path).parent - new_result_path = result_dir / new_result_name - try: - # 冲突时加 _N - if new_result_path.exists() and str(new_result_path) != str(result_path): - new_result_path = self._dedup_path(new_result_path) - if str(new_result_path) != str(result_path): - os.rename(result_path, str(new_result_path)) - logger.info(f"result 重命名: {result_path} -> {new_result_path}") - result_path = str(new_result_path) - except Exception as e: - logger.warning(f"result 重命名失败: {e}") + # 2. 构造新的基础文件名:[供应商]_[日期] + # 按照用户要求:按照供应商名称加日期进行修改,且不含“采购单”等冗余字眼 + base_name = f"{supplier_clean}_{date_part}" - # ── 2. 原图重命名 ── + file_hash = Path(ocr_excel_path).stem # 仅用作 SQLite PK,不入文件名 + logger.info(f"生成标准化文件名: {base_name}") + + # ── 1. result 重命名 ── + new_result_path = self._safe_rename( + result_path, f"{base_name}.xls" + ) + + # ── 2. output xlsx 重命名(保证 output/ 与 result/ 文件名一致)── + new_output_path = self._safe_rename( + ocr_excel_path, f"{base_name}.xlsx" + ) + # 同步 meta.json + old_meta = Path(ocr_excel_path).with_suffix('.meta.json') + if old_meta.exists(): + new_meta = Path(new_output_path).with_suffix('.meta.json') + if str(new_meta) != str(old_meta): + try: + if new_meta.exists(): os.remove(new_meta) + os.rename(old_meta, new_meta) + except Exception as e: + logger.warning(f"meta.json 重命名失败: {e}") + + # ── 2.5 同步 processed_files.json(避免下次 OCR 重新识别)── + if str(new_output_path) != str(ocr_excel_path): + self._sync_processed_record(Path(ocr_excel_path), Path(new_output_path)) + # 也要同步 ExcelProcessor 的记录 + self._update_excel_process_record(Path(ocr_excel_path), Path(new_output_path)) + + # ── 3. 原图重命名 ── try: - meta_row = self.metadata_db.get(file_hash) - src_image = (meta_row or {}).get('source_image', '') + # 尝试从 processed_files.json 反查原图路径 + src_image = self._find_input_image_for_output(str(ocr_excel_path)) + + # 如果没找到,再从 DB 拿 + if not src_image: + meta_row = self.metadata_db.get(file_hash) + src_image = (meta_row or {}).get('source_image', '') + if src_image and os.path.exists(src_image): src_p = Path(src_image) - stem = src_p.stem ext = src_p.suffix - new_image_name = f"{stem}_{date_part}_{supplier_clean}_{file_hash}{ext}" - new_image_path = src_p.parent / new_image_name - # 不覆盖已重命名的图片 - if str(new_image_path) != str(src_p) and not new_image_path.exists(): - os.rename(src_p, new_image_path) - logger.info(f"原图重命名: {src_p.name} -> {new_image_path.name}") - # 更新 source_image 路径 + # 严禁包含原文件名,统一格式: 采购单_YYYYMMDD_供应商.ext + new_image_name = f"{base_name}{ext}" + new_image_path = self._safe_rename(src_image, new_image_name) + + # ── 3.5 同步 processed_files.json 的 Key (原图路径) ── + if str(new_image_path) != str(src_p): + self._sync_processed_key(src_p, Path(new_image_path)) + + # 更新 source_image 路径到 DB + if str(new_image_path) != str(src_p): self.metadata_db.save( file_hash=file_hash, supplier=meta.supplier, @@ -385,14 +420,47 @@ class OrderService: raw_supplier_text=meta.raw_supplier_text, source_image=str(new_image_path), ) + else: + logger.warning(f"找不到原图,跳过重命名: {src_image}") except Exception as e: logger.warning(f"原图重命名失败: {e}") - return result_path + return str(new_result_path) except Exception as e: logger.error(f"_apply_metadata_to_filenames 失败: {e}") return result_path + @staticmethod + def _safe_rename(src: str, new_basename: str) -> str: + """把 src 重命名为 src.parent / new_basename。 + + - 目标已存在时加 _2 / _3 ... 后缀避免覆盖 + - 失败返回原路径,不抛异常 + """ + try: + src_p = Path(src) + if not src_p.exists(): + return src + new_p = src_p.parent / new_basename + if str(new_p) == str(src_p): + return src + if new_p.exists(): + # 冲突去重 + stem, suffix = new_p.stem, new_p.suffix + n = 2 + while True: + cand = src_p.parent / f"{stem}_{n}{suffix}" + if not cand.exists(): + new_p = cand + break + n += 1 + os.rename(src_p, new_p) + logger.info(f"重命名: {src_p.name} -> {new_p.name}") + return str(new_p) + except Exception as e: + logger.warning(f"重命名失败 {src} -> {new_basename}: {e}") + return src + @staticmethod def _dedup_path(p: Path) -> Path: """路径冲突时加 _N 后缀。""" @@ -404,3 +472,113 @@ class OrderService: if not cand.exists(): return cand n += 1 + + def _find_input_image_for_output(self, output_xlsx: str) -> Optional[str]: + """从 processed_files.json 反查生成该 output_xlsx 的原图路径。""" + try: + output_dir = self.config.get_path('Paths', 'output_folder', fallback='data/output') + record_file = os.path.join(output_dir, 'processed_files.json') + + if not os.path.exists(record_file): + logger.debug(f"记录文件不存在: {record_file}") + return None + + with open(record_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + target_output = os.path.normpath(output_xlsx) + found_img = None + + # 1. 尝试直接匹配和规范化路径匹配 + for img_path, out_path in data.items(): + if os.path.normpath(out_path) == target_output: + found_img = img_path + break + + if found_img: + if os.path.exists(found_img): + return found_img + + # 2. 模糊匹配:如果记录中的路径不存在,尝试在同目录下找 stem 匹配的文件 + # (解决因之前重命名导致的路径不一致问题) + img_p = Path(found_img) + parent = img_p.parent + if parent.exists(): + stem = img_p.stem + logger.debug(f"尝试模糊匹配原图: stem={stem[:20]}... in {parent}") + # 尝试寻找以原 stem 开头的文件 + for cand in parent.iterdir(): + if cand.is_file() and cand.stem.startswith(stem[:20]): + logger.info(f"模糊匹配成功: {cand.name}") + return str(cand) + logger.debug("模糊匹配失败") + else: + logger.debug(f"在 processed_files.json 中未找到输出文件 {output_xlsx} 对应的原图记录") + except Exception as e: + logger.debug(f"反查原图路径失败: {e}") + return None + + def _sync_processed_record(self, old_output: Path, new_output: Path) -> None: + """重命名 output xlsx 后,把 processed_files.json 里所有 value 为旧路径的项改成新路径。""" + self._update_processed_json(old_path=old_output, new_path=new_output, is_key=False) + + def _sync_processed_key(self, old_input: Path, new_input: Path) -> None: + """重命名原图后,把 processed_files.json 里的 key 从旧路径改为新路径。""" + self._update_processed_json(old_path=old_input, new_path=new_input, is_key=True) + + def _update_excel_process_record(self, old_xlsx: Path, new_xlsx: Path) -> None: + """重命名 xlsx 后,同步 excel_process_records.json (ExcelProcessor 使用的记录)。""" + self._update_processed_json( + old_path=old_xlsx, + new_path=new_xlsx, + is_key=True, + filename='excel_process_records.json' + ) + + def _update_processed_json(self, old_path: Path, new_path: Path, is_key: bool = False, filename: str = 'processed_files.json') -> None: + """更新处理记录 JSON。 + + Args: + old_path: 旧路径 + new_path: 新路径 + is_key: True 更新 key, False 更新 value + filename: JSON 文件名 + """ + try: + # 确定文件路径 + output_dir = self.config.get_path('Paths', 'output_folder', fallback='data/output') + record_file = os.path.join(output_dir, filename) + + if not os.path.exists(record_file): + return + + with open(record_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + old_str = str(old_path) + new_str = str(new_path) + changed = False + + # 规范化路径以便匹配 + old_norm = os.path.normpath(old_str) + + if is_key: + # 更新 Key + for k in list(data.keys()): + if k == old_str or os.path.normpath(k) == old_norm: + data[new_str] = data.pop(k) + changed = True + break + else: + # 更新 Value + for k, v in list(data.items()): + if v == old_str or os.path.normpath(v) == old_norm: + data[k] = new_str + changed = True + + if changed: + with open(record_file, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + logger.info(f"{filename} 已同步 ({'Key' if is_key else 'Value'}): {old_str} -> {new_str}") + except Exception as e: + logger.warning(f"更新 {filename} 失败: {e}") diff --git a/app/services/processor_service.py b/app/services/processor_service.py index 6b3fe05..a50209d 100644 --- a/app/services/processor_service.py +++ b/app/services/processor_service.py @@ -8,10 +8,10 @@ import logging from typing import Dict, Any, Optional, List from pathlib import Path -from ..core.processors.base import BaseProcessor -from ..core.processors.tobacco_processor import TobaccoProcessor -from ..core.processors.ocr_processor import OCRProcessor -from ..core.utils.log_utils import get_logger +from app.core.processors.base import BaseProcessor +from app.core.processors.tobacco_processor import TobaccoProcessor +from app.core.processors.ocr_processor import OCRProcessor +from app.core.utils.log_utils import get_logger logger = get_logger(__name__) @@ -65,7 +65,7 @@ class ProcessorService: for supplier_config in supplier_configs: try: - from ..core.processors.supplier_processors.generic_supplier_processor import GenericSupplierProcessor + from app.core.processors.supplier_processors.generic_supplier_processor import GenericSupplierProcessor processor = GenericSupplierProcessor(self.config, supplier_config) self.processors.append(processor) logger.info(f"加载供应商处理器: {processor.name}") diff --git a/app/services/special_suppliers_service.py b/app/services/special_suppliers_service.py index 3d21b93..7dcec6c 100644 --- a/app/services/special_suppliers_service.py +++ b/app/services/special_suppliers_service.py @@ -7,7 +7,7 @@ import time import pandas as pd from typing import Optional, Callable -from ..core.utils.log_utils import get_logger +from app.core.utils.log_utils import get_logger logger = get_logger(__name__) diff --git a/app/services/tobacco_service.py b/app/services/tobacco_service.py index 74247da..bb4f12b 100644 --- a/app/services/tobacco_service.py +++ b/app/services/tobacco_service.py @@ -20,7 +20,7 @@ from typing import Optional, Dict, Any, List, Tuple from app.core.utils.log_utils import get_logger from app.core.utils.string_utils import parse_monetary_string from app.core.utils.dialog_utils import show_custom_dialog # 导入自定义弹窗工具 -from ..config.settings import ConfigManager +from app.config.settings import ConfigManager logger = get_logger(__name__) diff --git a/app/ui/action_handlers.py b/app/ui/action_handlers.py index c42bdd3..da27d22 100644 --- a/app/ui/action_handlers.py +++ b/app/ui/action_handlers.py @@ -27,6 +27,27 @@ from .command_runner import get_running_task, set_running_task from .file_operations import select_file, select_excel_file, validate_unit_price_against_item_data +def _get_missing_barcodes_callback(log_widget): + """创建并返回缺失条码的回调函数""" + def callback(missing_barcodes): + if not missing_barcodes: + return + + msg = f"发现以下条码不在商品资料中,已记录到数据库:\n\n" + "\n".join([f"• {bc}" for bc in missing_barcodes]) + # 在主线程中弹出对话框 + def show_msg(): + messagebox.showwarning("发现缺失条码", msg) + + # 尝试使用 log_widget 的 winfo_toplevel() 来调用 after,确保在 UI 线程执行 + try: + log_widget.after(0, show_msg) + except Exception: + # 降级处理 + show_msg() + + return callback + + def _ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=False): """弹窗询问是否合并采购单,返回合并结果路径或 None。 @@ -138,108 +159,23 @@ def process_single_image_with_status(log_widget, status_bar): def run_pipeline_directly(log_widget, status_bar): - """直接运行完整处理流程""" + """运行完整处理流程:先选择图片,再执行 OCR+Excel 处理""" if get_running_task() is not None: messagebox.showinfo("任务进行中", "请等待当前任务完成后再执行新的操作。") return - def run_in_thread(): - set_running_task("pipeline") + # 先选择图片 + file_path = select_file( + log_widget, + [("支持文件", "*.jpg *.jpeg *.png *.bmp *.xlsx *.xls"), ("图片文件", "*.jpg *.jpeg *.png *.bmp"), ("Excel文件", "*.xlsx *.xls"), ("所有文件", "*.*")], + "选择待处理文件" + ) + if not file_path: + add_to_log(log_widget, "未选择文件,一键处理已取消\n", "warning") + return - if status_bar: - status_bar.set_running(True) - status_bar.set_status("开始完整处理流程...") - - start_time = datetime.datetime.now() - start_perf = time.perf_counter() - log_widget.configure(state=tk.NORMAL) - log_widget.delete(1.0, tk.END) - log_widget.insert(tk.END, "执行命令: 完整处理流程\n", "command") - log_widget.insert(tk.END, f"开始时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time") - log_widget.insert(tk.END, "=" * 50 + "\n\n", "separator") - log_widget.configure(state=tk.DISABLED) - - try: - config = ConfigManager() - - gui_handler = init_gui_logger(log_widget) - - ocr_service = OCRService(config) - order_service = OrderService(config) - - reporter = ProgressReporter(status_bar) - reporter.running() - reporter.set("开始OCR批量处理...", 10) - - total, success = ocr_service.batch_process(progress_cb=lambda p: reporter.set("OCR处理中...", p)) - if total == 0: - add_to_log(log_widget, "没有找到需要处理的图片\n", "warning") - if status_bar: - status_bar.set_status("未找到图片文件") - return - elif success == 0: - add_to_log(log_widget, "OCR处理没有成功处理任何新文件\n", "warning") - else: - add_to_log(log_widget, f"OCR处理完成,共处理 {success}/{total} 个文件\n", "success") - try: - processed_map = {} - config = ConfigManager() - pjson = config.get('Paths', 'processed_record', fallback='data/processed_files.json') - if os.path.exists(pjson): - with open(pjson, 'r', encoding='utf-8') as f: - processed_map = json.load(f) - outputs = list(processed_map.values()) - for p in outputs[-10:]: - if p: - add_recent_file(os.path.abspath(p)) - except Exception as e: - logger.debug(f"加载已处理文件记录失败: {e}") - reporter.set("开始Excel处理...", 92) - - add_to_log(log_widget, "开始Excel处理...\n", "info") - result = order_service.process_excel() - - if not result: - add_to_log(log_widget, "Excel处理失败\n", "error") - else: - add_to_log(log_widget, "Excel处理完成\n", "success") - try: - add_recent_file(result) - except Exception as e: - logger.debug(f"添加最近文件失败: {e}") - try: - validate_unit_price_against_item_data(result, log_widget) - except Exception as e: - logger.debug(f"单价校验失败: {e}") - - reporter.set("检查是否需要合并采购单...", 80) - _ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=True) - - end_time = datetime.datetime.now() - duration_sec = max(0.0, time.perf_counter() - start_perf) - - add_to_log(log_widget, f"\n{'=' * 50}\n", "separator") - add_to_log(log_widget, "完整处理流程执行完毕!\n", "success") - add_to_log(log_widget, f"结束时间: {end_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time") - add_to_log(log_widget, f"耗时: {duration_sec:.2f} 秒\n", "time") - reporter.set("处理完成", 100) - - except Exception as e: - add_to_log(log_widget, f"执行过程中发生错误: {str(e)}\n", "error") - import traceback - add_to_log(log_widget, f"详细错误信息: {traceback.format_exc()}\n", "error") - finally: - dispose_gui_logger() - reporter.done() - - set_running_task(None) - if status_bar: - status_bar.set_running(False) - status_bar.set_status("就绪") - - thread = Thread(target=run_in_thread) - thread.daemon = True - thread.start() + # 复用拖拽处理的逻辑,实现“先选图,后全流程” + process_dropped_file(log_widget, status_bar, file_path) def batch_ocr_with_status(log_widget, status_bar): @@ -318,7 +254,7 @@ def batch_process_all_inputs(log_widget, status_bar): from .memory_editor import show_memory_editor # noqa: F401 触发 import 顺序 cfg = ConfigManager() - svc = BatchService(cfg) + svc = BatchService(cfg, missing_barcodes_cb=_get_missing_barcodes_callback(log_widget)) def progress(done, total, entry): pct = int(done / total * 100) if total else 100 @@ -404,7 +340,7 @@ def batch_process_orders_with_status(log_widget, status_bar): init_gui_logger(log_widget) - order_service = OrderService() + order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget)) add_to_log(log_widget, "开始Excel处理...\n", "info") try: @@ -461,7 +397,7 @@ def merge_orders_with_status(log_widget, status_bar): init_gui_logger(log_widget) - order_service = OrderService() + order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget)) result = order_service.merge_all_purchase_orders(progress_cb=lambda p: reporter.set("合并处理中...", p)) @@ -511,7 +447,7 @@ def process_excel_file_with_status(log_widget, status_bar): init_gui_logger(log_widget) - order_service = OrderService() + order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget)) if file_path: try: @@ -589,7 +525,7 @@ def process_dropped_file(log_widget, status_bar, file_path): # 步骤2: Excel处理 reporter.set("Excel处理中...", 40) - order_service = OrderService() + order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget)) result = order_service.process_excel(excel_path, progress_cb=lambda p: reporter.set("Excel处理中...", p)) if not result: add_to_log(log_widget, "Excel处理失败\n", "error") @@ -622,7 +558,7 @@ def process_dropped_file(log_widget, status_bar, file_path): reporter = ProgressReporter(status_bar) reporter.running() init_gui_logger(log_widget) - order_service = OrderService() + order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget)) add_to_log(log_widget, f"开始一键处理Excel文件: {file_path}\n", "info") try: add_recent_file(file_path) diff --git a/app/ui/barcode_editor.py b/app/ui/barcode_editor.py index 19bbf3d..7e6fbcb 100644 --- a/app/ui/barcode_editor.py +++ b/app/ui/barcode_editor.py @@ -7,7 +7,7 @@ from tkinter import messagebox from app.core.excel.converter import UnitConverter from app.core.utils.dialog_utils import show_barcode_mapping_dialog -from .logging_ui import add_to_log +from app.ui.logging_ui import add_to_log def edit_barcode_mappings(log_widget): diff --git a/app/ui/command_runner.py b/app/ui/command_runner.py index b6abe0d..bb281e8 100644 --- a/app/ui/command_runner.py +++ b/app/ui/command_runner.py @@ -12,8 +12,8 @@ import tkinter as tk from tkinter import messagebox from threading import Thread -from .logging_ui import LogRedirector -from .result_previews import show_result_preview +from app.ui.logging_ui import LogRedirector +from app.ui.result_previews import show_result_preview # 任务状态跟踪 _RUNNING_TASK = None diff --git a/app/ui/config_dialog.py b/app/ui/config_dialog.py index a141b65..4461155 100644 --- a/app/ui/config_dialog.py +++ b/app/ui/config_dialog.py @@ -8,8 +8,8 @@ from tkinter import messagebox, filedialog, ttk from app.config.settings import ConfigManager -from .user_settings import load_user_settings, save_user_settings -from .ui_widgets import center_window +from app.ui.user_settings import load_user_settings, save_user_settings +from app.ui.ui_widgets import center_window from app.core.utils.dialog_utils import show_cloud_sync_dialog diff --git a/app/ui/db_viewer.py b/app/ui/db_viewer.py new file mode 100644 index 0000000..f1edcd6 --- /dev/null +++ b/app/ui/db_viewer.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""数据库内容查看器模块""" + +import os +import tkinter as tk +from tkinter import ttk, messagebox +import sqlite3 +from datetime import datetime +from typing import Optional, Dict, List, Any + +from app.config.settings import ConfigManager +from app.ui.ui_widgets import center_window +from app.ui.theme import THEMES, get_theme_mode + +class DatabaseViewer: + """通用数据库查看器,支持多表切换""" + + def __init__(self, root, config: Optional[ConfigManager] = None): + self.root = root + self.config = config or ConfigManager() + self.db_path = self.config.get_path('Paths', 'product_db', fallback='data/product_cache.db') + + # 确保路径是绝对路径 + if not os.path.isabs(self.db_path): + app_root = getattr(self.config, 'app_root', os.getcwd()) + self.db_path = os.path.join(app_root, self.db_path) + + self.dlg = tk.Toplevel(root) + self.dlg.title("数据库内容查看器") + self.dlg.geometry("1000x600") + center_window(self.dlg) + + theme = THEMES[get_theme_mode()] + self.dlg.configure(bg=theme["bg"]) + + # 使用 Notebook 实现多表切换 + self.notebook = ttk.Notebook(self.dlg) + self.notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # 定义要查看的表及其列信息 + self.table_configs = { + "order_metadata": { + "title": "订单记录", + "columns": { + "bill_date": ("单据日期", 100), + "supplier": ("供应商", 200), + "total_amount": ("总金额", 80), + "updated_at": ("处理时间", 150), + "source_image": ("原始图片", 250), + "file_hash": ("Hash", 120) + }, + "query": "SELECT * FROM order_metadata ORDER BY updated_at DESC" + }, + "missing_barcodes": { + "title": "缺失条码", + "columns": { + "barcode": ("条码", 150), + "name": ("商品名称", 200), + "count": ("出现次数", 80), + "last_seen": ("最后发现", 150), + "source_file": ("来源文件", 250) + }, + "query": "SELECT * FROM missing_barcodes ORDER BY last_seen DESC" + }, + "products": { + "title": "商品记忆库", + "columns": { + "barcode": ("条码", 120), + "name": ("名称", 180), + "specification": ("规格", 80), + "unit": ("单位", 50), + "price": ("单价", 70), + "confidence": ("置信度", 60), + "usage_count": ("使用次数", 70), + "last_seen": ("最后使用", 140) + }, + "query": "SELECT * FROM products ORDER BY last_seen DESC" + } + } + + self.trees = {} + self._init_tabs() + + # 底部按钮 + btn_frame = ttk.Frame(self.dlg) + btn_frame.pack(fill=tk.X, padx=10, pady=(0, 10)) + + ttk.Button(btn_frame, text="刷新当前表", command=self.refresh_current_tab).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="清空记录 (慎用)", command=self.clear_current_table).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="关闭", command=self.dlg.destroy).pack(side=tk.RIGHT, padx=5) + + def _init_tabs(self): + """初始化各个标签页""" + for table_id, config in self.table_configs.items(): + frame = ttk.Frame(self.notebook) + self.notebook.add(frame, text=config["title"]) + + # 搜索栏 + search_frame = ttk.Frame(frame) + search_frame.pack(fill=tk.X, padx=5, pady=5) + + ttk.Label(search_frame, text="搜索:").pack(side=tk.LEFT) + search_var = tk.StringVar() + search_entry = ttk.Entry(search_frame, textvariable=search_var, width=30) + search_entry.pack(side=tk.LEFT, padx=5) + + # Treeview + cols = list(config["columns"].keys()) + tree = ttk.Treeview(frame, columns=cols, show="headings") + + for col, (text, width) in config["columns"].items(): + tree.heading(col, text=text) + tree.column(col, width=width, anchor="center") + + scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=tree.yview) + tree.configure(yscrollcommand=scrollbar.set) + + tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # 双击复制单元格内容 + tree.bind("", lambda e, tid=table_id: self.copy_cell_value(e, tid)) + + self.trees[table_id] = { + "tree": tree, + "search_var": search_var, + "config": config + } + + # 绑定搜索事件 + search_var.trace_add("write", lambda *args, tid=table_id: self.load_table_data(tid)) + + # 初始加载数据 + self.load_table_data(table_id) + + def load_table_data(self, table_id): + """加载指定表的数据""" + if not os.path.exists(self.db_path): + return + + info = self.trees[table_id] + tree = info["tree"] + config = info["config"] + search_text = info["search_var"].get().lower() + + # 清空现有数据 + for item in tree.get_children(): + tree.delete(item) + + try: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # 检查表是否存在 + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table_id,)) + if not cursor.fetchone(): + conn.close() + return + + cursor.execute(config["query"]) + rows = cursor.fetchall() + + cols = list(config["columns"].keys()) + for row in rows: + vals = [row[c] if c in row.keys() else "" for c in cols] + + # 简单搜索过滤 + if search_text: + match = False + for val in vals: + if search_text in str(val).lower(): + match = True + break + if not match: + continue + + # 格式化金额 + if "total_amount" in row.keys(): + idx = cols.index("total_amount") + try: + vals[idx] = f"{float(vals[idx]):.2f}" + except: + pass + + tree.insert("", tk.END, values=vals) + + conn.close() + + # 自动调整列宽 + self.auto_resize_columns(table_id) + + except Exception as e: + print(f"加载表 {table_id} 数据失败: {e}") + + def auto_resize_columns(self, table_id): + """根据内容自动调整列宽""" + info = self.trees[table_id] + tree = info["tree"] + config = info["config"] + + for col in list(config["columns"].keys()): + # 获取表头宽度 + header_text = config["columns"][col][0] + max_w = len(header_text) * 12 + 20 + + # 获取内容宽度(检查前 20 行) + for item in tree.get_children()[:20]: + val = str(tree.set(item, col)) + w = len(val) * 8 + 20 + if w > max_w: + max_w = w + + # 限制最大宽度 + max_w = min(max_w, 400) + tree.column(col, width=max_w) + + def copy_cell_value(self, event, table_id): + """双击复制单元格内容到剪贴板""" + tree = self.trees[table_id]["tree"] + region = tree.identify_region(event.x, event.y) + if region == "cell": + column = tree.identify_column(event.x) + item = tree.identify_row(event.y) + value = tree.set(item, column) + + self.root.clipboard_clear() + self.root.clipboard_append(value) + messagebox.showinfo("成功", f"内容已复制到剪贴板:\n{value}") + + def refresh_current_tab(self): + """刷新当前选中的标签页""" + current_tab_idx = self.notebook.index(self.notebook.select()) + table_ids = list(self.table_configs.keys()) + if current_tab_idx < len(table_ids): + self.load_table_data(table_ids[current_tab_idx]) + + def clear_current_table(self): + """清空当前表的记录""" + current_tab_idx = self.notebook.index(self.notebook.select()) + table_ids = list(self.table_configs.keys()) + if current_tab_idx >= len(table_ids): + return + + table_id = table_ids[current_tab_idx] + title = self.table_configs[table_id]["title"] + + if not messagebox.askyesno("警告", f"确定要永久清空表 '{title}' 的所有记录吗?此操作不可恢复!"): + return + + try: + conn = sqlite3.connect(self.db_path) + conn.execute(f"DELETE FROM {table_id}") + conn.commit() + conn.close() + self.load_table_data(table_id) + messagebox.showinfo("成功", f"表 '{title}' 已清空") + except Exception as e: + messagebox.showerror("错误", f"清空表失败: {e}") + +def show_db_viewer(root, config: Optional[ConfigManager] = None): + """显示数据库查看器""" + DatabaseViewer(root, config=config) diff --git a/app/ui/file_operations.py b/app/ui/file_operations.py index 5507292..436b161 100644 --- a/app/ui/file_operations.py +++ b/app/ui/file_operations.py @@ -16,7 +16,17 @@ def select_file(log_widget, file_types=None, title="选择文件"): """通用文件选择对话框""" if file_types is None: file_types = [("所有文件", "*.*")] - file_path = filedialog.askopenfilename(title=title, filetypes=file_types) + + # 获取默认输入目录 + try: + config = ConfigManager() + initial_dir = config.get_path('Paths', 'input_folder', fallback='data/input') + if not os.path.exists(initial_dir): + initial_dir = os.getcwd() + except Exception: + initial_dir = os.getcwd() + + file_path = filedialog.askopenfilename(title=title, filetypes=file_types, initialdir=initial_dir) if file_path: add_to_log(log_widget, f"已选择文件: {file_path}\n", "info") return file_path diff --git a/app/ui/main_window.py b/app/ui/main_window.py index ca33b9d..b4cc8e6 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -13,7 +13,7 @@ from app.core.utils.log_utils import set_log_level from .theme import THEMES, get_theme_mode, set_theme_mode, create_modern_button, create_card_frame from .logging_ui import add_to_log, poll_log_queue -from .ui_widgets import StatusBar +from .ui_widgets import StatusBar, ToolTip from .user_settings import ( load_user_settings, save_user_settings, refresh_recent_list_widget, _extract_path_from_recent_item, clear_recent_files, RECENT_LIST_WIDGET, @@ -33,6 +33,7 @@ from .config_dialog import show_config_dialog from .barcode_editor import edit_barcode_mappings from .shortcuts import bind_keyboard_shortcuts from app.core.utils.dialog_utils import show_cloud_sync_dialog +from .db_viewer import show_db_viewer def _init_window(): @@ -100,8 +101,14 @@ def _create_left_panel(content_frame, theme, log_text, status_bar): pipeline_section.pack(fill=tk.X, pady=(0, 8)) pipeline_frame = tk.Frame(pipeline_section, bg=theme["card_bg"]) pipeline_frame.pack(fill=tk.X, padx=8, pady=6) - create_modern_button(pipeline_frame, "一键处理", lambda: run_pipeline_directly(log_text, status_bar), "primary", px_width=150, px_height=32).pack(anchor='w', pady=3) - create_modern_button(pipeline_frame, "一键处理全部图片", lambda: batch_process_all_inputs(log_text, status_bar), "primary", px_width=180, px_height=32).pack(anchor='w', pady=3) + + btn_onekey = create_modern_button(pipeline_frame, "一键处理", lambda: run_pipeline_directly(log_text, status_bar), "primary", px_width=150, px_height=32) + btn_onekey.pack(anchor='w', pady=3) + ToolTip(btn_onekey.winfo_children()[0], "选择图片或Excel,自动完成OCR识别和采购单生成全流程") + + btn_batch = create_modern_button(pipeline_frame, "批量处理", lambda: batch_process_all_inputs(log_text, status_bar), "primary", px_width=150, px_height=32) + btn_batch.pack(anchor='w', pady=3) + ToolTip(btn_batch.winfo_children()[0], "扫描 data/input 文件夹,处理所有未识别的图片") # OCR处理区 core_section = tk.LabelFrame( @@ -220,7 +227,7 @@ def _create_recent_files_section(parent, theme, log_text): create_modern_button(rf_btns, "清理无效", purge_invalid, "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(3, 0)) -def _create_right_panel(content_frame, theme, log_text, root): +def _create_right_panel(content_frame, theme, log_text, root, status_bar, config): """创建右侧面板:快捷操作、系统设置""" right_panel = create_card_frame(content_frame) right_panel.pack(side=tk.RIGHT, fill=tk.BOTH, expand=False, padx=(5, 0), pady=5) @@ -240,12 +247,13 @@ def _create_right_panel(content_frame, theme, log_text, root): tk.Frame(tools_buttons_frame, bg=theme["card_bg"]).pack(fill=tk.X, pady=3) create_modern_button(tools_buttons_frame, "打开结果目录", lambda: open_result_directory(), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) - create_modern_button(tools_buttons_frame, "打开输出目录", lambda: os.startfile(ConfigManager().get_path('Paths', 'output_folder', fallback='data/output', create=True)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) - create_modern_button(tools_buttons_frame, "打开输入目录", lambda: os.startfile(ConfigManager().get_path('Paths', 'input_folder', fallback='data/input', create=True)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) - create_modern_button(tools_buttons_frame, "合并订单", lambda: merge_orders_with_status(log_text, StatusBar(root)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) + create_modern_button(tools_buttons_frame, "打开输出目录", lambda: os.startfile(config.get_path('Paths', 'output_folder', fallback='data/output', create=True)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) + create_modern_button(tools_buttons_frame, "打开输入目录", lambda: os.startfile(config.get_path('Paths', 'input_folder', fallback='data/input', create=True)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) + create_modern_button(tools_buttons_frame, "合并订单", lambda: merge_orders_with_status(log_text, status_bar), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) + create_modern_button(tools_buttons_frame, "数据库内容", lambda: show_db_viewer(root, config=config), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) create_modern_button(tools_buttons_frame, "清除缓存", lambda: clean_cache(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) create_modern_button(tools_buttons_frame, "清理input/out文件", lambda: clean_data_files(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) - create_modern_button(tools_buttons_frame, "清理result文件", lambda: clean_result_files(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) + create_modern_button(tools_buttons_frame, "清理结果文件", lambda: clean_result_files(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) # 系统设置区 settings_section = tk.LabelFrame( @@ -255,7 +263,7 @@ def _create_right_panel(content_frame, theme, log_text, root): settings_section.pack(fill=tk.X, pady=(0, 8)) settings_buttons_frame = tk.Frame(settings_section, bg=theme["card_bg"]) settings_buttons_frame.pack(fill=tk.X, padx=8, pady=6) - create_modern_button(settings_buttons_frame, "系统设置", lambda: show_config_dialog(root, ConfigManager()), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) + create_modern_button(settings_buttons_frame, "系统设置", lambda: show_config_dialog(root, config), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) create_modern_button(settings_buttons_frame, "条码映射", lambda: edit_barcode_mappings(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) create_modern_button(settings_buttons_frame, "云端同步", lambda: show_cloud_sync_dialog(root), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) create_modern_button(settings_buttons_frame, "商品记忆库", lambda: show_memory_editor(root), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) @@ -448,6 +456,7 @@ def main(): """主函数""" try: root, theme, settings, dnd_supported = _init_window() + config = ConfigManager() # 主容器 main_container = tk.Frame(root, bg=theme["bg"]) @@ -469,7 +478,7 @@ def main(): _create_left_panel(content_frame, theme, log_text, status_bar) # 右侧面板 - _create_right_panel(content_frame, theme, log_text, root) + _create_right_panel(content_frame, theme, log_text, root, status_bar, config) # 拖拽区域 _setup_drag_area(mid_container, theme, dnd_supported, log_text, status_bar) diff --git a/app/ui/memory_editor.py b/app/ui/memory_editor.py index 082e01d..0f03d2e 100644 --- a/app/ui/memory_editor.py +++ b/app/ui/memory_editor.py @@ -6,7 +6,7 @@ from tkinter import ttk, messagebox, simpledialog from app.config.settings import ConfigManager from app.core.db.product_db import ProductDatabase -from .ui_widgets import center_window +from app.ui.ui_widgets import center_window def _get_product_db(): diff --git a/app/ui/result_previews.py b/app/ui/result_previews.py index 8be9957..5eb6180 100644 --- a/app/ui/result_previews.py +++ b/app/ui/result_previews.py @@ -8,8 +8,8 @@ import datetime import tkinter as tk from tkinter import messagebox, scrolledtext -from .theme import THEMES, get_theme_mode, apply_theme -from .ui_widgets import center_window +from app.ui.theme import THEMES, get_theme_mode, apply_theme +from app.ui.ui_widgets import center_window from app.core.utils.file_utils import format_file_size from app.config.settings import ConfigManager diff --git a/app/ui/shortcuts.py b/app/ui/shortcuts.py index 2aa3b5e..e1d7b34 100644 --- a/app/ui/shortcuts.py +++ b/app/ui/shortcuts.py @@ -5,15 +5,15 @@ import tkinter as tk from tkinter import messagebox -from .ui_widgets import center_window -from .action_handlers import ( +from app.ui.ui_widgets import center_window +from app.ui.action_handlers import ( process_single_image_with_status, process_excel_file_with_status, batch_ocr_with_status, run_pipeline_directly, merge_orders_with_status, ) -from .file_operations import clean_cache +from app.ui.file_operations import clean_cache def bind_keyboard_shortcuts(root, log_widget, status_bar): diff --git a/app/ui/ui_widgets.py b/app/ui/ui_widgets.py index 979eb7e..caa5b58 100644 --- a/app/ui/ui_widgets.py +++ b/app/ui/ui_widgets.py @@ -74,6 +74,36 @@ class ProgressReporter: pass +class ToolTip: + """给组件添加悬停提示""" + def __init__(self, widget, text): + self.widget = widget + self.text = text + self.tip_window = None + self.widget.bind("", self.show_tip) + self.widget.bind("", self.hide_tip) + + def show_tip(self, event=None): + if self.tip_window or not self.text: + return + x, y, _cx, cy = self.widget.bbox("insert") + x = x + self.widget.winfo_rootx() + 27 + y = y + cy + self.widget.winfo_rooty() + 27 + self.tip_window = tw = tk.Toplevel(self.widget) + tw.wm_overrideredirect(1) + tw.wm_geometry("+%d+%d" % (x, y)) + label = tk.Label(tw, text=self.text, justify=tk.LEFT, + background="#ffffe0", relief=tk.SOLID, borderwidth=1, + font=("tahoma", "8", "normal"), padx=4, pady=2) + label.pack(ipadx=1) + + def hide_tip(self, event=None): + tw = self.tip_window + self.tip_window = None + if tw: + tw.destroy() + + def create_collapsible_frame(parent, title, initial_state=True): """创建可折叠的面板""" frame = tk.Frame(parent) diff --git a/build_exe.py b/build_exe.py index cbddacf..ce8aa4d 100644 --- a/build_exe.py +++ b/build_exe.py @@ -40,63 +40,43 @@ added_files = [ ('config/barcode_mappings.json', 'config/'), ('config/config.ini', 'config/'), ('templates/银豹-采购单模板.xls', 'templates/'), - ('app', 'app'), ] # 需要隐式导入的模块 hidden_imports = [ - 'tkinter', - 'tkinter.ttk', - 'tkinter.filedialog', - 'tkinter.messagebox', - 'tkinter.scrolledtext', - 'pandas', - 'numpy', - 'openpyxl', - 'xlrd', - 'xlwt', - 'xlutils', - 'requests', - 'dotenv', - 'tkinterdnd2', - 'configparser', - 'threading', - 'datetime', - 'json', - 're', - 'subprocess', - 'shutil', - 'app.config.settings', - 'app.services.ocr_service', - 'app.services.order_service', - 'app.services.tobacco_service', - 'app.services.processor_service', - 'app.core.utils.dialog_utils', - 'app.core.utils.file_utils', - 'app.core.utils.log_utils', - 'app.core.utils.string_utils', - 'app.core.handlers.column_mapper', - 'app.core.excel.converter', - 'app.core.db.product_db', - 'app.ui.error_utils', - 'app.ui.theme', - 'app.ui.logging_ui', - 'app.ui.ui_widgets', - 'app.ui.user_settings', - 'app.ui.result_previews', - 'app.ui.command_runner', - 'app.ui.file_operations', - 'app.ui.action_handlers', - 'app.ui.barcode_editor', - 'app.ui.config_dialog', - 'app.ui.shortcuts', - 'app.ui.main_window', - 'app.ui.memory_editor', -] + 'tkinter', + 'tkinter.ttk', + 'tkinter.filedialog', + 'tkinter.messagebox', + 'tkinter.scrolledtext', + 'pandas', + 'numpy', + 'openpyxl', + 'xlrd', + 'xlwt', + 'xlutils', + 'xlutils.copy', + 'requests', + 'dotenv', + 'tkinterdnd2', + 'configparser', + 'threading', + 'datetime', + 'json', + 're', + 'subprocess', + 'shutil', + 'sqlite3', + 'logging', + 'base64', + 'concurrent.futures', + 'pathlib', + 'typing', + ] a = Analysis( ['启动器.py'], - pathex=[], + pathex=['.'], binaries=[], datas=added_files, hiddenimports=hidden_imports, @@ -217,107 +197,104 @@ def build_exe(): return True def create_portable_package(): - """创建便携版打包""" - print("创建便携版打包...") + """创建并更新便携版打包,并同步到桌面""" + print("更新便携版打包...") - # 创建发布目录 + # 1. 准备本地 release 目录 release_dir = Path('release') - if release_dir.exists(): - try: - shutil.rmtree(release_dir) - except Exception as e: - print(f"警告: 无法完全清理发布目录 (可能文件被占用): {e}") - # 如果目录还在,尝试清理能清理的部分 - for item in release_dir.iterdir(): - try: - if item.is_dir(): shutil.rmtree(item) - else: item.unlink() - except Exception: pass - release_dir.mkdir(exist_ok=True) + # 不再删除整个目录,以保留 data/input 等用户数据 + if not release_dir.exists(): + release_dir.mkdir(parents=True) + print(f"已创建本地发布目录: {release_dir}") + else: + print(f"本地发布目录已存在,将进行增量更新: {release_dir}") + # 2. 更新核心文件到本地 release # 复制exe文件 exe_file = Path('dist/OCR订单处理系统.exe') if exe_file.exists(): shutil.copy2(exe_file, release_dir) - print(f"已复制: {exe_file} -> {release_dir}") + print(f"已更新 EXE: {exe_file} -> {release_dir}") - # 创建必要的目录结构 - dirs_to_create = ['data/input', 'data/output', 'logs', 'templates', 'config'] - for dir_path in dirs_to_create: + # 确保必要的子目录存在 + dirs_to_ensure = ['data/input', 'data/output', 'data/result', 'logs', 'templates', 'config'] + for dir_path in dirs_to_ensure: (release_dir / dir_path).mkdir(parents=True, exist_ok=True) - print(f"已创建目录: {dir_path}") - # 复制配置文件(包含API密钥) - config_file = Path('config/config.ini') - if config_file.exists(): - shutil.copy2(config_file, release_dir / 'config') - print(f"已复制配置文件: {config_file} -> {release_dir / 'config'}") - else: - print(f"警告: 配置文件不存在: {config_file}") + # 复制配置文件 + files_to_copy = [ + (Path('config/config.ini'), release_dir / 'config'), + (Path('config/barcode_mappings.json'), release_dir / 'config'), + (Path('config.ini'), release_dir), + (Path('templates/银豹-采购单模板.xls'), release_dir / 'templates'), + (Path('templates/商品资料.xlsx'), release_dir / 'templates'), + ] - # 复制完整的条码映射文件 - barcode_mapping_file = Path('config/barcode_mappings.json') - if barcode_mapping_file.exists(): - shutil.copy2(barcode_mapping_file, release_dir / 'config') - print(f"已复制条码映射文件: {barcode_mapping_file} -> {release_dir / 'config'}") - else: - print(f"警告: 条码映射文件不存在: {barcode_mapping_file}") - - # 复制根目录的config.ini文件 - root_config_file = Path('config.ini') - if root_config_file.exists(): - shutil.copy2(root_config_file, release_dir) - print(f"已复制根配置文件: {root_config_file} -> {release_dir}") - else: - print(f"警告: 根配置文件不存在: {root_config_file}") - - # 复制模板文件 - template_file = Path('templates/银豹-采购单模板.xls') - if template_file.exists(): - shutil.copy2(template_file, release_dir / 'templates') - print(f"已复制模板文件: {template_file} -> {release_dir / 'templates'}") - else: - print(f"警告: 模板文件不存在: {template_file}") - item_file = Path('templates/商品资料.xlsx') - if item_file.exists(): - try: - (Path('dist') / 'templates').mkdir(exist_ok=True) - shutil.copy2(item_file, Path('dist') / 'templates') - except Exception: - pass - shutil.copy2(item_file, release_dir / 'templates') - print(f"已复制商品资料: {item_file} -> {release_dir / 'templates'}") - else: - print(f"警告: 商品资料文件不存在: {item_file}") - - # 创建README文件 + for src, dst_dir in files_to_copy: + if src.exists(): + dst_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst_dir) + print(f"已更新文件: {src} -> {dst_dir}") + else: + print(f"警告: 源文件不存在,跳过更新: {src}") + + # 3. 创建/更新 README readme_content = ''' # OCR订单处理系统 - 便携版 ## 使用说明 1. 双击 "OCR订单处理系统.exe" 启动程序 2. 将需要处理的图片文件放入 data/input 目录 -3. 处理结果将保存在 data/output 目录 -4. 日志文件保存在 logs 目录 +3. 处理结果将保存在 data/result 目录 (采购单 Excel) +4. 中间过程文件在 data/output 目录 +5. 日志文件保存在 logs 目录 ## 注意事项 -- 首次运行时需要配置百度OCR API密钥 - 支持的图片格式:jpg, jpeg, png, bmp -- 单个文件大小不超过4MB +- 单个文件大小建议不超过 4MB ## 目录结构 - OCR订单处理系统.exe - 主程序 - data/input/ - 输入图片目录 -- data/output/ - 输出结果目录 +- data/result/ - 最终采购单目录 - logs/ - 日志目录 ''' - with open(release_dir / 'README.txt', 'w', encoding='utf-8') as f: f.write(readme_content) - print("已创建README.txt") + + # 4. 同步到桌面 + try: + # 用户指定的特殊桌面路径 + desktop_path = Path(r"F:\Administrator\桌面") + if not desktop_path.exists(): + # 兜底:如果 F 盘路径不存在,尝试系统默认路径 + desktop_path = Path(os.path.join(os.path.expanduser("~"), "Desktop")) + + desktop_release = desktop_path / "OCR系统_Release" + + print(f"正在同步到桌面: {desktop_release}") + + # 使用自定义的同步逻辑,避免删除目标目录中的其他文件 + def sync_dir(src_root, dst_root): + if not dst_root.exists(): + dst_root.mkdir(parents=True) + + for item in src_root.iterdir(): + dst_item = dst_root / item.name + if item.is_dir(): + sync_dir(item, dst_item) + else: + # 如果是文件,直接覆盖更新 + shutil.copy2(item, dst_item) + + sync_dir(release_dir, desktop_release) + print(f"同步成功!桌面位置: {desktop_release.absolute()}") + + except Exception as e: + print(f"同步到桌面失败: {e}") - print(f"便携版打包完成,位置: {release_dir.absolute()}") + print(f"本地便携版更新完成,位置: {release_dir.absolute()}") def main(): """主函数""" diff --git a/check_db.py b/check_db.py new file mode 100644 index 0000000..1dd51e4 --- /dev/null +++ b/check_db.py @@ -0,0 +1,73 @@ +import sqlite3 +import os +import argparse +from datetime import datetime + +def check_db(table_name=None, limit=20): + db_path = r'e:\2025Code\orc-order-v3\orc-order-v3\release\data\product_cache.db' + if not os.path.exists(db_path): + # 尝试开发环境路径 + db_path = r'e:\2025Code\orc-order-v3\orc-order-v3\data\product_cache.db' + + if not os.path.exists(db_path): + print(f"错误: 找不到数据库文件 {db_path}") + return + + print(f"正在读取数据库: {db_path}") + print("-" * 60) + + try: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # 获取所有表名 + cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables = [row[0] for row in cursor.fetchall() if row[0] != 'sqlite_sequence'] + + if not table_name: + print(f"数据库中的表: {', '.join(tables)}") + print("\n使用 'python check_db.py [表名]' 查看具体内容") + + # 默认显示一些汇总信息 + for table in tables: + cursor.execute(f"SELECT COUNT(*) FROM {table}") + count = cursor.fetchone()[0] + print(f" - {table}: {count} 条记录") + + target_tables = [table_name] if table_name else tables + + for table in target_tables: + if table not in tables: + print(f"\n警告: 表 '{table}' 不存在") + continue + + print(f"\n=== 表: {table} (最近 {limit} 条) ===") + cursor.execute(f"SELECT * FROM {table} LIMIT {limit}") + rows = cursor.fetchall() + + if not rows: + print(" (空)") + continue + + # 打印表头 + keys = rows[0].keys() + header = " | ".join(f"{str(k):<15}" for k in keys) + print(header) + print("-" * len(header)) + + # 打印行 + for row in rows: + print(" | ".join(f"{str(row[k])[:15]:<15}" for k in keys)) + + conn.close() + except Exception as e: + print(f"读取数据库出错: {e}") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="查看 OCR 系统数据库内容") + parser.add_argument("table", nargs="?", help="要查看的表名 (products, order_metadata, missing_barcodes)") + parser.add_argument("--limit", type=int, default=20, help="显示记录条数 (默认 20)") + + args = parser.parse_args() + check_db(args.table, args.limit) diff --git a/config.ini b/config.ini index 8b21106..d263cba 100644 --- a/config.ini +++ b/config.ini @@ -1,9 +1,9 @@ [API] -api_key = kIehdWbbVD85K18qZYz6SeUf -secret_key = RCXTgmVjJJkNNMhfY5ASab0xY3mvF6d7 +api_key = yBU35EDIZ2ITLRk1MnFBN1tv +secret_key = 7L0VEOIcqHhaqzZec5LXsHGzJhgFivMr timeout = 30 -max_retries = 3 -retry_delay = 2 +max_retries = 5 +retry_delay = 1 api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table token_url = https://aip.baidubce.com/oauth/2.0/token form_ocr_url = https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result @@ -14,7 +14,7 @@ input_folder = data/input output_folder = data/output temp_folder = data/temp template_folder = templates -template_file = templates/银豹-采购单模板.xls +template_file = templates\银豹-采购单模板.xls processed_record = data/processed_files.json data_dir = data product_db = data/product_cache.db @@ -35,14 +35,15 @@ purchase_order = 银豹-采购单模板.xls item_data = 商品资料.xlsx [App] -version = 2026.07.19.1947 +version = 2026.07.20.2105 [Gitea] base_url = https://gitea.94kan.cn owner = houhuan repo = yixuan-sync-data -token = +token = 50b61e43a141d606ae2529cd1755bc666d800e08 [WebAuth] username = admin password_hash = $2b$12$nllT8o1QIMfWKuTlpQI3G./E2NS.gqf0EHZyNkJ8gMpVa9grTXRoC + diff --git a/config/config.ini b/config/config.ini index b4a8394..06de377 100644 --- a/config/config.ini +++ b/config/config.ini @@ -1,9 +1,9 @@ [API] -api_key = kIehdWbbVD85K18qZYz6SeUf -secret_key = RCXTgmVjJJkNNMhfY5ASab0xY3mvF6d7 +api_key = yBU35EDIZ2ITLRk1MnFBN1tv +secret_key = 7L0VEOIcqHhaqzZec5LXsHGzJhgFivMr timeout = 30 -max_retries = 3 -retry_delay = 2 +max_retries = 5 +retry_delay = 1 api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table token_url = https://aip.baidubce.com/oauth/2.0/token form_ocr_url = https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result diff --git a/debug_ocr.py b/debug_ocr.py new file mode 100644 index 0000000..3279784 --- /dev/null +++ b/debug_ocr.py @@ -0,0 +1,57 @@ + +import os +import base64 +import requests +import json +from pathlib import Path + +def get_access_token(api_key, secret_key): + url = f"https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id={api_key}&client_secret={secret_key}" + payload = "" + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + response = requests.request("POST", url, headers=headers, data=payload) + return response.json().get("access_token") + +def test_general_ocr(image_path, api_key, secret_key): + # 用户要求的接口: 通用卡证票据识别 + request_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/general_ocr" + + with open(image_path, "rb") as f: + img = base64.b64encode(f.read()) + + params = {"image": img} + access_token = get_access_token(api_key, secret_key) + request_url = request_url + "?access_token=" + access_token + headers = {'content-type': 'application/x-www-form-urlencoded'} + print(f"正在请求接口: {request_url}") + response = requests.post(request_url, data=params, headers=headers) + + return response.json() + +if __name__ == "__main__": + # 使用 config.ini 中的密钥 + api_key = "yBU35EDIZ2ITLRk1MnFBN1tv" + secret_key = "7L0VEOIcqHhaqzZec5LXsHGzJhgFivMr" + + # 测试图片路径 + test_image = r"F:\Administrator\桌面\OCR系统_Release\data\input\采购单_20260717_优链快批销售单.jpg" + + if not os.path.exists(test_image): + print(f"错误: 找不到测试图片 {test_image}") + else: + print(f"正在测试接口【/v1/general_ocr】图片: {os.path.basename(test_image)}") + result = test_general_ocr(test_image, api_key, secret_key) + + print("\n--- API 返回结果 ---") + print(json.dumps(result, indent=2, ensure_ascii=False)) + + if "error_code" in result: + if result["error_code"] == 6: + print("\n【重要提示】: 权限错误 (6)。请前往百度云控制台开启【通用票据识别】(General OCR) 服务。") + else: + print(f"\nAPI 返回错误: {result.get('error_msg')}") + else: + print("\n识别成功!请检查返回结果中的 words_result 是否包含供应商和日期。") diff --git a/debug_table_v2.py b/debug_table_v2.py new file mode 100644 index 0000000..58a7951 --- /dev/null +++ b/debug_table_v2.py @@ -0,0 +1,73 @@ + +import os +import base64 +import requests +import json +from pathlib import Path + +def get_access_token(api_key, secret_key): + url = f"https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id={api_key}&client_secret={secret_key}" + response = requests.request("POST", url) + return response.json().get("access_token") + +def test_table_v2(image_path, api_key, secret_key): + # 表格识别V2接口 + request_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/table" + + with open(image_path, "rb") as f: + img = base64.b64encode(f.read()) + + params = { + "image": img, + "is_sync": "true", + "request_type": "excel" + } + access_token = get_access_token(api_key, secret_key) + request_url = request_url + "?access_token=" + access_token + headers = {'content-type': 'application/x-www-form-urlencoded'} + response = requests.post(request_url, data=params, headers=headers) + + return response.json() + +if __name__ == "__main__": + api_key = "kIehdWbbVD85K18qZYz6SeUf" + secret_key = "RCXTgmVjJJkNNMhfY5ASab0xY3mvF6d7" + + input_dir = Path(r"F:\Administrator\桌面\OCR系统_Release\data\input") + images = list(input_dir.glob("*.jpg")) + list(input_dir.glob("*.png")) + + if not images: + print("错误: 桌面 Release 目录下没有找到图片文件") + else: + latest_image = max(images, key=os.path.getmtime) + print(f"正在使用【表格识别V2】接口测试图片: {latest_image.name}") + + result = test_table_v2(str(latest_image), api_key, secret_key) + + # 打印表格外的文字(header/footer) + if "tables_result" in result: + print("\n--- 表格识别结果 ---") + for table in result["tables_result"]: + print("\n[Header 区域]:") + header = table.get("header", []) + for cell in header: + print(f"- {cell.get('words')}") + + print("\n[Footer 区域]:") + footer = table.get("footer", []) + for cell in footer: + print(f"- {cell.get('words')}") + elif "result" in result and "tables_result" in result["result"]: + print("\n--- 表格识别结果 (嵌套结构) ---") + for table in result["result"]["tables_result"]: + print("\n[Header 区域]:") + header = table.get("header", []) + for cell in header: + print(f"- {cell.get('words')}") + + print("\n[Footer 区域]:") + footer = table.get("footer", []) + for cell in footer: + print(f"- {cell.get('words')}") + else: + print(f"识别失败或未找到表格: {json.dumps(result, indent=2, ensure_ascii=False)[:500]}") diff --git a/docs/数据库增强/ALIGNMENT_数据库增强.md b/docs/数据库增强/ALIGNMENT_数据库增强.md new file mode 100644 index 0000000..2cf0f06 --- /dev/null +++ b/docs/数据库增强/ALIGNMENT_数据库增强.md @@ -0,0 +1,35 @@ +# ALIGNMENT - 数据库增强与缺失条码提示 + +## 1. 原始需求描述 +- **DB内容查看**: 用户需要查看数据库(SQLite)中的内容。 +- **缺失条码提示与记录**: 在处理订单时,如果发现条码不在最新的 `商品资料.xlsx` 中,需要: + - 弹出 GUI 弹窗提示用户。 + - 将缺失条码记录到数据库中。 + +## 2. 需求理解与边界确认 +### 2.1 数据库查看 +- **方案**: 提供一个增强版的 `check_db.py` 脚本,可以一键列出 `order_metadata`(单据信息)、`products`(商品资料)以及新增的 `missing_barcodes`(缺失条码)。 +- **GUI集成**: 在主窗口菜单中尝试增加一个“查看处理记录”或“查看缺失条码”的快捷入口。 + +### 2.2 缺失条码处理 +- **检查点**: 在 `ExcelProcessor` 提取商品信息时,或在 `OrderService` 生成结果前进行比对。 +- **数据库记录**: 在 `product_cache.db` 中新建表 `missing_barcodes`。 + - 表结构: `barcode (TEXT, PK), name (TEXT), last_seen (TEXT), source_file (TEXT), count (INTEGER)`。 +- **弹窗提示**: + - 使用 `tkinter.messagebox` 或项目已有的 `dialog_utils` 进行弹窗。 + - **策略确认**: 由于批量处理时频繁弹窗会打断用户,我们将采用“每个文件处理完后,如果有缺失条码,汇总弹窗一次”的策略,平衡实时性与操作体验。 + +## 3. 技术对齐 +- **数据库**: 继续使用 `product_cache.db`,由 `ProductDatabase` 类管理新增表。 +- **UI**: 现有的 `app/ui/main_window.py` 是基于 `tkinter` 的,弹窗逻辑将与之对齐。 +- **配置**: 商品资料路径已在 `config.ini` 中定义,需确保指向 `release/templates/商品资料.xlsx`。 + +## 4. 验收标准 +- [ ] 运行 `check_db.py` 能看到所有表的结构化数据。 +- [ ] 故意处理一张包含未知条码的图片,程序能弹出警告窗口提示具体条码。 +- [ ] 弹窗后,检查 `missing_barcodes` 表,确认该条码已被正确记录。 +- [ ] 再次处理相同条码,记录中的 `count` 应该递增,且 `last_seen` 更新。 + +## 5. 待澄清问题 +- **Q**: 弹窗是否需要阻塞处理流程? +- **A**: 考虑到用户追求“一键处理”的自动化,建议在单个文件处理完成后弹窗,用户点击确认后再继续下一个文件,或者在批量任务结束后汇总显示所有缺失条码。**初步决定:按文件汇总提示。** diff --git a/docs/数据库增强/DESIGN_数据库增强.md b/docs/数据库增强/DESIGN_数据库增强.md new file mode 100644 index 0000000..f1b882d --- /dev/null +++ b/docs/数据库增强/DESIGN_数据库增强.md @@ -0,0 +1,60 @@ +# DESIGN - 数据库增强与缺失条码提示 + +## 1. 架构设计 + +### 1.1 数据库层扩展 +在 `app/core/db/product_db.py` 中增加对 `missing_barcodes` 表的管理。 + +```sql +CREATE TABLE IF NOT EXISTS missing_barcodes ( + barcode TEXT PRIMARY KEY, + name TEXT DEFAULT '', + last_seen TEXT, + source_file TEXT, + count INTEGER DEFAULT 1 +); +``` + +### 1.2 逻辑层流程 +在 `ExcelProcessor` 处理商品数据时,增加比对逻辑: +1. 提取条码。 +2. 调用 `ProductDatabase.is_barcode_exists(barcode)`。 +3. 若不存在: + - 记录到 `missing_barcodes` 表。 + - 将该条码加入“本次文件缺失列表”。 +4. 文件处理结束前,若“本次文件缺失列表”不为空,调用 UI 层的弹窗通知。 + +## 2. 核心组件交互 + +```mermaid +sequenceDiagram + participant P as ExcelProcessor + participant DB as ProductDatabase + participant UI as MainWindow/DialogUtils + + P->>DB: get_memory(barcode) + alt 条码不存在 + DB-->>P: None + P->>DB: record_missing_barcode(barcode, name, file) + P->>P: Add to missing_list + end + + Note over P: 文件处理即将完成 + + rect rgb(200, 220, 255) + P->>UI: show_warning("以下条码缺失: ...") + end +``` + +## 3. 接口定义 + +### ProductDatabase 类新增方法: +- `record_missing_barcode(barcode, name, source_file)`: 插入或更新缺失条码记录。 +- `get_missing_barcodes(limit=100)`: 获取最近的缺失条码记录。 + +### UI 提示逻辑: +- 修改 `app/services/order_service.py` 中的 `process_order` 或相关方法,在处理流程中捕获缺失条码并触发 UI 回调。 + +## 4. DB 查看器实现 +更新 `check_db.py`,使用 `tabulate` (如果安装了) 或简单的格式化输出打印所有表内容。 +增加参数支持:`python check_db.py --table missing_barcodes`。 diff --git a/docs/数据库增强/TASK_数据库增强.md b/docs/数据库增强/TASK_数据库增强.md new file mode 100644 index 0000000..9c4cd38 --- /dev/null +++ b/docs/数据库增强/TASK_数据库增强.md @@ -0,0 +1,27 @@ +# TASK - 数据库增强与缺失条码提示 + +## 1. 数据库层任务 +| 原子任务 | 输入契约 | 输出契约 | 验收标准 | +| :--- | :--- | :--- | :--- | +| **MB-DB-01** | 修改 `product_db.py` | 增加 `missing_barcodes` 表 | 运行后 DB 中出现该表 | +| **MB-DB-02** | 实现 `record_missing_barcode` | 方法可调用并写入数据 | 重复写入时 `count` 增加 | + +## 2. 逻辑与UI任务 +| 原子任务 | 输入契约 | 输出契约 | 验收标准 | +| :--- | :--- | :--- | :--- | +| **MB-UI-01** | 修改 `ExcelProcessor` | 在识别条码时检查 DB | 日志显示“发现缺失条码” | +| **MB-UI-02** | 集成弹窗提示 | 处理完成前触发 `messagebox` | 界面弹出包含条码的警告框 | + +## 3. 工具任务 +| 原子任务 | 输入契约 | 输出契约 | 验收标准 | +| :--- | :--- | :--- | :--- | +| **MB-TL-01** | 更新 `check_db.py` | 支持查看所有表 | 命令行输出清晰的表格数据 | + +## 任务依赖图 +```mermaid +graph TD + MB-DB-01 --> MB-DB-02 + MB-DB-02 --> MB-UI-01 + MB-UI-01 --> MB-UI-02 + MB-DB-01 --> MB-TL-01 +``` diff --git a/scripts/manual_sync.py b/scripts/manual_sync.py new file mode 100644 index 0000000..1986af4 --- /dev/null +++ b/scripts/manual_sync.py @@ -0,0 +1,63 @@ + +import os +import sys +import json +from pathlib import Path + +# Add project root to path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from app.core.utils.cloud_sync import GiteaSync +from app.config.settings import ConfigManager + +def sync_all(): + config = ConfigManager() + sync = GiteaSync.from_config(config) + if not sync: + print("Error: Gitea configuration missing in config.ini") + sys.exit(1) + + # Sync files defined in dialog_utils.py (re-implementing the logic here) + SYNC_FILES = [ + {"name": "条码映射", "remote": "barcode_mappings.json", "local": "config/barcode_mappings.json", "type": "json"}, + {"name": "供应商配置", "remote": "suppliers_config.json", "local": "config/suppliers_config.json", "type": "json"}, + {"name": "商品资料", "remote": "templates/商品资料.xlsx", "local": "templates/商品资料.xlsx", "type": "binary"}, + {"name": "采购单模板", "remote": "templates/银豹-采购单模板.xls", "local": "templates/银豹-采购单模板.xls", "type": "binary"}, + {"name": "商品记忆库 (DB)", "remote": "product_cache.db", "local": "data/product_cache.db", "type": "binary"}, + ] + + print(f"Starting sync to {sync.base_url}/{sync.owner}/{sync.repo}...") + + success_count = 0 + for entry in SYNC_FILES: + local_path = entry["local"] + remote_path = entry["remote"] + name = entry["name"] + + if not os.path.exists(local_path): + print(f"Skipping {name}: Local file not found at {local_path}") + continue + + print(f"Pushing {name} ({local_path}) -> {remote_path}...") + try: + if entry["type"] == "json": + with open(local_path, "r", encoding="utf-8") as f: + data = json.load(f) + # Get current SHA to update + sha = sync.file_exists(remote_path) + result = sync.push_json(remote_path, data, f"Sync: {name}", sha=sha) + else: + result = sync.push_binary(remote_path, local_path, f"Sync: {name}") + + if result: + print(f"Successfully synced {name}") + success_count += 1 + else: + print(f"Failed to sync {name}") + except Exception as e: + print(f"Error syncing {name}: {e}") + + print(f"\nSync finished. {success_count}/{len(SYNC_FILES)} files synced.") + +if __name__ == "__main__": + sync_all() diff --git a/verify_workflow.py b/verify_workflow.py new file mode 100644 index 0000000..d1e325d --- /dev/null +++ b/verify_workflow.py @@ -0,0 +1,81 @@ + +import os +import sys +import time +import logging +from pathlib import Path + +# 添加项目根目录到路径 +sys.path.append(os.path.abspath(os.path.dirname(__file__))) + +from app.config.settings import ConfigManager +from app.services.ocr_service import OCRService +from app.services.order_service import OrderService +from app.services.batch_service import BatchService + +def setup_test_logging(): + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + +def test_single_process(): + print("\n--- 测试单文件处理流程 ---") + config = ConfigManager() + ocr_service = OCRService(config) + order_service = OrderService(config) + + # 获取一张图片 + input_dir = Path(config.get_path('Paths', 'input_folder')) + images = list(input_dir.glob("*.jpg")) + list(input_dir.glob("*.png")) + + if not images: + print("跳过: 没有找到测试图片") + return + + test_img = str(images[0]) + print(f"处理图片: {test_img}") + + # 1. OCR 处理 (包含双 OCR 逻辑) + excel_path = ocr_service.process_image(test_img) + if excel_path: + print(f"OCR 成功: {excel_path}") + + # 2. 业务处理 (识别元信息 + 重命名) + result_path = order_service.process_excel(excel_path) + if result_path: + print(f"业务处理成功: {result_path}") + else: + print("业务处理失败") + else: + print("OCR 失败") + +def test_batch_process(): + print("\n--- 测试批量处理流程 ---") + config = ConfigManager() + batch_service = BatchService(config) + + def progress(done, total, entry): + print(f"进度: {done}/{total} - {entry.get('status')} - {entry.get('image')}") + + summary = batch_service.process_all_inputs(progress_cb=progress) + print(f"批量处理汇总: 总数={summary['total']}, 成功={summary['success']}, 失败={summary['failed']}") + +if __name__ == "__main__": + setup_test_logging() + + # 测试前先清理一下记录,确保会重新处理 + config = ConfigManager() + pjson = config.get_path('Paths', 'processed_record') + if os.path.exists(pjson): + # os.remove(pjson) # 不真正删除,避免影响用户数据 + pass + + try: + test_single_process() + time.sleep(1) # 间隔一下 + test_batch_process() + except Exception as e: + print(f"测试过程中出现异常: {e}") + import traceback + traceback.print_exc() diff --git a/web/backend/auth/dependencies.py b/web/backend/auth/dependencies.py index d9ddf90..208f927 100644 --- a/web/backend/auth/dependencies.py +++ b/web/backend/auth/dependencies.py @@ -3,7 +3,7 @@ from fastapi import Depends, HTTPException, status, Query, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials -from .jwt_handler import decode_token +from web.backend.auth.jwt_handler import decode_token security = HTTPBearer() diff --git a/web/backend/auth/jwt_handler.py b/web/backend/auth/jwt_handler.py index c08fa0d..7d9a71e 100644 --- a/web/backend/auth/jwt_handler.py +++ b/web/backend/auth/jwt_handler.py @@ -5,7 +5,7 @@ from typing import Optional from jose import jwt, JWTError -from ..config import get_or_generate_secret, JWT_ALGORITHM, JWT_EXPIRE_HOURS +from web.backend.config import get_or_generate_secret, JWT_ALGORITHM, JWT_EXPIRE_HOURS def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: diff --git a/web/backend/auth/router.py b/web/backend/auth/router.py index 81051ea..12d16b9 100644 --- a/web/backend/auth/router.py +++ b/web/backend/auth/router.py @@ -5,8 +5,8 @@ import bcrypt from fastapi import APIRouter, HTTPException, Depends, status from pydantic import BaseModel -from .jwt_handler import create_access_token -from .dependencies import get_current_user +from web.backend.auth.jwt_handler import create_access_token +from web.backend.auth.dependencies import get_current_user router = APIRouter(prefix="/api/auth", tags=["auth"]) diff --git a/web/backend/main.py b/web/backend/main.py index 5f4a4c9..3e797d0 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -15,20 +15,20 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse -from .config import get_or_generate_secret # noqa: trigger secret generation -from .services.task_manager import TaskManager -from .services.db_pool import DBPool -from .auth.router import router as auth_router -from .routers.files import router as files_router -from .routers.processing import router as processing_router -from .routers.memory import router as memory_router -from .routers.config_api import router as config_router -from .routers.barcodes import router as barcodes_router -from .routers.sync import router as sync_router -from .routers.websocket import router as ws_router -from .routers.logs import router as logs_router -from .routers.tasks import router as tasks_router -from .middleware.logging import LoggingMiddleware +from web.backend.config import get_or_generate_secret # noqa: trigger secret generation +from web.backend.services.task_manager import TaskManager +from web.backend.services.db_pool import DBPool +from web.backend.auth.router import router as auth_router +from web.backend.routers.files import router as files_router +from web.backend.routers.processing import router as processing_router +from web.backend.routers.memory import router as memory_router +from web.backend.routers.config_api import router as config_router +from web.backend.routers.barcodes import router as barcodes_router +from web.backend.routers.sync import router as sync_router +from web.backend.routers.websocket import router as ws_router +from web.backend.routers.logs import router as logs_router +from web.backend.routers.tasks import router as tasks_router +from web.backend.middleware.logging import LoggingMiddleware # Shared singletons task_manager = TaskManager() @@ -42,7 +42,7 @@ async def lifespan(app: FastAPI): ConfigManager() # Initialize DB and cleanup old records - from .services.db_schema import init_db, cleanup_old_records, sync_file_relations + from web.backend.services.db_schema import init_db, cleanup_old_records, sync_file_relations init_db() cleanup_old_records() diff --git a/web/backend/routers/barcodes.py b/web/backend/routers/barcodes.py index d107548..22acb68 100644 --- a/web/backend/routers/barcodes.py +++ b/web/backend/routers/barcodes.py @@ -7,7 +7,7 @@ from typing import Dict, Optional, List from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel -from ..auth.dependencies import get_current_user +from web.backend.auth.dependencies import get_current_user router = APIRouter(prefix="/api/barcodes", tags=["barcodes"]) diff --git a/web/backend/routers/config_api.py b/web/backend/routers/config_api.py index cea01dd..2cecef2 100644 --- a/web/backend/routers/config_api.py +++ b/web/backend/routers/config_api.py @@ -4,7 +4,7 @@ from typing import Dict, Optional, Any from fastapi import APIRouter, HTTPException, Depends from pydantic import BaseModel -from ..auth.dependencies import get_current_user +from web.backend.auth.dependencies import get_current_user router = APIRouter(prefix="/api/config", tags=["config"]) diff --git a/web/backend/routers/files.py b/web/backend/routers/files.py index b2bf94a..56e5d2c 100644 --- a/web/backend/routers/files.py +++ b/web/backend/routers/files.py @@ -10,9 +10,9 @@ from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query, from fastapi.responses import FileResponse, JSONResponse from pydantic import BaseModel -from ..auth.dependencies import get_current_user, get_current_user_flexible -from ..config import MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS -from ..services.db_schema import ( +from web.backend.auth.dependencies import get_current_user, get_current_user_flexible +from web.backend.config import MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS +from web.backend.services.db_schema import ( insert_file_metadata, query_file_history, query_file_stats, query_file_relations, delete_file_relations, sync_file_relations, query_file_relations_stats, reset_file_cache, @@ -259,7 +259,7 @@ class RelationDeleteRequest(BaseModel): def _cleanup_relation_for_deleted_file(directory: str, filename: str): """Clean up relation table when a file is deleted.""" import sqlite3 - from ..services.db_schema import _db_path + from web.backend.services.db_schema import _db_path try: conn = sqlite3.connect(_db_path) conn.row_factory = sqlite3.Row diff --git a/web/backend/routers/logs.py b/web/backend/routers/logs.py index 88c1636..1f7fea9 100644 --- a/web/backend/routers/logs.py +++ b/web/backend/routers/logs.py @@ -7,8 +7,8 @@ from typing import Optional from fastapi import APIRouter, Depends, Query -from ..auth.dependencies import get_current_user -from ..services.db_schema import query_http_logs, query_http_log_stats +from web.backend.auth.dependencies import get_current_user +from web.backend.services.db_schema import query_http_logs, query_http_log_stats logger = logging.getLogger(__name__) diff --git a/web/backend/routers/memory.py b/web/backend/routers/memory.py index c221473..abe3887 100644 --- a/web/backend/routers/memory.py +++ b/web/backend/routers/memory.py @@ -6,7 +6,7 @@ from pathlib import Path from fastapi import APIRouter, HTTPException, Depends, Query from pydantic import BaseModel -from ..auth.dependencies import get_current_user +from web.backend.auth.dependencies import get_current_user router = APIRouter(prefix="/api/memory", tags=["memory"]) diff --git a/web/backend/routers/processing.py b/web/backend/routers/processing.py index 5bbf390..656bec3 100644 --- a/web/backend/routers/processing.py +++ b/web/backend/routers/processing.py @@ -12,9 +12,9 @@ from typing import Optional, List from fastapi import APIRouter, HTTPException, Depends, Request from pydantic import BaseModel -from ..auth.dependencies import get_current_user -from ..services.service_wrapper import ServiceWrapper -from ..services.db_schema import upsert_file_relation +from web.backend.auth.dependencies import get_current_user +from web.backend.services.service_wrapper import ServiceWrapper +from web.backend.services.db_schema import upsert_file_relation router = APIRouter(prefix="/api/processing", tags=["processing"]) diff --git a/web/backend/routers/sync.py b/web/backend/routers/sync.py index 13e027f..f0a5cc3 100644 --- a/web/backend/routers/sync.py +++ b/web/backend/routers/sync.py @@ -6,8 +6,8 @@ from pathlib import Path from fastapi import APIRouter, HTTPException, Depends, Request from pydantic import BaseModel -from ..auth.dependencies import get_current_user -from ..services.task_manager import TaskManager +from web.backend.auth.dependencies import get_current_user +from web.backend.services.task_manager import TaskManager router = APIRouter(prefix="/api/sync", tags=["sync"]) diff --git a/web/backend/routers/tasks.py b/web/backend/routers/tasks.py index 02df263..8ab6664 100644 --- a/web/backend/routers/tasks.py +++ b/web/backend/routers/tasks.py @@ -6,8 +6,8 @@ from typing import Optional import httpx from fastapi import APIRouter, Depends, HTTPException, Request -from ..auth.dependencies import get_current_user -from ..services import db_schema +from web.backend.auth.dependencies import get_current_user +from web.backend.services import db_schema router = APIRouter(prefix="/api/tasks", tags=["tasks"]) diff --git a/web/backend/routers/websocket.py b/web/backend/routers/websocket.py index d7cdb0f..f3c85d4 100644 --- a/web/backend/routers/websocket.py +++ b/web/backend/routers/websocket.py @@ -1,7 +1,7 @@ """WebSocket endpoint for real-time task progress.""" from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query -from ..auth.jwt_handler import decode_token +from web.backend.auth.jwt_handler import decode_token from jose import JWTError router = APIRouter(tags=["websocket"])