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)
+98 -121
View File
@@ -40,63 +40,43 @@ added_files = [
('config/barcode_mappings.json', 'config/'),
('config/config.ini', 'config/'),
('templates/银豹-采购单模板.xls', 'templates/'),
('app', 'app'),
]
# 需要隐式导入的模块
hidden_imports = [
'tkinter',
'tkinter.ttk',
'tkinter.filedialog',
'tkinter.messagebox',
'tkinter.scrolledtext',
'pandas',
'numpy',
'openpyxl',
'xlrd',
'xlwt',
'xlutils',
'requests',
'dotenv',
'tkinterdnd2',
'configparser',
'threading',
'datetime',
'json',
're',
'subprocess',
'shutil',
'app.config.settings',
'app.services.ocr_service',
'app.services.order_service',
'app.services.tobacco_service',
'app.services.processor_service',
'app.core.utils.dialog_utils',
'app.core.utils.file_utils',
'app.core.utils.log_utils',
'app.core.utils.string_utils',
'app.core.handlers.column_mapper',
'app.core.excel.converter',
'app.core.db.product_db',
'app.ui.error_utils',
'app.ui.theme',
'app.ui.logging_ui',
'app.ui.ui_widgets',
'app.ui.user_settings',
'app.ui.result_previews',
'app.ui.command_runner',
'app.ui.file_operations',
'app.ui.action_handlers',
'app.ui.barcode_editor',
'app.ui.config_dialog',
'app.ui.shortcuts',
'app.ui.main_window',
'app.ui.memory_editor',
]
'tkinter',
'tkinter.ttk',
'tkinter.filedialog',
'tkinter.messagebox',
'tkinter.scrolledtext',
'pandas',
'numpy',
'openpyxl',
'xlrd',
'xlwt',
'xlutils',
'xlutils.copy',
'requests',
'dotenv',
'tkinterdnd2',
'configparser',
'threading',
'datetime',
'json',
're',
'subprocess',
'shutil',
'sqlite3',
'logging',
'base64',
'concurrent.futures',
'pathlib',
'typing',
]
a = Analysis(
['启动器.py'],
pathex=[],
pathex=['.'],
binaries=[],
datas=added_files,
hiddenimports=hidden_imports,
@@ -217,107 +197,104 @@ def build_exe():
return True
def create_portable_package():
"""创建便携版打包"""
print("创建便携版打包...")
"""创建并更新便携版打包,并同步到桌面"""
print("更新便携版打包...")
# 创建发布目录
# 1. 准备本地 release 目录
release_dir = Path('release')
if release_dir.exists():
try:
shutil.rmtree(release_dir)
except Exception as e:
print(f"警告: 无法完全清理发布目录 (可能文件被占用): {e}")
# 如果目录还在,尝试清理能清理的部分
for item in release_dir.iterdir():
try:
if item.is_dir(): shutil.rmtree(item)
else: item.unlink()
except Exception: pass
release_dir.mkdir(exist_ok=True)
# 不再删除整个目录,以保留 data/input 等用户数据
if not release_dir.exists():
release_dir.mkdir(parents=True)
print(f"已创建本地发布目录: {release_dir}")
else:
print(f"本地发布目录已存在,将进行增量更新: {release_dir}")
# 2. 更新核心文件到本地 release
# 复制exe文件
exe_file = Path('dist/OCR订单处理系统.exe')
if exe_file.exists():
shutil.copy2(exe_file, release_dir)
print(f"复制: {exe_file} -> {release_dir}")
print(f"更新 EXE: {exe_file} -> {release_dir}")
# 创建必要的目录结构
dirs_to_create = ['data/input', 'data/output', 'logs', 'templates', 'config']
for dir_path in dirs_to_create:
# 确保必要的目录存在
dirs_to_ensure = ['data/input', 'data/output', 'data/result', 'logs', 'templates', 'config']
for dir_path in dirs_to_ensure:
(release_dir / dir_path).mkdir(parents=True, exist_ok=True)
print(f"已创建目录: {dir_path}")
# 复制配置文件(包含API密钥)
config_file = Path('config/config.ini')
if config_file.exists():
shutil.copy2(config_file, release_dir / 'config')
print(f"已复制配置文件: {config_file} -> {release_dir / 'config'}")
else:
print(f"警告: 配置文件不存在: {config_file}")
# 复制配置文件
files_to_copy = [
(Path('config/config.ini'), release_dir / 'config'),
(Path('config/barcode_mappings.json'), release_dir / 'config'),
(Path('config.ini'), release_dir),
(Path('templates/银豹-采购单模板.xls'), release_dir / 'templates'),
(Path('templates/商品资料.xlsx'), release_dir / 'templates'),
]
# 复制完整的条码映射文件
barcode_mapping_file = Path('config/barcode_mappings.json')
if barcode_mapping_file.exists():
shutil.copy2(barcode_mapping_file, release_dir / 'config')
print(f"复制条码映射文件: {barcode_mapping_file} -> {release_dir / 'config'}")
else:
print(f"警告: 条码映射文件不存在: {barcode_mapping_file}")
# 复制根目录的config.ini文件
root_config_file = Path('config.ini')
if root_config_file.exists():
shutil.copy2(root_config_file, release_dir)
print(f"已复制根配置文件: {root_config_file} -> {release_dir}")
else:
print(f"警告: 根配置文件不存在: {root_config_file}")
# 复制模板文件
template_file = Path('templates/银豹-采购单模板.xls')
if template_file.exists():
shutil.copy2(template_file, release_dir / 'templates')
print(f"已复制模板文件: {template_file} -> {release_dir / 'templates'}")
else:
print(f"警告: 模板文件不存在: {template_file}")
item_file = Path('templates/商品资料.xlsx')
if item_file.exists():
try:
(Path('dist') / 'templates').mkdir(exist_ok=True)
shutil.copy2(item_file, Path('dist') / 'templates')
except Exception:
pass
shutil.copy2(item_file, release_dir / 'templates')
print(f"已复制商品资料: {item_file} -> {release_dir / 'templates'}")
else:
print(f"警告: 商品资料文件不存在: {item_file}")
# 创建README文件
for src, dst_dir in files_to_copy:
if src.exists():
dst_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst_dir)
print(f"更新文件: {src} -> {dst_dir}")
else:
print(f"警告: 文件不存在,跳过更新: {src}")
# 3. 创建/更新 README
readme_content = '''
# OCR订单处理系统 - 便携版
## 使用说明
1. 双击 "OCR订单处理系统.exe" 启动程序
2. 将需要处理的图片文件放入 data/input 目录
3. 处理结果将保存在 data/output 目录
4. 日志文件保存在 logs 目录
3. 处理结果将保存在 data/result 目录 (采购单 Excel)
4. 中间过程文件在 data/output 目录
5. 日志文件保存在 logs 目录
## 注意事项
- 首次运行时需要配置百度OCR API密钥
- 支持的图片格式jpg, jpeg, png, bmp
- 单个文件大小不超过4MB
- 单个文件大小建议不超过 4MB
## 目录结构
- OCR订单处理系统.exe - 主程序
- data/input/ - 输入图片目录
- data/output/ - 输出结果目录
- data/result/ - 最终采购单目录
- logs/ - 日志目录
'''
with open(release_dir / 'README.txt', 'w', encoding='utf-8') as f:
f.write(readme_content)
print("已创建README.txt")
# 4. 同步到桌面
try:
# 用户指定的特殊桌面路径
desktop_path = Path(r"F:\Administrator\桌面")
if not desktop_path.exists():
# 兜底:如果 F 盘路径不存在,尝试系统默认路径
desktop_path = Path(os.path.join(os.path.expanduser("~"), "Desktop"))
desktop_release = desktop_path / "OCR系统_Release"
print(f"正在同步到桌面: {desktop_release}")
# 使用自定义的同步逻辑,避免删除目标目录中的其他文件
def sync_dir(src_root, dst_root):
if not dst_root.exists():
dst_root.mkdir(parents=True)
for item in src_root.iterdir():
dst_item = dst_root / item.name
if item.is_dir():
sync_dir(item, dst_item)
else:
# 如果是文件,直接覆盖更新
shutil.copy2(item, dst_item)
sync_dir(release_dir, desktop_release)
print(f"同步成功!桌面位置: {desktop_release.absolute()}")
except Exception as e:
print(f"同步到桌面失败: {e}")
print(f"便携版打包完成,位置: {release_dir.absolute()}")
print(f"本地便携版更新完成,位置: {release_dir.absolute()}")
def main():
"""主函数"""
+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_key = kIehdWbbVD85K18qZYz6SeUf
secret_key = RCXTgmVjJJkNNMhfY5ASab0xY3mvF6d7
api_key = yBU35EDIZ2ITLRk1MnFBN1tv
secret_key = 7L0VEOIcqHhaqzZec5LXsHGzJhgFivMr
timeout = 30
max_retries = 3
retry_delay = 2
max_retries = 5
retry_delay = 1
api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table
token_url = https://aip.baidubce.com/oauth/2.0/token
form_ocr_url = https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result
@@ -14,7 +14,7 @@ input_folder = data/input
output_folder = data/output
temp_folder = data/temp
template_folder = templates
template_file = templates/银豹-采购单模板.xls
template_file = templates\银豹-采购单模板.xls
processed_record = data/processed_files.json
data_dir = data
product_db = data/product_cache.db
@@ -35,14 +35,15 @@ purchase_order = 银豹-采购单模板.xls
item_data = 商品资料.xlsx
[App]
version = 2026.07.19.1947
version = 2026.07.20.2105
[Gitea]
base_url = https://gitea.94kan.cn
owner = houhuan
repo = yixuan-sync-data
token =
token = 50b61e43a141d606ae2529cd1755bc666d800e08
[WebAuth]
username = admin
password_hash = $2b$12$nllT8o1QIMfWKuTlpQI3G./E2NS.gqf0EHZyNkJ8gMpVa9grTXRoC
+4 -4
View File
@@ -1,9 +1,9 @@
[API]
api_key = kIehdWbbVD85K18qZYz6SeUf
secret_key = RCXTgmVjJJkNNMhfY5ASab0xY3mvF6d7
api_key = yBU35EDIZ2ITLRk1MnFBN1tv
secret_key = 7L0VEOIcqHhaqzZec5LXsHGzJhgFivMr
timeout = 30
max_retries = 3
retry_delay = 2
max_retries = 5
retry_delay = 1
api_url = https://aip.baidubce.com/rest/2.0/ocr/v1/table
token_url = https://aip.baidubce.com/oauth/2.0/token
form_ocr_url = https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_request_result
+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.security import HTTPBearer, HTTPAuthorizationCredentials
from .jwt_handler import decode_token
from web.backend.auth.jwt_handler import decode_token
security = HTTPBearer()
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Optional
from jose import jwt, JWTError
from ..config import get_or_generate_secret, JWT_ALGORITHM, JWT_EXPIRE_HOURS
from web.backend.config import get_or_generate_secret, JWT_ALGORITHM, JWT_EXPIRE_HOURS
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
+2 -2
View File
@@ -5,8 +5,8 @@ import bcrypt
from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel
from .jwt_handler import create_access_token
from .dependencies import get_current_user
from web.backend.auth.jwt_handler import create_access_token
from web.backend.auth.dependencies import get_current_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
+15 -15
View File
@@ -15,20 +15,20 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from .config import get_or_generate_secret # noqa: trigger secret generation
from .services.task_manager import TaskManager
from .services.db_pool import DBPool
from .auth.router import router as auth_router
from .routers.files import router as files_router
from .routers.processing import router as processing_router
from .routers.memory import router as memory_router
from .routers.config_api import router as config_router
from .routers.barcodes import router as barcodes_router
from .routers.sync import router as sync_router
from .routers.websocket import router as ws_router
from .routers.logs import router as logs_router
from .routers.tasks import router as tasks_router
from .middleware.logging import LoggingMiddleware
from web.backend.config import get_or_generate_secret # noqa: trigger secret generation
from web.backend.services.task_manager import TaskManager
from web.backend.services.db_pool import DBPool
from web.backend.auth.router import router as auth_router
from web.backend.routers.files import router as files_router
from web.backend.routers.processing import router as processing_router
from web.backend.routers.memory import router as memory_router
from web.backend.routers.config_api import router as config_router
from web.backend.routers.barcodes import router as barcodes_router
from web.backend.routers.sync import router as sync_router
from web.backend.routers.websocket import router as ws_router
from web.backend.routers.logs import router as logs_router
from web.backend.routers.tasks import router as tasks_router
from web.backend.middleware.logging import LoggingMiddleware
# Shared singletons
task_manager = TaskManager()
@@ -42,7 +42,7 @@ async def lifespan(app: FastAPI):
ConfigManager()
# Initialize DB and cleanup old records
from .services.db_schema import init_db, cleanup_old_records, sync_file_relations
from web.backend.services.db_schema import init_db, cleanup_old_records, sync_file_relations
init_db()
cleanup_old_records()
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import Dict, Optional, List
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
from web.backend.auth.dependencies import get_current_user
router = APIRouter(prefix="/api/barcodes", tags=["barcodes"])
+1 -1
View File
@@ -4,7 +4,7 @@ from typing import Dict, Optional, Any
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
from web.backend.auth.dependencies import get_current_user
router = APIRouter(prefix="/api/config", tags=["config"])
+4 -4
View File
@@ -10,9 +10,9 @@ from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query,
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel
from ..auth.dependencies import get_current_user, get_current_user_flexible
from ..config import MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS
from ..services.db_schema import (
from web.backend.auth.dependencies import get_current_user, get_current_user_flexible
from web.backend.config import MAX_UPLOAD_SIZE, ALLOWED_EXTENSIONS
from web.backend.services.db_schema import (
insert_file_metadata, query_file_history, query_file_stats,
query_file_relations, delete_file_relations, sync_file_relations,
query_file_relations_stats, reset_file_cache,
@@ -259,7 +259,7 @@ class RelationDeleteRequest(BaseModel):
def _cleanup_relation_for_deleted_file(directory: str, filename: str):
"""Clean up relation table when a file is deleted."""
import sqlite3
from ..services.db_schema import _db_path
from web.backend.services.db_schema import _db_path
try:
conn = sqlite3.connect(_db_path)
conn.row_factory = sqlite3.Row
+2 -2
View File
@@ -7,8 +7,8 @@ from typing import Optional
from fastapi import APIRouter, Depends, Query
from ..auth.dependencies import get_current_user
from ..services.db_schema import query_http_logs, query_http_log_stats
from web.backend.auth.dependencies import get_current_user
from web.backend.services.db_schema import query_http_logs, query_http_log_stats
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
from web.backend.auth.dependencies import get_current_user
router = APIRouter(prefix="/api/memory", tags=["memory"])
+3 -3
View File
@@ -12,9 +12,9 @@ from typing import Optional, List
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
from ..services.service_wrapper import ServiceWrapper
from ..services.db_schema import upsert_file_relation
from web.backend.auth.dependencies import get_current_user
from web.backend.services.service_wrapper import ServiceWrapper
from web.backend.services.db_schema import upsert_file_relation
router = APIRouter(prefix="/api/processing", tags=["processing"])
+2 -2
View File
@@ -6,8 +6,8 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
from ..auth.dependencies import get_current_user
from ..services.task_manager import TaskManager
from web.backend.auth.dependencies import get_current_user
from web.backend.services.task_manager import TaskManager
router = APIRouter(prefix="/api/sync", tags=["sync"])
+2 -2
View File
@@ -6,8 +6,8 @@ from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from ..auth.dependencies import get_current_user
from ..services import db_schema
from web.backend.auth.dependencies import get_current_user
from web.backend.services import db_schema
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
+1 -1
View File
@@ -1,7 +1,7 @@
"""WebSocket endpoint for real-time task progress."""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from ..auth.jwt_handler import decode_token
from web.backend.auth.jwt_handler import decode_token
from jose import JWTError
router = APIRouter(tags=["websocket"])