{{ card.risk }}
+ +diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index 82cf3a8..42af4f0 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -12,6 +12,7 @@ from app.schemas.reimbursement import ( ExpenseClaimAttachmentActionResponse, ExpenseClaimActionResponse, ExpenseClaimAttachmentRead, + ExpenseClaimApprovalPayload, ExpenseClaimItemCreate, ExpenseClaimItemActionResponse, ExpenseClaimItemUpdate, @@ -59,6 +60,16 @@ def list_expense_claims(db: DbSession, current_user: CurrentUser) -> list[Expens return ExpenseClaimService(db).list_claims(current_user) +@router.get( + "/claims/approvals", + response_model=list[ExpenseClaimRead], + summary="查询当前用户审批待办报销单列表", + description="返回当前登录用户有权处理的待审批报销单据,不混入个人报销列表。", +) +def list_expense_claim_approvals(db: DbSession, current_user: CurrentUser) -> list[ExpenseClaimRead]: + return ExpenseClaimService(db).list_approval_claims(current_user) + + @router.get( "/claims/{claim_id}", response_model=ExpenseClaimRead, @@ -420,7 +431,7 @@ def submit_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser "/claims/{claim_id}/return", response_model=ExpenseClaimRead, summary="退回报销单", - description="财务人员或高级管理人员可将可见报销单退回到待提交状态。", + description="财务人员、高级管理人员或当前审批人可将可见报销单退回到待提交状态。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, @@ -440,7 +451,40 @@ def return_expense_claim( ) -> ExpenseClaimRead: service = ExpenseClaimService(db) try: - claim = service.return_claim(claim_id, current_user, reason=payload.reason) + claim = service.return_claim(claim_id, current_user, reason=payload.reason, reason_codes=payload.reason_codes) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + if claim is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") + return claim + + +@router.post( + "/claims/{claim_id}/approve", + response_model=ExpenseClaimRead, + summary="直属领导审批通过报销单", + description="当前审批人确认报销信息无误后,将报销单从直属领导审批流转到财务审批。", + responses={ + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "报销单不存在。", + }, + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "当前用户或单据状态不允许审批通过。", + }, + }, +) +def approve_expense_claim( + claim_id: str, + payload: ExpenseClaimApprovalPayload, + db: DbSession, + current_user: CurrentUser, +) -> ExpenseClaimRead: + service = ExpenseClaimService(db) + try: + claim = service.approve_claim(claim_id, current_user, opinion=payload.opinion) except ValueError as error: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index f42af5e..06f9ec8 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -150,6 +150,11 @@ class ExpenseClaimActionResponse(BaseModel): class ExpenseClaimReturnPayload(BaseModel): reason: str | None = Field(default=None, max_length=500) + reason_codes: list[str] = Field(default_factory=list, max_length=10) + + +class ExpenseClaimApprovalPayload(BaseModel): + opinion: str | None = Field(default=None, max_length=500) class ExpenseClaimAttachmentActionResponse(BaseModel): diff --git a/server/src/app/services/document_intelligence.py b/server/src/app/services/document_intelligence.py index d391fce..4f116a8 100644 --- a/server/src/app/services/document_intelligence.py +++ b/server/src/app/services/document_intelligence.py @@ -104,7 +104,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = ( scene_code="transport", scene_label="交通票据", expense_type="transport", - keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"), + keywords=("滴滴出行", "滴滴", "网约车", "出租车", "打车", "乘车", "用车", "叫车", "车费", "车资", "的士", "快车", "专车", "订单号", "上车", "下车", "起点", "终点", "里程", "司机"), score_bias=0.38, ), DocumentRule( diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 8bf8f17..ca917c8 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -2,10 +2,11 @@ from __future__ import annotations import base64 import binascii -import json -import mimetypes -import re -import shutil +import json +import mimetypes +import re +import shutil +import uuid from collections import defaultdict from datetime import UTC, date, datetime, timedelta from decimal import Decimal, InvalidOperation @@ -55,8 +56,9 @@ EXPENSE_TYPE_LABELS = { } PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"} -APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"} -MAX_DRAFT_CLAIMS_PER_USER = 3 +APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"} +MAX_DRAFT_CLAIMS_PER_USER = 3 +EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned") LOCATION_REQUIRED_EXPENSE_TYPES = { "travel", "meeting", @@ -74,13 +76,19 @@ EXPENSE_SCENE_KEYWORDS = { "hotel": ("酒店", "住宿", "房费", "客房", "入住", "离店"), "transport": ( "交通", - "打车", - "出租车", - "网约车", - "滴滴", - "出行", - "高铁", - "动车", + "打车", + "出租车", + "网约车", + "滴滴", + "出行", + "乘车", + "用车", + "叫车", + "车费", + "车资", + "的士", + "高铁", + "动车", "火车", "机票", "航班", @@ -123,11 +131,19 @@ DOCUMENT_SCENE_LABELS = { "other": "其他票据", } -DOCUMENT_ASSOCIATION_REVIEW_ACTIONS = { - "link_to_existing_draft", - "create_new_claim_from_documents", -} -MAX_CLAIM_NO_RETRY_ATTEMPTS = 3 +DOCUMENT_ASSOCIATION_REVIEW_ACTIONS = { + "link_to_existing_draft", + "create_new_claim_from_documents", +} +RETURN_REASON_OPTIONS = { + "missing_attachment": "附件缺失或不清晰", + "invoice_mismatch": "票据类型/金额与明细不一致", + "over_policy": "超出制度标准或缺少超标说明", + "business_explanation": "业务事由/地点/人员信息不完整", + "duplicate_or_abnormal": "疑似重复或异常票据", + "approval_question": "审批人需要补充说明", +} +MAX_CLAIM_NO_RETRY_ATTEMPTS = 3 DOCUMENT_AMOUNT_PATTERNS = ( re.compile( r"(?:价税合计|合计金额|费用合计|订单(?:总)?金额|支付(?:金额)?|实付(?:金额)?|实收(?:金额)?|总(?:额|计|价)|票价|金额|车费|消费金额)" @@ -137,18 +153,28 @@ DOCUMENT_AMOUNT_PATTERNS = ( re.compile(r"([0-9]+(?:[.,][0-9]{1,2})?)\s*元"), ) DOCUMENT_DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)") -SYSTEM_GENERATED_REASON_PREFIXES = ( - "我上传了", - "请按当前已识别信息", - "请把当前上传的票据", +SYSTEM_GENERATED_REASON_PREFIXES = ( + "我上传了", + "请按当前已识别信息", + "请把当前上传的票据", "请基于当前上传的多张票据", "我已核对右侧识别结果", "请同步修正逐票据识别结果", "我已修改识别信息", - "查看报销草稿", - "请解释一下当前这笔报销的合规风险和待补充项", -) -AI_REVIEW_LOOKBACK_DAYS = 90 + "查看报销草稿", + "请解释一下当前这笔报销的合规风险和待补充项", +) +LEADING_REASON_TIME_PATTERNS = ( + re.compile( + r"^\s*(?:识别事项(?:有)?[::]\s*)?" + r"(?:业务发生(?:时间|日期)|费用发生(?:时间|日期)|发生(?:时间|日期)|报销(?:时间|日期)|时间)[::]?\s*" + r"(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,,。;;、]?\s*" + ), + re.compile( + r"^\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,,。;;、]\s*" + ), +) +AI_REVIEW_LOOKBACK_DAYS = 90 AI_REVIEW_REPEAT_RISK_WARNING_COUNT = 1 AI_REVIEW_REPEAT_RISK_BLOCK_COUNT = 2 TRAVEL_REVIEW_RELEVANT_EXPENSE_TYPES = {"travel", "hotel", "transport"} @@ -278,31 +304,44 @@ class ExpenseClaimService: self.db = db self.audit_service = AuditLogService(db) - def list_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: - stmt = ( - select(ExpenseClaim) - .options( - selectinload(ExpenseClaim.items), + def list_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: + stmt = ( + select(ExpenseClaim) + .options( + selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee).selectinload(Employee.manager), selectinload(ExpenseClaim.employee).selectinload(Employee.roles), ) - .order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc()) - ) - stmt = self._apply_claim_scope(stmt, current_user) - return list(self.db.scalars(stmt).all()) - - def get_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None: - stmt = ( - select(ExpenseClaim) - .options( - selectinload(ExpenseClaim.items), + .order_by(ExpenseClaim.created_at.desc(), ExpenseClaim.occurred_at.desc()) + ) + stmt = self._apply_claim_scope(stmt, current_user) + return list(self.db.scalars(stmt).all()) + + def list_approval_claims(self, current_user: CurrentUserContext) -> list[ExpenseClaim]: + stmt = ( + select(ExpenseClaim) + .options( + selectinload(ExpenseClaim.items), + selectinload(ExpenseClaim.employee).selectinload(Employee.manager), + selectinload(ExpenseClaim.employee).selectinload(Employee.roles), + ) + .order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.created_at.desc()) + ) + stmt = self._apply_approval_claim_scope(stmt, current_user) + return list(self.db.scalars(stmt).all()) + + def get_claim(self, claim_id: str, current_user: CurrentUserContext) -> ExpenseClaim | None: + stmt = ( + select(ExpenseClaim) + .options( + selectinload(ExpenseClaim.items), selectinload(ExpenseClaim.employee).selectinload(Employee.manager), selectinload(ExpenseClaim.employee).selectinload(Employee.roles), - ) - .where(ExpenseClaim.id == claim_id) - ) - stmt = self._apply_claim_scope(stmt, current_user) - return self.db.scalar(stmt) + ) + .where(ExpenseClaim.id == claim_id) + ) + stmt = self._apply_claim_scope(stmt, current_user, include_approval_scope=True) + return self.db.scalar(stmt) def update_claim_item( self, @@ -846,34 +885,81 @@ class ExpenseClaimService: current_user: CurrentUserContext, *, reason: str | None = None, + reason_codes: list[str] | None = None, ) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) if claim is None: return None - if not self._has_privileged_claim_access(current_user): - raise ValueError("只有财务人员或高级管理人员可以退回报销单。") + if not self._can_return_claim(current_user, claim): + raise ValueError("只有财务人员、高级管理人员或当前审批人可以退回报销单。") normalized_status = str(claim.status or "").strip().lower() if normalized_status == "draft": raise ValueError("草稿状态无需退回。") + if normalized_status == "returned": + raise ValueError("该单据已处于退回待提交状态,无需重复退回。") if normalized_status in {"approved", "completed", "paid"}: raise ValueError("已完成单据不允许退回。") before_json = self._serialize_claim(claim) operator = current_user.name or current_user.username + previous_status = str(claim.status or "").strip() + previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节" + previous_stage_key = self._normalize_return_stage_key(previous_stage) return_reason = str(reason or "").strip() + reason_code_payload = self._normalize_return_reason_code_payload(reason_codes) + normalized_reason_codes = reason_code_payload["reason_codes"] + unknown_reason_codes = reason_code_payload["unknown_reason_codes"] + risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes] + existing_return_flags = self._collect_return_flags(claim.risk_flags_json) + return_count = len(existing_return_flags) + 1 + stage_return_count = ( + sum( + 1 + for flag in existing_return_flags + if ( + str(flag.get("return_stage_key") or "").strip() + or self._normalize_return_stage_key(str(flag.get("return_stage") or "").strip()) + ) + == previous_stage_key + ) + + 1 + ) + message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points) return_flag = { "source": "manual_return", + "event_type": "expense_claim_return", + "return_event_id": str(uuid.uuid4()), "severity": "medium", "label": "人工退回", - "message": return_reason or f"{operator} 已退回该报销单,请申请人调整后重新提交。", + "message": message, + "reason": return_reason, + "reason_codes": normalized_reason_codes, + "risk_points": risk_points, "operator": operator, + "operator_username": current_user.username, + "operator_role_codes": [ + str(item).strip().lower() + for item in current_user.role_codes + if str(item).strip() + ], + "previous_status": previous_status, + "previous_approval_stage": previous_stage, + "return_stage": previous_stage, + "return_stage_key": previous_stage_key, + "next_status": "returned", + "next_approval_stage": "待提交", + "return_count": return_count, + "stage_return_count": stage_return_count, "created_at": datetime.now(UTC).isoformat(), } + if unknown_reason_codes: + return_flag["unknown_reason_codes"] = unknown_reason_codes claim.status = "returned" claim.approval_stage = "待提交" + claim.submitted_at = None claim.risk_flags_json = [*list(claim.risk_flags_json or []), return_flag] self.db.commit() @@ -890,6 +976,74 @@ class ExpenseClaimService: return claim + def approve_claim( + self, + claim_id: str, + current_user: CurrentUserContext, + *, + opinion: str | None = None, + ) -> ExpenseClaim | None: + claim = self.get_claim(claim_id, current_user) + if claim is None: + return None + + if not self._can_approve_claim(current_user, claim): + raise ValueError("只有当前直属领导审批人可以审批通过该报销单。") + + normalized_status = str(claim.status or "").strip().lower() + if normalized_status != "submitted": + raise ValueError("只有审批中的报销单可以审批通过。") + + previous_stage = str(claim.approval_stage or "").strip() + if previous_stage != "直属领导审批": + raise ValueError("当前节点不是直属领导审批,不能执行领导审批通过。") + + before_json = self._serialize_claim(claim) + operator = current_user.name or current_user.username + leader_opinion = str(opinion or "").strip() + next_stage = "财务审批" + approval_flag = { + "source": "manual_approval", + "event_type": "expense_claim_approval", + "approval_event_id": str(uuid.uuid4()), + "severity": "info", + "label": "领导审批通过", + "message": leader_opinion or f"{operator} 已审批通过,流转至{next_stage}。", + "opinion": leader_opinion, + "operator": operator, + "operator_username": current_user.username, + "operator_role_codes": [ + str(item).strip().lower() + for item in current_user.role_codes + if str(item).strip() + ], + "previous_status": str(claim.status or "").strip(), + "previous_approval_stage": previous_stage, + "next_status": "submitted", + "next_approval_stage": next_stage, + "created_at": datetime.now(UTC).isoformat(), + } + + claim.status = "submitted" + claim.approval_stage = next_stage + if claim.submitted_at is None: + claim.submitted_at = datetime.now(UTC) + claim.risk_flags_json = [*list(claim.risk_flags_json or []), approval_flag] + + self.db.commit() + self.db.refresh(claim) + + self.audit_service.log_action( + actor=operator, + action="expense_claim.approve", + resource_type="expense_claim", + resource_id=claim.id, + before_json=before_json, + after_json=self._serialize_claim(claim), + ) + + return claim + def upsert_draft_from_ontology( self, *, @@ -994,9 +1148,10 @@ class ExpenseClaimService: final_attachment_count = ( attachment_count if attachment_count > 0 else int(claim.invoice_count or 0) if claim is not None else 0 ) - final_risk_flags = list(ontology.risk_flags) or ( - list(claim.risk_flags_json or []) if claim is not None else [] - ) + final_risk_flags = self._merge_persistent_claim_risk_flags( + existing_flags=list(claim.risk_flags_json or []) if claim is not None else [], + next_flags=list(ontology.risk_flags), + ) try: if claim is None: @@ -1121,14 +1276,14 @@ class ExpenseClaimService: request_id=run_id, ) - return { - "message": ( - f"已{'创建' if is_new_claim else '更新'}报销草稿 {claim.claim_no},当前状态为 draft。" - "你可以继续补充费用明细、客户单位和票据附件。" - ), - "draft_only": True, - "claim_id": claim.id, - "claim_no": claim.claim_no, + return { + "message": ( + f"已{'创建' if is_new_claim else '更新'}报销草稿 {claim.claim_no},当前状态为 draft。" + "请核对识别结果,确认无误后继续提交。" + ), + "draft_only": True, + "claim_id": claim.id, + "claim_no": claim.claim_no, "status": claim.status, "amount": float(claim.amount), "invoice_count": int(claim.invoice_count or 0), @@ -1147,12 +1302,12 @@ class ExpenseClaimService: if review_action == "link_to_existing_draft" and association_candidate is not None: return association_candidate - draft_claim_id = str(context_json.get("draft_claim_id") or "").strip() - if draft_claim_id: - claim = self.db.get(ExpenseClaim, draft_claim_id) - if claim is not None and str(claim.status or "").strip() == "draft": - return claim - return None + draft_claim_id = str(context_json.get("draft_claim_id") or "").strip() + if draft_claim_id: + claim = self.db.get(ExpenseClaim, draft_claim_id) + if claim is not None and self._is_editable_claim_status(claim.status): + return claim + return None claim_codes = [ item.normalized_value @@ -1163,11 +1318,11 @@ class ExpenseClaimService: return None stmt = ( - select(ExpenseClaim) - .where(ExpenseClaim.claim_no.in_(claim_codes)) - .where(ExpenseClaim.status == "draft") - .limit(1) - ) + select(ExpenseClaim) + .where(ExpenseClaim.claim_no.in_(claim_codes)) + .where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES)) + .limit(1) + ) return self.db.scalar(stmt) def _find_association_candidate( @@ -1178,11 +1333,11 @@ class ExpenseClaimService: user_id: str | None, employee: Employee | None, ) -> ExpenseClaim | None: - draft_claim_id = str(context_json.get("draft_claim_id") or "").strip() - if draft_claim_id: - claim = self.db.get(ExpenseClaim, draft_claim_id) - if claim is not None and str(claim.status or "").strip() == "draft": - return claim + draft_claim_id = str(context_json.get("draft_claim_id") or "").strip() + if draft_claim_id: + claim = self.db.get(ExpenseClaim, draft_claim_id) + if claim is not None and self._is_editable_claim_status(claim.status): + return claim owner_filters = self._build_draft_owner_filters( employee=employee, @@ -1202,11 +1357,11 @@ class ExpenseClaimService: return None stmt = ( - select(ExpenseClaim) - .where(ExpenseClaim.status == "draft") - .where(or_(*owner_filters)) - .order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.created_at.desc()) - .limit(1) + select(ExpenseClaim) + .where(ExpenseClaim.status.in_(EDITABLE_CLAIM_STATUSES)) + .where(or_(*owner_filters)) + .order_by(ExpenseClaim.updated_at.desc(), ExpenseClaim.created_at.desc()) + .limit(1) ) return self.db.scalar(stmt) @@ -1380,11 +1535,11 @@ class ExpenseClaimService: claim.items.append(item) self.db.add(item) item.item_date = spec["item_date"] - item.item_type = spec["item_type"] - item.item_reason = spec["item_reason"] - item.item_location = spec["item_location"] - item.item_amount = spec["item_amount"] - item.invoice_id = spec["invoice_id"] + item.item_type = spec["item_type"] + item.item_reason = spec["item_reason"] + item.item_location = spec["item_location"] + item.item_amount = spec["item_amount"] + item.invoice_id = self._merge_attachment_reference(item.invoice_id, spec["invoice_id"]) for stale_item in existing_items[len(item_specs) :]: claim.items.remove(stale_item) @@ -1396,17 +1551,23 @@ class ExpenseClaimService: claim: ExpenseClaim, item_specs: list[dict[str, Any]], ) -> None: - existing_invoice_ids = { - str(item.invoice_id or "").strip() - for item in claim.items - if str(item.invoice_id or "").strip() - } - for spec in item_specs: - invoice_id = str(spec.get("invoice_id") or "").strip() - if invoice_id and invoice_id in existing_invoice_ids: - continue - claim.items.append( - ExpenseClaimItem( + existing_invoice_ids = { + str(item.invoice_id or "").strip() + for item in claim.items + if str(item.invoice_id or "").strip() + } + existing_invoice_names = { + self._resolve_attachment_display_name(item.invoice_id) + for item in claim.items + if str(item.invoice_id or "").strip() + } + for spec in item_specs: + invoice_id = str(spec.get("invoice_id") or "").strip() + invoice_name = self._resolve_attachment_display_name(invoice_id) + if invoice_id and (invoice_id in existing_invoice_ids or invoice_name in existing_invoice_names): + continue + claim.items.append( + ExpenseClaimItem( claim_id=claim.id, item_date=spec["item_date"], item_type=spec["item_type"], @@ -1416,9 +1577,10 @@ class ExpenseClaimService: invoice_id=spec["invoice_id"], ) ) - self.db.add(claim.items[-1]) - if invoice_id: - existing_invoice_ids.add(invoice_id) + self.db.add(claim.items[-1]) + if invoice_id: + existing_invoice_ids.add(invoice_id) + existing_invoice_names.add(invoice_name) def _resolve_document_item_type(self, document: dict[str, Any], *, fallback: str) -> str: scene_code = str(document.get("scene_code") or "").strip() @@ -1570,10 +1732,14 @@ class ExpenseClaimService: item.item_date = occurred_at.date() item.item_type = expense_type - item.item_reason = reason - item.item_location = location - item.item_amount = amount - item.invoice_id = attachment_names[0] if attachment_names else item.invoice_id + item.item_reason = reason + item.item_location = location + item.item_amount = amount + item.invoice_id = ( + self._merge_attachment_reference(item.invoice_id, attachment_names[0]) + if attachment_names + else item.invoice_id + ) def _generate_claim_no(self, occurred_at: datetime) -> str: month_code = occurred_at.strftime("%Y%m") @@ -1776,8 +1942,8 @@ class ExpenseClaimService: return "travel" if any(word in compact for word in ("住宿", "酒店", "宾馆")): return "hotel" - if any(word in compact for word in ("交通", "打车", "网约车", "出租车", "停车", "车费")): - return "transport" + if any(word in compact for word in ("交通", "打车", "网约车", "出租车", "乘车", "用车", "叫车", "车费", "车资", "的士", "停车")): + return "transport" if any(word in compact for word in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")): return "meal" if "会务" in compact: @@ -1798,25 +1964,25 @@ class ExpenseClaimService: return None @staticmethod - def _resolve_reason( - *, - message: str, - context_json: dict[str, Any], - allow_message_fallback: bool, + def _resolve_reason( + *, + message: str, + context_json: dict[str, Any], + allow_message_fallback: bool, ) -> str | None: review_form_values = context_json.get("review_form_values") - if isinstance(review_form_values, dict): - for key in ("reason", "business_reason"): - value = str(review_form_values.get(key) or "").strip() - if value: - return value - - explicit_text = context_json.get("user_input_text") - if isinstance(explicit_text, str): - normalized_explicit_text = explicit_text.strip() - if normalized_explicit_text: - return normalized_explicit_text[:500] - return None + if isinstance(review_form_values, dict): + for key in ("reason", "business_reason"): + value = str(review_form_values.get(key) or "").strip() + if value: + return ExpenseClaimService._strip_leading_time_from_reason(value) + + explicit_text = context_json.get("user_input_text") + if isinstance(explicit_text, str): + normalized_explicit_text = explicit_text.strip() + if normalized_explicit_text: + return ExpenseClaimService._strip_leading_time_from_reason(normalized_explicit_text)[:500] or None + return None request_context = context_json.get("request_context") if ( @@ -1831,13 +1997,22 @@ class ExpenseClaimService: return None normalized_message = str(message or "").strip() - compact_message = re.sub(r"\s+", "", normalized_message) - if compact_message.startswith(SYSTEM_GENERATED_REASON_PREFIXES): - return None - return normalized_message[:500] or None - - @staticmethod - def _resolve_location(*, message: str, context_json: dict[str, Any]) -> str | None: + compact_message = re.sub(r"\s+", "", normalized_message) + if compact_message.startswith(SYSTEM_GENERATED_REASON_PREFIXES): + return None + return ExpenseClaimService._strip_leading_time_from_reason(normalized_message)[:500] or None + + @staticmethod + def _strip_leading_time_from_reason(value: str) -> str: + reason = str(value or "").strip() + for pattern in LEADING_REASON_TIME_PATTERNS: + next_reason = pattern.sub("", reason).strip() + if next_reason != reason: + return next_reason + return reason + + @staticmethod + def _resolve_location(*, message: str, context_json: dict[str, Any]) -> str | None: review_form_values = context_json.get("review_form_values") if isinstance(review_form_values, dict): for key in ("business_location", "location"): @@ -1960,27 +2135,46 @@ class ExpenseClaimService: return normalized return f"attachment{suffix or '.bin'}" - def _resolve_attachment_path(self, storage_key: str | None) -> Path | None: - normalized = str(storage_key or "").strip() - if not normalized: - return None - + def _resolve_attachment_path(self, storage_key: str | None) -> Path | None: + normalized = str(storage_key or "").strip() + if not normalized: + return None + root = self._get_attachment_storage_root() path = (root / normalized).resolve() try: path.relative_to(root) except ValueError as exc: - raise FileNotFoundError("Attachment path is invalid") from exc - return path - - def _to_attachment_storage_key(self, file_path: Path) -> str: - root = self._get_attachment_storage_root() - return file_path.resolve().relative_to(root).as_posix() - - def _resolve_item_attachment_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]: - file_path = self._resolve_attachment_path(item.invoice_id) - if file_path is None or not file_path.exists(): - raise FileNotFoundError("Attachment not found") + raise FileNotFoundError("Attachment path is invalid") from exc + return path + + def _resolve_item_attachment_path(self, item: ExpenseClaimItem) -> Path | None: + if not str(item.invoice_id or "").strip(): + return None + + file_path = self._resolve_attachment_path(item.invoice_id) + if file_path is not None and file_path.exists(): + return file_path + + filename = self._normalize_attachment_filename(item.invoice_id) + if not filename: + return file_path + + fallback_path = (self._build_item_attachment_dir(item.claim_id, item.id) / filename).resolve() + try: + fallback_path.relative_to(self._get_attachment_storage_root()) + except ValueError as exc: + raise FileNotFoundError("Attachment path is invalid") from exc + return fallback_path + + def _to_attachment_storage_key(self, file_path: Path) -> str: + root = self._get_attachment_storage_root() + return file_path.resolve().relative_to(root).as_posix() + + def _resolve_item_attachment_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]: + file_path = self._resolve_item_attachment_path(item) + if file_path is None or not file_path.exists(): + raise FileNotFoundError("Attachment not found") metadata = self._read_attachment_meta(file_path) filename = str(metadata.get("file_name") or file_path.name) @@ -1988,12 +2182,12 @@ class ExpenseClaimService: filename, fallback=str(metadata.get("media_type") or ""), ) - return file_path, media_type, filename - - def _delete_item_attachment_files(self, item: ExpenseClaimItem) -> None: - file_path = self._resolve_attachment_path(item.invoice_id) - if file_path is None: - return + return file_path, media_type, filename + + def _delete_item_attachment_files(self, item: ExpenseClaimItem) -> None: + file_path = self._resolve_item_attachment_path(item) + if file_path is None: + return root = self._get_attachment_storage_root() if file_path.parent == root: @@ -2188,9 +2382,25 @@ class ExpenseClaimService: resolved = str(media_type or "").strip() or (mimetypes.guess_type(filename)[0] or "") return resolved.startswith("image/") or resolved == "application/pdf" - @staticmethod - def _resolve_attachment_display_name(storage_key: str | None) -> str: - return Path(str(storage_key or "").strip()).name + @staticmethod + def _resolve_attachment_display_name(storage_key: str | None) -> str: + return Path(str(storage_key or "").strip()).name + + @classmethod + def _merge_attachment_reference(cls, current_invoice_id: str | None, next_invoice_id: str | None) -> str | None: + normalized_next = str(next_invoice_id or "").strip() + if not normalized_next: + return None + + normalized_current = str(current_invoice_id or "").strip() + if ( + normalized_current + and cls._resolve_attachment_display_name(normalized_current) + == cls._resolve_attachment_display_name(normalized_next) + ): + return normalized_current + + return normalized_next def _build_attachment_document_info(self, document: Any) -> dict[str, Any]: insight = build_document_insight( @@ -2590,10 +2800,10 @@ class ExpenseClaimService: } @staticmethod - def _serialize_claim(claim: ExpenseClaim) -> dict[str, Any]: - return { - "id": claim.id, - "claim_no": claim.claim_no, + def _serialize_claim(claim: ExpenseClaim) -> dict[str, Any]: + return { + "id": claim.id, + "claim_no": claim.claim_no, "employee_name": claim.employee_name, "department_name": claim.department_name, "project_code": claim.project_code, @@ -2604,11 +2814,98 @@ class ExpenseClaimService: "invoice_count": int(claim.invoice_count or 0), "status": claim.status, "approval_stage": claim.approval_stage, - "risk_flags_json": list(claim.risk_flags_json or []), - } - - @staticmethod - def _normalize_optional_text(value: str | None, *, fallback: str = "", allow_empty: bool = False) -> str | None: + "risk_flags_json": list(claim.risk_flags_json or []), + } + + @staticmethod + def _collect_return_flags(risk_flags: Any) -> list[dict[str, Any]]: + if not isinstance(risk_flags, list): + return [] + + return [ + flag + for flag in risk_flags + if isinstance(flag, dict) and str(flag.get("source") or "").strip() == "manual_return" + ] + + @staticmethod + def _normalize_return_reason_codes(reason_codes: list[str] | None) -> list[str]: + return ExpenseClaimService._normalize_return_reason_code_payload(reason_codes)["reason_codes"] + + @staticmethod + def _normalize_return_reason_code_payload(reason_codes: list[str] | None) -> dict[str, list[str]]: + normalized_codes: list[str] = [] + unknown_codes: list[str] = [] + for item in reason_codes or []: + code = str(item or "").strip() + if not code: + continue + if code in RETURN_REASON_OPTIONS and code not in normalized_codes: + normalized_codes.append(code) + elif code not in RETURN_REASON_OPTIONS and code not in unknown_codes: + unknown_codes.append(code) + return { + "reason_codes": normalized_codes, + "unknown_reason_codes": unknown_codes, + } + + @staticmethod + def _merge_persistent_claim_risk_flags(*, existing_flags: list[Any], next_flags: list[Any]) -> list[Any]: + if not next_flags: + return list(existing_flags or []) + + merged_flags = list(next_flags or []) + next_return_markers = { + ExpenseClaimService._build_return_flag_marker(flag) + for flag in merged_flags + if isinstance(flag, dict) and str(flag.get("source") or "").strip() == "manual_return" + } + for flag in list(existing_flags or []): + if not (isinstance(flag, dict) and str(flag.get("source") or "").strip() == "manual_return"): + continue + marker = ExpenseClaimService._build_return_flag_marker(flag) + if marker in next_return_markers: + continue + merged_flags.append(flag) + next_return_markers.add(marker) + return merged_flags + + @staticmethod + def _build_return_flag_marker(flag: dict[str, Any]) -> tuple[str, str, str]: + event_id = str(flag.get("return_event_id") or "").strip() + if event_id: + return ("event_id", event_id, "") + return ( + str(flag.get("return_count") or "").strip(), + str(flag.get("created_at") or "").strip(), + str(flag.get("message") or flag.get("reason") or "").strip(), + ) + + @staticmethod + def _build_default_return_message(*, operator: str, risk_points: list[str]) -> str: + if risk_points: + return f"{operator} 退回该报销单:{'、'.join(risk_points)}。请申请人调整后重新提交。" + return f"{operator} 已退回该报销单,请申请人调整后重新提交。" + + @staticmethod + def _normalize_return_stage_key(stage: str | None) -> str: + normalized = str(stage or "").strip() + if "直属" in normalized or "领导" in normalized or "负责人" in normalized: + return "direct_manager" + if "财务" in normalized: + return "finance" + if "AI" in normalized or "预审" in normalized: + return "ai_review" + if "归档" in normalized or "入账" in normalized: + return "archive" + return "unknown" + + @staticmethod + def _is_editable_claim_status(status: str | None) -> bool: + return str(status or "").strip().lower() in EDITABLE_CLAIM_STATUSES + + @staticmethod + def _normalize_optional_text(value: str | None, *, fallback: str = "", allow_empty: bool = False) -> str | None: normalized = str(value or "").strip() if normalized: return normalized @@ -2632,9 +2929,8 @@ class ExpenseClaimService: compact = text.replace(" ", "") return compact in {"待补充", "暂无", "无", "未知", "处理中"} - def _ensure_draft_claim(self, claim: ExpenseClaim) -> None: - normalized_status = str(claim.status or "").strip().lower() - if normalized_status not in {"draft", "supplement", "returned"}: + def _ensure_draft_claim(self, claim: ExpenseClaim) -> None: + if not self._is_editable_claim_status(claim.status): raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。") def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]: @@ -4298,19 +4594,53 @@ class ExpenseClaimService: return bool(policy.location_required) @staticmethod - def _has_privileged_claim_access(current_user: CurrentUserContext) -> bool: - if current_user.is_admin: - return True - role_codes = { - str(item).strip().lower() + def _has_privileged_claim_access(current_user: CurrentUserContext) -> bool: + if current_user.is_admin: + return True + role_codes = { + str(item).strip().lower() for item in current_user.role_codes if str(item).strip() - } - return bool(role_codes & PRIVILEGED_CLAIM_ROLE_CODES) - - @staticmethod - def _normalize_role_codes(current_user: CurrentUserContext) -> set[str]: - return { + } + return bool(role_codes & PRIVILEGED_CLAIM_ROLE_CODES) + + def _can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: + if self._has_privileged_claim_access(current_user): + return True + + role_codes = self._normalize_role_codes(current_user) + if not (role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES): + return False + if str(claim.status or "").strip().lower() != "submitted": + return False + if str(claim.approval_stage or "").strip() != "直属领导审批": + return False + + current_employee = self._resolve_current_employee(current_user) + if current_employee is not None and str(claim.employee_id or "").strip() == current_employee.id: + return False + + claim_employee = claim.employee + if current_employee is not None and claim_employee is not None: + if claim_employee.manager_id == current_employee.id: + return True + if claim_employee.manager is not None and claim_employee.manager.id == current_employee.id: + return True + + approver_name = str( + current_employee.name if current_employee is not None and current_employee.name else current_user.name or "" + ).strip() + if not approver_name: + return False + + return self._resolve_claim_manager_name(claim) == approver_name + + def _can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: + return self._can_return_claim(current_user, claim) + + @staticmethod + def _normalize_role_codes(current_user: CurrentUserContext) -> set[str]: + return { str(item).strip().lower() for item in current_user.role_codes if str(item).strip() @@ -4459,13 +4789,10 @@ class ExpenseClaimService: ) return same_name_count == 1 - def _apply_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any: - if self._has_privileged_claim_access(current_user): - return stmt - - conditions = [] - username = str(current_user.username or "").strip() - employee = self._resolve_current_employee(current_user) + def _build_personal_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]: + conditions = [] + username = str(current_user.username or "").strip() + employee = self._resolve_current_employee(current_user) def add_condition(field_name: str, value: str | None) -> None: normalized = str(value or "").strip() @@ -4481,32 +4808,76 @@ class ExpenseClaimService: add_condition("employee_name", employee.email) if self._employee_name_is_unique(employee): add_condition("employee_name", employee.name) - else: - add_condition("employee_id", username) - add_condition("employee_name", username) - - if not conditions: - return stmt.where(ExpenseClaim.id == "__no_visible_claim__") - - role_codes = self._normalize_role_codes(current_user) - if role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES: - pending_leader_approval = and_( - ExpenseClaim.status == "submitted", - ExpenseClaim.approval_stage == "直属领导审批", - ) - if employee is not None: - subordinate_ids = select(Employee.id).where(Employee.manager_id == employee.id) - conditions.append(and_(pending_leader_approval, ExpenseClaim.employee_id.in_(subordinate_ids))) - manager_name = str( - employee.name if employee is not None and employee.name else current_user.name or "" - ).strip() - if manager_name: - managed_department_ids = select(OrganizationUnit.id).where(OrganizationUnit.manager_name == manager_name) - managed_department_names = select(OrganizationUnit.name).where(OrganizationUnit.manager_name == manager_name) - conditions.append(and_(pending_leader_approval, ExpenseClaim.department_id.in_(managed_department_ids))) - conditions.append(and_(pending_leader_approval, ExpenseClaim.department_name.in_(managed_department_names))) - - return stmt.where(or_(*conditions)) + else: + add_condition("employee_id", username) + add_condition("employee_name", username) + + return conditions + + def _build_approval_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]: + role_codes = self._normalize_role_codes(current_user) + if not (role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES): + return [] + + employee = self._resolve_current_employee(current_user) + manager_name = str( + employee.name if employee is not None and employee.name else current_user.name or "" + ).strip() + pending_leader_approval_parts = [ + ExpenseClaim.status == "submitted", + ExpenseClaim.approval_stage == "直属领导审批", + ] + if employee is not None: + pending_leader_approval_parts.append( + or_(ExpenseClaim.employee_id.is_(None), ExpenseClaim.employee_id != employee.id) + ) + if manager_name: + pending_leader_approval_parts.append(ExpenseClaim.employee_name != manager_name) + + pending_leader_approval = and_(*pending_leader_approval_parts) + conditions = [] + + if employee is not None: + subordinate_ids = select(Employee.id).where(Employee.manager_id == employee.id) + conditions.append(and_(pending_leader_approval, ExpenseClaim.employee_id.in_(subordinate_ids))) + + if manager_name: + managed_department_ids = select(OrganizationUnit.id).where(OrganizationUnit.manager_name == manager_name) + managed_department_names = select(OrganizationUnit.name).where(OrganizationUnit.manager_name == manager_name) + conditions.append(and_(pending_leader_approval, ExpenseClaim.department_id.in_(managed_department_ids))) + conditions.append(and_(pending_leader_approval, ExpenseClaim.department_name.in_(managed_department_names))) + + return conditions + + def _apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any: + if self._has_privileged_claim_access(current_user): + return stmt.where(ExpenseClaim.status == "submitted") + + conditions = self._build_approval_claim_conditions(current_user) + if not conditions: + return stmt.where(ExpenseClaim.id == "__no_visible_claim__") + + return stmt.where(or_(*conditions)) + + def _apply_claim_scope( + self, + stmt: Any, + current_user: CurrentUserContext, + *, + include_approval_scope: bool = False, + ) -> Any: + if self._has_privileged_claim_access(current_user): + return stmt + + conditions = self._build_personal_claim_conditions(current_user) + + if not conditions: + return stmt.where(ExpenseClaim.id == "__no_visible_claim__") + + if include_approval_scope: + conditions.extend(self._build_approval_claim_conditions(current_user)) + + return stmt.where(or_(*conditions)) def _ensure_ready(self) -> None: AgentFoundationService(self.db).ensure_foundation_ready() diff --git a/server/src/app/services/ontology.py b/server/src/app/services/ontology.py index 6347177..a13cfbc 100644 --- a/server/src/app/services/ontology.py +++ b/server/src/app/services/ontology.py @@ -167,11 +167,16 @@ EXPENSE_TYPE_KEYWORDS = { "出差": "travel", "住宿": "hotel", "酒店": "hotel", - "交通": "transport", - "打车": "transport", - "网约车": "transport", - "出租车": "transport", - "停车费": "transport", + "交通": "transport", + "打车": "transport", + "网约车": "transport", + "出租车": "transport", + "乘车": "transport", + "乘车费": "transport", + "用车": "transport", + "叫车": "transport", + "车资": "transport", + "停车费": "transport", "餐费": "meal", "用餐": "meal", "会务": "meeting", @@ -202,9 +207,14 @@ EXPENSE_NARRATIVE_KEYWORDS = ( "花了", "支出", "垫付", - "打车", - "车费", - "餐费", + "打车", + "车费", + "乘车", + "乘车费", + "用车", + "叫车", + "车资", + "餐费", "吃饭", "用餐", "宴请", @@ -1190,8 +1200,11 @@ class SemanticOntologyService: ) ) - if any(keyword in query for keyword in ("打车", "网约车", "出租车", "车费", "停车费", "过路费")): - upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9)) + if any( + keyword in query + for keyword in ("打车", "网约车", "出租车", "车费", "乘车", "用车", "叫车", "车资", "停车费", "过路费") + ): + upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9)) if any(keyword in query for keyword in ("出差", "机票", "火车", "高铁", "行程单")): upsert(self._make_entity("expense_type", "差旅", "travel", role="filter", confidence=0.88)) diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index 11087eb..c110368 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -226,6 +226,16 @@ SYSTEM_GENERATED_REASON_PREFIXES = ( "查看报销草稿", "请解释一下当前这笔报销的合规风险和待补充项", ) +LEADING_REASON_TIME_PATTERNS = ( + re.compile( + r"^\s*(?:识别事项(?:有)?[::]\s*)?" + r"(?:业务发生(?:时间|日期)|费用发生(?:时间|日期)|发生(?:时间|日期)|报销(?:时间|日期)|时间)[::]?\s*" + r"(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,,。;;、]?\s*" + ), + re.compile( + r"^\s*(?:19|20)\d{2}[-/年.]\d{1,2}[-/月.]\d{1,2}日?\s*[,,。;;、]\s*" + ), +) AMOUNT_UNIT_ALIASES = { "员": "元", "圆": "元", @@ -2298,8 +2308,11 @@ class UserAgentService: @staticmethod def _resolve_submission_blocked_reasons(payload: UserAgentRequest) -> list[str]: raw_reasons = payload.tool_payload.get("submission_blocked_reasons") - if raw_reasons is None: + submission_blocked = bool(payload.tool_payload.get("submission_blocked")) + if raw_reasons is None and submission_blocked: raw_reasons = payload.tool_payload.get("missing_fields") + if raw_reasons is None and not submission_blocked: + return [] reasons: list[str] = [] if isinstance(raw_reasons, list): @@ -2311,11 +2324,18 @@ class UserAgentService: if item.strip() ) - if not reasons: + if not reasons and submission_blocked: message = str(payload.tool_payload.get("message") or "").strip() - prefix = "提交前请先补全信息:" - if message.startswith(prefix): - message = message[len(prefix):].strip() + for prefix in ( + "提交前请先补全信息:", + "AI预审暂未通过,原因如下:", + "AI预审未通过,原因如下:", + "AI预审暂未通过:", + "AI预审未通过:", + ): + if message.startswith(prefix): + message = message[len(prefix):].strip() + break if message: reasons.extend( item.strip() @@ -2769,7 +2789,7 @@ class UserAgentService: @classmethod def _resolve_reason_text(cls, message: str) -> str: - reason = cls._extract_message_reason(message) + reason = cls._strip_leading_time_from_reason(cls._extract_message_reason(message)) if not reason: return "" @@ -2799,6 +2819,15 @@ class UserAgentService: return reason + @staticmethod + def _strip_leading_time_from_reason(value: str) -> str: + reason = str(value or "").strip() + for pattern in LEADING_REASON_TIME_PATTERNS: + next_reason = pattern.sub("", reason).strip() + if next_reason != reason: + return next_reason + return reason + @staticmethod def _should_skip_model_answer( payload: UserAgentRequest, @@ -3490,7 +3519,7 @@ class UserAgentService: return "travel", "差旅费" if any(keyword in compact for keyword in ("住宿", "酒店", "宾馆")): return "hotel", "住宿费" - if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "车费", "停车")): + if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "乘车", "用车", "叫车", "车费", "车资", "的士", "停车")): return "transport", "交通费" if any(keyword in compact for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")): return "meal", "餐费" @@ -3698,7 +3727,7 @@ class UserAgentService: "group_code": "travel", "scene_label": "住宿票据", } - if any(keyword in compact for keyword in ("打车", "出租车", "滴滴", "网约车", "过路费", "停车")): + if any(keyword in compact for keyword in ("打车", "出租车", "滴滴", "网约车", "乘车", "用车", "叫车", "车费", "车资", "的士", "过路费", "停车")): return { "document_type": "transport_receipt", "expense_type": "transport", diff --git a/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf b/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf similarity index 100% rename from server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf rename to server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf diff --git a/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf.meta.json b/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf.meta.json similarity index 69% rename from server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf.meta.json rename to server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf.meta.json index b3d1c7a..e44c696 100644 --- a/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf.meta.json +++ b/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf.meta.json @@ -1,23 +1,25 @@ { "file_name": "发票_3_京S98876.pdf", - "storage_key": "1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.pdf", + "storage_key": "193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf", "media_type": "application/pdf", "size_bytes": 61170, - "uploaded_at": "2026-05-20T02:21:35.637474+00:00", + "uploaded_at": "2026-05-20T12:25:49.243144+00:00", "previewable": true, "preview_kind": "image", - "preview_storage_key": "1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.preview.png", + "preview_storage_key": "193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.preview.png", "preview_media_type": "image/png", "preview_file_name": "发票_3_京S98876.preview.png", "analysis": { - "severity": "medium", - "label": "中风险", - "headline": "AI提示:附件存在明显待整改项", - "summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。", + "severity": "pass", + "label": "AI提示符合条件", + "headline": "AI提示:附件符合基础校验条件", + "summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。", "points": [ - "用途字段:当前费用项目为其他,但附件内容更像住宿、交通相关票据。" + "票据类型:已识别为增值税发票。", + "附件类型要求:当前费用项目为交通费,已识别为增值税发票,符合当前交通费场景的附件要求。", + "金额字段:已识别到与当前明细接近的金额 121.54 元。" ], - "suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。" + "suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。" }, "document_info": { "document_type": "vat_invoice", @@ -49,23 +51,25 @@ }, "requirement_check": { "matches": true, - "current_expense_type": "other", - "current_expense_type_label": "其他费用", + "current_expense_type": "transport", + "current_expense_type_label": "交通费", "allowed_scene_labels": [ - "其他票据" + "交通" ], "allowed_document_type_labels": [ + "停车/通行费票据", "一般收据/凭证", + "出租车/网约车票据", "增值税发票" ], "recognized_scene_code": "other", "recognized_scene_label": "通用发票", "recognized_document_type": "vat_invoice", "recognized_document_type_label": "增值税发票", - "mismatch_severity": "medium", + "mismatch_severity": "high", "rule_code": "rule.expense.scene_submission_standard", "rule_name": "报销场景提交与附件标准", - "message": "当前费用项目为其他费用,已识别为增值税发票,符合当前其他费用场景的附件要求。" + "message": "当前费用项目为交通费,已识别为增值税发票,符合当前交通费场景的附件要求。" }, "ocr_status": "recognized", "ocr_error": "", diff --git a/server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.preview.png b/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.preview.png similarity index 100% rename from server/storage/expense_claims/1e0f6a47-fba8-4da8-8748-24465bdb06f3/800c9a01-9dc5-4515-8170-d8ae02ade93e/发票_3_京S98876.preview.png rename to server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.preview.png diff --git a/server/storage/knowledge/.index.json b/server/storage/knowledge/.index.json index 480ee35..9e38834 100644 --- a/server/storage/knowledge/.index.json +++ b/server/storage/knowledge/.index.json @@ -14,8 +14,8 @@ "updated_at": "2026-05-17T09:28:28.999515+00:00", "uploaded_by": "admin", "version_number": 1, - "ingest_status": 1, - "ingest_status_updated_at": "2026-05-20T06:29:01.123795+00:00", + "ingest_status": 3, + "ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00", "ingest_completed_at": "2026-05-17T10:01:33.272539+00:00", "ingest_document_name": "远光《公司支出管理办法(2024)》.pdf", "ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00", diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 9758784..beedf1d 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import UTC, date, datetime from decimal import Decimal +import pytest from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool @@ -110,6 +111,19 @@ def test_resolve_expense_type_maps_office_supplies_review_value_to_office() -> N assert expense_type == "office" +def test_resolve_expense_type_maps_riding_fare_review_value_to_transport() -> None: + expense_type = ExpenseClaimService._resolve_expense_type( + [], + context_json={ + "review_form_values": { + "expense_type": "乘车费用" + } + }, + ) + + assert expense_type == "transport" + + def test_upsert_draft_from_ontology_defers_multi_document_association_choice() -> None: user_id = "zhangsan@example.com" @@ -238,6 +252,48 @@ def test_upsert_draft_from_ontology_keeps_reason_missing_for_attachment_only_upl assert claim.reason == "待补充" +def test_upsert_draft_from_ontology_strips_recognized_business_time_from_reason() -> None: + user_id = "transport-time@example.com" + message = "业务发生时间:2026-03-04,送客户去林萃小区办事,请报销乘车费用" + + with build_session() as db: + employee = Employee( + employee_no="E5004", + name="赵六", + email=user_id, + ) + db.add(employee) + db.commit() + + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=message, + user_id=user_id, + ) + ) + result = ExpenseClaimService(db).upsert_draft_from_ontology( + run_id=ontology.run_id, + user_id=user_id, + message=message, + ontology=ontology, + context_json={ + "name": "赵六", + "user_input_text": message, + }, + ) + + claim = db.get(ExpenseClaim, result["claim_id"]) + assert claim is not None + assert claim.occurred_at.date() == date(2026, 3, 4) + assert claim.reason == "送客户去林萃小区办事,请报销乘车费用" + assert len(claim.items) == 1 + assert claim.items[0].item_date == date(2026, 3, 4) + assert claim.items[0].item_reason == "送客户去林萃小区办事,请报销乘车费用" + assert "客户单位" not in result["message"] + assert "票据附件" not in result["message"] + assert "费用明细" not in result["message"] + + def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents() -> None: user_id = "lisi@example.com" @@ -348,6 +404,100 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents( assert float(new_claim.amount) == 50.5 +def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_events() -> None: + user_id = "returned-owner@example.com" + return_flag = { + "source": "manual_return", + "return_event_id": "return-event-1", + "message": "第一次退回:附件缺失。", + "reason": "附件缺失。", + "return_count": 1, + "return_stage": "直属领导审批", + "return_stage_key": "direct_manager", + "risk_points": ["附件缺失或不清晰"], + } + + with build_session() as db: + employee = Employee( + employee_no="E5004", + name="赵六", + email=user_id, + ) + db.add(employee) + db.flush() + existing_claim = ExpenseClaim( + claim_no="EXP-202605-012", + employee_id=employee.id, + employee_name="赵六", + department_name="市场部", + project_code=None, + expense_type="transport", + reason="原有交通报销", + location="上海", + amount=Decimal("20.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 13, tzinfo=UTC), + status="returned", + approval_stage="待提交", + risk_flags_json=[return_flag], + ) + existing_claim.items = [ + ExpenseClaimItem( + claim_id=existing_claim.id, + item_date=date(2026, 5, 13), + item_type="transport", + item_reason="原有交通报销", + item_location="上海", + item_amount=Decimal("20.00"), + invoice_id="old-trip.png", + ) + ] + db.add(existing_claim) + db.commit() + + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我补充了交通票据,更新这张退回单据", + user_id=user_id, + ) + ) + ontology.risk_flags = ["系统识别:票据金额待人工核对。"] + + result = ExpenseClaimService(db).upsert_draft_from_ontology( + run_id=ontology.run_id, + user_id=user_id, + message="我补充了交通票据,更新这张退回单据", + ontology=ontology, + context_json={ + "name": "赵六", + "draft_claim_id": existing_claim.id, + "attachment_names": ["new-trip.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "new-trip.png", + "summary": "滴滴出行 支付金额 32 元", + "text": "滴滴出行 支付金额 32 元", + "document_type": "taxi_receipt", + "scene_code": "transport", + } + ], + }, + ) + + db.refresh(existing_claim) + assert result["claim_id"] == existing_claim.id + assert existing_claim.status == "draft" + assert "系统识别:票据金额待人工核对。" in existing_claim.risk_flags_json + manual_returns = [ + flag + for flag in list(existing_claim.risk_flags_json or []) + if isinstance(flag, dict) and flag.get("source") == "manual_return" + ] + assert manual_returns == [return_flag] + + def test_generate_claim_no_uses_max_suffix_instead_of_count() -> None: with build_session() as db: db.add_all( @@ -642,6 +792,44 @@ def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_pat assert not attachment_root.exists() +def test_attachment_preview_resolves_legacy_filename_in_claim_item_directory(monkeypatch, tmp_path) -> None: + current_user = CurrentUserContext( + username="emp-1", + name="张三", + role_codes=[], + is_admin=False, + ) + + monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path) + + with build_session() as db: + claim = build_claim(expense_type="transport", location="上海") + claim.items[0].invoice_id = "legacy-ticket.pdf" + db.add(claim) + db.commit() + + attachment_dir = tmp_path / claim.id / claim.items[0].id + attachment_dir.mkdir(parents=True) + file_path = attachment_dir / "legacy-ticket.pdf" + file_path.write_bytes(b"legacy-pdf-bytes") + (attachment_dir / "legacy-ticket.pdf.meta.json").write_text( + '{"file_name":"legacy-ticket.pdf","media_type":"application/pdf","previewable":true}', + encoding="utf-8", + ) + + payload = ExpenseClaimService(db).get_claim_item_attachment_preview_content( + claim_id=claim.id, + item_id=claim.items[0].id, + current_user=current_user, + ) + + assert payload is not None + resolved_path, media_type, filename = payload + assert resolved_path == file_path + assert media_type == "application/pdf" + assert filename == "legacy-ticket.pdf" + + def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None: current_user = CurrentUserContext( username="emp-submit@example.com", @@ -677,6 +865,43 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None: assert submitted.submitted_at is not None +def test_submit_claim_allows_returned_claim_to_be_resubmitted() -> None: + current_user = CurrentUserContext( + username="emp-submit@example.com", + name="张三", + role_codes=[], + is_admin=False, + ) + + with build_session() as db: + manager = Employee( + employee_no="E7100", + name="李经理", + email="manager-returned@example.com", + ) + employee = Employee( + employee_no="E7101", + name="张三", + email="emp-submit@example.com", + manager=manager, + ) + claim = build_claim(expense_type="transport", location="上海") + claim.employee = employee + claim.employee_id = employee.id + claim.status = "returned" + claim.approval_stage = "待补充" + claim.items[0].invoice_id = "taxi-ticket.png" + db.add_all([manager, employee, claim]) + db.commit() + + submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user) + + assert submitted is not None + assert submitted.status == "submitted" + assert submitted.approval_stage == "直属领导审批" + assert submitted.submitted_at is not None + + def test_submit_claim_backfills_department_from_current_employee() -> None: current_user = CurrentUserContext( username="emp-dept@example.com", @@ -1327,7 +1552,377 @@ def test_privileged_user_can_return_and_delete_submitted_claim() -> None: assert db.get(ExpenseClaim, claim_id) is None -def test_list_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None: +def test_direct_manager_can_return_subordinate_claim_to_pending_submission() -> None: + current_user = CurrentUserContext( + username="manager-return@example.com", + name="李经理", + role_codes=["manager"], + is_admin=False, + ) + + with build_session() as db: + manager = Employee( + employee_no="E8100", + name="李经理", + email="manager-return@example.com", + ) + employee = Employee( + employee_no="E8101", + name="张三", + email="zhangsan-return@example.com", + manager=manager, + ) + db.add_all([manager, employee]) + db.flush() + claim = ExpenseClaim( + claim_no="EXP-RET-201", + employee_id=employee.id, + employee_name="张三", + department_name="市场部", + project_code="PRJ-A", + expense_type="transport", + reason="交通报销", + location="上海", + amount=Decimal("66.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + claim_id = claim.id + + returned = ExpenseClaimService(db).return_claim(claim_id, current_user, reason="请补充行程说明") + + assert returned is not None + assert returned.status == "returned" + assert returned.approval_stage == "待提交" + assert returned.submitted_at is None + assert any( + isinstance(flag, dict) + and flag.get("source") == "manual_return" + and flag.get("message") == "请补充行程说明" + for flag in returned.risk_flags_json + ) + + +def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> None: + current_user = CurrentUserContext( + username="manager-approve@example.com", + name="李经理", + role_codes=["manager"], + is_admin=False, + ) + + with build_session() as db: + manager = Employee( + employee_no="E8110", + name="李经理", + email="manager-approve@example.com", + ) + employee = Employee( + employee_no="E8111", + name="张三", + email="zhangsan-approve@example.com", + manager=manager, + ) + db.add_all([manager, employee]) + db.flush() + claim = ExpenseClaim( + claim_no="EXP-APP-201", + employee_id=employee.id, + employee_name="张三", + department_name="市场部", + project_code="PRJ-A", + expense_type="transport", + reason="交通报销", + location="上海", + amount=Decimal("66.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + claim_id = claim.id + + approved = ExpenseClaimService(db).approve_claim( + claim_id, + current_user, + opinion="情况属实,同意报销。", + ) + + assert approved is not None + assert approved.status == "submitted" + assert approved.approval_stage == "财务审批" + assert approved.submitted_at is not None + assert any( + isinstance(flag, dict) + and flag.get("source") == "manual_approval" + and flag.get("event_type") == "expense_claim_approval" + and flag.get("opinion") == "情况属实,同意报销。" + and flag.get("previous_approval_stage") == "直属领导审批" + and flag.get("next_approval_stage") == "财务审批" + for flag in approved.risk_flags_json + ) + + +def test_return_claim_rejects_already_returned_claim_without_adding_event() -> None: + current_user = CurrentUserContext( + username="finance-returned@example.com", + name="财务", + role_codes=["finance"], + is_admin=False, + ) + return_flag = { + "source": "manual_return", + "return_event_id": "return-event-existing", + "message": "请补充附件。", + "return_count": 1, + "return_stage": "直属领导审批", + "return_stage_key": "direct_manager", + } + + with build_session() as db: + claim = ExpenseClaim( + claim_no="EXP-RET-202", + employee_name="张三", + department_name="市场部", + project_code="PRJ-A", + expense_type="transport", + reason="交通报销", + location="上海", + amount=Decimal("66.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC), + submitted_at=None, + status="returned", + approval_stage="待提交", + risk_flags_json=[return_flag], + ) + db.add(claim) + db.commit() + claim_id = claim.id + + with pytest.raises(ValueError, match="无需重复退回"): + ExpenseClaimService(db).return_claim(claim_id, current_user, reason="重复退回") + + db.refresh(claim) + manual_returns = [ + flag + for flag in list(claim.risk_flags_json or []) + if isinstance(flag, dict) and flag.get("source") == "manual_return" + ] + assert manual_returns == [return_flag] + + +def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -> None: + current_user = CurrentUserContext( + username="finance-return@example.com", + name="财务复核", + role_codes=["finance"], + is_admin=False, + ) + + with build_session() as db: + claim = ExpenseClaim( + claim_no="EXP-RET-301", + employee_name="张三", + department_name="市场部", + project_code="PRJ-A", + expense_type="transport", + reason="交通报销", + location="上海", + amount=Decimal("66.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + claim_id = claim.id + + service = ExpenseClaimService(db) + first_returned = service.return_claim( + claim_id, + current_user, + reason="发票金额与明细金额不一致,请重新核对。", + reason_codes=["invoice_mismatch", "business_explanation"], + ) + assert first_returned is not None + + first_returned.status = "submitted" + first_returned.approval_stage = "财务审批" + first_returned.submitted_at = datetime(2026, 5, 12, 11, 0, tzinfo=UTC) + db.commit() + + second_returned = service.return_claim( + claim_id, + current_user, + reason="超标说明仍不完整,请补充制度例外依据。", + reason_codes=["over_policy"], + ) + + assert second_returned is not None + return_events = [ + flag + for flag in list(second_returned.risk_flags_json or []) + if isinstance(flag, dict) and flag.get("source") == "manual_return" + ] + assert len(return_events) == 2 + assert return_events[0]["return_count"] == 1 + assert return_events[0]["stage_return_count"] == 1 + assert return_events[0]["return_stage"] == "直属领导审批" + assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"] + assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"] + assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。" + assert return_events[0]["operator_role_codes"] == ["finance"] + assert return_events[1]["return_count"] == 2 + assert return_events[1]["stage_return_count"] == 1 + assert return_events[1]["return_stage"] == "财务审批" + assert return_events[1]["risk_points"] == ["超出制度标准或缺少超标说明"] + + +def test_submit_returned_claim_preserves_manual_return_events() -> None: + current_user = CurrentUserContext( + username="emp-submit-returned@example.com", + name="张三", + role_codes=[], + is_admin=False, + ) + return_flag = { + "source": "manual_return", + "return_event_id": "return-event-submit", + "message": "第一次退回:业务说明不完整。", + "reason": "业务说明不完整。", + "return_count": 1, + "return_stage": "直属领导审批", + "return_stage_key": "direct_manager", + "risk_points": ["业务事由/地点/人员信息不完整"], + } + + with build_session() as db: + manager = Employee( + employee_no="E8200", + name="李经理", + email="manager-submit-returned@example.com", + ) + employee = Employee( + employee_no="E8201", + name="张三", + email="emp-submit-returned@example.com", + manager=manager, + ) + claim = build_claim(expense_type="office", location="上海") + claim.employee = employee + claim.employee_id = employee.id + claim.employee_name = "张三" + claim.department_name = "市场部" + claim.status = "returned" + claim.approval_stage = "待提交" + claim.risk_flags_json = [return_flag] + db.add_all([manager, employee, claim]) + db.commit() + + submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user) + + assert submitted is not None + assert submitted.status == "submitted" + assert submitted.approval_stage == "直属领导审批" + assert any( + isinstance(flag, dict) + and flag.get("source") == "manual_return" + and flag.get("return_event_id") == "return-event-submit" + for flag in list(submitted.risk_flags_json or []) + ) + + +def test_manager_personal_claims_exclude_subordinate_pending_approval_claims() -> None: + current_user = CurrentUserContext( + username="manager-personal@example.com", + name="李经理", + role_codes=["manager"], + is_admin=False, + ) + + with build_session() as db: + manager = Employee( + employee_no="E8300", + name="李经理", + email="manager-personal@example.com", + ) + employee = Employee( + employee_no="E8301", + name="张三", + email="zhangsan-personal@example.com", + manager=manager, + ) + db.add_all([manager, employee]) + db.flush() + db.add_all( + [ + ExpenseClaim( + claim_no="EXP-MGR-OWN", + employee_id=manager.id, + employee_name="李经理", + department_name="市场部", + project_code="PRJ-MGR", + expense_type="office", + reason="本人报销", + location="上海", + amount=Decimal("88.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC), + submitted_at=None, + status="draft", + approval_stage="待提交", + risk_flags_json=[], + ), + ExpenseClaim( + claim_no="EXP-MGR-SUB", + employee_id=employee.id, + employee_name="张三", + department_name="市场部", + project_code="PRJ-MGR", + expense_type="transport", + reason="下属待审批报销", + location="上海", + amount=Decimal("66.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 12, 11, 0, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ), + ] + ) + db.commit() + + service = ExpenseClaimService(db) + personal_claims = service.list_claims(current_user) + approval_claims = service.list_approval_claims(current_user) + + assert [claim.claim_no for claim in personal_claims] == ["EXP-MGR-OWN"] + assert [claim.claim_no for claim in approval_claims] == ["EXP-MGR-SUB"] + + +def test_list_approval_claims_allows_direct_manager_to_view_pending_claims_for_approval() -> None: current_user = CurrentUserContext( username="manager@example.com", name="李经理", @@ -1402,7 +1997,7 @@ def test_list_claims_allows_direct_manager_to_view_pending_claims_for_approval() ) db.commit() - claims = ExpenseClaimService(db).list_claims(current_user) + claims = ExpenseClaimService(db).list_approval_claims(current_user) assert len(claims) == 1 assert claims[0].claim_no == "EXP-MGR-201" diff --git a/server/tests/test_ontology_service.py b/server/tests/test_ontology_service.py index 95fd49a..f6e956e 100644 --- a/server/tests/test_ontology_service.py +++ b/server/tests/test_ontology_service.py @@ -433,11 +433,11 @@ def test_semantic_ontology_service_extracts_day_before_yesterday_from_client_loc assert result.time_range.end_date == "2026-05-11" -def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None: - session_factory = build_session_factory() - with session_factory() as db: - result = SemanticOntologyService(db).parse( - OntologyParseRequest( +def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest( query="我买了办公用品和文具,花了88元,帮我报销", user_id="pytest", ) @@ -446,15 +446,33 @@ def test_semantic_ontology_service_maps_office_supplies_to_office_expense_type() assert result.scenario == "expense" assert result.intent == "draft" assert any( - item.type == "expense_type" and item.normalized_value == "office" - for item in result.entities - ) - - -def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None: - session_factory = build_session_factory() - with session_factory() as db: - service = SemanticOntologyService(db) + item.type == "expense_type" and item.normalized_value == "office" + for item in result.entities + ) + + +def test_semantic_ontology_service_maps_riding_fare_to_transport_expense_type() -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="业务发生时间:2026-03-04,送客户去林萃小区办事,请报销乘车费用", + user_id="pytest", + ) + ) + + assert result.scenario == "expense" + assert result.intent == "draft" + assert any( + item.type == "expense_type" and item.normalized_value == "transport" + for item in result.entities + ) + + +def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None: + session_factory = build_session_factory() + with session_factory() as db: + service = SemanticOntologyService(db) monkeypatch.setattr( service, "_parse_with_model", diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index ac9144b..5693478 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -289,6 +289,67 @@ def test_claim_item_attachment_upload_flags_non_invoice_image_as_high_risk(monke assert any("附件内容" in point for point in analysis["points"]) +def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() -> None: + client, session_factory = build_client() + with session_factory() as db: + manager = Employee( + id="mgr-approve-1", + employee_no="E21001", + name="李经理", + email="manager-approve-api@example.com", + ) + employee = Employee( + id="emp-approve-1", + employee_no="E11001", + name="张三", + email="zhangsan-approve-api@example.com", + manager=manager, + ) + claim = ExpenseClaim( + id="claim-approve-1", + claim_no="EXP-APP-API-001", + employee_id=employee.id, + employee_name="张三", + department_id="dept-1", + department_name="市场部", + project_code=None, + expense_type="transport", + reason="交通报销", + location="上海", + amount=Decimal("88.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 13, tzinfo=UTC), + submitted_at=datetime(2026, 5, 13, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="直属领导审批", + risk_flags_json=[], + ) + db.add_all([manager, employee, claim]) + db.commit() + + response = client.post( + "/api/v1/reimbursements/claims/claim-approve-1/approve", + json={"opinion": "情况属实,同意报销。"}, + headers={ + "X-Auth-Username": "manager-approve-api@example.com", + "X-Auth-Name": "manager-approve-api@example.com", + "X-Auth-Role-Codes": "manager", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "submitted" + assert payload["approval_stage"] == "财务审批" + assert any( + item["source"] == "manual_approval" + and item["opinion"] == "情况属实,同意报销。" + and item["next_approval_stage"] == "财务审批" + for item in payload["risk_flags_json"] + ) + + def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None: preview_bytes = b"fake-preview-png" preview_data_url = f"data:image/png;base64,{base64.b64encode(preview_bytes).decode('ascii')}" diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index 95f930e..70c3afd 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -546,11 +546,11 @@ def test_user_agent_guides_implicit_expense_draft_request() -> None: assert slot_map["amount"].value == "1000.00元" -def test_user_agent_guides_narrative_with_day_before_yesterday() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( +def test_user_agent_guides_narrative_with_day_before_yesterday() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( query="我前天请客户吃饭花了200元", user_id="pytest", context_json={ @@ -571,15 +571,106 @@ def test_user_agent_guides_narrative_with_day_before_yesterday() -> None: assert response.review_payload is not None slot_map = {item.key: item for item in response.review_payload.slot_cards} - assert slot_map["time_range"].raw_value == "前天" - assert slot_map["time_range"].value == "2026-05-11" - assert "时间为 2026-05-11" in response.review_payload.intent_summary - - -def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( + assert slot_map["time_range"].raw_value == "前天" + assert slot_map["time_range"].value == "2026-05-11" + assert "时间为 2026-05-11" in response.review_payload.intent_summary + + +def test_user_agent_guides_riding_fare_as_transport_expense() -> None: + session_factory = build_session_factory() + with session_factory() as db: + message = "业务发生时间:2026-03-04,送客户去林萃小区办事,请报销乘车费用" + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=message, + user_id="pytest", + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message=message, + ontology=ontology, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + slot_map = {item.key: item for item in response.review_payload.slot_cards} + assert slot_map["expense_type"].value == "交通费" + assert slot_map["expense_type"].normalized_value == "transport" + assert slot_map["time_range"].value == "2026-03-04" + assert slot_map["reason"].value == "送客户去林萃小区办事,请报销乘车费用" + assert "业务发生时间" not in slot_map["reason"].raw_value + assert "“交通费”" in response.review_payload.intent_summary + + +def test_user_agent_does_not_treat_draft_saved_message_as_precheck_risk_for_transport() -> None: + session_factory = build_session_factory() + with session_factory() as db: + message = "业务发生时间:2026-03-04,送客户去林萃小区办事,打车花了32元,请报销乘车费用" + context_json = { + "name": "赵六", + "attachment_names": ["didi-trip.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "didi-trip.png", + "summary": "滴滴出行 支付金额 32 元", + "text": "滴滴出行 支付金额 32 元", + "document_type": "taxi_receipt", + "scene_code": "transport", + "document_fields": [ + {"key": "amount", "label": "支付金额", "value": "32.00"}, + ], + "warnings": [], + } + ], + } + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=message, + user_id="pytest", + context_json=context_json, + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message=message, + ontology=ontology, + context_json=context_json, + tool_payload={ + "draft_only": True, + "claim_id": "claim-1", + "claim_no": "EXP-202603-001", + "status": "draft", + "message": ( + "已创建报销草稿 EXP-202603-001,当前状态为 draft。" + "你可以继续补充费用明细、客户单位和票据附件。" + ), + }, + ) + ) + + assert response.review_payload is not None + assert response.review_payload.can_proceed is True + assert "客户名称" not in response.review_payload.missing_slots + assert "参与人员" not in response.review_payload.missing_slots + assert "票据附件" not in response.review_payload.missing_slots + risk_text = "\n".join( + f"{item.title}\n{item.content}" for item in response.review_payload.risk_briefs + ) + assert "AI预审未通过" not in risk_text + assert "已创建报销草稿" not in risk_text + + +def test_user_agent_attachment_only_upload_uses_generic_scene_reason_without_fabrication() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( OntologyParseRequest( query="我上传了 1 份票据,请结合附件名称给出报销建议并尽量生成草稿。", user_id="pytest", diff --git a/web/src/assets/styles/views/employee-management-view.css b/web/src/assets/styles/views/employee-management-view.css index 33ab0d3..c208021 100644 --- a/web/src/assets/styles/views/employee-management-view.css +++ b/web/src/assets/styles/views/employee-management-view.css @@ -1094,10 +1094,10 @@ tbody tr:last-child td { } .history-row { - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) 128px 112px; align-items: center; - justify-content: space-between; - gap: 12px; + column-gap: 16px; padding: 12px 0; border-top: 1px solid #edf2f7; } @@ -1108,42 +1108,40 @@ tbody tr:last-child td { } .history-row strong { - flex: 1 1 auto; min-width: 0; color: #0f172a; font-size: 13px; font-weight: 800; line-height: 1.45; -} - -.history-row-meta { - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 20px; - flex-shrink: 0; - margin-left: 16px; - padding-left: 16px; - border-left: 1px solid #e2e8f0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .history-row-owner, .history-row-time { display: inline-block; + min-width: 0; margin-top: 0; color: #64748b; font-size: 12px; line-height: 1.45; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .history-row-owner { + padding-left: 16px; + border-left: 1px solid #e2e8f0; color: #475569; font-weight: 700; } .history-row-time { color: #64748b; + font-variant-numeric: tabular-nums; + text-align: right; } td.cell-updated { @@ -1298,4 +1296,18 @@ td.cell-updated { .role-grid { grid-template-columns: 1fr; } + + .history-row { + grid-template-columns: minmax(0, 1fr); + row-gap: 6px; + } + + .history-row-owner { + padding-left: 0; + border-left: 0; + } + + .history-row-time { + text-align: left; + } } diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index 0165a7b..a83cfde 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -119,13 +119,28 @@ font-weight: 800; } -.applicant-meta-line { +.applicant-profile-meta { display: flex; flex-wrap: wrap; - gap: 8px 0; + align-items: flex-start; + gap: 12px 28px; } -.applicant-meta-line span { +.applicant-profile-meta__org { + display: grid; + gap: 6px; + min-width: 0; +} + +.applicant-profile-meta__role { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 0; + min-width: 0; +} + +.applicant-meta-item { min-width: 0; position: relative; display: inline-flex; @@ -136,11 +151,11 @@ line-height: 1.5; } -.applicant-meta-line span + span { +.applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item { margin-left: 16px; } -.applicant-meta-line span + span::before { +.applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item::before { content: "•"; position: absolute; left: -10px; @@ -148,12 +163,17 @@ font-size: 12px; } -.applicant-meta-line em { - font-style: normal; - color: #64748b; +.applicant-meta-item--sub strong { + font-weight: 750; } -.applicant-meta-line strong { +.applicant-meta-item em { + font-style: normal; + color: #64748b; + flex-shrink: 0; +} + +.applicant-meta-item strong { color: #0f172a; font-weight: 800; } @@ -245,14 +265,21 @@ .progress-line { grid-column: 1 / -1; display: grid; - grid-template-columns: repeat(var(--progress-columns, 5), minmax(0, 1fr)); + grid-template-columns: repeat(var(--progress-columns, 5), minmax(118px, 1fr)); + overflow-x: auto; + overscroll-behavior-x: contain; + padding: 4px 2px 2px; } .progress-step { position: relative; display: grid; + grid-template-rows: 26px minmax(62px, auto); justify-items: center; - gap: 5px; + align-items: start; + gap: 10px; + min-width: 0; + padding: 0 6px; color: #94a3b8; } @@ -297,8 +324,8 @@ .progress-step span { position: relative; z-index: 1; - width: 26px; - height: 26px; + width: 24px; + height: 24px; display: grid; place-items: center; border-radius: 999px; @@ -326,7 +353,7 @@ background: #10b981 !important; color: #fff !important; box-shadow: 0 0 0 4px rgba(16, 185, 129, .15) !important; - animation: breathe-dot 3s ease-in-out infinite !important; + animation: breathe-dot 3.2s ease-in-out infinite !important; transform-origin: center !important; } @@ -344,19 +371,81 @@ .progress-step strong { color: #334155; font-size: 12px; + line-height: 1.35; text-align: center; } .progress-step.current strong { color: #059669; } -.progress-step small { + +.progress-step-copy { + width: 100%; + min-width: 0; + display: grid; + justify-items: center; + align-content: start; + gap: 6px; +} + +.progress-step-status { + max-width: 100%; + min-height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 9px; + border: 1px solid #e2e8f0; + border-radius: 999px; + background: #f8fafc; + color: #64748b; font-size: 11px; + font-weight: 850; + line-height: 1; text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.progress-step.done .progress-step-status { + border-color: rgba(16, 185, 129, .2); + background: #ecfdf5; + color: #047857; +} + +.progress-step.current .progress-step-status { + border-color: rgba(5, 150, 105, .22); + background: #059669; + color: #fff; + box-shadow: 0 8px 18px rgba(5, 150, 105, .14); +} + +.progress-step:not(.done):not(.current) .progress-step-status { + background: #f8fafc; + color: #94a3b8; } .progress-step.current small { color: #059669; } +.progress-step-meta { + display: block; + width: 100%; + min-height: 16px; + color: #64748b; + font-size: 11px; + font-style: normal; + line-height: 1.35; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.progress-step.current .progress-step-meta { + color: #475569; +} + .detail-grid { display: block; min-width: 0; @@ -472,6 +561,40 @@ white-space: pre-wrap; } +.leader-approval-card { + border-color: rgba(5, 150, 105, .18); + background: linear-gradient(180deg, #ffffff 0%, #f7fdfb 100%); +} + +.leader-approval-card textarea { + min-height: 96px; + background: #fff; + color: #0f172a; +} + +.leader-approval-card textarea:focus { + outline: 0; + border-color: rgba(5, 150, 105, .5); + box-shadow: 0 0 0 3px rgba(5, 150, 105, .1); +} + +.leader-opinion-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 8px; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.leader-opinion-meta strong { + flex: 0 0 auto; + color: #047857; + font-weight: 850; +} + .detail-expense-table { min-width: 0; overflow-x: auto; @@ -510,13 +633,13 @@ background: #fbfefd; } -.detail-expense-table .col-time { width: 13%; } -.detail-expense-table .col-type { width: 15%; } -.detail-expense-table .col-desc { width: 23%; } -.detail-expense-table .col-amount { width: 12%; } -.detail-expense-table .col-attachment { width: 19%; } -.detail-expense-table .col-risk { width: 18%; } -.detail-expense-table .col-action { width: 10%; } +.detail-expense-table .col-time { width: 11%; } +.detail-expense-table .col-filled-at { width: 15%; } +.detail-expense-table .col-type { width: 13%; } +.detail-expense-table .col-desc { width: 19%; } +.detail-expense-table .col-amount { width: 11%; } +.detail-expense-table .col-attachment { width: 22%; } +.detail-expense-table .col-action { width: 9%; } .cell-editor { display: grid; @@ -574,6 +697,7 @@ } .expense-time strong, +.expense-filled-at strong, .expense-type strong, .expense-desc strong, .expense-amount strong { @@ -586,6 +710,7 @@ } .expense-time span, +.expense-filled-at span, .expense-type span, .expense-desc span { display: block; @@ -599,6 +724,11 @@ white-space: nowrap; } +.expense-filled-at strong { + font-size: 12px; + white-space: nowrap; +} + .expense-desc, .detail-expense-table .col-desc { text-align: left; @@ -853,12 +983,6 @@ font-weight: 700; } -.total-row td { - color: #0f172a; - font-weight: 900; - background: #f8fafc; -} - .empty-row-cell { padding: 22px 16px; color: #64748b; @@ -868,31 +992,6 @@ background: #fcfdfd; } -.expense-total-bar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px 20px; - flex-wrap: wrap; -} - -.expense-total-bar strong { - color: #0f172a; - font-size: 13px; - font-weight: 900; -} - -.expense-total-meta { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 10px 16px; - flex-wrap: wrap; - color: #475569; - font-size: 12px; - font-weight: 700; -} - .expense-upload-input { display: none; } @@ -910,7 +1009,7 @@ } .attachment-preview-card { - width: min(920px, calc(100vw - 48px)); + width: min(1160px, calc(100vw - 48px)); max-height: calc(100vh - 48px); display: grid; grid-template-rows: auto minmax(0, 1fr); @@ -931,6 +1030,39 @@ gap: 16px; } +.attachment-preview-toolbar { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.attachment-preview-nav, +.attachment-preview-close { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid #d7e0ea; + border-radius: 999px; + background: rgba(255, 255, 255, .9); + color: #475569; +} + +.attachment-preview-nav:disabled { + cursor: not-allowed; + opacity: .5; +} + +.attachment-preview-count { + min-width: 48px; + color: #64748b; + font-size: 12px; + font-weight: 800; + text-align: center; +} + .attachment-preview-badge { display: inline-flex; align-items: center; @@ -951,27 +1083,30 @@ font-weight: 800; } -.attachment-preview-close { - width: 36px; - height: 36px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid #d7e0ea; - border-radius: 999px; - background: rgba(255, 255, 255, .9); - color: #475569; -} - .attachment-preview-body { min-height: 0; display: grid; - place-items: center; + grid-template-columns: minmax(0, 1.25fr) minmax(320px, .75fr); + align-items: stretch; + gap: 16px; overflow: hidden; + background: transparent; +} + +.attachment-source-pane, +.attachment-insight-pane { + min-height: 0; + border: 1px solid #e2e8f0; border-radius: 20px; + overflow: hidden; background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); } +.attachment-source-pane { + display: grid; + place-items: center; +} + .attachment-preview-image, .attachment-preview-frame { width: 100%; @@ -982,6 +1117,96 @@ background: #fff; } +.attachment-insight-pane { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + padding: 18px; + overflow-y: auto; + background: #fff; +} + +.attachment-insight-head { + display: grid; + gap: 6px; + padding-bottom: 14px; + border-bottom: 1px solid #e2e8f0; +} + +.attachment-insight-head span, +.attachment-insight-section span { + color: #64748b; + font-size: 12px; + font-weight: 800; +} + +.attachment-insight-head strong { + color: #0f172a; + font-size: 18px; + line-height: 1.35; +} + +.attachment-insight-content { + display: grid; + align-content: start; + gap: 14px; + padding-top: 14px; +} + +.attachment-insight-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.attachment-insight-section { + display: grid; + gap: 8px; + padding: 12px; + border-radius: 14px; + background: #f8fafc; +} + +.attachment-insight-section ul { + display: grid; + gap: 6px; + margin: 0; + padding-left: 16px; + color: #334155; + font-size: 12px; + line-height: 1.55; +} + +.attachment-risk-card { + display: grid; + gap: 6px; + padding: 10px; + border: 1px solid #fee2e2; + border-radius: 12px; + background: #fff7f7; +} + +.attachment-risk-card.medium { + border-color: #fed7aa; + background: #fffaf2; +} + +.attachment-risk-card strong { + color: #991b1b; + font-size: 12px; + line-height: 1.45; +} + +.attachment-risk-card.medium strong { + color: #9a3412; +} + +.attachment-risk-card p { + margin: 0; + color: #475569; + font-size: 12px; + line-height: 1.55; +} + .attachment-preview-state { min-height: 320px; display: grid; @@ -993,6 +1218,11 @@ text-align: center; } +.attachment-preview-state.compact { + min-height: 180px; + padding: 20px; +} + .attachment-preview-state i { font-size: 24px; } @@ -1074,6 +1304,33 @@ line-height: 1.6; } +.submit-confirm-summary { + display: grid; + gap: 8px; + padding: 12px 14px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} + +.submit-confirm-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + color: #64748b; + font-size: 13px; + line-height: 1.55; +} + +.submit-confirm-row strong { + min-width: 0; + color: #0f172a; + font-weight: 780; + text-align: right; + word-break: break-word; +} + .validation-card { border: 1px solid #e6f0eb; background: linear-gradient(180deg, #fcfffd 0%, #f7fbf9 100%); @@ -1140,6 +1397,109 @@ line-height: 1.55; } +.risk-advice-list { + display: grid; + gap: 12px; + margin-top: 14px; +} + +.risk-advice-card { + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid #fee2e2; + border-radius: 8px; + background: #fffafa; +} + +.risk-advice-card.medium { + border-color: #fed7aa; + background: #fffaf2; +} + +.risk-advice-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.risk-advice-card-head span { + min-height: 24px; + display: inline-flex; + align-items: center; + padding: 0 9px; + border-radius: 999px; + background: #fee2e2; + color: #b91c1c; + font-size: 11px; + font-weight: 850; + white-space: nowrap; +} + +.risk-advice-card.medium .risk-advice-card-head span { + background: #ffedd5; + color: #c2410c; +} + +.risk-advice-card-head strong { + min-width: 0; + color: #0f172a; + font-size: 13px; + line-height: 1.45; + text-align: right; +} + +.risk-advice-point { + margin: 0; + color: #7f1d1d; + font-size: 14px; + font-weight: 800; + line-height: 1.5; +} + +.risk-advice-card.medium .risk-advice-point { + color: #9a3412; +} + +.risk-advice-meta { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; +} + +.risk-advice-meta > div { + min-width: 0; + display: grid; + gap: 6px; + padding: 10px; + border-radius: 8px; + background: rgba(255, 255, 255, .72); +} + +.risk-advice-meta span { + color: #64748b; + font-size: 11px; + font-weight: 850; +} + +.risk-advice-meta ul { + display: grid; + gap: 4px; + margin: 0; + padding-left: 16px; + color: #334155; + font-size: 12px; + line-height: 1.55; +} + +.risk-advice-meta p { + margin: 0; + color: #334155; + font-size: 12px; + line-height: 1.55; +} + .detail-overlay { position: fixed; inset: 0; @@ -1630,7 +1990,7 @@ } .detail-expense-table table { - min-width: 980px; + min-width: 1080px; } .ai-entry-grid { @@ -1665,16 +2025,21 @@ font-size: 16px; } - .applicant-meta-line { + .applicant-profile-meta { + display: grid; + gap: 10px; + } + + .applicant-profile-meta__role { display: grid; gap: 6px; } - .applicant-meta-line span + span { + .applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item { margin-left: 0; } - .applicant-meta-line span + span::before { + .applicant-profile-meta__role .applicant-meta-item + .applicant-meta-item::before { content: none; } @@ -1726,12 +2091,7 @@ .smart-entry-btn { align-self: flex-start; } .detail-expense-table table { - min-width: 980px; - } - - .expense-total-bar, - .expense-total-meta { - justify-content: flex-start; + min-width: 1080px; } .detail-actions { @@ -1764,12 +2124,34 @@ } .attachment-preview-card { - width: min(100vw - 28px, 920px); + width: min(calc(100vw - 28px), 920px); max-height: calc(100vh - 28px); padding: 18px; border-radius: 20px; } + .attachment-preview-head { + flex-wrap: wrap; + } + + .attachment-preview-toolbar { + order: 2; + width: 100%; + justify-content: flex-start; + } + + .attachment-preview-body { + grid-template-columns: minmax(0, 1fr); + } + + .attachment-insight-pane { + max-height: 320px; + } + + .risk-advice-meta { + grid-template-columns: minmax(0, 1fr); + } + .attachment-preview-image, .attachment-preview-frame { min-height: 360px; diff --git a/web/src/assets/workbench-icons/README.md b/web/src/assets/workbench-icons/README.md new file mode 100644 index 0000000..551b2b8 --- /dev/null +++ b/web/src/assets/workbench-icons/README.md @@ -0,0 +1,5 @@ +# Workbench Icons + +Icons in this folder are sourced from [Heroicons](https://heroicons.com) (MIT License). + +Used on the Personal Workbench todo and progress lists. diff --git a/web/src/assets/workbench-icons/outline-briefcase.svg b/web/src/assets/workbench-icons/outline-briefcase.svg new file mode 100644 index 0000000..d0c1fc3 --- /dev/null +++ b/web/src/assets/workbench-icons/outline-briefcase.svg @@ -0,0 +1,3 @@ + diff --git a/web/src/assets/workbench-icons/outline-document-text.svg b/web/src/assets/workbench-icons/outline-document-text.svg new file mode 100644 index 0000000..8c03e9e --- /dev/null +++ b/web/src/assets/workbench-icons/outline-document-text.svg @@ -0,0 +1,3 @@ + diff --git a/web/src/assets/workbench-icons/outline-paper-airplane.svg b/web/src/assets/workbench-icons/outline-paper-airplane.svg new file mode 100644 index 0000000..80db4d0 --- /dev/null +++ b/web/src/assets/workbench-icons/outline-paper-airplane.svg @@ -0,0 +1,3 @@ + diff --git a/web/src/assets/workbench-icons/outline-shopping-bag.svg b/web/src/assets/workbench-icons/outline-shopping-bag.svg new file mode 100644 index 0000000..7c5f29b --- /dev/null +++ b/web/src/assets/workbench-icons/outline-shopping-bag.svg @@ -0,0 +1,3 @@ + diff --git a/web/src/assets/workbench-icons/outline-truck.svg b/web/src/assets/workbench-icons/outline-truck.svg new file mode 100644 index 0000000..7f475c8 --- /dev/null +++ b/web/src/assets/workbench-icons/outline-truck.svg @@ -0,0 +1,3 @@ + diff --git a/web/src/assets/workbench-icons/outline-users.svg b/web/src/assets/workbench-icons/outline-users.svg new file mode 100644 index 0000000..1de4a72 --- /dev/null +++ b/web/src/assets/workbench-icons/outline-users.svg @@ -0,0 +1,3 @@ + diff --git a/web/src/assets/workbench-icons/solid-shopping-bag.svg b/web/src/assets/workbench-icons/solid-shopping-bag.svg new file mode 100644 index 0000000..87a0e41 --- /dev/null +++ b/web/src/assets/workbench-icons/solid-shopping-bag.svg @@ -0,0 +1,3 @@ + diff --git a/web/src/assets/workbench-icons/solid-truck.svg b/web/src/assets/workbench-icons/solid-truck.svg new file mode 100644 index 0000000..c919e58 --- /dev/null +++ b/web/src/assets/workbench-icons/solid-truck.svg @@ -0,0 +1,5 @@ + diff --git a/web/src/components/business/PersonalWorkbench.vue b/web/src/components/business/PersonalWorkbench.vue index 15a1e7c..640e419 100644 --- a/web/src/components/business/PersonalWorkbench.vue +++ b/web/src/components/business/PersonalWorkbench.vue @@ -14,8 +14,7 @@
自动识别报销类别、核对附件完整性,并生成可继续提交的报销草稿。
暂无变更记录 diff --git a/web/src/views/TravelReimbursementCreateView.vue b/web/src/views/TravelReimbursementCreateView.vue index 6fb1159..694b3a9 100644 --- a/web/src/views/TravelReimbursementCreateView.vue +++ b/web/src/views/TravelReimbursementCreateView.vue @@ -619,29 +619,45 @@ v-if="activeReviewPayload" type="button" class="review-insight-switch-icon-btn" + :class="{ + available: true, + active: isReviewOverviewDrawer + }" + :disabled="submitting || reviewActionBusy" + title="报销识别核对" + aria-label="报销识别核对" + @click="switchToReviewOverviewDrawer" + > + + + +
- {{ isTravelRequest ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与系统校验。' }} + {{ isTravelRequest ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。' }}
{{ aiAdvice.summary }}
+{{ card.risk }}
+ +