Update: Refactor to absolute imports, fix QPS limits, and enhance Gitea sync with SQLite support
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# 导入
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
from typing import Dict, Any
|
||||
|
||||
# 导出所有处理程序类
|
||||
from .barcode_mapper import BarcodeMapper
|
||||
from .unit_converter_handlers import JianUnitHandler, BoxUnitHandler, TiHeUnitHandler, GiftUnitHandler, UnitHandler
|
||||
from app.core.excel.handlers.barcode_mapper import BarcodeMapper
|
||||
from app.core.excel.handlers.unit_converter_handlers import JianUnitHandler, BoxUnitHandler, TiHeUnitHandler, GiftUnitHandler, UnitHandler
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -221,6 +225,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
|
||||
for col in df.columns:
|
||||
@@ -607,6 +626,9 @@ class ExcelProcessor:
|
||||
logger.error(f"文件不存在: {file_path}")
|
||||
return None
|
||||
|
||||
self.current_missing_barcodes = [] # 重置缺失列表
|
||||
self.current_file_path = file_path # 设置当前处理文件路径
|
||||
|
||||
try:
|
||||
# 读取Excel文件时不立即指定表头
|
||||
if progress_cb:
|
||||
@@ -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)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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']
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
+127
-4
@@ -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 []
|
||||
@@ -307,6 +326,110 @@ class BaiduOCRClient:
|
||||
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]:
|
||||
"""
|
||||
获取Excel结果
|
||||
|
||||
@@ -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,12 +238,53 @@ 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(text)
|
||||
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:
|
||||
# 优先找包含 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):
|
||||
# 如果年份以 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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']
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
供应商处理器模块初始化文件
|
||||
"""
|
||||
|
||||
from .generic_supplier_processor import GenericSupplierProcessor
|
||||
from app.core.processors.supplier_processors.generic_supplier_processor import GenericSupplierProcessor
|
||||
|
||||
__all__ = ['GenericSupplierProcessor']
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+26
-20
@@ -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__)
|
||||
|
||||
@@ -63,15 +63,9 @@ class OCRService:
|
||||
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 = ''
|
||||
|
||||
+220
-42
@@ -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:
|
||||
# 尝试从 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}")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
+38
-102
@@ -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")
|
||||
|
||||
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("未找到图片文件")
|
||||
# 先选择图片
|
||||
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
|
||||
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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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("<Double-1>", 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)
|
||||
@@ -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
|
||||
|
||||
+19
-10
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+3
-3
@@ -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):
|
||||
|
||||
@@ -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("<Enter>", self.show_tip)
|
||||
self.widget.bind("<Leave>", 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)
|
||||
|
||||
+75
-98
@@ -40,7 +40,6 @@ added_files = [
|
||||
('config/barcode_mappings.json', 'config/'),
|
||||
('config/config.ini', 'config/'),
|
||||
('templates/银豹-采购单模板.xls', 'templates/'),
|
||||
('app', 'app'),
|
||||
]
|
||||
|
||||
# 需要隐式导入的模块
|
||||
@@ -56,6 +55,7 @@ hidden_imports = [
|
||||
'xlrd',
|
||||
'xlwt',
|
||||
'xlutils',
|
||||
'xlutils.copy',
|
||||
'requests',
|
||||
'dotenv',
|
||||
'tkinterdnd2',
|
||||
@@ -66,37 +66,17 @@ hidden_imports = [
|
||||
'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',
|
||||
]
|
||||
'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'}")
|
||||
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"警告: 条码映射文件不存在: {barcode_mapping_file}")
|
||||
print(f"警告: 源文件不存在,跳过更新: {src}")
|
||||
|
||||
# 复制根目录的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文件
|
||||
# 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")
|
||||
|
||||
print(f"便携版打包完成,位置: {release_dir.absolute()}")
|
||||
# 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()}")
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
|
||||
+73
@@ -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)
|
||||
+8
-7
@@ -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
|
||||
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
@@ -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 是否包含供应商和日期。")
|
||||
@@ -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]}")
|
||||
@@ -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**: 考虑到用户追求“一键处理”的自动化,建议在单个文件处理完成后弹窗,用户点击确认后再继续下一个文件,或者在批量任务结束后汇总显示所有缺失条码。**初步决定:按文件汇总提示。**
|
||||
@@ -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`。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
+15
-15
@@ -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()
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user