feat(server): 重构费用报销服务,优化报销单创建和审批流程逻辑
This commit is contained in:
@@ -54,6 +54,32 @@ class ExpenseClaimAttachmentAnalysisRead(BaseModel):
|
|||||||
suggestion: str = ""
|
suggestion: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimAttachmentDocumentFieldRead(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimAttachmentDocumentInfoRead(BaseModel):
|
||||||
|
document_type: str = "other"
|
||||||
|
document_type_label: str = "其他单据"
|
||||||
|
scene_code: str = "other"
|
||||||
|
scene_label: str = "其他票据"
|
||||||
|
fields: list[ExpenseClaimAttachmentDocumentFieldRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseClaimAttachmentRequirementRead(BaseModel):
|
||||||
|
matches: bool = False
|
||||||
|
current_expense_type: str = "other"
|
||||||
|
current_expense_type_label: str = "其他"
|
||||||
|
allowed_scene_labels: list[str] = Field(default_factory=list)
|
||||||
|
recognized_scene_code: str = "other"
|
||||||
|
recognized_scene_label: str = "其他票据"
|
||||||
|
recognized_document_type: str = "other"
|
||||||
|
recognized_document_type_label: str = "其他单据"
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimAttachmentRead(BaseModel):
|
class ExpenseClaimAttachmentRead(BaseModel):
|
||||||
file_name: str
|
file_name: str
|
||||||
storage_key: str
|
storage_key: str
|
||||||
@@ -62,6 +88,8 @@ class ExpenseClaimAttachmentRead(BaseModel):
|
|||||||
uploaded_at: datetime | None = None
|
uploaded_at: datetime | None = None
|
||||||
previewable: bool = True
|
previewable: bool = True
|
||||||
analysis: ExpenseClaimAttachmentAnalysisRead | None = None
|
analysis: ExpenseClaimAttachmentAnalysisRead | None = None
|
||||||
|
document_info: ExpenseClaimAttachmentDocumentInfoRead | None = None
|
||||||
|
requirement_check: ExpenseClaimAttachmentRequirementRead | None = None
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimItemUpdate(BaseModel):
|
class ExpenseClaimItemUpdate(BaseModel):
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.schemas.ontology import OntologyEntity, OntologyParseResult
|
|||||||
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
|
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate
|
||||||
from app.services.agent_foundation import AgentFoundationService
|
from app.services.agent_foundation import AgentFoundationService
|
||||||
from app.services.audit import AuditLogService
|
from app.services.audit import AuditLogService
|
||||||
|
from app.services.document_intelligence import build_document_insight
|
||||||
from app.services.ocr import OcrService
|
from app.services.ocr import OcrService
|
||||||
|
|
||||||
EXPENSE_TYPE_LABELS = {
|
EXPENSE_TYPE_LABELS = {
|
||||||
@@ -89,6 +90,18 @@ EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = {
|
|||||||
"training": {"training"},
|
"training": {"training"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DOCUMENT_SCENE_LABELS = {
|
||||||
|
"travel": "差旅",
|
||||||
|
"hotel": "住宿",
|
||||||
|
"transport": "交通",
|
||||||
|
"meal": "餐饮",
|
||||||
|
"entertainment": "业务招待",
|
||||||
|
"office": "办公用品",
|
||||||
|
"meeting": "会务",
|
||||||
|
"training": "培训",
|
||||||
|
"other": "其他票据",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ExpenseClaimService:
|
class ExpenseClaimService:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
@@ -307,19 +320,28 @@ class ExpenseClaimService:
|
|||||||
item=item,
|
item=item,
|
||||||
)
|
)
|
||||||
ocr_document = None
|
ocr_document = None
|
||||||
|
document_info = None
|
||||||
|
requirement_check = None
|
||||||
ocr_status = "empty"
|
ocr_status = "empty"
|
||||||
ocr_error = ""
|
ocr_error = ""
|
||||||
try:
|
try:
|
||||||
ocr_result = OcrService().recognize_files(
|
ocr_result = OcrService(self.db).recognize_files(
|
||||||
[(normalized_name, content, media_type or "application/octet-stream")]
|
[(normalized_name, content, media_type or "application/octet-stream")]
|
||||||
)
|
)
|
||||||
documents = list(ocr_result.documents or [])
|
documents = list(ocr_result.documents or [])
|
||||||
if documents:
|
if documents:
|
||||||
ocr_document = documents[0]
|
ocr_document = documents[0]
|
||||||
ocr_status = "recognized"
|
ocr_status = "recognized"
|
||||||
|
document_info = self._build_attachment_document_info(ocr_document)
|
||||||
|
requirement_check = self._build_attachment_requirement_check(
|
||||||
|
item=item,
|
||||||
|
document_info=document_info,
|
||||||
|
)
|
||||||
attachment_analysis = self._build_attachment_analysis(
|
attachment_analysis = self._build_attachment_analysis(
|
||||||
document=ocr_document,
|
document=ocr_document,
|
||||||
item=item,
|
item=item,
|
||||||
|
document_info=document_info,
|
||||||
|
requirement_check=requirement_check,
|
||||||
)
|
)
|
||||||
except Exception as exc: # pragma: no cover - fallback path depends on OCR runtime
|
except Exception as exc: # pragma: no cover - fallback path depends on OCR runtime
|
||||||
ocr_status = "failed"
|
ocr_status = "failed"
|
||||||
@@ -342,12 +364,21 @@ class ExpenseClaimService:
|
|||||||
"uploaded_at": datetime.now(UTC).isoformat(),
|
"uploaded_at": datetime.now(UTC).isoformat(),
|
||||||
"previewable": self._is_previewable_media_type(media_type, normalized_name),
|
"previewable": self._is_previewable_media_type(media_type, normalized_name),
|
||||||
"analysis": attachment_analysis,
|
"analysis": attachment_analysis,
|
||||||
|
"document_info": document_info,
|
||||||
|
"requirement_check": requirement_check,
|
||||||
"ocr_status": ocr_status,
|
"ocr_status": ocr_status,
|
||||||
"ocr_error": ocr_error,
|
"ocr_error": ocr_error,
|
||||||
"ocr_text": str(getattr(ocr_document, "text", "") or ""),
|
"ocr_text": str(getattr(ocr_document, "text", "") or ""),
|
||||||
"ocr_summary": str(getattr(ocr_document, "summary", "") or ""),
|
"ocr_summary": str(getattr(ocr_document, "summary", "") or ""),
|
||||||
"ocr_avg_score": float(getattr(ocr_document, "avg_score", 0.0) or 0.0),
|
"ocr_avg_score": float(getattr(ocr_document, "avg_score", 0.0) or 0.0),
|
||||||
"ocr_line_count": int(getattr(ocr_document, "line_count", 0) or 0),
|
"ocr_line_count": int(getattr(ocr_document, "line_count", 0) or 0),
|
||||||
|
"ocr_classification_source": str(getattr(ocr_document, "classification_source", "") or ""),
|
||||||
|
"ocr_classification_confidence": float(getattr(ocr_document, "classification_confidence", 0.0) or 0.0),
|
||||||
|
"ocr_classification_evidence": [
|
||||||
|
str(item)
|
||||||
|
for item in getattr(ocr_document, "classification_evidence", []) or []
|
||||||
|
if str(item).strip()
|
||||||
|
],
|
||||||
"ocr_warnings": [str(item) for item in getattr(ocr_document, "warnings", []) or []],
|
"ocr_warnings": [str(item) for item in getattr(ocr_document, "warnings", []) or []],
|
||||||
}
|
}
|
||||||
self._write_attachment_meta(file_path, meta)
|
self._write_attachment_meta(file_path, meta)
|
||||||
@@ -1129,6 +1160,14 @@ class ExpenseClaimService:
|
|||||||
if not isinstance(analysis, dict):
|
if not isinstance(analysis, dict):
|
||||||
analysis = None
|
analysis = None
|
||||||
|
|
||||||
|
document_info = metadata.get("document_info")
|
||||||
|
if not isinstance(document_info, dict):
|
||||||
|
document_info = None
|
||||||
|
|
||||||
|
requirement_check = metadata.get("requirement_check")
|
||||||
|
if not isinstance(requirement_check, dict):
|
||||||
|
requirement_check = None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"file_name": str(metadata.get("file_name") or filename),
|
"file_name": str(metadata.get("file_name") or filename),
|
||||||
"storage_key": str(item.invoice_id or ""),
|
"storage_key": str(item.invoice_id or ""),
|
||||||
@@ -1137,6 +1176,8 @@ class ExpenseClaimService:
|
|||||||
"uploaded_at": uploaded_at,
|
"uploaded_at": uploaded_at,
|
||||||
"previewable": bool(metadata.get("previewable", self._is_previewable_media_type(media_type, filename))),
|
"previewable": bool(metadata.get("previewable", self._is_previewable_media_type(media_type, filename))),
|
||||||
"analysis": analysis,
|
"analysis": analysis,
|
||||||
|
"document_info": document_info,
|
||||||
|
"requirement_check": requirement_check,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1153,6 +1194,120 @@ class ExpenseClaimService:
|
|||||||
def _resolve_attachment_display_name(storage_key: str | None) -> str:
|
def _resolve_attachment_display_name(storage_key: str | None) -> str:
|
||||||
return Path(str(storage_key or "").strip()).name
|
return Path(str(storage_key or "").strip()).name
|
||||||
|
|
||||||
|
def _build_attachment_document_info(self, document: Any) -> dict[str, Any]:
|
||||||
|
insight = build_document_insight(
|
||||||
|
filename=str(getattr(document, "filename", "") or ""),
|
||||||
|
summary=str(getattr(document, "summary", "") or ""),
|
||||||
|
text=str(getattr(document, "text", "") or ""),
|
||||||
|
)
|
||||||
|
raw_fields = list(getattr(document, "document_fields", []) or [])
|
||||||
|
normalized_fields: list[dict[str, str]] = []
|
||||||
|
for item in raw_fields:
|
||||||
|
key = ""
|
||||||
|
label = ""
|
||||||
|
value = ""
|
||||||
|
if isinstance(item, dict):
|
||||||
|
key = str(item.get("key") or "").strip()
|
||||||
|
label = str(item.get("label") or "").strip()
|
||||||
|
value = str(item.get("value") or "").strip()
|
||||||
|
else:
|
||||||
|
key = str(getattr(item, "key", "") or "").strip()
|
||||||
|
label = str(getattr(item, "label", "") or "").strip()
|
||||||
|
value = str(getattr(item, "value", "") or "").strip()
|
||||||
|
if key and label and value:
|
||||||
|
normalized_fields.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"label": label,
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not normalized_fields:
|
||||||
|
normalized_fields = [
|
||||||
|
{
|
||||||
|
"key": field.key,
|
||||||
|
"label": field.label,
|
||||||
|
"value": field.value,
|
||||||
|
}
|
||||||
|
for field in insight.fields
|
||||||
|
if field.value
|
||||||
|
]
|
||||||
|
|
||||||
|
document_type = str(getattr(document, "document_type", "") or "").strip()
|
||||||
|
if document_type in {"", "other"}:
|
||||||
|
document_type = insight.document_type
|
||||||
|
|
||||||
|
document_type_label = str(getattr(document, "document_type_label", "") or "").strip()
|
||||||
|
if not document_type_label or document_type_label == "其他单据":
|
||||||
|
document_type_label = insight.document_type_label
|
||||||
|
|
||||||
|
scene_code = str(getattr(document, "scene_code", "") or "").strip()
|
||||||
|
if scene_code in {"", "other"}:
|
||||||
|
scene_code = insight.scene_code
|
||||||
|
|
||||||
|
scene_label = str(getattr(document, "scene_label", "") or "").strip()
|
||||||
|
if not scene_label or scene_label == "其他票据":
|
||||||
|
scene_label = insight.scene_label
|
||||||
|
|
||||||
|
return {
|
||||||
|
"document_type": document_type,
|
||||||
|
"document_type_label": document_type_label,
|
||||||
|
"scene_code": scene_code,
|
||||||
|
"scene_label": scene_label,
|
||||||
|
"fields": normalized_fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_attachment_requirement_check(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
item: ExpenseClaimItem,
|
||||||
|
document_info: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
expense_type = str(item.item_type or "").strip().lower() or "other"
|
||||||
|
expense_label = self._resolve_expense_type_label(expense_type)
|
||||||
|
allowed_scenes = EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES.get(expense_type, set())
|
||||||
|
allowed_scene_labels = [self._resolve_document_scene_label(code) for code in sorted(allowed_scenes)]
|
||||||
|
recognized_scene_code = str(document_info.get("scene_code") or "other").strip() or "other"
|
||||||
|
recognized_scene_label = str(
|
||||||
|
document_info.get("scene_label") or self._resolve_document_scene_label(recognized_scene_code)
|
||||||
|
).strip()
|
||||||
|
recognized_document_type = str(document_info.get("document_type") or "other").strip() or "other"
|
||||||
|
recognized_document_type_label = str(document_info.get("document_type_label") or "其他单据").strip() or "其他单据"
|
||||||
|
matches = not allowed_scenes or recognized_scene_code in allowed_scenes
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
if allowed_scene_labels:
|
||||||
|
message = (
|
||||||
|
f"当前费用项目为{expense_label},已识别为{recognized_document_type_label},"
|
||||||
|
f"符合当前{expense_label}场景的附件要求。"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = f"当前费用项目为{expense_label},已识别为{recognized_document_type_label}。"
|
||||||
|
else:
|
||||||
|
expected_text = "、".join(label + "相关票据" for label in allowed_scene_labels) or "对应场景票据"
|
||||||
|
message = (
|
||||||
|
f"当前费用项目为{expense_label},要求上传{expected_text};"
|
||||||
|
f"当前识别为{recognized_document_type_label},不符合当前场景,建议过滤或更换附件。"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"matches": matches,
|
||||||
|
"current_expense_type": expense_type,
|
||||||
|
"current_expense_type_label": expense_label,
|
||||||
|
"allowed_scene_labels": allowed_scene_labels,
|
||||||
|
"recognized_scene_code": recognized_scene_code,
|
||||||
|
"recognized_scene_label": recognized_scene_label,
|
||||||
|
"recognized_document_type": recognized_document_type,
|
||||||
|
"recognized_document_type_label": recognized_document_type_label,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_document_scene_label(scene_code: str) -> str:
|
||||||
|
normalized = str(scene_code or "").strip().lower()
|
||||||
|
return DOCUMENT_SCENE_LABELS.get(normalized, "其他票据")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_amount_candidates(text: str) -> list[Decimal]:
|
def _extract_amount_candidates(text: str) -> list[Decimal]:
|
||||||
values: list[Decimal] = []
|
values: list[Decimal] = []
|
||||||
@@ -1285,7 +1440,14 @@ class ExpenseClaimService:
|
|||||||
"suggestion": "建议重新上传更清晰的票据图片,或稍后重试识别后再提交。",
|
"suggestion": "建议重新上传更清晰的票据图片,或稍后重试识别后再提交。",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _build_attachment_analysis(self, *, document: Any, item: ExpenseClaimItem) -> dict[str, Any]:
|
def _build_attachment_analysis(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
document: Any,
|
||||||
|
item: ExpenseClaimItem,
|
||||||
|
document_info: dict[str, Any] | None = None,
|
||||||
|
requirement_check: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
warnings = [str(value).strip() for value in list(getattr(document, "warnings", []) or []) if str(value).strip()]
|
warnings = [str(value).strip() for value in list(getattr(document, "warnings", []) or []) if str(value).strip()]
|
||||||
text = " ".join(
|
text = " ".join(
|
||||||
[
|
[
|
||||||
@@ -1296,11 +1458,19 @@ class ExpenseClaimService:
|
|||||||
compact_text = text.replace(" ", "")
|
compact_text = text.replace(" ", "")
|
||||||
avg_score = float(getattr(document, "avg_score", 0.0) or 0.0)
|
avg_score = float(getattr(document, "avg_score", 0.0) or 0.0)
|
||||||
line_count = int(getattr(document, "line_count", 0) or 0)
|
line_count = int(getattr(document, "line_count", 0) or 0)
|
||||||
|
document_info = document_info or self._build_attachment_document_info(document)
|
||||||
|
requirement_check = requirement_check or self._build_attachment_requirement_check(
|
||||||
|
item=item,
|
||||||
|
document_info=document_info,
|
||||||
|
)
|
||||||
document_scene_matches = self._detect_expense_scenes(text)
|
document_scene_matches = self._detect_expense_scenes(text)
|
||||||
purpose_mismatch_point = self._build_purpose_mismatch_point(
|
purpose_mismatch_point = self._build_purpose_mismatch_point(
|
||||||
item=item,
|
item=item,
|
||||||
document_scenes=set(document_scene_matches.keys()),
|
document_scenes=set(document_scene_matches.keys()),
|
||||||
)
|
)
|
||||||
|
recognized_document_type = str(document_info.get("document_type") or "other").strip().lower() or "other"
|
||||||
|
recognized_document_label = str(document_info.get("document_type_label") or "其他单据").strip() or "其他单据"
|
||||||
|
requirement_matches = bool(requirement_check.get("matches"))
|
||||||
|
|
||||||
has_ticket_keyword = any(
|
has_ticket_keyword = any(
|
||||||
keyword in compact_text
|
keyword in compact_text
|
||||||
@@ -1329,8 +1499,8 @@ class ExpenseClaimService:
|
|||||||
points.append(f"识别提示:{warnings[0]}")
|
points.append(f"识别提示:{warnings[0]}")
|
||||||
if line_count == 0 or not compact_text:
|
if line_count == 0 or not compact_text:
|
||||||
points.append("附件内容:未识别到有效文字,当前附件更像普通图片或内容过于模糊。")
|
points.append("附件内容:未识别到有效文字,当前附件更像普通图片或内容过于模糊。")
|
||||||
if not has_ticket_keyword:
|
if recognized_document_type == "other" and not has_ticket_keyword:
|
||||||
points.append("票据类型:未识别到发票、票据、电子行程单等关键字。")
|
points.append("票据类型:未识别到发票、票据、电子行程单等关键字,暂无法判断票据类型。")
|
||||||
if not amount_candidates:
|
if not amount_candidates:
|
||||||
points.append("金额字段:未识别到可用于核对的金额。")
|
points.append("金额字段:未识别到可用于核对的金额。")
|
||||||
elif amount_mismatch:
|
elif amount_mismatch:
|
||||||
@@ -1338,6 +1508,8 @@ class ExpenseClaimService:
|
|||||||
points.append(f"金额字段:附件识别金额 {candidate_text} 元与报销金额 {item_amount} 元不一致。")
|
points.append(f"金额字段:附件识别金额 {candidate_text} 元与报销金额 {item_amount} 元不一致。")
|
||||||
if not has_date_text:
|
if not has_date_text:
|
||||||
points.append("日期字段:未识别到开票日期或业务发生日期。")
|
points.append("日期字段:未识别到开票日期或业务发生日期。")
|
||||||
|
if not requirement_matches:
|
||||||
|
points.append(f"附件类型要求:{requirement_check.get('message')}")
|
||||||
if purpose_mismatch_point:
|
if purpose_mismatch_point:
|
||||||
points.append(purpose_mismatch_point)
|
points.append(purpose_mismatch_point)
|
||||||
if avg_score and avg_score < 0.72:
|
if avg_score and avg_score < 0.72:
|
||||||
@@ -1349,9 +1521,10 @@ class ExpenseClaimService:
|
|||||||
"severity": "pass",
|
"severity": "pass",
|
||||||
"label": "AI提示符合条件",
|
"label": "AI提示符合条件",
|
||||||
"headline": "AI提示:附件符合基础校验条件",
|
"headline": "AI提示:附件符合基础校验条件",
|
||||||
"summary": "已识别到票据关键字段,附件可继续进入人工复核与报销流程。",
|
"summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。",
|
||||||
"points": [
|
"points": [
|
||||||
"票据类型:已识别到可用于报销核验的票据关键字。",
|
f"票据类型:已识别为{recognized_document_label}。",
|
||||||
|
f"附件类型要求:{requirement_check.get('message')}",
|
||||||
f"金额字段:已识别到与当前明细接近的金额 {item_amount} 元。",
|
f"金额字段:已识别到与当前明细接近的金额 {item_amount} 元。",
|
||||||
],
|
],
|
||||||
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。",
|
"suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。",
|
||||||
@@ -1365,21 +1538,22 @@ class ExpenseClaimService:
|
|||||||
if (
|
if (
|
||||||
line_count == 0
|
line_count == 0
|
||||||
or not compact_text
|
or not compact_text
|
||||||
or (not has_ticket_keyword and issue_count >= 2)
|
or (recognized_document_type == "other" and not has_ticket_keyword and issue_count >= 2)
|
||||||
|
or not requirement_matches
|
||||||
or (purpose_mismatch_point and amount_mismatch)
|
or (purpose_mismatch_point and amount_mismatch)
|
||||||
):
|
):
|
||||||
severity = "high"
|
severity = "high"
|
||||||
label = "高风险"
|
label = "高风险"
|
||||||
headline = "AI提示:附件不符合票据校验条件"
|
headline = "AI提示:附件不符合票据校验条件"
|
||||||
summary = "当前附件存在明显异常,票据内容与填写信息不一致,或无法作为有效报销材料。"
|
summary = "当前附件存在明显异常,票据类型与当前费用场景不匹配,或无法作为有效报销材料。"
|
||||||
elif purpose_mismatch_point or amount_mismatch or issue_count >= 2 or warnings or (avg_score and avg_score < 0.72):
|
elif purpose_mismatch_point or amount_mismatch or issue_count >= 2 or warnings or (avg_score and avg_score < 0.72):
|
||||||
severity = "medium"
|
severity = "medium"
|
||||||
label = "中风险"
|
label = "中风险"
|
||||||
headline = "AI提示:附件存在明显待整改项"
|
headline = "AI提示:附件存在明显待整改项"
|
||||||
summary = "当前附件可见部分内容,但金额、用途、日期或票据类型仍有缺失或不一致。"
|
summary = "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。"
|
||||||
|
|
||||||
suggestion = {
|
suggestion = {
|
||||||
"high": "建议重新上传清晰的票据原件,确保包含发票抬头、金额、日期等核心字段。",
|
"high": "建议过滤当前不匹配的票据,重新上传符合当前费用场景的清晰原件。",
|
||||||
"medium": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。",
|
"medium": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。",
|
||||||
"low": "建议人工再次核对金额和业务说明,确认后可继续流转。",
|
"low": "建议人工再次核对金额和业务说明,确认后可继续流转。",
|
||||||
}[severity]
|
}[severity]
|
||||||
@@ -1503,14 +1677,35 @@ class ExpenseClaimService:
|
|||||||
list(metadata.get("ocr_warnings") or []),
|
list(metadata.get("ocr_warnings") or []),
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
|
stored_document_info = metadata.get("document_info")
|
||||||
|
if not isinstance(stored_document_info, dict):
|
||||||
|
stored_document_info = {}
|
||||||
document = SimpleNamespace(
|
document = SimpleNamespace(
|
||||||
|
filename=str(metadata.get("file_name") or file_path.name),
|
||||||
text=str(metadata.get("ocr_text") or ""),
|
text=str(metadata.get("ocr_text") or ""),
|
||||||
summary=str(metadata.get("ocr_summary") or ""),
|
summary=str(metadata.get("ocr_summary") or ""),
|
||||||
avg_score=float(metadata.get("ocr_avg_score") or 0.0),
|
avg_score=float(metadata.get("ocr_avg_score") or 0.0),
|
||||||
line_count=int(metadata.get("ocr_line_count") or 0),
|
line_count=int(metadata.get("ocr_line_count") or 0),
|
||||||
|
document_type=str(stored_document_info.get("document_type") or ""),
|
||||||
|
document_type_label=str(stored_document_info.get("document_type_label") or ""),
|
||||||
|
scene_code=str(stored_document_info.get("scene_code") or ""),
|
||||||
|
scene_label=str(stored_document_info.get("scene_label") or ""),
|
||||||
|
document_fields=list(stored_document_info.get("fields") or []),
|
||||||
warnings=[str(value) for value in list(metadata.get("ocr_warnings") or []) if str(value).strip()],
|
warnings=[str(value) for value in list(metadata.get("ocr_warnings") or []) if str(value).strip()],
|
||||||
)
|
)
|
||||||
analysis = self._build_attachment_analysis(document=document, item=item)
|
document_info = self._build_attachment_document_info(document)
|
||||||
|
requirement_check = self._build_attachment_requirement_check(
|
||||||
|
item=item,
|
||||||
|
document_info=document_info,
|
||||||
|
)
|
||||||
|
analysis = self._build_attachment_analysis(
|
||||||
|
document=document,
|
||||||
|
item=item,
|
||||||
|
document_info=document_info,
|
||||||
|
requirement_check=requirement_check,
|
||||||
|
)
|
||||||
|
metadata["document_info"] = document_info
|
||||||
|
metadata["requirement_check"] = requirement_check
|
||||||
else:
|
else:
|
||||||
analysis = self._build_fallback_attachment_analysis(media_type=media_type, item=item)
|
analysis = self._build_fallback_attachment_analysis(media_type=media_type, item=item)
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
|
|||||||
)
|
)
|
||||||
assert uploaded_meta is not None
|
assert uploaded_meta is not None
|
||||||
assert uploaded_meta["analysis"]["severity"] == "pass"
|
assert uploaded_meta["analysis"]["severity"] == "pass"
|
||||||
|
assert uploaded_meta["document_info"]["document_type"] == "office_invoice"
|
||||||
|
assert uploaded_meta["requirement_check"]["matches"] is True
|
||||||
|
|
||||||
updated = service.update_claim_item(
|
updated = service.update_claim_item(
|
||||||
claim_id=claim.id,
|
claim_id=claim.id,
|
||||||
@@ -207,8 +209,9 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
|
|||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
)
|
)
|
||||||
assert refreshed_meta is not None
|
assert refreshed_meta is not None
|
||||||
assert refreshed_meta["analysis"]["severity"] == "medium"
|
assert refreshed_meta["analysis"]["severity"] == "high"
|
||||||
assert any("用途字段" in point for point in refreshed_meta["analysis"]["points"])
|
assert refreshed_meta["requirement_check"]["matches"] is False
|
||||||
|
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
|
||||||
|
|
||||||
|
|
||||||
def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None:
|
def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None:
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
|
|||||||
upload_payload = upload_response.json()
|
upload_payload = upload_response.json()
|
||||||
assert upload_payload["attachment"]["file_name"] == "office-note.png"
|
assert upload_payload["attachment"]["file_name"] == "office-note.png"
|
||||||
assert upload_payload["attachment"]["analysis"]["label"] == "AI提示符合条件"
|
assert upload_payload["attachment"]["analysis"]["label"] == "AI提示符合条件"
|
||||||
|
assert upload_payload["attachment"]["document_info"]["document_type"] == "office_invoice"
|
||||||
|
assert upload_payload["attachment"]["requirement_check"]["matches"] is True
|
||||||
assert upload_payload["invoice_id"]
|
assert upload_payload["invoice_id"]
|
||||||
|
|
||||||
meta_response = client.get(
|
meta_response = client.get(
|
||||||
@@ -164,6 +166,7 @@ def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path)
|
|||||||
meta_payload = meta_response.json()
|
meta_payload = meta_response.json()
|
||||||
assert meta_payload["media_type"] == "image/png"
|
assert meta_payload["media_type"] == "image/png"
|
||||||
assert meta_payload["analysis"]["headline"]
|
assert meta_payload["analysis"]["headline"]
|
||||||
|
assert meta_payload["document_info"]["fields"][0]["label"] == "金额"
|
||||||
|
|
||||||
content_response = client.get(
|
content_response = client.get(
|
||||||
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment",
|
f"/api/v1/reimbursements/claims/{claim_id}/items/{item_id}/attachment",
|
||||||
@@ -228,7 +231,8 @@ def test_claim_item_attachment_upload_flags_purpose_and_amount_mismatch(monkeypa
|
|||||||
analysis = upload_response.json()["attachment"]["analysis"]
|
analysis = upload_response.json()["attachment"]["analysis"]
|
||||||
assert analysis["severity"] == "high"
|
assert analysis["severity"] == "high"
|
||||||
assert any("金额字段" in point for point in analysis["points"])
|
assert any("金额字段" in point for point in analysis["points"])
|
||||||
assert any("用途字段" in point for point in analysis["points"])
|
assert any("附件类型要求" in point for point in analysis["points"])
|
||||||
|
assert upload_response.json()["attachment"]["requirement_check"]["matches"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monkeypatch, tmp_path) -> None:
|
def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monkeypatch, tmp_path) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user