Update: Refactor to absolute imports, fix QPS limits, and enhance Gitea sync with SQLite support

This commit is contained in:
2026-07-21 10:25:29 +08:00
parent ef04fc5627
commit 97d9d98b4c
67 changed files with 1691 additions and 457 deletions
+2 -2
View File
@@ -9,8 +9,8 @@ import configparser
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from dotenv import load_dotenv from dotenv import load_dotenv
from ..core.utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
from .defaults import DEFAULT_CONFIG from app.config.defaults import DEFAULT_CONFIG
# 加载 .env 文件 # 加载 .env 文件
load_dotenv() load_dotenv()
View File
+62 -3
View File
@@ -16,9 +16,9 @@ from typing import Dict, List, Optional, Tuple, Callable
import pandas as pd import pandas as pd
from ..utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
from ..utils.file_utils import smart_read_excel from app.core.utils.file_utils import smart_read_excel
from ...core.handlers.column_mapper import ColumnMapper from app.core.handlers.column_mapper import ColumnMapper
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -43,6 +43,13 @@ class ProductDatabase:
max_price REAL DEFAULT 0.0, max_price REAL DEFAULT 0.0,
price_count INTEGER DEFAULT 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 = { _NEW_COLUMNS = {
@@ -90,16 +97,68 @@ class ProductDatabase:
def _migrate_schema(self): def _migrate_schema(self):
conn = self._connect() conn = self._connect()
try: try:
# 迁移 products 表
cursor = conn.execute("PRAGMA table_info(products)") cursor = conn.execute("PRAGMA table_info(products)")
existing_cols = {row[1] for row in cursor.fetchall()} existing_cols = {row[1] for row in cursor.fetchall()}
for col_name, col_type in self._NEW_COLUMNS.items(): for col_name, col_type in self._NEW_COLUMNS.items():
if col_name not in existing_cols: if col_name not in existing_cols:
conn.execute(f"ALTER TABLE products ADD COLUMN {col_name} {col_type}") conn.execute(f"ALTER TABLE products ADD COLUMN {col_name} {col_type}")
logger.info(f"数据库迁移: 添加列 {col_name}") 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() conn.commit()
finally: finally:
conn.close() 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()
# ══════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════
# 导入 # 导入
# ══════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════
+4 -4
View File
@@ -10,13 +10,13 @@ import os
import json import json
from typing import Dict, Tuple, Optional, Any, List, Union from typing import Dict, Tuple, Optional, Any, List, Union
from ..utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
from .handlers.barcode_mapper import BarcodeMapper from app.core.excel.handlers.barcode_mapper import BarcodeMapper
from .handlers.unit_converter_handlers import ( from app.core.excel.handlers.unit_converter_handlers import (
JianUnitHandler, BoxUnitHandler, TiHeUnitHandler, JianUnitHandler, BoxUnitHandler, TiHeUnitHandler,
GiftUnitHandler, UnitHandler GiftUnitHandler, UnitHandler
) )
from .validators import ProductValidator from app.core.excel.validators import ProductValidator
logger = get_logger(__name__) logger = get_logger(__name__)
+2 -2
View File
@@ -7,5 +7,5 @@
from typing import Dict, Any from typing import Dict, Any
# 导出所有处理程序类 # 导出所有处理程序类
from .barcode_mapper import BarcodeMapper from app.core.excel.handlers.barcode_mapper import BarcodeMapper
from .unit_converter_handlers import JianUnitHandler, BoxUnitHandler, TiHeUnitHandler, GiftUnitHandler, UnitHandler from app.core.excel.handlers.unit_converter_handlers import JianUnitHandler, BoxUnitHandler, TiHeUnitHandler, GiftUnitHandler, UnitHandler
+1 -1
View File
@@ -7,7 +7,7 @@
import logging import logging
from typing import Dict, Optional, Any 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__) logger = get_logger(__name__)
@@ -8,7 +8,7 @@ import logging
from typing import Dict, Optional, Any, Tuple, Protocol from typing import Dict, Optional, Any, Tuple, Protocol
from abc import ABC, abstractmethod 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__) logger = get_logger(__name__)
+5 -5
View File
@@ -14,17 +14,17 @@ from xlutils.copy import copy as xlcopy
from typing import Dict, List, Optional, Tuple, Union, Any, Callable from typing import Dict, List, Optional, Tuple, Union, Any, Callable
from datetime import datetime from datetime import datetime
from ...config.settings import ConfigManager from app.config.settings import ConfigManager
from ..utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
from ..handlers.column_mapper import ColumnMapper from app.core.handlers.column_mapper import ColumnMapper
from ..utils.file_utils import ( from app.core.utils.file_utils import (
ensure_dir, ensure_dir,
get_file_extension, get_file_extension,
get_files_by_extensions, get_files_by_extensions,
load_json, load_json,
save_json save_json
) )
from ..utils.string_utils import ( from app.core.utils.string_utils import (
clean_string, clean_string,
clean_barcode, clean_barcode,
format_barcode format_barcode
+39 -9
View File
@@ -14,23 +14,23 @@ from xlutils.copy import copy as xlcopy
from typing import Dict, List, Optional, Tuple, Union, Any, Callable from typing import Dict, List, Optional, Tuple, Union, Any, Callable
from datetime import datetime from datetime import datetime
from ...config.settings import ConfigManager from app.config.settings import ConfigManager
from ..utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
from ..utils.file_utils import ( from app.core.utils.file_utils import (
ensure_dir, ensure_dir,
get_file_extension, get_file_extension,
get_latest_file, get_latest_file,
load_json, load_json,
save_json save_json
) )
from ..utils.string_utils import ( from app.core.utils.string_utils import (
clean_string, clean_string,
extract_number, extract_number,
format_barcode, format_barcode,
parse_monetary_string parse_monetary_string
) )
from .converter import UnitConverter from app.core.excel.converter import UnitConverter
from ..handlers.column_mapper import ColumnMapper from app.core.handlers.column_mapper import ColumnMapper
logger = get_logger(__name__) 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处理器 初始化Excel处理器
Args: Args:
config: 配置信息 config: 配置信息
product_db: 商品数据库实例(可选,由外部传入以共享) product_db: 商品数据库实例(可选,由外部传入以共享)
missing_barcodes_cb: 缺失条码的回调函数,接收条码列表
""" """
self.config = config self.config = config
self.missing_barcodes_cb = missing_barcodes_cb
self.current_missing_barcodes = [] # 记录当前文件处理中缺失的条码
self.current_file_path = "" # 记录当前处理的文件路径
# 修复ConfigParser对象没有get_path方法的问题 # 修复ConfigParser对象没有get_path方法的问题
try: try:
@@ -62,7 +66,7 @@ class ExcelProcessor:
logger.warning(f"模板文件不存在: {self.template_path}") 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() self.processed_files = self._load_processed_files()
# 确保目录存在 # 确保目录存在
@@ -80,7 +84,7 @@ class ExcelProcessor:
if product_db is not None: if product_db is not None:
self.product_db = product_db self.product_db = product_db
else: 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' 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') tpl_folder = config.get('Paths', 'template_folder', fallback='templates')
item_data = config.get('Templates', 'item_data', fallback='商品资料.xlsx') item_data = config.get('Templates', 'item_data', fallback='商品资料.xlsx')
@@ -220,6 +224,21 @@ class ExcelProcessor:
# 跳过空条码行 # 跳过空条码行
if not product['barcode']: if not product['barcode']:
continue 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 skip_row = False
@@ -606,6 +625,9 @@ class ExcelProcessor:
if not os.path.exists(file_path): if not os.path.exists(file_path):
logger.error(f"文件不存在: {file_path}") logger.error(f"文件不存在: {file_path}")
return None return None
self.current_missing_barcodes = [] # 重置缺失列表
self.current_file_path = file_path # 设置当前处理文件路径
try: try:
# 读取Excel文件时不立即指定表头 # 读取Excel文件时不立即指定表头
@@ -672,6 +694,14 @@ class ExcelProcessor:
# 不再自动打开输出目录 # 不再自动打开输出目录
logger.info(f"采购单已保存到: {output_file}") 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: if progress_cb:
try: try:
progress_cb(100) progress_cb(100)
+2 -2
View File
@@ -8,8 +8,8 @@ import re
import logging import logging
from typing import Dict, Any, Optional, List, Tuple, Union from typing import Dict, Any, Optional, List, Tuple, Union
from ..utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
from ..utils.string_utils import parse_monetary_string from app.core.utils.string_utils import parse_monetary_string
logger = get_logger(__name__) logger = get_logger(__name__)
+3 -3
View File
@@ -2,8 +2,8 @@
数据处理handlers模块初始化文件 数据处理handlers模块初始化文件
""" """
from .data_cleaner import DataCleaner from app.core.handlers.data_cleaner import DataCleaner
from .column_mapper import ColumnMapper from app.core.handlers.column_mapper import ColumnMapper
from .calculator import DataCalculator from app.core.handlers.calculator import DataCalculator
__all__ = ['DataCleaner', 'ColumnMapper', 'DataCalculator'] __all__ = ['DataCleaner', 'ColumnMapper', 'DataCalculator']
+1 -1
View File
@@ -7,7 +7,7 @@
import pandas as pd import pandas as pd
import numpy as np import numpy as np
from typing import Dict, Any, Optional, List, Union 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__) logger = get_logger(__name__)
+3 -3
View File
@@ -7,7 +7,7 @@
import re import re
import pandas as pd import pandas as pd
from typing import Dict, Any, Optional, List, Union 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__) logger = get_logger(__name__)
@@ -22,7 +22,7 @@ class ColumnMapper:
STANDARD_COLUMNS = { STANDARD_COLUMNS = {
'barcode': [ 'barcode': [
'条码', '条形码', '商品条码', '商品条形码', '产品条码', '商品编码', '条码', '条形码', '商品条码', '商品条形码', '产品条码', '商品编码',
'商品编号', '条码(必填)', '电脑条码', '条码ID', '商品编号', '条码(必填)', '电脑条码', '条码ID', '单品条码',
'barcode', 'Barcode', 'BarCode', 'code', '编码', 'barcode', 'Barcode', 'BarCode', 'code', '编码',
], ],
'name': [ 'name': [
@@ -363,7 +363,7 @@ class ColumnMapper:
""" """
header_keywords = [ header_keywords = [
'条码', '条形码', '商品条码', '商品名称', '名称', '规格', '条码', '条形码', '商品条码', '商品名称', '名称', '规格',
'单价', '数量', '金额', '单位', '必填', '编码', '单价', '数量', '金额', '单位', '必填', '编码', '单品条码', '序号',
] ]
best_row = -1 best_row = -1
+1 -1
View File
@@ -6,7 +6,7 @@
import pandas as pd import pandas as pd
from typing import Dict, Any, Optional, List, Union 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__) logger = get_logger(__name__)
+127 -4
View File
@@ -9,7 +9,7 @@ import base64
import requests import requests
from typing import Dict, Optional, Union, List 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__) logger = get_logger(__name__)
@@ -229,10 +229,19 @@ class BaiduOCRClient:
logger.debug(f"百度OCR API返回结果: {result}") logger.debug(f"百度OCR API返回结果: {result}")
if 'error_code' in result: if 'error_code' in result:
error_code = result.get('error_code')
error_msg = result.get('error_msg', '未知错误') 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}") logger.error(f"百度OCR API错误: {error_msg}")
# 如果是授权错误,尝试刷新令牌 # 如果是授权错误,尝试刷新令牌
if result.get('error_code') in [110, 111]: # 授权相关错误码 if error_code in [110, 111]: # 授权相关错误码
logger.info("尝试刷新访问令牌...") logger.info("尝试刷新访问令牌...")
self.token_manager.refresh_token() self.token_manager.refresh_token()
return None return None
@@ -292,8 +301,18 @@ class BaiduOCRClient:
if response.status_code == 200: if response.status_code == 200:
result = response.json() result = response.json()
if 'error_code' in result: if 'error_code' in result:
logger.warning(f"通用识别错误: {result.get('error_msg')}") error_code = result.get('error_code')
if result.get('error_code') in (110, 111): 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() self.token_manager.refresh_token()
return None return None
words_list = result.get('words_result') or [] words_list = result.get('words_result') or []
@@ -306,6 +325,110 @@ class BaiduOCRClient:
time.sleep(self.retry_delay * (2 ** attempt)) time.sleep(self.retry_delay * (2 ** attempt))
logger.error("通用识别失败") logger.error("通用识别失败")
return None 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]: def get_excel_result(self, request_id_or_result: Union[str, Dict]) -> Optional[bytes]:
""" """
+150 -13
View File
@@ -16,6 +16,14 @@ from typing import List, Optional, Tuple
SUPPLIER_KEYWORDS = ( SUPPLIER_KEYWORDS = (
"供货单", "供应商", "供货方", "批发", "酒行", "商行", "供货单", "供应商", "供货方", "批发", "酒行", "商行",
"经销", "专卖店", "配送单", "送货单", "采购单", "订单", "经销", "专卖店", "配送单", "送货单", "采购单", "订单",
"销售单", "出库单", "经营部", "商贸", "有限公司", "发证机构",
)
# 供应商名称中需要剔除的冗余噪声词
SUPPLIER_NOISE_WORDS = (
"标题", "采购单", "销售单", "入库单", "出库单", "送货单",
"单据", "订单", "配送单", "清单", "供货单", "预览", "详情",
"打印", "副本", "记账", "", "存根",
) )
# 总金额关键词(命中后取该行最近一个金额数字) # 总金额关键词(命中后取该行最近一个金额数字)
@@ -24,6 +32,11 @@ AMOUNT_KEYWORDS = (
"合计", "总计", "总计金额", "合计", "总计", "总计金额",
) )
# 日期关键词(用于辅助定位日期)
DATE_KEYWORDS = (
"单据日期", "下单时间", "日期", "时间", "开单日期", "制单日期", "打印时间", "业务日期", "下单日期",
)
# 日期正则:4 种格式 # 日期正则:4 种格式
DATE_PATTERNS = [ DATE_PATTERNS = [
# 2026年07月17日 / 2026年7月17日 # 2026年07月17日 / 2026年7月17日
@@ -52,6 +65,16 @@ class OrderMetadata:
return asdict(self) return asdict(self)
# 排除关键词:包含这些词的行绝对不是供应商抬头
EXCLUDE_KEYWORDS = (
"购货单位", "客户名称", "收货地址", "联系电话", "经手人",
"地址", "电话", "传真", "邮编", "网址", "开户行", "账号",
"税号", "业务员", "联系人", "单据编号", "流水号", "页码",
"四川省", "成都市", "武侯区", "双流区", "高新区", "金牛区",
"成华区", "锦江区", "龙泉驿区", "青羊区", "新都区", "温江区",
"街道", "社区", "", "", "", "", "", "",
)
class OrderMetadataExtractor: class OrderMetadataExtractor:
"""单据元信息识别器。""" """单据元信息识别器。"""
@@ -61,20 +84,35 @@ class OrderMetadataExtractor:
# 供应商名最大长度 # 供应商名最大长度
MAX_SUPPLIER_LEN = 50 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 原始文本和/或解析后的二维数组提取三字段。 """从 OCR 原始文本和/或解析后的二维数组提取三字段。
Args: Args:
ocr_text: OCR 原始字符串全文带换行 ocr_text: OCR 原始字符串全文从表格 OCR 提取
ocr_rows: 解析后的二维数组可选用于 row-level 精确匹配 ocr_rows: 解析后的二维数组可选
general_text: 从通用票据识别接口 (/v1/general_ocr) 提取的文本优先级最高
Returns: Returns:
OrderMetadata OrderMetadata
""" """
text = ocr_text or '' # 优先使用通用票据识别的文本进行供应商和日期提取
supplier, raw_supplier = self._extract_supplier(text, ocr_rows) supplier_source = general_text if general_text else ocr_text
bill_date = self._extract_bill_date(text) date_source = general_text if general_text else ocr_text
total_amount = self._extract_total_amount(text, ocr_rows)
# 金额通常在表格内,优先使用 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( return OrderMetadata(
supplier=supplier, supplier=supplier,
@@ -95,9 +133,15 @@ class OrderMetadataExtractor:
cleaned = self._clean_supplier_line(line) cleaned = self._clean_supplier_line(line)
if not cleaned: if not cleaned:
continue continue
# 严格排除:购货单位、地址、电话等干扰项
if any(k in cleaned for k in EXCLUDE_KEYWORDS):
continue
for kw in SUPPLIER_KEYWORDS: for kw in SUPPLIER_KEYWORDS:
if kw in cleaned: if kw in cleaned:
return self._truncate(cleaned), cleaned final_name = self._final_cleanup_supplier(cleaned)
return self._truncate(final_name), cleaned
# 2) 兜底:取顶部第一个"含中文且无数字行号"且长度 ≥ 4 的非空行 # 2) 兜底:取顶部第一个"含中文且无数字行号"且长度 ≥ 4 的非空行
# 但排除"纯日期行"(避免把日期当供应商) # 但排除"纯日期行"(避免把日期当供应商)
@@ -105,6 +149,11 @@ class OrderMetadataExtractor:
cleaned = self._clean_supplier_line(line) cleaned = self._clean_supplier_line(line)
if not cleaned: if not cleaned:
continue continue
# 兜底也要严格排除地址和电话行
if any(k in cleaned for k in EXCLUDE_KEYWORDS):
continue
if not (re.search(r'[\u4e00-\u9fa5]', cleaned) and len(cleaned) >= 4): if not (re.search(r'[\u4e00-\u9fa5]', cleaned) and len(cleaned) >= 4):
continue continue
# 排除:纯日期(YYYY-MM-DD / YYYY/MM/DD / YYYY年MM月DD日 / YYYYMMDD # 排除:纯日期(YYYY-MM-DD / YYYY/MM/DD / YYYY年MM月DD日 / YYYYMMDD
@@ -117,16 +166,63 @@ class OrderMetadataExtractor:
# 排除:以"单据"开头的行 # 排除:以"单据"开头的行
if re.match(r'^\s*单据[:]?', cleaned): if re.match(r'^\s*单据[:]?', cleaned):
continue continue
return self._truncate(cleaned), cleaned
final_name = self._final_cleanup_supplier(cleaned)
if final_name:
return self._truncate(final_name), cleaned
return '', '' 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 @staticmethod
def _clean_supplier_line(line: str) -> str: def _clean_supplier_line(line: str) -> str:
"""清理一行文本:去前后空白、去首尾日期/编号/电话/标点。""" """清理一行文本:去前后空白、去首尾日期/编号/电话/标点。"""
s = line.strip() s = line.strip()
if not s: if not s:
return '' 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) s = re.sub(r'^[\s\d\-\.\/年月日:]+', '', s)
# 去行尾标点 # 去行尾标点
@@ -142,13 +238,54 @@ class OrderMetadataExtractor:
def _extract_bill_date(self, text: str) -> str: def _extract_bill_date(self, text: str) -> str:
"""提取单据日期,标准化为 YYYYMMDD。""" """提取单据日期,标准化为 YYYYMMDD。"""
if not text:
return ''
lines = text.splitlines()
# 1. 优先尝试关键词定位逻辑(如用户建议:搜索“时间”或“日期”)
for i, line in enumerate(lines):
line_clean = line.strip().replace('', ':')
# 检查是否包含日期相关关键词
for kw in DATE_KEYWORDS:
if kw in line_clean:
# a) 尝试在当前行找日期
for pat in DATE_PATTERNS:
m = pat.search(line_clean)
if m:
y, mo, d = m.group(1), m.group(2), m.group(3)
if self._is_valid_date(y, mo, d):
return f"{y}{int(mo):02d}{int(d):02d}"
# b) 如果当前行没找到,尝试在下一行找(针对表格 OCR 错位情况)
if i + 1 < len(lines):
next_line = lines[i+1].strip()
for pat in DATE_PATTERNS:
m = pat.search(next_line)
if m:
y, mo, d = m.group(1), m.group(2), m.group(3)
if self._is_valid_date(y, mo, d):
return f"{y}{int(mo):02d}{int(d):02d}"
# 2. 兜底策略:全文正则匹配
for pat in DATE_PATTERNS: for pat in DATE_PATTERNS:
m = pat.search(text) # 优先找包含 202 开头的年份(更像当前日期)
if m: matches = pat.finditer(text)
for m in matches:
y, mo, d = m.group(1), m.group(2), m.group(3) y, mo, d = m.group(1), m.group(2), m.group(3)
if self._is_valid_date(y, mo, d): if self._is_valid_date(y, mo, d):
return f"{y}{int(mo):02d}{int(d):02d}" # 如果年份以 202 开头,优先返回
return '' 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 @staticmethod
def _is_valid_date(y: str, m: str, d: str) -> bool: def _is_valid_date(y: str, m: str, d: str) -> bool:
+11 -7
View File
@@ -10,8 +10,8 @@ import base64
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional, Tuple, Callable from typing import Dict, List, Optional, Tuple, Callable
from ..utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
from ..utils.file_utils import ( from app.core.utils.file_utils import (
ensure_dir, ensure_dir,
get_file_extension, get_file_extension,
get_files_by_extensions, get_files_by_extensions,
@@ -20,6 +20,7 @@ from ..utils.file_utils import (
load_json, load_json,
save_json save_json
) )
from app.config.settings import ConfigManager
from .baidu_ocr import BaiduOCRClient from .baidu_ocr import BaiduOCRClient
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -102,14 +103,16 @@ class OCRProcessor:
OCR处理器负责协调OCR识别和结果处理 OCR处理器负责协调OCR识别和结果处理
""" """
def __init__(self, config): def __init__(self, config: Optional[ConfigManager] = None):
""" """
初始化OCR处理器 初始化OCR处理器
Args: Args:
config: 配置信息 config: 配置管理器
""" """
self.config = config self.config = config or ConfigManager()
self.ocr_client = None
self._ensure_ocr_client()
# 修复ConfigParser对象没有get_path方法的问题 # 修复ConfigParser对象没有get_path方法的问题
try: try:
@@ -348,9 +351,10 @@ class OCRProcessor:
if max_workers is None: if max_workers is None:
try: 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: except Exception:
max_workers = 4 max_workers = 1
# 获取未处理的图片 # 获取未处理的图片
unprocessed_images = self.get_unprocessed_images() unprocessed_images = self.get_unprocessed_images()
+3 -3
View File
@@ -2,8 +2,8 @@
处理器模块初始化文件 处理器模块初始化文件
""" """
from .base import BaseProcessor from app.core.processors.base import BaseProcessor
from .ocr_processor import OCRProcessor from app.core.processors.ocr_processor import OCRProcessor
from .tobacco_processor import TobaccoProcessor from app.core.processors.tobacco_processor import TobaccoProcessor
__all__ = ['BaseProcessor', 'OCRProcessor', 'TobaccoProcessor'] __all__ = ['BaseProcessor', 'OCRProcessor', 'TobaccoProcessor']
+1 -1
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import logging import logging
import pandas as pd 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__) logger = get_logger(__name__)
+4 -4
View File
@@ -8,10 +8,10 @@ import os
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from .base import BaseProcessor from app.core.processors.base import BaseProcessor
from ...services.ocr_service import OCRService from app.services.ocr_service import OCRService
from ...services.order_service import OrderService from app.services.order_service import OrderService
from ...core.utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
logger = get_logger(__name__) 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'] __all__ = ['GenericSupplierProcessor']
@@ -9,12 +9,12 @@ import pandas as pd
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from pathlib import Path from pathlib import Path
from ..base import BaseProcessor from app.core.processors.base import BaseProcessor
from ...utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
from ...handlers.rule_engine import apply_rules from app.core.handlers.rule_engine import apply_rules
from ...handlers.column_mapper import ColumnMapper from app.core.handlers.column_mapper import ColumnMapper
from ...handlers.data_cleaner import DataCleaner from app.core.handlers.data_cleaner import DataCleaner
from ...handlers.calculator import DataCalculator from app.core.handlers.calculator import DataCalculator
logger = get_logger(__name__) logger = get_logger(__name__)
+4 -4
View File
@@ -14,10 +14,10 @@ from openpyxl import load_workbook
from typing import Optional, Dict, Any, List, Tuple from typing import Optional, Dict, Any, List, Tuple
from pathlib import Path from pathlib import Path
from .base import BaseProcessor from app.core.processors.base import BaseProcessor
from ...core.utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
from ...core.utils.string_utils import parse_monetary_string from app.core.utils.string_utils import parse_monetary_string
from ...core.utils.dialog_utils import show_custom_dialog from app.core.utils.dialog_utils import show_custom_dialog
logger = get_logger(__name__) logger = get_logger(__name__)
+1 -1
View File
@@ -6,7 +6,7 @@ from typing import Optional, Tuple
import requests import requests
from .log_utils import get_logger from app.core.utils.log_utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
+6 -6
View File
@@ -13,7 +13,7 @@ import tkinter as tk
from tkinter import messagebox, ttk, simpledialog from tkinter import messagebox, ttk, simpledialog
from datetime import datetime from datetime import datetime
from .cloud_sync import GiteaSync from app.core.utils.cloud_sync import GiteaSync
from app.config.settings import ConfigManager from app.config.settings import ConfigManager
def create_custom_dialog(title="提示", message="", result_file=None, time_info=None, 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_size = os.path.getsize(result_file)
file_time = datetime.fromtimestamp(os.path.getmtime(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) 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) 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", "type": "binary",
}, },
{ {
"name": "商品记忆库", "name": "商品记忆库 (DB)",
"remote": "product_memory.json", "remote": "product_cache.db",
"local": "data/product_memory.json", "local": "data/product_cache.db",
"type": "json", "type": "binary",
}, },
] ]
+1 -1
View File
@@ -12,7 +12,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Union, Any 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__) logger = get_logger(__name__)
+5 -4
View File
@@ -11,8 +11,8 @@ import os
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
from ..config.settings import ConfigManager from app.config.settings import ConfigManager
from ..core.utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
from .ocr_service import OCRService from .ocr_service import OCRService
from .order_service import OrderService from .order_service import OrderService
@@ -25,10 +25,11 @@ IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.bmp', '.webp', '.tif', '.tiff'}
class BatchService: 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.config = config or ConfigManager()
self.missing_barcodes_cb = missing_barcodes_cb
self.ocr_service = OCRService(self.config) 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( self._input_folder = self.config.get_path(
'Paths', 'input_folder', fallback='data/input', create=True 'Paths', 'input_folder', fallback='data/input', create=True
) )
+27 -21
View File
@@ -7,9 +7,9 @@ OCR服务模块
from typing import Dict, List, Optional, Tuple, Union, Any, Callable from typing import Dict, List, Optional, Tuple, Union, Any, Callable
import os import os
from ..config.settings import ConfigManager from app.config.settings import ConfigManager
from ..core.utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
from ..core.ocr.table_ocr import OCRProcessor from app.core.ocr.table_ocr import OCRProcessor
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -62,16 +62,10 @@ class OCRService:
if not self._is_valid_image(image_path): if not self._is_valid_image(image_path):
logger.error(f"不支持的文件类型: {image_path}") logger.error(f"不支持的文件类型: {image_path}")
return None return None
# 检查是否已处理 # 不再做 xlsx 已存在就跳过的判断(xlsx 可能被业务层重命名过),
excel_file = self._get_excel_path(image_path) # 跳过逻辑交给 OCRProcessor 内部的 record_manager.is_processed(image_path)
if os.path.exists(excel_file): # —— 由它读 processed_files.json(业务层会同步更新 xlsx 路径)
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
# 执行OCR识别 # 执行OCR识别
result = self.ocr_processor.process_image(image_path) result = self.ocr_processor.process_image(image_path)
@@ -212,24 +206,36 @@ class OCRService:
base = Path(excel_path) base = Path(excel_path)
meta_path = base.with_suffix('.meta.json') meta_path = base.with_suffix('.meta.json')
# 0) 优先:调百度通用文字识别(/accurate),覆盖全图文字(含手写抬头/日期) # 0) 优先:调百度通用票务识别(/general_ocr),精准捕获表头(含手写抬头/日期)
general_text = '' general_text = ''
general_lines = [] general_lines = []
try: try:
# OCRService.ocr_processor = core.ocr.table_ocr.OCRProcessor
# 其 .ocr_client = BaiduOCRClient (process_image 时初始化)
client = getattr(self.ocr_processor, 'ocr_client', None) client = getattr(self.ocr_processor, 'ocr_client', None)
if client is None: if client is None:
# 显式触发一次 process_image 准备流程 (不会重复 OCR)
try: try:
self.ocr_processor._ensure_ocr_client() self.ocr_processor._ensure_ocr_client()
except Exception: except Exception:
pass pass
client = getattr(self.ocr_processor, 'ocr_client', None) 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)) words = client.recognize_general(str(image_path))
if words: if words:
# 按 location.top 排序(顶部先),方便后续提取供应商/日期
def _top(w): def _top(w):
loc = w.get('location') or {} loc = w.get('location') or {}
try: try:
@@ -239,9 +245,9 @@ class OCRService:
words_sorted = sorted(words, key=_top) 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_lines = [(w.get('words') or '').strip() for w in words_sorted if (w.get('words') or '').strip()]
general_text = '\n'.join(general_lines) general_text = '\n'.join(general_lines)
logger.info(f"通用识别获取 {len(general_lines)} 行文字") logger.info(f"通用高精度识别获取 {len(general_lines)} 行文字")
except Exception as e: except Exception as e:
logger.warning(f"通用识别失败(不影响主流程): {e}") logger.warning(f"获取元数据识别失败(不影响主流程): {e}")
# 1) 表格识别 header/body 拼表内文字(已在前面逻辑处理) # 1) 表格识别 header/body 拼表内文字(已在前面逻辑处理)
ocr_text = '' ocr_text = ''
+222 -44
View File
@@ -12,13 +12,13 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union, Any, Callable from typing import Dict, List, Optional, Tuple, Union, Any, Callable
from ..config.settings import ConfigManager from app.config.settings import ConfigManager
from ..core.utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
from ..core.excel.processor import ExcelProcessor from app.core.excel.processor import ExcelProcessor
from ..core.excel.merger import PurchaseOrderMerger from app.core.excel.merger import PurchaseOrderMerger
from ..core.db.product_db import ProductDatabase from app.core.db.product_db import ProductDatabase
from ..core.db.order_metadata_db import OrderMetadataDB from app.core.db.order_metadata_db import OrderMetadataDB
from ..core.ocr.metadata_extractor import OrderMetadataExtractor, sanitize_for_filename from app.core.ocr.metadata_extractor import OrderMetadataExtractor, sanitize_for_filename
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -27,15 +27,17 @@ class OrderService:
订单服务协调Excel处理和订单合并流程 订单服务协调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: Args:
config: 配置管理器如果为None则创建新的 config: 配置管理器如果为None则创建新的
missing_barcodes_cb: 缺失条码的回调函数
""" """
logger.info("初始化OrderService") logger.info("初始化OrderService")
self.config = config or ConfigManager() 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' 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) self.product_db = ProductDatabase(db_path, tpl_path)
# 创建Excel处理器和采购单合并器 # 创建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) self.order_merger = PurchaseOrderMerger(self.config)
# 元信息识别器 + 单据元信息库 # 元信息识别器 + 单据元信息库
@@ -291,15 +297,16 @@ class OrderService:
if meta_path.exists(): if meta_path.exists():
try: try:
payload = json.loads(meta_path.read_text(encoding='utf-8')) payload = json.loads(meta_path.read_text(encoding='utf-8'))
# 优先用通用识别文本(含手写抬头/日期) # 区分表格 OCR 文本和通用识别文本
ocr_text = payload.get('general_text') or payload.get('ocr_text', '') or '' ocr_text = payload.get('ocr_text', '') or ''
general_text = payload.get('general_text', '') or ''
ocr_rows = payload.get('ocr_rows', []) or [] ocr_rows = payload.get('ocr_rows', []) or []
source_image = payload.get('image_path', '') or '' source_image = payload.get('image_path', '') or ''
except Exception as e: except Exception as e:
logger.warning(f"读 meta.json 失败: {e}") logger.warning(f"读 meta.json 失败: {e}")
# 兜底:从 xlsx 拼文本(与 OCRService._write_meta_json 的兜底一致) # 兜底:从 xlsx 拼文本(与 OCRService._write_meta_json 的兜底一致)
if not ocr_text: if not ocr_text and not general_text:
try: try:
import xlrd import xlrd
rb = xlrd.open_workbook(str(ocr_excel_path)) rb = xlrd.open_workbook(str(ocr_excel_path))
@@ -313,7 +320,7 @@ class OrderService:
except Exception as e: except Exception as e:
logger.debug(f"从 xlsx 拼 OCR 文本失败: {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( self.metadata_db.save(
file_hash=file_hash, file_hash=file_hash,
supplier=meta.supplier, supplier=meta.supplier,
@@ -334,49 +341,77 @@ class OrderService:
def _apply_metadata_to_filenames(self, result_path: str, def _apply_metadata_to_filenames(self, result_path: str,
ocr_excel_path: str, ocr_excel_path: str,
meta) -> str: meta) -> str:
"""应用新文件名规则: """应用新文件名规则(按供应商+日期)
- result: 采购单_{YYYYMMDD}_{供应商}_{hash}.xls - result (xls){供应商}_{日期}.xls
- 原图: {原stem}_{YYYYMMDD}_{供应商}_{hash}.{ext} - output xlsx {供应商}_{日期}.xlsx
- 原图 {供应商}_{日期}.{ext}
冲突时加 _2 / _3 ...任一步骤失败不影响整体
任一步骤失败不影响 result 文件本身
Returns: Returns:
result 路径无论重命名是否成功都返回失败时返回原路径 result 路径无论重命名是否成功都返回失败时返回原路径
""" """
try: try:
file_hash = Path(ocr_excel_path).stem # 1. 提取并清理元数据
supplier_clean = sanitize_for_filename(meta.supplier) or '未知供应商' supplier_clean = sanitize_for_filename(meta.supplier) or '未知供应商'
date_part = meta.bill_date or '未知日期' date_part = meta.bill_date or '未知日期'
# ── 1. result 重命名 ── # 2. 构造新的基础文件名:[供应商]_[日期]
new_result_name = f"采购单_{date_part}_{supplier_clean}_{file_hash}.xls" # 按照用户要求:按照供应商名称加日期进行修改,且不含“采购单”等冗余字眼
result_dir = Path(result_path).parent base_name = f"{supplier_clean}_{date_part}"
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. 原图重命名 ── 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: try:
meta_row = self.metadata_db.get(file_hash) # 尝试从 processed_files.json 反查原图路径
src_image = (meta_row or {}).get('source_image', '') 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): if src_image and os.path.exists(src_image):
src_p = Path(src_image) src_p = Path(src_image)
stem = src_p.stem
ext = src_p.suffix ext = src_p.suffix
new_image_name = f"{stem}_{date_part}_{supplier_clean}_{file_hash}{ext}" # 严禁包含原文件名,统一格式: 采购单_YYYYMMDD_供应商.ext
new_image_path = src_p.parent / new_image_name new_image_name = f"{base_name}{ext}"
# 不覆盖已重命名的图片 new_image_path = self._safe_rename(src_image, new_image_name)
if str(new_image_path) != str(src_p) and not new_image_path.exists():
os.rename(src_p, new_image_path) # ── 3.5 同步 processed_files.json 的 Key (原图路径) ──
logger.info(f"原图重命名: {src_p.name} -> {new_image_path.name}") if str(new_image_path) != str(src_p):
# 更新 source_image 路径 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( self.metadata_db.save(
file_hash=file_hash, file_hash=file_hash,
supplier=meta.supplier, supplier=meta.supplier,
@@ -385,14 +420,47 @@ class OrderService:
raw_supplier_text=meta.raw_supplier_text, raw_supplier_text=meta.raw_supplier_text,
source_image=str(new_image_path), source_image=str(new_image_path),
) )
else:
logger.warning(f"找不到原图,跳过重命名: {src_image}")
except Exception as e: except Exception as e:
logger.warning(f"原图重命名失败: {e}") logger.warning(f"原图重命名失败: {e}")
return result_path return str(new_result_path)
except Exception as e: except Exception as e:
logger.error(f"_apply_metadata_to_filenames 失败: {e}") logger.error(f"_apply_metadata_to_filenames 失败: {e}")
return result_path 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 @staticmethod
def _dedup_path(p: Path) -> Path: def _dedup_path(p: Path) -> Path:
"""路径冲突时加 _N 后缀。""" """路径冲突时加 _N 后缀。"""
@@ -404,3 +472,113 @@ class OrderService:
if not cand.exists(): if not cand.exists():
return cand return cand
n += 1 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}")
+5 -5
View File
@@ -8,10 +8,10 @@ import logging
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from pathlib import Path from pathlib import Path
from ..core.processors.base import BaseProcessor from app.core.processors.base import BaseProcessor
from ..core.processors.tobacco_processor import TobaccoProcessor from app.core.processors.tobacco_processor import TobaccoProcessor
from ..core.processors.ocr_processor import OCRProcessor from app.core.processors.ocr_processor import OCRProcessor
from ..core.utils.log_utils import get_logger from app.core.utils.log_utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -65,7 +65,7 @@ class ProcessorService:
for supplier_config in supplier_configs: for supplier_config in supplier_configs:
try: 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) processor = GenericSupplierProcessor(self.config, supplier_config)
self.processors.append(processor) self.processors.append(processor)
logger.info(f"加载供应商处理器: {processor.name}") logger.info(f"加载供应商处理器: {processor.name}")
+1 -1
View File
@@ -7,7 +7,7 @@ import time
import pandas as pd import pandas as pd
from typing import Optional, Callable 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__) logger = get_logger(__name__)
+1 -1
View File
@@ -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.log_utils import get_logger
from app.core.utils.string_utils import parse_monetary_string from app.core.utils.string_utils import parse_monetary_string
from app.core.utils.dialog_utils import show_custom_dialog # 导入自定义弹窗工具 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__) logger = get_logger(__name__)
+39 -103
View File
@@ -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 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): def _ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=False):
"""弹窗询问是否合并采购单,返回合并结果路径或 None。 """弹窗询问是否合并采购单,返回合并结果路径或 None。
@@ -138,108 +159,23 @@ def process_single_image_with_status(log_widget, status_bar):
def run_pipeline_directly(log_widget, status_bar): def run_pipeline_directly(log_widget, status_bar):
"""直接运行完整处理流程""" """运行完整处理流程:先选择图片,再执行 OCR+Excel 处理"""
if get_running_task() is not None: if get_running_task() is not None:
messagebox.showinfo("任务进行中", "请等待当前任务完成后再执行新的操作。") messagebox.showinfo("任务进行中", "请等待当前任务完成后再执行新的操作。")
return return
def run_in_thread(): # 先选择图片
set_running_task("pipeline") file_path = select_file(
log_widget,
[("支持文件", "*.jpg *.jpeg *.png *.bmp *.xlsx *.xls"), ("图片文件", "*.jpg *.jpeg *.png *.bmp"), ("Excel文件", "*.xlsx *.xls"), ("所有文件", "*.*")],
"选择待处理文件"
)
if not file_path:
add_to_log(log_widget, "未选择文件,一键处理已取消\n", "warning")
return
if status_bar: # 复用拖拽处理的逻辑,实现“先选图,后全流程”
status_bar.set_running(True) process_dropped_file(log_widget, status_bar, file_path)
status_bar.set_status("开始完整处理流程...")
start_time = datetime.datetime.now()
start_perf = time.perf_counter()
log_widget.configure(state=tk.NORMAL)
log_widget.delete(1.0, tk.END)
log_widget.insert(tk.END, "执行命令: 完整处理流程\n", "command")
log_widget.insert(tk.END, f"开始时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time")
log_widget.insert(tk.END, "=" * 50 + "\n\n", "separator")
log_widget.configure(state=tk.DISABLED)
try:
config = ConfigManager()
gui_handler = init_gui_logger(log_widget)
ocr_service = OCRService(config)
order_service = OrderService(config)
reporter = ProgressReporter(status_bar)
reporter.running()
reporter.set("开始OCR批量处理...", 10)
total, success = ocr_service.batch_process(progress_cb=lambda p: reporter.set("OCR处理中...", p))
if total == 0:
add_to_log(log_widget, "没有找到需要处理的图片\n", "warning")
if status_bar:
status_bar.set_status("未找到图片文件")
return
elif success == 0:
add_to_log(log_widget, "OCR处理没有成功处理任何新文件\n", "warning")
else:
add_to_log(log_widget, f"OCR处理完成,共处理 {success}/{total} 个文件\n", "success")
try:
processed_map = {}
config = ConfigManager()
pjson = config.get('Paths', 'processed_record', fallback='data/processed_files.json')
if os.path.exists(pjson):
with open(pjson, 'r', encoding='utf-8') as f:
processed_map = json.load(f)
outputs = list(processed_map.values())
for p in outputs[-10:]:
if p:
add_recent_file(os.path.abspath(p))
except Exception as e:
logger.debug(f"加载已处理文件记录失败: {e}")
reporter.set("开始Excel处理...", 92)
add_to_log(log_widget, "开始Excel处理...\n", "info")
result = order_service.process_excel()
if not result:
add_to_log(log_widget, "Excel处理失败\n", "error")
else:
add_to_log(log_widget, "Excel处理完成\n", "success")
try:
add_recent_file(result)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception as e:
logger.debug(f"单价校验失败: {e}")
reporter.set("检查是否需要合并采购单...", 80)
_ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=True)
end_time = datetime.datetime.now()
duration_sec = max(0.0, time.perf_counter() - start_perf)
add_to_log(log_widget, f"\n{'=' * 50}\n", "separator")
add_to_log(log_widget, "完整处理流程执行完毕!\n", "success")
add_to_log(log_widget, f"结束时间: {end_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time")
add_to_log(log_widget, f"耗时: {duration_sec:.2f}\n", "time")
reporter.set("处理完成", 100)
except Exception as e:
add_to_log(log_widget, f"执行过程中发生错误: {str(e)}\n", "error")
import traceback
add_to_log(log_widget, f"详细错误信息: {traceback.format_exc()}\n", "error")
finally:
dispose_gui_logger()
reporter.done()
set_running_task(None)
if status_bar:
status_bar.set_running(False)
status_bar.set_status("就绪")
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def batch_ocr_with_status(log_widget, status_bar): 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 顺序 from .memory_editor import show_memory_editor # noqa: F401 触发 import 顺序
cfg = ConfigManager() cfg = ConfigManager()
svc = BatchService(cfg) svc = BatchService(cfg, missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
def progress(done, total, entry): def progress(done, total, entry):
pct = int(done / total * 100) if total else 100 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) 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") add_to_log(log_widget, "开始Excel处理...\n", "info")
try: try:
@@ -461,7 +397,7 @@ def merge_orders_with_status(log_widget, status_bar):
init_gui_logger(log_widget) 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)) 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) init_gui_logger(log_widget)
order_service = OrderService() order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
if file_path: if file_path:
try: try:
@@ -589,7 +525,7 @@ def process_dropped_file(log_widget, status_bar, file_path):
# 步骤2: Excel处理 # 步骤2: Excel处理
reporter.set("Excel处理中...", 40) 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)) result = order_service.process_excel(excel_path, progress_cb=lambda p: reporter.set("Excel处理中...", p))
if not result: if not result:
add_to_log(log_widget, "Excel处理失败\n", "error") 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 = ProgressReporter(status_bar)
reporter.running() reporter.running()
init_gui_logger(log_widget) 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") add_to_log(log_widget, f"开始一键处理Excel文件: {file_path}\n", "info")
try: try:
add_recent_file(file_path) add_recent_file(file_path)
+1 -1
View File
@@ -7,7 +7,7 @@ from tkinter import messagebox
from app.core.excel.converter import UnitConverter from app.core.excel.converter import UnitConverter
from app.core.utils.dialog_utils import show_barcode_mapping_dialog 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): def edit_barcode_mappings(log_widget):
+2 -2
View File
@@ -12,8 +12,8 @@ import tkinter as tk
from tkinter import messagebox from tkinter import messagebox
from threading import Thread from threading import Thread
from .logging_ui import LogRedirector from app.ui.logging_ui import LogRedirector
from .result_previews import show_result_preview from app.ui.result_previews import show_result_preview
# 任务状态跟踪 # 任务状态跟踪
_RUNNING_TASK = None _RUNNING_TASK = None
+2 -2
View File
@@ -8,8 +8,8 @@ from tkinter import messagebox, filedialog, ttk
from app.config.settings import ConfigManager from app.config.settings import ConfigManager
from .user_settings import load_user_settings, save_user_settings from app.ui.user_settings import load_user_settings, save_user_settings
from .ui_widgets import center_window from app.ui.ui_widgets import center_window
from app.core.utils.dialog_utils import show_cloud_sync_dialog from app.core.utils.dialog_utils import show_cloud_sync_dialog
+264
View File
@@ -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)
+11 -1
View File
@@ -16,7 +16,17 @@ def select_file(log_widget, file_types=None, title="选择文件"):
"""通用文件选择对话框""" """通用文件选择对话框"""
if file_types is None: if file_types is None:
file_types = [("所有文件", "*.*")] 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: if file_path:
add_to_log(log_widget, f"已选择文件: {file_path}\n", "info") add_to_log(log_widget, f"已选择文件: {file_path}\n", "info")
return file_path return file_path
+19 -10
View File
@@ -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 .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 .logging_ui import add_to_log, poll_log_queue
from .ui_widgets import StatusBar from .ui_widgets import StatusBar, ToolTip
from .user_settings import ( from .user_settings import (
load_user_settings, save_user_settings, refresh_recent_list_widget, load_user_settings, save_user_settings, refresh_recent_list_widget,
_extract_path_from_recent_item, clear_recent_files, 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 .barcode_editor import edit_barcode_mappings
from .shortcuts import bind_keyboard_shortcuts from .shortcuts import bind_keyboard_shortcuts
from app.core.utils.dialog_utils import show_cloud_sync_dialog from app.core.utils.dialog_utils import show_cloud_sync_dialog
from .db_viewer import show_db_viewer
def _init_window(): 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_section.pack(fill=tk.X, pady=(0, 8))
pipeline_frame = tk.Frame(pipeline_section, bg=theme["card_bg"]) pipeline_frame = tk.Frame(pipeline_section, bg=theme["card_bg"])
pipeline_frame.pack(fill=tk.X, padx=8, pady=6) 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处理区 # OCR处理区
core_section = tk.LabelFrame( 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)) 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 = create_card_frame(content_frame)
right_panel.pack(side=tk.RIGHT, fill=tk.BOTH, expand=False, padx=(5, 0), pady=5) 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) 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: 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(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(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: 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, StatusBar(root)), "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, "清除缓存", 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, "清理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( 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_section.pack(fill=tk.X, pady=(0, 8))
settings_buttons_frame = tk.Frame(settings_section, bg=theme["card_bg"]) settings_buttons_frame = tk.Frame(settings_section, bg=theme["card_bg"])
settings_buttons_frame.pack(fill=tk.X, padx=8, pady=6) 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: 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_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) 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: try:
root, theme, settings, dnd_supported = _init_window() root, theme, settings, dnd_supported = _init_window()
config = ConfigManager()
# 主容器 # 主容器
main_container = tk.Frame(root, bg=theme["bg"]) 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_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) _setup_drag_area(mid_container, theme, dnd_supported, log_text, status_bar)
+1 -1
View File
@@ -6,7 +6,7 @@ from tkinter import ttk, messagebox, simpledialog
from app.config.settings import ConfigManager from app.config.settings import ConfigManager
from app.core.db.product_db import ProductDatabase 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(): def _get_product_db():
+2 -2
View File
@@ -8,8 +8,8 @@ import datetime
import tkinter as tk import tkinter as tk
from tkinter import messagebox, scrolledtext from tkinter import messagebox, scrolledtext
from .theme import THEMES, get_theme_mode, apply_theme from app.ui.theme import THEMES, get_theme_mode, apply_theme
from .ui_widgets import center_window from app.ui.ui_widgets import center_window
from app.core.utils.file_utils import format_file_size from app.core.utils.file_utils import format_file_size
from app.config.settings import ConfigManager from app.config.settings import ConfigManager
+3 -3
View File
@@ -5,15 +5,15 @@
import tkinter as tk import tkinter as tk
from tkinter import messagebox from tkinter import messagebox
from .ui_widgets import center_window from app.ui.ui_widgets import center_window
from .action_handlers import ( from app.ui.action_handlers import (
process_single_image_with_status, process_single_image_with_status,
process_excel_file_with_status, process_excel_file_with_status,
batch_ocr_with_status, batch_ocr_with_status,
run_pipeline_directly, run_pipeline_directly,
merge_orders_with_status, 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): def bind_keyboard_shortcuts(root, log_widget, status_bar):
+30
View File
@@ -74,6 +74,36 @@ class ProgressReporter:
pass 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): def create_collapsible_frame(parent, title, initial_state=True):
"""创建可折叠的面板""" """创建可折叠的面板"""
frame = tk.Frame(parent) frame = tk.Frame(parent)
+98 -121
View File
@@ -40,63 +40,43 @@ added_files = [
('config/barcode_mappings.json', 'config/'), ('config/barcode_mappings.json', 'config/'),
('config/config.ini', 'config/'), ('config/config.ini', 'config/'),
('templates/银豹-采购单模板.xls', 'templates/'), ('templates/银豹-采购单模板.xls', 'templates/'),
('app', 'app'),
] ]
# 需要隐式导入的模块 # 需要隐式导入的模块
hidden_imports = [ hidden_imports = [
'tkinter', 'tkinter',
'tkinter.ttk', 'tkinter.ttk',
'tkinter.filedialog', 'tkinter.filedialog',
'tkinter.messagebox', 'tkinter.messagebox',
'tkinter.scrolledtext', 'tkinter.scrolledtext',
'pandas', 'pandas',
'numpy', 'numpy',
'openpyxl', 'openpyxl',
'xlrd', 'xlrd',
'xlwt', 'xlwt',
'xlutils', 'xlutils',
'requests', 'xlutils.copy',
'dotenv', 'requests',
'tkinterdnd2', 'dotenv',
'configparser', 'tkinterdnd2',
'threading', 'configparser',
'datetime', 'threading',
'json', 'datetime',
're', 'json',
'subprocess', 're',
'shutil', 'subprocess',
'app.config.settings', 'shutil',
'app.services.ocr_service', 'sqlite3',
'app.services.order_service', 'logging',
'app.services.tobacco_service', 'base64',
'app.services.processor_service', 'concurrent.futures',
'app.core.utils.dialog_utils', 'pathlib',
'app.core.utils.file_utils', 'typing',
'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',
]
a = Analysis( a = Analysis(
['启动器.py'], ['启动器.py'],
pathex=[], pathex=['.'],
binaries=[], binaries=[],
datas=added_files, datas=added_files,
hiddenimports=hidden_imports, hiddenimports=hidden_imports,
@@ -217,107 +197,104 @@ def build_exe():
return True return True
def create_portable_package(): def create_portable_package():
"""创建便携版打包""" """创建并更新便携版打包,并同步到桌面"""
print("创建便携版打包...") print("更新便携版打包...")
# 创建发布目录 # 1. 准备本地 release 目录
release_dir = Path('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文件
exe_file = Path('dist/OCR订单处理系统.exe') exe_file = Path('dist/OCR订单处理系统.exe')
if exe_file.exists(): if exe_file.exists():
shutil.copy2(exe_file, release_dir) 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'] dirs_to_ensure = ['data/input', 'data/output', 'data/result', 'logs', 'templates', 'config']
for dir_path in dirs_to_create: for dir_path in dirs_to_ensure:
(release_dir / dir_path).mkdir(parents=True, exist_ok=True) (release_dir / dir_path).mkdir(parents=True, exist_ok=True)
print(f"已创建目录: {dir_path}")
# 复制配置文件(包含API密钥) # 复制配置文件
config_file = Path('config/config.ini') files_to_copy = [
if config_file.exists(): (Path('config/config.ini'), release_dir / 'config'),
shutil.copy2(config_file, release_dir / 'config') (Path('config/barcode_mappings.json'), release_dir / 'config'),
print(f"已复制配置文件: {config_file} -> {release_dir / 'config'}") (Path('config.ini'), release_dir),
else: (Path('templates/银豹-采购单模板.xls'), release_dir / 'templates'),
print(f"警告: 配置文件不存在: {config_file}") (Path('templates/商品资料.xlsx'), release_dir / 'templates'),
]
# 复制完整的条码映射文件 for src, dst_dir in files_to_copy:
barcode_mapping_file = Path('config/barcode_mappings.json') if src.exists():
if barcode_mapping_file.exists(): dst_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(barcode_mapping_file, release_dir / 'config') shutil.copy2(src, dst_dir)
print(f"复制条码映射文件: {barcode_mapping_file} -> {release_dir / 'config'}") print(f"更新文件: {src} -> {dst_dir}")
else: else:
print(f"警告: 条码映射文件不存在: {barcode_mapping_file}") print(f"警告: 文件不存在,跳过更新: {src}")
# 复制根目录的config.ini文件 # 3. 创建/更新 README
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文件
readme_content = ''' readme_content = '''
# OCR订单处理系统 - 便携版 # OCR订单处理系统 - 便携版
## 使用说明 ## 使用说明
1. 双击 "OCR订单处理系统.exe" 启动程序 1. 双击 "OCR订单处理系统.exe" 启动程序
2. 将需要处理的图片文件放入 data/input 目录 2. 将需要处理的图片文件放入 data/input 目录
3. 处理结果将保存在 data/output 目录 3. 处理结果将保存在 data/result 目录 (采购单 Excel)
4. 日志文件保存在 logs 目录 4. 中间过程文件在 data/output 目录
5. 日志文件保存在 logs 目录
## 注意事项 ## 注意事项
- 首次运行时需要配置百度OCR API密钥
- 支持的图片格式jpg, jpeg, png, bmp - 支持的图片格式jpg, jpeg, png, bmp
- 单个文件大小不超过4MB - 单个文件大小建议不超过 4MB
## 目录结构 ## 目录结构
- OCR订单处理系统.exe - 主程序 - OCR订单处理系统.exe - 主程序
- data/input/ - 输入图片目录 - data/input/ - 输入图片目录
- data/output/ - 输出结果目录 - data/result/ - 最终采购单目录
- logs/ - 日志目录 - logs/ - 日志目录
''' '''
with open(release_dir / 'README.txt', 'w', encoding='utf-8') as f: with open(release_dir / 'README.txt', 'w', encoding='utf-8') as f:
f.write(readme_content) f.write(readme_content)
print("已创建README.txt")
# 4. 同步到桌面
try:
# 用户指定的特殊桌面路径
desktop_path = Path(r"F:\Administrator\桌面")
if not desktop_path.exists():
# 兜底:如果 F 盘路径不存在,尝试系统默认路径
desktop_path = Path(os.path.join(os.path.expanduser("~"), "Desktop"))
desktop_release = desktop_path / "OCR系统_Release"
print(f"正在同步到桌面: {desktop_release}")
# 使用自定义的同步逻辑,避免删除目标目录中的其他文件
def sync_dir(src_root, dst_root):
if not dst_root.exists():
dst_root.mkdir(parents=True)
for item in src_root.iterdir():
dst_item = dst_root / item.name
if item.is_dir():
sync_dir(item, dst_item)
else:
# 如果是文件,直接覆盖更新
shutil.copy2(item, dst_item)
sync_dir(release_dir, desktop_release)
print(f"同步成功!桌面位置: {desktop_release.absolute()}")
except Exception as e:
print(f"同步到桌面失败: {e}")
print(f"便携版打包完成,位置: {release_dir.absolute()}") print(f"本地便携版更新完成,位置: {release_dir.absolute()}")
def main(): def main():
"""主函数""" """主函数"""
+73
View File
@@ -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
View File
@@ -1,9 +1,9 @@
[API] [API]
api_key = kIehdWbbVD85K18qZYz6SeUf api_key = yBU35EDIZ2ITLRk1MnFBN1tv
secret_key = RCXTgmVjJJkNNMhfY5ASab0xY3mvF6d7 secret_key = 7L0VEOIcqHhaqzZec5LXsHGzJhgFivMr
timeout = 30 timeout = 30
max_retries = 3 max_retries = 5
retry_delay = 2 retry_delay = 1
api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table
token_url = https://aip.baidubce.com/oauth/2.0/token 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 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 output_folder = data/output
temp_folder = data/temp temp_folder = data/temp
template_folder = templates template_folder = templates
template_file = templates/银豹-采购单模板.xls template_file = templates\银豹-采购单模板.xls
processed_record = data/processed_files.json processed_record = data/processed_files.json
data_dir = data data_dir = data
product_db = data/product_cache.db product_db = data/product_cache.db
@@ -35,14 +35,15 @@ purchase_order = 银豹-采购单模板.xls
item_data = 商品资料.xlsx item_data = 商品资料.xlsx
[App] [App]
version = 2026.07.19.1947 version = 2026.07.20.2105
[Gitea] [Gitea]
base_url = https://gitea.94kan.cn base_url = https://gitea.94kan.cn
owner = houhuan owner = houhuan
repo = yixuan-sync-data repo = yixuan-sync-data
token = token = 50b61e43a141d606ae2529cd1755bc666d800e08
[WebAuth] [WebAuth]
username = admin username = admin
password_hash = $2b$12$nllT8o1QIMfWKuTlpQI3G./E2NS.gqf0EHZyNkJ8gMpVa9grTXRoC password_hash = $2b$12$nllT8o1QIMfWKuTlpQI3G./E2NS.gqf0EHZyNkJ8gMpVa9grTXRoC
+4 -4
View File
@@ -1,9 +1,9 @@
[API] [API]
api_key = kIehdWbbVD85K18qZYz6SeUf api_key = yBU35EDIZ2ITLRk1MnFBN1tv
secret_key = RCXTgmVjJJkNNMhfY5ASab0xY3mvF6d7 secret_key = 7L0VEOIcqHhaqzZec5LXsHGzJhgFivMr
timeout = 30 timeout = 30
max_retries = 3 max_retries = 5
retry_delay = 2 retry_delay = 1
api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table
token_url = https://aip.baidubce.com/oauth/2.0/token 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 form_ocr_url = https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result
+57
View File
@@ -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 是否包含供应商和日期。")
+73
View File
@@ -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
```
+63
View File
@@ -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()
+81
View File
@@ -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()
+1 -1
View File
@@ -3,7 +3,7 @@
from fastapi import Depends, HTTPException, status, Query, Request from fastapi import Depends, HTTPException, status, Query, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .jwt_handler import decode_token from web.backend.auth.jwt_handler import decode_token
security = HTTPBearer() security = HTTPBearer()
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Optional
from jose import jwt, JWTError 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: def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
+2 -2
View File
@@ -5,8 +5,8 @@ import bcrypt
from fastapi import APIRouter, HTTPException, Depends, status from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel from pydantic import BaseModel
from .jwt_handler import create_access_token from web.backend.auth.jwt_handler import create_access_token
from .dependencies import get_current_user from web.backend.auth.dependencies import get_current_user
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
+15 -15
View File
@@ -15,20 +15,20 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from .config import get_or_generate_secret # noqa: trigger secret generation from web.backend.config import get_or_generate_secret # noqa: trigger secret generation
from .services.task_manager import TaskManager from web.backend.services.task_manager import TaskManager
from .services.db_pool import DBPool from web.backend.services.db_pool import DBPool
from .auth.router import router as auth_router from web.backend.auth.router import router as auth_router
from .routers.files import router as files_router from web.backend.routers.files import router as files_router
from .routers.processing import router as processing_router from web.backend.routers.processing import router as processing_router
from .routers.memory import router as memory_router from web.backend.routers.memory import router as memory_router
from .routers.config_api import router as config_router from web.backend.routers.config_api import router as config_router
from .routers.barcodes import router as barcodes_router from web.backend.routers.barcodes import router as barcodes_router
from .routers.sync import router as sync_router from web.backend.routers.sync import router as sync_router
from .routers.websocket import router as ws_router from web.backend.routers.websocket import router as ws_router
from .routers.logs import router as logs_router from web.backend.routers.logs import router as logs_router
from .routers.tasks import router as tasks_router from web.backend.routers.tasks import router as tasks_router
from .middleware.logging import LoggingMiddleware from web.backend.middleware.logging import LoggingMiddleware
# Shared singletons # Shared singletons
task_manager = TaskManager() task_manager = TaskManager()
@@ -42,7 +42,7 @@ async def lifespan(app: FastAPI):
ConfigManager() ConfigManager()
# Initialize DB and cleanup old records # 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() init_db()
cleanup_old_records() cleanup_old_records()
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import Dict, Optional, List
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel 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"]) router = APIRouter(prefix="/api/barcodes", tags=["barcodes"])
+1 -1
View File
@@ -4,7 +4,7 @@ from typing import Dict, Optional, Any
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel 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"]) router = APIRouter(prefix="/api/config", tags=["config"])
+4 -4
View File
@@ -10,9 +10,9 @@ from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query,
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from ..auth.dependencies import get_current_user, get_current_user_flexible from web.backend.auth.dependencies import get_current_user, get_current_user_flexible
from ..config import MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS from web.backend.config import MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS
from ..services.db_schema import ( from web.backend.services.db_schema import (
insert_file_metadata, query_file_history, query_file_stats, insert_file_metadata, query_file_history, query_file_stats,
query_file_relations, delete_file_relations, sync_file_relations, query_file_relations, delete_file_relations, sync_file_relations,
query_file_relations_stats, reset_file_cache, query_file_relations_stats, reset_file_cache,
@@ -259,7 +259,7 @@ class RelationDeleteRequest(BaseModel):
def _cleanup_relation_for_deleted_file(directory: str, filename: str): def _cleanup_relation_for_deleted_file(directory: str, filename: str):
"""Clean up relation table when a file is deleted.""" """Clean up relation table when a file is deleted."""
import sqlite3 import sqlite3
from ..services.db_schema import _db_path from web.backend.services.db_schema import _db_path
try: try:
conn = sqlite3.connect(_db_path) conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
+2 -2
View File
@@ -7,8 +7,8 @@ from typing import Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from ..auth.dependencies import get_current_user from web.backend.auth.dependencies import get_current_user
from ..services.db_schema import query_http_logs, query_http_log_stats from web.backend.services.db_schema import query_http_logs, query_http_log_stats
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+1 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel 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"]) router = APIRouter(prefix="/api/memory", tags=["memory"])
+3 -3
View File
@@ -12,9 +12,9 @@ from typing import Optional, List
from fastapi import APIRouter, HTTPException, Depends, Request from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel from pydantic import BaseModel
from ..auth.dependencies import get_current_user from web.backend.auth.dependencies import get_current_user
from ..services.service_wrapper import ServiceWrapper from web.backend.services.service_wrapper import ServiceWrapper
from ..services.db_schema import upsert_file_relation from web.backend.services.db_schema import upsert_file_relation
router = APIRouter(prefix="/api/processing", tags=["processing"]) router = APIRouter(prefix="/api/processing", tags=["processing"])
+2 -2
View File
@@ -6,8 +6,8 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Request from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel from pydantic import BaseModel
from ..auth.dependencies import get_current_user from web.backend.auth.dependencies import get_current_user
from ..services.task_manager import TaskManager from web.backend.services.task_manager import TaskManager
router = APIRouter(prefix="/api/sync", tags=["sync"]) router = APIRouter(prefix="/api/sync", tags=["sync"])
+2 -2
View File
@@ -6,8 +6,8 @@ from typing import Optional
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from ..auth.dependencies import get_current_user from web.backend.auth.dependencies import get_current_user
from ..services import db_schema from web.backend.services import db_schema
router = APIRouter(prefix="/api/tasks", tags=["tasks"]) router = APIRouter(prefix="/api/tasks", tags=["tasks"])
+1 -1
View File
@@ -1,7 +1,7 @@
"""WebSocket endpoint for real-time task progress.""" """WebSocket endpoint for real-time task progress."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query 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 from jose import JWTError
router = APIRouter(tags=["websocket"]) router = APIRouter(tags=["websocket"])