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 dotenv import load_dotenv
from ..core.utils.log_utils import get_logger
from .defaults import DEFAULT_CONFIG
from app.core.utils.log_utils import get_logger
from app.config.defaults import DEFAULT_CONFIG
# 加载 .env 文件
load_dotenv()
View File
+62 -3
View File
@@ -16,9 +16,9 @@ from typing import Dict, List, Optional, Tuple, Callable
import pandas as pd
from ..utils.log_utils import get_logger
from ..utils.file_utils import smart_read_excel
from ...core.handlers.column_mapper import ColumnMapper
from app.core.utils.log_utils import get_logger
from app.core.utils.file_utils import smart_read_excel
from app.core.handlers.column_mapper import ColumnMapper
logger = get_logger(__name__)
@@ -43,6 +43,13 @@ class ProductDatabase:
max_price REAL DEFAULT 0.0,
price_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS missing_barcodes (
barcode TEXT PRIMARY KEY,
name TEXT DEFAULT '',
last_seen TEXT,
source_file TEXT,
count INTEGER DEFAULT 1
);
"""
_NEW_COLUMNS = {
@@ -90,16 +97,68 @@ class ProductDatabase:
def _migrate_schema(self):
conn = self._connect()
try:
# 迁移 products 表
cursor = conn.execute("PRAGMA table_info(products)")
existing_cols = {row[1] for row in cursor.fetchall()}
for col_name, col_type in self._NEW_COLUMNS.items():
if col_name not in existing_cols:
conn.execute(f"ALTER TABLE products ADD COLUMN {col_name} {col_type}")
logger.info(f"数据库迁移: 添加列 {col_name}")
# 确保 missing_barcodes 表存在
conn.execute("""
CREATE TABLE IF NOT EXISTS missing_barcodes (
barcode TEXT PRIMARY KEY,
name TEXT DEFAULT '',
last_seen TEXT,
source_file TEXT,
count INTEGER DEFAULT 1
)
""")
conn.commit()
finally:
conn.close()
# ══════════════════════════════════════════════════════════════
# 缺失条码记录
# ══════════════════════════════════════════════════════════════
def record_missing_barcode(self, barcode: str, name: str = '', source_file: str = ''):
"""记录缺失条码。"""
barcode = str(barcode).strip()
if not barcode:
return
now = datetime.now().isoformat(timespec='seconds')
conn = self._connect()
try:
conn.execute("""
INSERT INTO missing_barcodes (barcode, name, last_seen, source_file, count)
VALUES (?, ?, ?, ?, 1)
ON CONFLICT(barcode) DO UPDATE SET
name = CASE WHEN excluded.name != '' THEN excluded.name ELSE name END,
last_seen = excluded.last_seen,
source_file = excluded.source_file,
count = count + 1
""", (barcode, name, now, os.path.basename(source_file)))
conn.commit()
logger.info(f"已记录缺失条码: {barcode} ({name})")
except Exception as e:
logger.error(f"记录缺失条码失败: {e}")
finally:
conn.close()
def get_missing_barcodes(self, limit: int = 100) -> List[Dict]:
"""获取缺失条码列表。"""
conn = self._connect()
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"SELECT * FROM missing_barcodes ORDER BY last_seen DESC LIMIT ?",
(limit,)).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
# ══════════════════════════════════════════════════════════════
# 导入
# ══════════════════════════════════════════════════════════════
+4 -4
View File
@@ -10,13 +10,13 @@ import os
import json
from typing import Dict, Tuple, Optional, Any, List, Union
from ..utils.log_utils import get_logger
from .handlers.barcode_mapper import BarcodeMapper
from .handlers.unit_converter_handlers import (
from app.core.utils.log_utils import get_logger
from app.core.excel.handlers.barcode_mapper import BarcodeMapper
from app.core.excel.handlers.unit_converter_handlers import (
JianUnitHandler, BoxUnitHandler, TiHeUnitHandler,
GiftUnitHandler, UnitHandler
)
from .validators import ProductValidator
from app.core.excel.validators import ProductValidator
logger = get_logger(__name__)
+2 -2
View File
@@ -7,5 +7,5 @@
from typing import Dict, Any
# 导出所有处理程序类
from .barcode_mapper import BarcodeMapper
from .unit_converter_handlers import JianUnitHandler, BoxUnitHandler, TiHeUnitHandler, GiftUnitHandler, UnitHandler
from app.core.excel.handlers.barcode_mapper import BarcodeMapper
from app.core.excel.handlers.unit_converter_handlers import JianUnitHandler, BoxUnitHandler, TiHeUnitHandler, GiftUnitHandler, UnitHandler
+1 -1
View File
@@ -7,7 +7,7 @@
import logging
from typing import Dict, Optional, Any
from ...utils.log_utils import get_logger
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
@@ -8,7 +8,7 @@ import logging
from typing import Dict, Optional, Any, Tuple, Protocol
from abc import ABC, abstractmethod
from ...utils.log_utils import get_logger
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
+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 datetime import datetime
from ...config.settings import ConfigManager
from ..utils.log_utils import get_logger
from ..handlers.column_mapper import ColumnMapper
from ..utils.file_utils import (
from app.config.settings import ConfigManager
from app.core.utils.log_utils import get_logger
from app.core.handlers.column_mapper import ColumnMapper
from app.core.utils.file_utils import (
ensure_dir,
get_file_extension,
get_files_by_extensions,
load_json,
save_json
)
from ..utils.string_utils import (
from app.core.utils.string_utils import (
clean_string,
clean_barcode,
format_barcode
+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 datetime import datetime
from ...config.settings import ConfigManager
from ..utils.log_utils import get_logger
from ..utils.file_utils import (
from app.config.settings import ConfigManager
from app.core.utils.log_utils import get_logger
from app.core.utils.file_utils import (
ensure_dir,
get_file_extension,
get_latest_file,
load_json,
save_json
)
from ..utils.string_utils import (
from app.core.utils.string_utils import (
clean_string,
extract_number,
format_barcode,
parse_monetary_string
)
from .converter import UnitConverter
from ..handlers.column_mapper import ColumnMapper
from app.core.excel.converter import UnitConverter
from app.core.handlers.column_mapper import ColumnMapper
logger = get_logger(__name__)
@@ -40,15 +40,19 @@ class ExcelProcessor:
提取条码、单价和数量,并按照采购单模板的格式填充
"""
def __init__(self, config, product_db=None):
def __init__(self, config, product_db=None, missing_barcodes_cb: Optional[Callable[[List[str]], None]] = None):
"""
初始化Excel处理器
Args:
config: 配置信息
product_db: 商品数据库实例(可选,由外部传入以共享)
missing_barcodes_cb: 缺失条码的回调函数,接收条码列表
"""
self.config = config
self.missing_barcodes_cb = missing_barcodes_cb
self.current_missing_barcodes = [] # 记录当前文件处理中缺失的条码
self.current_file_path = "" # 记录当前处理的文件路径
# 修复ConfigParser对象没有get_path方法的问题
try:
@@ -62,7 +66,7 @@ class ExcelProcessor:
logger.warning(f"模板文件不存在: {self.template_path}")
# 设置缓存文件路径
self.cache_file = os.path.join(self.output_dir, "processed_files.json")
self.cache_file = os.path.join(self.output_dir, "excel_process_records.json")
self.processed_files = self._load_processed_files()
# 确保目录存在
@@ -80,7 +84,7 @@ class ExcelProcessor:
if product_db is not None:
self.product_db = product_db
else:
from ..db.product_db import ProductDatabase
from app.core.db.product_db import ProductDatabase
db_path = config.get_path('Paths', 'product_db', fallback='data/product_cache.db') if hasattr(config, 'get_path') else 'data/product_cache.db'
tpl_folder = config.get('Paths', 'template_folder', fallback='templates')
item_data = config.get('Templates', 'item_data', fallback='商品资料.xlsx')
@@ -220,6 +224,21 @@ class ExcelProcessor:
# 跳过空条码行
if not product['barcode']:
continue
# 检查条码是否存在于数据库(商品资料)
bc = product['barcode']
mem = self.product_db.get_memory(bc)
if not mem or mem.get('confidence', 0) < 50:
# 如果不存在,或者置信度低(说明不是来自商品资料模板),记录为缺失
if bc not in self.current_missing_barcodes:
self.current_missing_barcodes.append(bc)
# 记录到数据库
self.product_db.record_missing_barcode(
bc,
product.get('name', ''),
self.current_file_path
)
logger.warning(f"条码缺失: {bc} ({product.get('name', '')})")
# 检查备注列,过滤换货、退货、作废等非采购行
skip_row = False
@@ -606,6 +625,9 @@ class ExcelProcessor:
if not os.path.exists(file_path):
logger.error(f"文件不存在: {file_path}")
return None
self.current_missing_barcodes = [] # 重置缺失列表
self.current_file_path = file_path # 设置当前处理文件路径
try:
# 读取Excel文件时不立即指定表头
@@ -672,6 +694,14 @@ class ExcelProcessor:
# 不再自动打开输出目录
logger.info(f"采购单已保存到: {output_file}")
# 处理完成,如果有缺失条码,触发回调
if self.current_missing_barcodes and self.missing_barcodes_cb:
try:
self.missing_barcodes_cb(self.current_missing_barcodes)
except Exception as e:
logger.error(f"触发缺失条码回调失败: {e}")
if progress_cb:
try:
progress_cb(100)
+2 -2
View File
@@ -8,8 +8,8 @@ import re
import logging
from typing import Dict, Any, Optional, List, Tuple, Union
from ..utils.log_utils import get_logger
from ..utils.string_utils import parse_monetary_string
from app.core.utils.log_utils import get_logger
from app.core.utils.string_utils import parse_monetary_string
logger = get_logger(__name__)
+3 -3
View File
@@ -2,8 +2,8 @@
数据处理handlers模块初始化文件
"""
from .data_cleaner import DataCleaner
from .column_mapper import ColumnMapper
from .calculator import DataCalculator
from app.core.handlers.data_cleaner import DataCleaner
from app.core.handlers.column_mapper import ColumnMapper
from app.core.handlers.calculator import DataCalculator
__all__ = ['DataCleaner', 'ColumnMapper', 'DataCalculator']
+1 -1
View File
@@ -7,7 +7,7 @@
import pandas as pd
import numpy as np
from typing import Dict, Any, Optional, List, Union
from ...core.utils.log_utils import get_logger
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
+3 -3
View File
@@ -7,7 +7,7 @@
import re
import pandas as pd
from typing import Dict, Any, Optional, List, Union
from ...core.utils.log_utils import get_logger
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
@@ -22,7 +22,7 @@ class ColumnMapper:
STANDARD_COLUMNS = {
'barcode': [
'条码', '条形码', '商品条码', '商品条形码', '产品条码', '商品编码',
'商品编号', '条码(必填)', '电脑条码', '条码ID',
'商品编号', '条码(必填)', '电脑条码', '条码ID', '单品条码',
'barcode', 'Barcode', 'BarCode', 'code', '编码',
],
'name': [
@@ -363,7 +363,7 @@ class ColumnMapper:
"""
header_keywords = [
'条码', '条形码', '商品条码', '商品名称', '名称', '规格',
'单价', '数量', '金额', '单位', '必填', '编码',
'单价', '数量', '金额', '单位', '必填', '编码', '单品条码', '序号',
]
best_row = -1
+1 -1
View File
@@ -6,7 +6,7 @@
import pandas as pd
from typing import Dict, Any, Optional, List, Union
from ...core.utils.log_utils import get_logger
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
+127 -4
View File
@@ -9,7 +9,7 @@ import base64
import requests
from typing import Dict, Optional, Union, List
from ..utils.log_utils import get_logger
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
@@ -229,10 +229,19 @@ class BaiduOCRClient:
logger.debug(f"百度OCR API返回结果: {result}")
if 'error_code' in result:
error_code = result.get('error_code')
error_msg = result.get('error_msg', '未知错误')
# 如果是 QPS 限制 (18),增加延迟并重试
if error_code == 18:
wait_time = self.retry_delay * (attempt + 1)
logger.warning(f"触发 QPS 限制,将在 {wait_time} 秒后重试 (尝试 {attempt+1}/{self.max_retries})")
time.sleep(wait_time)
continue
logger.error(f"百度OCR API错误: {error_msg}")
# 如果是授权错误,尝试刷新令牌
if result.get('error_code') in [110, 111]: # 授权相关错误码
if error_code in [110, 111]: # 授权相关错误码
logger.info("尝试刷新访问令牌...")
self.token_manager.refresh_token()
return None
@@ -292,8 +301,18 @@ class BaiduOCRClient:
if response.status_code == 200:
result = response.json()
if 'error_code' in result:
logger.warning(f"通用识别错误: {result.get('error_msg')}")
if result.get('error_code') in (110, 111):
error_code = result.get('error_code')
error_msg = result.get('error_msg', '未知错误')
# 如果是 QPS 限制 (18),增加延迟并重试
if error_code == 18:
wait_time = self.retry_delay * (attempt + 1)
logger.warning(f"触发 QPS 限制 (General),将在 {wait_time} 秒后重试 (尝试 {attempt+1}/{self.max_retries})")
time.sleep(wait_time)
continue
logger.warning(f"通用识别错误: {error_msg}")
if error_code in (110, 111):
self.token_manager.refresh_token()
return None
words_list = result.get('words_result') or []
@@ -306,6 +325,110 @@ class BaiduOCRClient:
time.sleep(self.retry_delay * (2 ** attempt))
logger.error("通用识别失败")
return None
def recognize_ticket(self, image_data: Union[str, bytes]) -> Optional[List[Dict]]:
"""通用票务识别:用于精准捕获表头抬头、日期等关键信息。
使用用户指定的 URL: https://aip.baidubce.com/rest/2.0/ocr/v1/general_ocr
Returns:
[{'words': '...'}, ...]
"""
access_token = self.token_manager.get_token()
if not access_token:
logger.error("无法获取访问令牌,无法进行票务识别")
return None
if isinstance(image_data, str):
image_data = self.read_image(image_data)
if image_data is None:
return None
# 使用用户指定的通用票务识别接口
url = self.config.get('API', 'ticket_ocr_url',
fallback='https://aip.baidubce.com/rest/2.0/ocr/v1/general_ocr')
url = f"{url}?access_token={access_token}"
image_base64 = base64.b64encode(image_data).decode('utf-8')
payload = {
'image': image_base64,
'detect_direction': 'true', # 开启方向检测,应对旋转的图片
}
headers = {'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'}
for attempt in range(self.max_retries):
try:
response = requests.post(url, data=payload, headers=headers,
timeout=self.timeout)
if response.status_code == 200:
result = response.json()
if 'error_code' in result:
error_code = result.get('error_code')
error_msg = result.get('error_msg', '未知错误')
# 如果是 QPS 限制 (18),增加延迟并重试
if error_code == 18:
wait_time = self.retry_delay * (attempt + 1)
logger.warning(f"触发 QPS 限制 (Ticket),将在 {wait_time} 秒后重试 (尝试 {attempt+1}/{self.max_retries})")
time.sleep(wait_time)
continue
# 如果是权限错误 (6),记录详细日志告知用户
if error_code == 6:
logger.error("权限错误 (6): 您的百度 API Key 未开启【通用卡证票据识别】(General OCR) 服务。请前往百度云控制台手动开启该服务,否则无法精准识别表头。")
logger.warning(f"票务识别错误: {error_msg}")
if error_code in (110, 111):
self.token_manager.refresh_token()
return None
# 1. 优先处理 results 结构 (通用卡证票据识别的新结构)
if 'results' in result and isinstance(result['results'], dict):
extracted = []
# 通常只有一个结果 "0"
for res_id, res_content in result['results'].items():
if not isinstance(res_content, dict): continue
for k, v_list in res_content.items():
if isinstance(v_list, list):
for item in v_list:
if isinstance(item, dict) and 'words' in item:
words_val = item['words']
if isinstance(words_val, list):
for w in words_val:
extracted.append({'words': f"{k}: {w}"})
else:
extracted.append({'words': f"{k}: {words_val}"})
return extracted
# 2. 通用票据/票务识别通常也返回 words_result
words_list = result.get('words_result')
# 如果返回的是结构化字典,提取其中的文字行
if isinstance(words_list, dict):
extracted = []
for k, v in words_list.items():
if isinstance(v, dict) and 'words' in v:
words_val = v['words']
if isinstance(words_val, list):
for w in words_val:
extracted.append({'words': f"{k}: {w}"})
else:
extracted.append({'words': f"{k}: {words_val}"})
elif isinstance(v, str):
extracted.append({'words': f"{k}: {v}"})
return extracted
return words_list or []
logger.warning(f"票务识别请求失败 (尝试 {attempt+1}): {response.text[:200]}")
except Exception as e:
logger.warning(f"票务识别异常 (尝试 {attempt+1}): {e}")
if attempt < self.max_retries - 1:
time.sleep(self.retry_delay * (2 ** attempt))
logger.error("票务识别失败")
return None
def get_excel_result(self, request_id_or_result: Union[str, Dict]) -> Optional[bytes]:
"""
+150 -13
View File
@@ -16,6 +16,14 @@ from typing import List, Optional, Tuple
SUPPLIER_KEYWORDS = (
"供货单", "供应商", "供货方", "批发", "酒行", "商行",
"经销", "专卖店", "配送单", "送货单", "采购单", "订单",
"销售单", "出库单", "经营部", "商贸", "有限公司", "发证机构",
)
# 供应商名称中需要剔除的冗余噪声词
SUPPLIER_NOISE_WORDS = (
"标题", "采购单", "销售单", "入库单", "出库单", "送货单",
"单据", "订单", "配送单", "清单", "供货单", "预览", "详情",
"打印", "副本", "记账", "", "存根",
)
# 总金额关键词(命中后取该行最近一个金额数字)
@@ -24,6 +32,11 @@ AMOUNT_KEYWORDS = (
"合计", "总计", "总计金额",
)
# 日期关键词(用于辅助定位日期)
DATE_KEYWORDS = (
"单据日期", "下单时间", "日期", "时间", "开单日期", "制单日期", "打印时间", "业务日期", "下单日期",
)
# 日期正则:4 种格式
DATE_PATTERNS = [
# 2026年07月17日 / 2026年7月17日
@@ -52,6 +65,16 @@ class OrderMetadata:
return asdict(self)
# 排除关键词:包含这些词的行绝对不是供应商抬头
EXCLUDE_KEYWORDS = (
"购货单位", "客户名称", "收货地址", "联系电话", "经手人",
"地址", "电话", "传真", "邮编", "网址", "开户行", "账号",
"税号", "业务员", "联系人", "单据编号", "流水号", "页码",
"四川省", "成都市", "武侯区", "双流区", "高新区", "金牛区",
"成华区", "锦江区", "龙泉驿区", "青羊区", "新都区", "温江区",
"街道", "社区", "", "", "", "", "", "",
)
class OrderMetadataExtractor:
"""单据元信息识别器。"""
@@ -61,20 +84,35 @@ class OrderMetadataExtractor:
# 供应商名最大长度
MAX_SUPPLIER_LEN = 50
def extract(self, ocr_text: str, ocr_rows: Optional[List[List[str]]] = None) -> OrderMetadata:
def extract(self, ocr_text: str, ocr_rows: Optional[List[List[str]]] = None, general_text: Optional[str] = None) -> OrderMetadata:
"""从 OCR 原始文本和/或解析后的二维数组提取三字段。
Args:
ocr_text: OCR 原始字符串全文(带换行
ocr_rows: 解析后的二维数组(可选),用于 row-level 精确匹配
ocr_text: OCR 原始字符串全文(从表格 OCR 提取
ocr_rows: 解析后的二维数组(可选)
general_text: 从通用票据识别接口 (/v1/general_ocr) 提取的文本,优先级最高
Returns:
OrderMetadata
"""
text = ocr_text or ''
supplier, raw_supplier = self._extract_supplier(text, ocr_rows)
bill_date = self._extract_bill_date(text)
total_amount = self._extract_total_amount(text, ocr_rows)
# 优先使用通用票据识别的文本进行供应商和日期提取
supplier_source = general_text if general_text else ocr_text
date_source = general_text if general_text else ocr_text
# 金额通常在表格内,优先使用 ocr_text
amount_source = ocr_text or general_text or ''
supplier, raw_supplier = self._extract_supplier(supplier_source, ocr_rows)
bill_date = self._extract_bill_date(date_source)
total_amount = self._extract_total_amount(amount_source, ocr_rows)
# 如果通用票据识别没抓到供应商,尝试用表格 OCR 的文本补位
if not supplier and general_text and ocr_text:
supplier, raw_supplier = self._extract_supplier(ocr_text, ocr_rows)
# 如果通用票据识别没抓到日期,尝试用表格 OCR 的文本补位
if not bill_date and general_text and ocr_text:
bill_date = self._extract_bill_date(ocr_text)
return OrderMetadata(
supplier=supplier,
@@ -95,9 +133,15 @@ class OrderMetadataExtractor:
cleaned = self._clean_supplier_line(line)
if not cleaned:
continue
# 严格排除:购货单位、地址、电话等干扰项
if any(k in cleaned for k in EXCLUDE_KEYWORDS):
continue
for kw in SUPPLIER_KEYWORDS:
if kw in cleaned:
return self._truncate(cleaned), cleaned
final_name = self._final_cleanup_supplier(cleaned)
return self._truncate(final_name), cleaned
# 2) 兜底:取顶部第一个"含中文且无数字行号"且长度 ≥ 4 的非空行
# 但排除"纯日期行"(避免把日期当供应商)
@@ -105,6 +149,11 @@ class OrderMetadataExtractor:
cleaned = self._clean_supplier_line(line)
if not cleaned:
continue
# 兜底也要严格排除地址和电话行
if any(k in cleaned for k in EXCLUDE_KEYWORDS):
continue
if not (re.search(r'[\u4e00-\u9fa5]', cleaned) and len(cleaned) >= 4):
continue
# 排除:纯日期(YYYY-MM-DD / YYYY/MM/DD / YYYY年MM月DD日 / YYYYMMDD
@@ -117,16 +166,63 @@ class OrderMetadataExtractor:
# 排除:以"单据"开头的行
if re.match(r'^\s*单据[:]?', cleaned):
continue
return self._truncate(cleaned), cleaned
final_name = self._final_cleanup_supplier(cleaned)
if final_name:
return self._truncate(final_name), cleaned
return '', ''
@staticmethod
def _final_cleanup_supplier(s: str) -> str:
"""最后的供应商名称深度清理:去除“标题”、“采购单”等噪声。"""
if not s:
return ''
# 1. 统一处理全角和常见符号
s = s.replace('', ':').replace('', '(').replace('', ')')
# 2. 去除“标题:”或“名称:”这类前缀
s = re.sub(r'^(标题|名称|供应商|供货方|单位|商户)[:\s]*', '', s)
# 3. 循环去除噪声词
noise_sorted = sorted(SUPPLIER_NOISE_WORDS, key=len, reverse=True)
changed = True
while changed:
original = s
for noise in noise_sorted:
# 简单替换所有匹配的噪声词
s = s.replace(noise, '')
# 去除括号及其中间的噪声词(如 (采购单) )
s = re.sub(r'\(\s*\)', '', s)
# 去除首尾残留标点和空白
s = re.sub(r'^[:\s\-_\|\.\(\)]+', '', s)
s = re.sub(r'[:\s\-_\|\.\(\)]+$', '', s)
changed = (s != original)
return s.strip()
@staticmethod
def _clean_supplier_line(line: str) -> str:
"""清理一行文本:去前后空白、去首尾日期/编号/电话/标点。"""
s = line.strip()
if not s:
return ''
# 统一全角冒号
s = s.replace('', ':')
# 如果是“标题: 新双利采购单”,保留冒号后面的部分进行初步处理
if ':' in s:
parts = s.split(':', 1)
# 如果冒号前面是“标题”、“名称”等词,则取后面
if any(k in parts[0] for k in ("标题", "名称", "供应商", "商户")):
s = parts[1].strip()
# 去行首日期/编号前缀
s = re.sub(r'^[\s\d\-\.\/年月日:]+', '', s)
# 去行尾标点
@@ -142,13 +238,54 @@ class OrderMetadataExtractor:
def _extract_bill_date(self, text: str) -> str:
"""提取单据日期,标准化为 YYYYMMDD。"""
if not text:
return ''
lines = text.splitlines()
# 1. 优先尝试关键词定位逻辑(如用户建议:搜索“时间”或“日期”)
for i, line in enumerate(lines):
line_clean = line.strip().replace('', ':')
# 检查是否包含日期相关关键词
for kw in DATE_KEYWORDS:
if kw in line_clean:
# a) 尝试在当前行找日期
for pat in DATE_PATTERNS:
m = pat.search(line_clean)
if m:
y, mo, d = m.group(1), m.group(2), m.group(3)
if self._is_valid_date(y, mo, d):
return f"{y}{int(mo):02d}{int(d):02d}"
# b) 如果当前行没找到,尝试在下一行找(针对表格 OCR 错位情况)
if i + 1 < len(lines):
next_line = lines[i+1].strip()
for pat in DATE_PATTERNS:
m = pat.search(next_line)
if m:
y, mo, d = m.group(1), m.group(2), m.group(3)
if self._is_valid_date(y, mo, d):
return f"{y}{int(mo):02d}{int(d):02d}"
# 2. 兜底策略:全文正则匹配
for pat in DATE_PATTERNS:
m = pat.search(text)
if m:
# 优先找包含 202 开头的年份(更像当前日期)
matches = pat.finditer(text)
for m in matches:
y, mo, d = m.group(1), m.group(2), m.group(3)
if self._is_valid_date(y, mo, d):
return f"{y}{int(mo):02d}{int(d):02d}"
return ''
# 如果年份以 202 开头,优先返回
if y.startswith('202'):
return f"{y}{int(mo):02d}{int(d):02d}"
# 否则记录下来作为候选
last_valid = f"{y}{int(mo):02d}{int(d):02d}"
# 如果没有 202 开头的,返回最后一个有效的
try:
return last_valid
except NameError:
return ''
@staticmethod
def _is_valid_date(y: str, m: str, d: str) -> bool:
+11 -7
View File
@@ -10,8 +10,8 @@ import base64
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List, Optional, Tuple, Callable
from ..utils.log_utils import get_logger
from ..utils.file_utils import (
from app.core.utils.log_utils import get_logger
from app.core.utils.file_utils import (
ensure_dir,
get_file_extension,
get_files_by_extensions,
@@ -20,6 +20,7 @@ from ..utils.file_utils import (
load_json,
save_json
)
from app.config.settings import ConfigManager
from .baidu_ocr import BaiduOCRClient
logger = get_logger(__name__)
@@ -102,14 +103,16 @@ class OCRProcessor:
OCR处理器,负责协调OCR识别和结果处理
"""
def __init__(self, config):
def __init__(self, config: Optional[ConfigManager] = None):
"""
初始化OCR处理器
Args:
config: 配置信息
config: 配置管理器
"""
self.config = config
self.config = config or ConfigManager()
self.ocr_client = None
self._ensure_ocr_client()
# 修复ConfigParser对象没有get_path方法的问题
try:
@@ -348,9 +351,10 @@ class OCRProcessor:
if max_workers is None:
try:
max_workers = self.config.getint('Performance', 'max_workers', fallback=4)
# 强制设为 1 以严格遵守百度 QPS=2 的限制(一张图调两个接口就满了)
max_workers = self.config.getint('Performance', 'max_workers', fallback=1)
except Exception:
max_workers = 4
max_workers = 1
# 获取未处理的图片
unprocessed_images = self.get_unprocessed_images()
+3 -3
View File
@@ -2,8 +2,8 @@
处理器模块初始化文件
"""
from .base import BaseProcessor
from .ocr_processor import OCRProcessor
from .tobacco_processor import TobaccoProcessor
from app.core.processors.base import BaseProcessor
from app.core.processors.ocr_processor import OCRProcessor
from app.core.processors.tobacco_processor import TobaccoProcessor
__all__ = ['BaseProcessor', 'OCRProcessor', 'TobaccoProcessor']
+1 -1
View File
@@ -10,7 +10,7 @@ from pathlib import Path
import logging
import pandas as pd
from ...core.utils.log_utils import get_logger
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
+4 -4
View File
@@ -8,10 +8,10 @@ import os
from pathlib import Path
from typing import Optional, Dict, Any, List
from .base import BaseProcessor
from ...services.ocr_service import OCRService
from ...services.order_service import OrderService
from ...core.utils.log_utils import get_logger
from app.core.processors.base import BaseProcessor
from app.services.ocr_service import OCRService
from app.services.order_service import OrderService
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
@@ -2,6 +2,6 @@
供应商处理器模块初始化文件
"""
from .generic_supplier_processor import GenericSupplierProcessor
from app.core.processors.supplier_processors.generic_supplier_processor import GenericSupplierProcessor
__all__ = ['GenericSupplierProcessor']
@@ -9,12 +9,12 @@ import pandas as pd
from typing import Optional, Dict, Any, List
from pathlib import Path
from ..base import BaseProcessor
from ...utils.log_utils import get_logger
from ...handlers.rule_engine import apply_rules
from ...handlers.column_mapper import ColumnMapper
from ...handlers.data_cleaner import DataCleaner
from ...handlers.calculator import DataCalculator
from app.core.processors.base import BaseProcessor
from app.core.utils.log_utils import get_logger
from app.core.handlers.rule_engine import apply_rules
from app.core.handlers.column_mapper import ColumnMapper
from app.core.handlers.data_cleaner import DataCleaner
from app.core.handlers.calculator import DataCalculator
logger = get_logger(__name__)
+4 -4
View File
@@ -14,10 +14,10 @@ from openpyxl import load_workbook
from typing import Optional, Dict, Any, List, Tuple
from pathlib import Path
from .base import BaseProcessor
from ...core.utils.log_utils import get_logger
from ...core.utils.string_utils import parse_monetary_string
from ...core.utils.dialog_utils import show_custom_dialog
from app.core.processors.base import BaseProcessor
from app.core.utils.log_utils import get_logger
from app.core.utils.string_utils import parse_monetary_string
from app.core.utils.dialog_utils import show_custom_dialog
logger = get_logger(__name__)
+1 -1
View File
@@ -6,7 +6,7 @@ from typing import Optional, Tuple
import requests
from .log_utils import get_logger
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
+6 -6
View File
@@ -13,7 +13,7 @@ import tkinter as tk
from tkinter import messagebox, ttk, simpledialog
from datetime import datetime
from .cloud_sync import GiteaSync
from app.core.utils.cloud_sync import GiteaSync
from app.config.settings import ConfigManager
def create_custom_dialog(title="提示", message="", result_file=None, time_info=None,
@@ -82,7 +82,7 @@ def create_custom_dialog(title="提示", message="", result_file=None, time_info
file_size = os.path.getsize(result_file)
file_time = datetime.fromtimestamp(os.path.getmtime(result_file))
from .file_utils import format_file_size
from app.core.utils.file_utils import format_file_size
size_text = format_file_size(file_size)
tk.Label(file_frame, text=f"文件大小: {size_text}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
@@ -831,10 +831,10 @@ SYNC_FILES = [
"type": "binary",
},
{
"name": "商品记忆库",
"remote": "product_memory.json",
"local": "data/product_memory.json",
"type": "json",
"name": "商品记忆库 (DB)",
"remote": "product_cache.db",
"local": "data/product_cache.db",
"type": "binary",
},
]
+1 -1
View File
@@ -12,7 +12,7 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Union, Any
from .log_utils import get_logger
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
+5 -4
View File
@@ -11,8 +11,8 @@ import os
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from ..config.settings import ConfigManager
from ..core.utils.log_utils import get_logger
from app.config.settings import ConfigManager
from app.core.utils.log_utils import get_logger
from .ocr_service import OCRService
from .order_service import OrderService
@@ -25,10 +25,11 @@ IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.bmp', '.webp', '.tif', '.tiff'}
class BatchService:
"""批量处理服务。"""
def __init__(self, config: Optional[ConfigManager] = None):
def __init__(self, config: Optional[ConfigManager] = None, missing_barcodes_cb: Optional[Callable[[List[str]], None]] = None):
self.config = config or ConfigManager()
self.missing_barcodes_cb = missing_barcodes_cb
self.ocr_service = OCRService(self.config)
self.order_service = OrderService(self.config)
self.order_service = OrderService(self.config, missing_barcodes_cb=self.missing_barcodes_cb)
self._input_folder = self.config.get_path(
'Paths', 'input_folder', fallback='data/input', create=True
)
+27 -21
View File
@@ -7,9 +7,9 @@ OCR服务模块
from typing import Dict, List, Optional, Tuple, Union, Any, Callable
import os
from ..config.settings import ConfigManager
from ..core.utils.log_utils import get_logger
from ..core.ocr.table_ocr import OCRProcessor
from app.config.settings import ConfigManager
from app.core.utils.log_utils import get_logger
from app.core.ocr.table_ocr import OCRProcessor
logger = get_logger(__name__)
@@ -62,16 +62,10 @@ class OCRService:
if not self._is_valid_image(image_path):
logger.error(f"不支持的文件类型: {image_path}")
return None
# 检查是否已处理
excel_file = self._get_excel_path(image_path)
if os.path.exists(excel_file):
logger.info(f"文件已处理过,跳过OCR识别: {image_path}")
# 即使 xlsx 已存在,仍补写 meta.json(首次 OCR 后可能未生成)
meta_path = str(excel_file).replace('.xlsx', '.meta.json')
if not os.path.exists(meta_path):
self._write_meta_json(str(excel_file), {}, image_path)
return excel_file
# 不再做 xlsx 已存在就跳过的判断(xlsx 可能被业务层重命名过),
# 跳过逻辑交给 OCRProcessor 内部的 record_manager.is_processed(image_path)
# —— 由它读 processed_files.json(业务层会同步更新 xlsx 路径)
# 执行OCR识别
result = self.ocr_processor.process_image(image_path)
@@ -212,24 +206,36 @@ class OCRService:
base = Path(excel_path)
meta_path = base.with_suffix('.meta.json')
# 0) 优先:调百度通用文字识别(/accurate),覆盖全图文字(含手写抬头/日期)
# 0) 优先:调百度通用票务识别(/general_ocr),精准捕获表头(含手写抬头/日期)
general_text = ''
general_lines = []
try:
# OCRService.ocr_processor = core.ocr.table_ocr.OCRProcessor
# 其 .ocr_client = BaiduOCRClient (process_image 时初始化)
client = getattr(self.ocr_processor, 'ocr_client', None)
if client is None:
# 显式触发一次 process_image 准备流程 (不会重复 OCR)
try:
self.ocr_processor._ensure_ocr_client()
except Exception:
pass
client = getattr(self.ocr_processor, 'ocr_client', None)
if client and hasattr(client, 'recognize_general') and image_path and os.path.exists(str(image_path)):
# 关键:在进行第二次 OCR (票务识别) 前稍微等待,避免触发 QPS 限制
import time
time.sleep(0.5)
# 优先使用票务识别 (Ticket OCR)
if client and hasattr(client, 'recognize_ticket') and image_path and os.path.exists(str(image_path)):
logger.info("使用通用票务识别捕获表头元数据...")
words = client.recognize_ticket(str(image_path))
if words:
general_lines = [(w.get('words') or '').strip() for w in words if (w.get('words') or '').strip()]
general_text = '\n'.join(general_lines)
logger.info(f"票务识别获取 {len(general_lines)} 行文字")
# 如果票务识别无结果或不可用,则兜底使用高精度文字识别
if not general_lines and client and hasattr(client, 'recognize_general') and image_path and os.path.exists(str(image_path)):
logger.info("尝试高精度文字识别作为兜底...")
words = client.recognize_general(str(image_path))
if words:
# 按 location.top 排序(顶部先),方便后续提取供应商/日期
def _top(w):
loc = w.get('location') or {}
try:
@@ -239,9 +245,9 @@ class OCRService:
words_sorted = sorted(words, key=_top)
general_lines = [(w.get('words') or '').strip() for w in words_sorted if (w.get('words') or '').strip()]
general_text = '\n'.join(general_lines)
logger.info(f"通用识别获取 {len(general_lines)} 行文字")
logger.info(f"通用高精度识别获取 {len(general_lines)} 行文字")
except Exception as e:
logger.warning(f"通用识别失败(不影响主流程): {e}")
logger.warning(f"获取元数据识别失败(不影响主流程): {e}")
# 1) 表格识别 header/body 拼表内文字(已在前面逻辑处理)
ocr_text = ''
+222 -44
View File
@@ -12,13 +12,13 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union, Any, Callable
from ..config.settings import ConfigManager
from ..core.utils.log_utils import get_logger
from ..core.excel.processor import ExcelProcessor
from ..core.excel.merger import PurchaseOrderMerger
from ..core.db.product_db import ProductDatabase
from ..core.db.order_metadata_db import OrderMetadataDB
from ..core.ocr.metadata_extractor import OrderMetadataExtractor, sanitize_for_filename
from app.config.settings import ConfigManager
from app.core.utils.log_utils import get_logger
from app.core.excel.processor import ExcelProcessor
from app.core.excel.merger import PurchaseOrderMerger
from app.core.db.product_db import ProductDatabase
from app.core.db.order_metadata_db import OrderMetadataDB
from app.core.ocr.metadata_extractor import OrderMetadataExtractor, sanitize_for_filename
logger = get_logger(__name__)
@@ -27,15 +27,17 @@ class OrderService:
订单服务协调Excel处理和订单合并流程
"""
def __init__(self, config: Optional[ConfigManager] = None):
def __init__(self, config: Optional[ConfigManager] = None, missing_barcodes_cb: Optional[Callable[[List[str]], None]] = None):
"""
初始化订单服务
Args:
config: 配置管理器如果为None则创建新的
missing_barcodes_cb: 缺失条码的回调函数
"""
logger.info("初始化OrderService")
self.config = config or ConfigManager()
self.missing_barcodes_cb = missing_barcodes_cb
# 创建共享的商品数据库实例
db_path = self.config.get_path('Paths', 'product_db', fallback='data/product_cache.db') if hasattr(self.config, 'get_path') else 'data/product_cache.db'
@@ -45,7 +47,11 @@ class OrderService:
self.product_db = ProductDatabase(db_path, tpl_path)
# 创建Excel处理器和采购单合并器
self.excel_processor = ExcelProcessor(self.config, product_db=self.product_db)
self.excel_processor = ExcelProcessor(
self.config,
product_db=self.product_db,
missing_barcodes_cb=self.missing_barcodes_cb
)
self.order_merger = PurchaseOrderMerger(self.config)
# 元信息识别器 + 单据元信息库
@@ -291,15 +297,16 @@ class OrderService:
if meta_path.exists():
try:
payload = json.loads(meta_path.read_text(encoding='utf-8'))
# 优先用通用识别文本(含手写抬头/日期)
ocr_text = payload.get('general_text') or payload.get('ocr_text', '') or ''
# 区分表格 OCR 文本和通用识别文本
ocr_text = payload.get('ocr_text', '') or ''
general_text = payload.get('general_text', '') or ''
ocr_rows = payload.get('ocr_rows', []) or []
source_image = payload.get('image_path', '') or ''
except Exception as e:
logger.warning(f"读 meta.json 失败: {e}")
# 兜底:从 xlsx 拼文本(与 OCRService._write_meta_json 的兜底一致)
if not ocr_text:
if not ocr_text and not general_text:
try:
import xlrd
rb = xlrd.open_workbook(str(ocr_excel_path))
@@ -313,7 +320,7 @@ class OrderService:
except Exception as e:
logger.debug(f"从 xlsx 拼 OCR 文本失败: {e}")
meta = self.extractor.extract(ocr_text, ocr_rows)
meta = self.extractor.extract(ocr_text, ocr_rows, general_text=general_text)
self.metadata_db.save(
file_hash=file_hash,
supplier=meta.supplier,
@@ -334,49 +341,77 @@ class OrderService:
def _apply_metadata_to_filenames(self, result_path: str,
ocr_excel_path: str,
meta) -> str:
"""应用新文件名规则:
- result: 采购单_{YYYYMMDD}_{供应商}_{hash}.xls
- 原图: {原stem}_{YYYYMMDD}_{供应商}_{hash}.{ext}
"""应用新文件名规则(按供应商+日期)
- result (xls){供应商}_{日期}.xls
- output xlsx {供应商}_{日期}.xlsx
- 原图 {供应商}_{日期}.{ext}
冲突时加 _2 / _3 ...任一步骤失败不影响整体
任一步骤失败不影响 result 文件本身
Returns:
result 路径无论重命名是否成功都返回失败时返回原路径
"""
try:
file_hash = Path(ocr_excel_path).stem
# 1. 提取并清理元数据
supplier_clean = sanitize_for_filename(meta.supplier) or '未知供应商'
date_part = meta.bill_date or '未知日期'
# ── 1. result 重命名 ──
new_result_name = f"采购单_{date_part}_{supplier_clean}_{file_hash}.xls"
result_dir = Path(result_path).parent
new_result_path = result_dir / new_result_name
try:
# 冲突时加 _N
if new_result_path.exists() and str(new_result_path) != str(result_path):
new_result_path = self._dedup_path(new_result_path)
if str(new_result_path) != str(result_path):
os.rename(result_path, str(new_result_path))
logger.info(f"result 重命名: {result_path} -> {new_result_path}")
result_path = str(new_result_path)
except Exception as e:
logger.warning(f"result 重命名失败: {e}")
# 2. 构造新的基础文件名:[供应商]_[日期]
# 按照用户要求:按照供应商名称加日期进行修改,且不含“采购单”等冗余字眼
base_name = f"{supplier_clean}_{date_part}"
# ── 2. 原图重命名 ──
file_hash = Path(ocr_excel_path).stem # 仅用作 SQLite PK,不入文件名
logger.info(f"生成标准化文件名: {base_name}")
# ── 1. result 重命名 ──
new_result_path = self._safe_rename(
result_path, f"{base_name}.xls"
)
# ── 2. output xlsx 重命名(保证 output/ 与 result/ 文件名一致)──
new_output_path = self._safe_rename(
ocr_excel_path, f"{base_name}.xlsx"
)
# 同步 meta.json
old_meta = Path(ocr_excel_path).with_suffix('.meta.json')
if old_meta.exists():
new_meta = Path(new_output_path).with_suffix('.meta.json')
if str(new_meta) != str(old_meta):
try:
if new_meta.exists(): os.remove(new_meta)
os.rename(old_meta, new_meta)
except Exception as e:
logger.warning(f"meta.json 重命名失败: {e}")
# ── 2.5 同步 processed_files.json(避免下次 OCR 重新识别)──
if str(new_output_path) != str(ocr_excel_path):
self._sync_processed_record(Path(ocr_excel_path), Path(new_output_path))
# 也要同步 ExcelProcessor 的记录
self._update_excel_process_record(Path(ocr_excel_path), Path(new_output_path))
# ── 3. 原图重命名 ──
try:
meta_row = self.metadata_db.get(file_hash)
src_image = (meta_row or {}).get('source_image', '')
# 尝试从 processed_files.json 反查原图路径
src_image = self._find_input_image_for_output(str(ocr_excel_path))
# 如果没找到,再从 DB 拿
if not src_image:
meta_row = self.metadata_db.get(file_hash)
src_image = (meta_row or {}).get('source_image', '')
if src_image and os.path.exists(src_image):
src_p = Path(src_image)
stem = src_p.stem
ext = src_p.suffix
new_image_name = f"{stem}_{date_part}_{supplier_clean}_{file_hash}{ext}"
new_image_path = src_p.parent / new_image_name
# 不覆盖已重命名的图片
if str(new_image_path) != str(src_p) and not new_image_path.exists():
os.rename(src_p, new_image_path)
logger.info(f"原图重命名: {src_p.name} -> {new_image_path.name}")
# 更新 source_image 路径
# 严禁包含原文件名,统一格式: 采购单_YYYYMMDD_供应商.ext
new_image_name = f"{base_name}{ext}"
new_image_path = self._safe_rename(src_image, new_image_name)
# ── 3.5 同步 processed_files.json 的 Key (原图路径) ──
if str(new_image_path) != str(src_p):
self._sync_processed_key(src_p, Path(new_image_path))
# 更新 source_image 路径到 DB
if str(new_image_path) != str(src_p):
self.metadata_db.save(
file_hash=file_hash,
supplier=meta.supplier,
@@ -385,14 +420,47 @@ class OrderService:
raw_supplier_text=meta.raw_supplier_text,
source_image=str(new_image_path),
)
else:
logger.warning(f"找不到原图,跳过重命名: {src_image}")
except Exception as e:
logger.warning(f"原图重命名失败: {e}")
return result_path
return str(new_result_path)
except Exception as e:
logger.error(f"_apply_metadata_to_filenames 失败: {e}")
return result_path
@staticmethod
def _safe_rename(src: str, new_basename: str) -> str:
"""把 src 重命名为 src.parent / new_basename。
- 目标已存在时加 _2 / _3 ... 后缀避免覆盖
- 失败返回原路径不抛异常
"""
try:
src_p = Path(src)
if not src_p.exists():
return src
new_p = src_p.parent / new_basename
if str(new_p) == str(src_p):
return src
if new_p.exists():
# 冲突去重
stem, suffix = new_p.stem, new_p.suffix
n = 2
while True:
cand = src_p.parent / f"{stem}_{n}{suffix}"
if not cand.exists():
new_p = cand
break
n += 1
os.rename(src_p, new_p)
logger.info(f"重命名: {src_p.name} -> {new_p.name}")
return str(new_p)
except Exception as e:
logger.warning(f"重命名失败 {src} -> {new_basename}: {e}")
return src
@staticmethod
def _dedup_path(p: Path) -> Path:
"""路径冲突时加 _N 后缀。"""
@@ -404,3 +472,113 @@ class OrderService:
if not cand.exists():
return cand
n += 1
def _find_input_image_for_output(self, output_xlsx: str) -> Optional[str]:
"""从 processed_files.json 反查生成该 output_xlsx 的原图路径。"""
try:
output_dir = self.config.get_path('Paths', 'output_folder', fallback='data/output')
record_file = os.path.join(output_dir, 'processed_files.json')
if not os.path.exists(record_file):
logger.debug(f"记录文件不存在: {record_file}")
return None
with open(record_file, 'r', encoding='utf-8') as f:
data = json.load(f)
target_output = os.path.normpath(output_xlsx)
found_img = None
# 1. 尝试直接匹配和规范化路径匹配
for img_path, out_path in data.items():
if os.path.normpath(out_path) == target_output:
found_img = img_path
break
if found_img:
if os.path.exists(found_img):
return found_img
# 2. 模糊匹配:如果记录中的路径不存在,尝试在同目录下找 stem 匹配的文件
# (解决因之前重命名导致的路径不一致问题)
img_p = Path(found_img)
parent = img_p.parent
if parent.exists():
stem = img_p.stem
logger.debug(f"尝试模糊匹配原图: stem={stem[:20]}... in {parent}")
# 尝试寻找以原 stem 开头的文件
for cand in parent.iterdir():
if cand.is_file() and cand.stem.startswith(stem[:20]):
logger.info(f"模糊匹配成功: {cand.name}")
return str(cand)
logger.debug("模糊匹配失败")
else:
logger.debug(f"在 processed_files.json 中未找到输出文件 {output_xlsx} 对应的原图记录")
except Exception as e:
logger.debug(f"反查原图路径失败: {e}")
return None
def _sync_processed_record(self, old_output: Path, new_output: Path) -> None:
"""重命名 output xlsx 后,把 processed_files.json 里所有 value 为旧路径的项改成新路径。"""
self._update_processed_json(old_path=old_output, new_path=new_output, is_key=False)
def _sync_processed_key(self, old_input: Path, new_input: Path) -> None:
"""重命名原图后,把 processed_files.json 里的 key 从旧路径改为新路径。"""
self._update_processed_json(old_path=old_input, new_path=new_input, is_key=True)
def _update_excel_process_record(self, old_xlsx: Path, new_xlsx: Path) -> None:
"""重命名 xlsx 后,同步 excel_process_records.json (ExcelProcessor 使用的记录)。"""
self._update_processed_json(
old_path=old_xlsx,
new_path=new_xlsx,
is_key=True,
filename='excel_process_records.json'
)
def _update_processed_json(self, old_path: Path, new_path: Path, is_key: bool = False, filename: str = 'processed_files.json') -> None:
"""更新处理记录 JSON。
Args:
old_path: 旧路径
new_path: 新路径
is_key: True 更新 key, False 更新 value
filename: JSON 文件名
"""
try:
# 确定文件路径
output_dir = self.config.get_path('Paths', 'output_folder', fallback='data/output')
record_file = os.path.join(output_dir, filename)
if not os.path.exists(record_file):
return
with open(record_file, 'r', encoding='utf-8') as f:
data = json.load(f)
old_str = str(old_path)
new_str = str(new_path)
changed = False
# 规范化路径以便匹配
old_norm = os.path.normpath(old_str)
if is_key:
# 更新 Key
for k in list(data.keys()):
if k == old_str or os.path.normpath(k) == old_norm:
data[new_str] = data.pop(k)
changed = True
break
else:
# 更新 Value
for k, v in list(data.items()):
if v == old_str or os.path.normpath(v) == old_norm:
data[k] = new_str
changed = True
if changed:
with open(record_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"{filename} 已同步 ({'Key' if is_key else 'Value'}): {old_str} -> {new_str}")
except Exception as e:
logger.warning(f"更新 {filename} 失败: {e}")
+5 -5
View File
@@ -8,10 +8,10 @@ import logging
from typing import Dict, Any, Optional, List
from pathlib import Path
from ..core.processors.base import BaseProcessor
from ..core.processors.tobacco_processor import TobaccoProcessor
from ..core.processors.ocr_processor import OCRProcessor
from ..core.utils.log_utils import get_logger
from app.core.processors.base import BaseProcessor
from app.core.processors.tobacco_processor import TobaccoProcessor
from app.core.processors.ocr_processor import OCRProcessor
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
@@ -65,7 +65,7 @@ class ProcessorService:
for supplier_config in supplier_configs:
try:
from ..core.processors.supplier_processors.generic_supplier_processor import GenericSupplierProcessor
from app.core.processors.supplier_processors.generic_supplier_processor import GenericSupplierProcessor
processor = GenericSupplierProcessor(self.config, supplier_config)
self.processors.append(processor)
logger.info(f"加载供应商处理器: {processor.name}")
+1 -1
View File
@@ -7,7 +7,7 @@ import time
import pandas as pd
from typing import Optional, Callable
from ..core.utils.log_utils import get_logger
from app.core.utils.log_utils import get_logger
logger = get_logger(__name__)
+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.string_utils import parse_monetary_string
from app.core.utils.dialog_utils import show_custom_dialog # 导入自定义弹窗工具
from ..config.settings import ConfigManager
from app.config.settings import ConfigManager
logger = get_logger(__name__)
+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
def _get_missing_barcodes_callback(log_widget):
"""创建并返回缺失条码的回调函数"""
def callback(missing_barcodes):
if not missing_barcodes:
return
msg = f"发现以下条码不在商品资料中,已记录到数据库:\n\n" + "\n".join([f"{bc}" for bc in missing_barcodes])
# 在主线程中弹出对话框
def show_msg():
messagebox.showwarning("发现缺失条码", msg)
# 尝试使用 log_widget 的 winfo_toplevel() 来调用 after,确保在 UI 线程执行
try:
log_widget.after(0, show_msg)
except Exception:
# 降级处理
show_msg()
return callback
def _ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=False):
"""弹窗询问是否合并采购单,返回合并结果路径或 None。
@@ -138,108 +159,23 @@ def process_single_image_with_status(log_widget, status_bar):
def run_pipeline_directly(log_widget, status_bar):
"""直接运行完整处理流程"""
"""运行完整处理流程:先选择图片,再执行 OCR+Excel 处理"""
if get_running_task() is not None:
messagebox.showinfo("任务进行中", "请等待当前任务完成后再执行新的操作。")
return
def run_in_thread():
set_running_task("pipeline")
# 先选择图片
file_path = select_file(
log_widget,
[("支持文件", "*.jpg *.jpeg *.png *.bmp *.xlsx *.xls"), ("图片文件", "*.jpg *.jpeg *.png *.bmp"), ("Excel文件", "*.xlsx *.xls"), ("所有文件", "*.*")],
"选择待处理文件"
)
if not file_path:
add_to_log(log_widget, "未选择文件,一键处理已取消\n", "warning")
return
if status_bar:
status_bar.set_running(True)
status_bar.set_status("开始完整处理流程...")
start_time = datetime.datetime.now()
start_perf = time.perf_counter()
log_widget.configure(state=tk.NORMAL)
log_widget.delete(1.0, tk.END)
log_widget.insert(tk.END, "执行命令: 完整处理流程\n", "command")
log_widget.insert(tk.END, f"开始时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time")
log_widget.insert(tk.END, "=" * 50 + "\n\n", "separator")
log_widget.configure(state=tk.DISABLED)
try:
config = ConfigManager()
gui_handler = init_gui_logger(log_widget)
ocr_service = OCRService(config)
order_service = OrderService(config)
reporter = ProgressReporter(status_bar)
reporter.running()
reporter.set("开始OCR批量处理...", 10)
total, success = ocr_service.batch_process(progress_cb=lambda p: reporter.set("OCR处理中...", p))
if total == 0:
add_to_log(log_widget, "没有找到需要处理的图片\n", "warning")
if status_bar:
status_bar.set_status("未找到图片文件")
return
elif success == 0:
add_to_log(log_widget, "OCR处理没有成功处理任何新文件\n", "warning")
else:
add_to_log(log_widget, f"OCR处理完成,共处理 {success}/{total} 个文件\n", "success")
try:
processed_map = {}
config = ConfigManager()
pjson = config.get('Paths', 'processed_record', fallback='data/processed_files.json')
if os.path.exists(pjson):
with open(pjson, 'r', encoding='utf-8') as f:
processed_map = json.load(f)
outputs = list(processed_map.values())
for p in outputs[-10:]:
if p:
add_recent_file(os.path.abspath(p))
except Exception as e:
logger.debug(f"加载已处理文件记录失败: {e}")
reporter.set("开始Excel处理...", 92)
add_to_log(log_widget, "开始Excel处理...\n", "info")
result = order_service.process_excel()
if not result:
add_to_log(log_widget, "Excel处理失败\n", "error")
else:
add_to_log(log_widget, "Excel处理完成\n", "success")
try:
add_recent_file(result)
except Exception as e:
logger.debug(f"添加最近文件失败: {e}")
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception as e:
logger.debug(f"单价校验失败: {e}")
reporter.set("检查是否需要合并采购单...", 80)
_ask_and_merge_purchase_orders(order_service, log_widget, add_to_recent=True)
end_time = datetime.datetime.now()
duration_sec = max(0.0, time.perf_counter() - start_perf)
add_to_log(log_widget, f"\n{'=' * 50}\n", "separator")
add_to_log(log_widget, "完整处理流程执行完毕!\n", "success")
add_to_log(log_widget, f"结束时间: {end_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time")
add_to_log(log_widget, f"耗时: {duration_sec:.2f}\n", "time")
reporter.set("处理完成", 100)
except Exception as e:
add_to_log(log_widget, f"执行过程中发生错误: {str(e)}\n", "error")
import traceback
add_to_log(log_widget, f"详细错误信息: {traceback.format_exc()}\n", "error")
finally:
dispose_gui_logger()
reporter.done()
set_running_task(None)
if status_bar:
status_bar.set_running(False)
status_bar.set_status("就绪")
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
# 复用拖拽处理的逻辑,实现“先选图,后全流程”
process_dropped_file(log_widget, status_bar, file_path)
def batch_ocr_with_status(log_widget, status_bar):
@@ -318,7 +254,7 @@ def batch_process_all_inputs(log_widget, status_bar):
from .memory_editor import show_memory_editor # noqa: F401 触发 import 顺序
cfg = ConfigManager()
svc = BatchService(cfg)
svc = BatchService(cfg, missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
def progress(done, total, entry):
pct = int(done / total * 100) if total else 100
@@ -404,7 +340,7 @@ def batch_process_orders_with_status(log_widget, status_bar):
init_gui_logger(log_widget)
order_service = OrderService()
order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
add_to_log(log_widget, "开始Excel处理...\n", "info")
try:
@@ -461,7 +397,7 @@ def merge_orders_with_status(log_widget, status_bar):
init_gui_logger(log_widget)
order_service = OrderService()
order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
result = order_service.merge_all_purchase_orders(progress_cb=lambda p: reporter.set("合并处理中...", p))
@@ -511,7 +447,7 @@ def process_excel_file_with_status(log_widget, status_bar):
init_gui_logger(log_widget)
order_service = OrderService()
order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
if file_path:
try:
@@ -589,7 +525,7 @@ def process_dropped_file(log_widget, status_bar, file_path):
# 步骤2: Excel处理
reporter.set("Excel处理中...", 40)
order_service = OrderService()
order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
result = order_service.process_excel(excel_path, progress_cb=lambda p: reporter.set("Excel处理中...", p))
if not result:
add_to_log(log_widget, "Excel处理失败\n", "error")
@@ -622,7 +558,7 @@ def process_dropped_file(log_widget, status_bar, file_path):
reporter = ProgressReporter(status_bar)
reporter.running()
init_gui_logger(log_widget)
order_service = OrderService()
order_service = OrderService(missing_barcodes_cb=_get_missing_barcodes_callback(log_widget))
add_to_log(log_widget, f"开始一键处理Excel文件: {file_path}\n", "info")
try:
add_recent_file(file_path)
+1 -1
View File
@@ -7,7 +7,7 @@ from tkinter import messagebox
from app.core.excel.converter import UnitConverter
from app.core.utils.dialog_utils import show_barcode_mapping_dialog
from .logging_ui import add_to_log
from app.ui.logging_ui import add_to_log
def edit_barcode_mappings(log_widget):
+2 -2
View File
@@ -12,8 +12,8 @@ import tkinter as tk
from tkinter import messagebox
from threading import Thread
from .logging_ui import LogRedirector
from .result_previews import show_result_preview
from app.ui.logging_ui import LogRedirector
from app.ui.result_previews import show_result_preview
# 任务状态跟踪
_RUNNING_TASK = None
+2 -2
View File
@@ -8,8 +8,8 @@ from tkinter import messagebox, filedialog, ttk
from app.config.settings import ConfigManager
from .user_settings import load_user_settings, save_user_settings
from .ui_widgets import center_window
from app.ui.user_settings import load_user_settings, save_user_settings
from app.ui.ui_widgets import center_window
from app.core.utils.dialog_utils import show_cloud_sync_dialog
+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:
file_types = [("所有文件", "*.*")]
file_path = filedialog.askopenfilename(title=title, filetypes=file_types)
# 获取默认输入目录
try:
config = ConfigManager()
initial_dir = config.get_path('Paths', 'input_folder', fallback='data/input')
if not os.path.exists(initial_dir):
initial_dir = os.getcwd()
except Exception:
initial_dir = os.getcwd()
file_path = filedialog.askopenfilename(title=title, filetypes=file_types, initialdir=initial_dir)
if file_path:
add_to_log(log_widget, f"已选择文件: {file_path}\n", "info")
return file_path
+19 -10
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 .logging_ui import add_to_log, poll_log_queue
from .ui_widgets import StatusBar
from .ui_widgets import StatusBar, ToolTip
from .user_settings import (
load_user_settings, save_user_settings, refresh_recent_list_widget,
_extract_path_from_recent_item, clear_recent_files, RECENT_LIST_WIDGET,
@@ -33,6 +33,7 @@ from .config_dialog import show_config_dialog
from .barcode_editor import edit_barcode_mappings
from .shortcuts import bind_keyboard_shortcuts
from app.core.utils.dialog_utils import show_cloud_sync_dialog
from .db_viewer import show_db_viewer
def _init_window():
@@ -100,8 +101,14 @@ def _create_left_panel(content_frame, theme, log_text, status_bar):
pipeline_section.pack(fill=tk.X, pady=(0, 8))
pipeline_frame = tk.Frame(pipeline_section, bg=theme["card_bg"])
pipeline_frame.pack(fill=tk.X, padx=8, pady=6)
create_modern_button(pipeline_frame, "一键处理", lambda: run_pipeline_directly(log_text, status_bar), "primary", px_width=150, px_height=32).pack(anchor='w', pady=3)
create_modern_button(pipeline_frame, "一键处理全部图片", lambda: batch_process_all_inputs(log_text, status_bar), "primary", px_width=180, px_height=32).pack(anchor='w', pady=3)
btn_onekey = create_modern_button(pipeline_frame, "一键处理", lambda: run_pipeline_directly(log_text, status_bar), "primary", px_width=150, px_height=32)
btn_onekey.pack(anchor='w', pady=3)
ToolTip(btn_onekey.winfo_children()[0], "选择图片或Excel,自动完成OCR识别和采购单生成全流程")
btn_batch = create_modern_button(pipeline_frame, "批量处理", lambda: batch_process_all_inputs(log_text, status_bar), "primary", px_width=150, px_height=32)
btn_batch.pack(anchor='w', pady=3)
ToolTip(btn_batch.winfo_children()[0], "扫描 data/input 文件夹,处理所有未识别的图片")
# OCR处理区
core_section = tk.LabelFrame(
@@ -220,7 +227,7 @@ def _create_recent_files_section(parent, theme, log_text):
create_modern_button(rf_btns, "清理无效", purge_invalid, "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(3, 0))
def _create_right_panel(content_frame, theme, log_text, root):
def _create_right_panel(content_frame, theme, log_text, root, status_bar, config):
"""创建右侧面板:快捷操作、系统设置"""
right_panel = create_card_frame(content_frame)
right_panel.pack(side=tk.RIGHT, fill=tk.BOTH, expand=False, padx=(5, 0), pady=5)
@@ -240,12 +247,13 @@ def _create_right_panel(content_frame, theme, log_text, root):
tk.Frame(tools_buttons_frame, bg=theme["card_bg"]).pack(fill=tk.X, pady=3)
create_modern_button(tools_buttons_frame, "打开结果目录", lambda: open_result_directory(), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "打开输出目录", lambda: os.startfile(ConfigManager().get_path('Paths', 'output_folder', fallback='data/output', create=True)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "打开输入目录", lambda: os.startfile(ConfigManager().get_path('Paths', 'input_folder', fallback='data/input', create=True)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "合并订单", lambda: merge_orders_with_status(log_text, StatusBar(root)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "打开输出目录", lambda: os.startfile(config.get_path('Paths', 'output_folder', fallback='data/output', create=True)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "打开输入目录", lambda: os.startfile(config.get_path('Paths', 'input_folder', fallback='data/input', create=True)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "合并订单", lambda: merge_orders_with_status(log_text, status_bar), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "数据库内容", lambda: show_db_viewer(root, config=config), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "清除缓存", lambda: clean_cache(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "清理input/out文件", lambda: clean_data_files(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "清理result文件", lambda: clean_result_files(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(tools_buttons_frame, "清理结果文件", lambda: clean_result_files(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
# 系统设置区
settings_section = tk.LabelFrame(
@@ -255,7 +263,7 @@ def _create_right_panel(content_frame, theme, log_text, root):
settings_section.pack(fill=tk.X, pady=(0, 8))
settings_buttons_frame = tk.Frame(settings_section, bg=theme["card_bg"])
settings_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
create_modern_button(settings_buttons_frame, "系统设置", lambda: show_config_dialog(root, ConfigManager()), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "系统设置", lambda: show_config_dialog(root, config), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "条码映射", lambda: edit_barcode_mappings(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "云端同步", lambda: show_cloud_sync_dialog(root), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "商品记忆库", lambda: show_memory_editor(root), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
@@ -448,6 +456,7 @@ def main():
"""主函数"""
try:
root, theme, settings, dnd_supported = _init_window()
config = ConfigManager()
# 主容器
main_container = tk.Frame(root, bg=theme["bg"])
@@ -469,7 +478,7 @@ def main():
_create_left_panel(content_frame, theme, log_text, status_bar)
# 右侧面板
_create_right_panel(content_frame, theme, log_text, root)
_create_right_panel(content_frame, theme, log_text, root, status_bar, config)
# 拖拽区域
_setup_drag_area(mid_container, theme, dnd_supported, log_text, status_bar)
+1 -1
View File
@@ -6,7 +6,7 @@ from tkinter import ttk, messagebox, simpledialog
from app.config.settings import ConfigManager
from app.core.db.product_db import ProductDatabase
from .ui_widgets import center_window
from app.ui.ui_widgets import center_window
def _get_product_db():
+2 -2
View File
@@ -8,8 +8,8 @@ import datetime
import tkinter as tk
from tkinter import messagebox, scrolledtext
from .theme import THEMES, get_theme_mode, apply_theme
from .ui_widgets import center_window
from app.ui.theme import THEMES, get_theme_mode, apply_theme
from app.ui.ui_widgets import center_window
from app.core.utils.file_utils import format_file_size
from app.config.settings import ConfigManager
+3 -3
View File
@@ -5,15 +5,15 @@
import tkinter as tk
from tkinter import messagebox
from .ui_widgets import center_window
from .action_handlers import (
from app.ui.ui_widgets import center_window
from app.ui.action_handlers import (
process_single_image_with_status,
process_excel_file_with_status,
batch_ocr_with_status,
run_pipeline_directly,
merge_orders_with_status,
)
from .file_operations import clean_cache
from app.ui.file_operations import clean_cache
def bind_keyboard_shortcuts(root, log_widget, status_bar):
+30
View File
@@ -74,6 +74,36 @@ class ProgressReporter:
pass
class ToolTip:
"""给组件添加悬停提示"""
def __init__(self, widget, text):
self.widget = widget
self.text = text
self.tip_window = None
self.widget.bind("<Enter>", self.show_tip)
self.widget.bind("<Leave>", self.hide_tip)
def show_tip(self, event=None):
if self.tip_window or not self.text:
return
x, y, _cx, cy = self.widget.bbox("insert")
x = x + self.widget.winfo_rootx() + 27
y = y + cy + self.widget.winfo_rooty() + 27
self.tip_window = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(1)
tw.wm_geometry("+%d+%d" % (x, y))
label = tk.Label(tw, text=self.text, justify=tk.LEFT,
background="#ffffe0", relief=tk.SOLID, borderwidth=1,
font=("tahoma", "8", "normal"), padx=4, pady=2)
label.pack(ipadx=1)
def hide_tip(self, event=None):
tw = self.tip_window
self.tip_window = None
if tw:
tw.destroy()
def create_collapsible_frame(parent, title, initial_state=True):
"""创建可折叠的面板"""
frame = tk.Frame(parent)