File size: 7,487 Bytes
b4f2238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
"""OCR 关键词分类服务 - 将图片分为对账单/出库单/收款凭证/其他"""

from __future__ import annotations

import base64
import logging
import re
from dataclasses import dataclass, field
from importlib import import_module
from typing import Any

logger = logging.getLogger("apps.evidence_sorting")

# 文件类型常量
TYPE_STATEMENT = "statement"  # 对账单
TYPE_DELIVERY = "delivery"  # 出库单/出仓单
TYPE_RECEIPT = "receipt"  # 收款凭证
TYPE_OTHER = "other"  # 其他

# 关键词权重表
_KEYWORDS: dict[str, list[str]] = {
    TYPE_STATEMENT: ["对账单", "对帐单", "对账", "月结", "月度汇总"],
    TYPE_DELIVERY: [
        "出库单",
        "出仓单",
        "出货单",
        "发货单",
        "送货单",
        "承运单",
        "提货单",
        "出库",
        "出仓",
    ],
    TYPE_RECEIPT: [
        "收款",
        "转账",
        "付款",
        "汇款",
        "收到",
        "到账",
        "银行回单",
        "电子回单",
        "交易成功",
        "支付成功",
        "中国农业银行",
        "中国工商银行",
        "中国建设银行",
        "中国银行",
        "招商银行",
        "微信支付",
        "支付宝",
    ],
}


@dataclass
class ClassifiedImage:
    """分类后的图片"""

    filename: str
    category: str  # statement / delivery / receipt / other
    ocr_text: str
    date: str | None = None  # YYYYMMDD
    amount: str | None = None  # 如 "65500"
    signed: bool | None = None  # 对账单是否签名
    confidence: float = 0.0
    image_data: str = ""  # base64
    rotation: int = 0


@dataclass
class ClassifyResult:
    """分类结果"""

    images: list[ClassifiedImage] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)


class ClassifierService:
    """图片 OCR + 关键词分类"""

    def __init__(self) -> None:
        self._ocr_service: Any | None = None

    def _get_ocr_service(self) -> Any:
        if self._ocr_service is None:
            module = import_module("apps.image_rotation.services.orientation.service")
            orientation_service_cls = module.OrientationDetectionService
            self._ocr_service = orientation_service_cls()
        return self._ocr_service

    def classify_images(self, images: list[dict[str, Any]]) -> ClassifyResult:
        """
        批量 OCR + 分类

        Args:
            images: [{"filename": str, "data": str(base64)}]

        Returns:
            ClassifyResult
        """
        result = ClassifyResult()
        ocr_svc = self._get_ocr_service()

        for idx, img in enumerate(images):
            filename: str = img.get("filename", f"image_{idx}")
            data: str = img.get("data", "")
            try:
                # 去掉 data:image/xxx;base64, 前缀
                raw_data = data.split(",", 1)[-1] if "," in data else data
                image_bytes = base64.b64decode(raw_data)

                # OCR + 方向检测
                ocr_result = ocr_svc.detect_orientation_with_text(image_bytes)
                ocr_text: str = ocr_result.get("ocr_text", "")
                rotation: int = ocr_result.get("rotation", 0)
                confidence: float = ocr_result.get("confidence", 0.0)

                # 关键词分类
                category = self._classify_by_keywords(ocr_text, filename)

                # 提取日期和金额
                date = self._extract_date(ocr_text)
                amount = self._extract_amount(ocr_text)

                # 对账单检测签名
                signed: bool | None = None
                if category == TYPE_STATEMENT:
                    signed = self._detect_signed(ocr_text)

                result.images.append(
                    ClassifiedImage(
                        filename=filename,
                        category=category,
                        ocr_text=ocr_text,
                        date=date,
                        amount=amount,
                        signed=signed,
                        confidence=confidence,
                        image_data=data,
                        rotation=rotation,
                    )
                )
            except Exception as e:
                logger.warning("分类失败: %s - %s", filename, e)
                result.errors.append(f"{filename}: {e!s}")
                result.images.append(
                    ClassifiedImage(
                        filename=filename,
                        category=TYPE_OTHER,
                        ocr_text="",
                        image_data=data,
                    )
                )

        logger.info(
            "分类完成: %d 张图片, %d 个错误",
            len(result.images),
            len(result.errors),
        )
        return result

    def _classify_by_keywords(self, text: str, filename: str = "") -> str:
        """关键词匹配分类,返回匹配数最多的类型"""
        if not text:
            # OCR 识别不出文字的 PNG 文件,大概率是对账单截图
            if filename.lower().endswith(".png"):
                return TYPE_STATEMENT
            return TYPE_OTHER

        scores: dict[str, int] = dict.fromkeys(_KEYWORDS, 0)
        for category, keywords in _KEYWORDS.items():
            for kw in keywords:
                if kw in text:
                    scores[category] += 1

        best = max(scores, key=lambda k: scores[k])
        if scores[best] == 0:
            return TYPE_OTHER
        return best

    def _extract_date(self, text: str) -> str | None:
        """从 OCR 文本提取日期 → YYYYMMDD"""
        # 匹配 YYYY年MM月DD日 / YYYY-MM-DD / YYYY/MM/DD
        patterns = [
            r"(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日",
            r"(\d{4})[-/](\d{1,2})[-/](\d{1,2})",
        ]
        for pat in patterns:
            m = re.search(pat, text)
            if m:
                y, mo, d = m.group(1), m.group(2).zfill(2), m.group(3).zfill(2)
                return f"{y}{mo}{d}"
        return None

    def _extract_amount(self, text: str) -> str | None:
        """从 OCR 文本提取金额"""
        # 匹配 ¥123,456.78 / 123456.78元 / 金额:123456 等
        patterns = [
            r"[¥¥]\s*([\d,]+\.?\d*)",
            r"([\d,]+\.?\d*)\s*元",
            r"金额[::]\s*([\d,]+\.?\d*)",
            r"合计[::]\s*([\d,]+\.?\d*)",
            r"总计[::]\s*([\d,]+\.?\d*)",
        ]
        amounts: list[float] = []
        for pat in patterns:
            for m in re.finditer(pat, text):
                try:
                    val = float(m.group(1).replace(",", ""))
                    amounts.append(val)
                except ValueError:
                    pass
        if amounts:
            # 返回最大金额
            best = max(amounts)
            # 保留小数(如果有)
            if best == int(best):
                return str(int(best))
            return str(best)
        return None

    def _detect_signed(self, text: str) -> bool:
        """检测对账单是否有签名痕迹(通过 OCR 文本中的签名相关关键词)"""
        sign_keywords = ["签名", "签章", "盖章", "确认", "签字"]
        # 如果 OCR 文本中有大量手写体识别(置信度低的文字),可能有签名
        # 简单策略:检查关键词
        return any(kw in text for kw in sign_keywords)