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 @@
- AI 报销助手 -

描述费用或上传票据,AI 直接帮你判断怎么报

+

嗨,{{ assistantGreetingName }},描述费用或上传票据,AI 直接帮你判断怎么报

自动识别报销类别、核对附件完整性,并生成可继续提交的报销草稿。

@@ -71,9 +70,11 @@
-
- -
+
{{ item.title }} @@ -99,9 +100,11 @@
-
- -
+
{{ item.title }} @@ -142,6 +145,7 @@ +function formatRangeLabel(start, end) { + if (!start || !end) return '选择时间段' + if (start === end) return start + return `${start} ~ ${end}` +} + +function toDateLabel(date) { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function buildPresetRangeLabel(label) { + const now = new Date() + const today = toDateLabel(now) + + if (label === '今日') { + return today + } + + if (label === '近10日') { + const start = new Date(now) + start.setHours(0, 0, 0, 0) + start.setDate(start.getDate() - 9) + return `${toDateLabel(start)} ~ ${today}` + } + + if (label === '本周') { + const start = new Date(now) + const day = start.getDay() || 7 + start.setHours(0, 0, 0, 0) + start.setDate(start.getDate() - day + 1) + return `${toDateLabel(start)} ~ ${today}` + } + + if (label === '本月') { + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` + } + + return today +} + diff --git a/web/src/components/shared/WorkbenchListIcon.vue b/web/src/components/shared/WorkbenchListIcon.vue new file mode 100644 index 0000000..1edc3d4 --- /dev/null +++ b/web/src/components/shared/WorkbenchListIcon.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/web/src/composables/useAppShell.js b/web/src/composables/useAppShell.js index 12ef8df..70fbeae 100644 --- a/web/src/composables/useAppShell.js +++ b/web/src/composables/useAppShell.js @@ -1,12 +1,14 @@ import { computed, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' +import { useApprovalInbox } from './useApprovalInbox.js' import { useNavigation, navItems } from './useNavigation.js' import { useRequests } from './useRequests.js' import { useSystemState } from './useSystemState.js' import { useToast } from './useToast.js' import { fetchLatestConversation } from '../services/orchestrator.js' import { normalizeRequestForUi } from '../utils/requestViewModel.js' +import { buildWorkbenchSummary } from '../utils/workbenchSummary.js' const SESSION_TYPE_EXPENSE = 'expense' @@ -107,6 +109,7 @@ export function useAppShell() { } = useRequests() const { currentUser } = useSystemState() const { toast } = useToast() + const { refreshApprovalInbox } = useApprovalInbox() const customRange = ref({ start: '2024-07-06', end: '2024-07-12' }) @@ -128,6 +131,7 @@ export function useAppShell() { const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : [])) const requestsListActive = computed(() => activeView.value === 'requests' && !detailMode.value) + const workbenchActive = computed(() => activeView.value === 'workbench') watch(requestsListActive, (isActive, wasActive) => { if (isActive && !wasActive) { @@ -135,6 +139,16 @@ export function useAppShell() { } }) + watch(workbenchActive, (isActive, wasActive) => { + if (isActive && !wasActive) { + void reloadRequests() + } + }) + + const workbenchSummary = computed(() => + buildWorkbenchSummary(requests.value, currentUser.value) + ) + const topBarView = computed(() => { if (detailMode.value) { return { @@ -250,8 +264,9 @@ export function useAppShell() { const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim() smartEntryOpen.value = false await reloadRequests() + void refreshApprovalInbox() if (status === 'submitted') { - toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`) + toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`) } else { toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`) } @@ -271,10 +286,12 @@ export function useAppShell() { async function handleRequestUpdated() { await reloadRequests() + void refreshApprovalInbox() } async function handleRequestDeleted() { await reloadRequests() + void refreshApprovalInbox() router.push({ name: 'app-requests' }) } @@ -301,6 +318,7 @@ export function useAppShell() { openTravelCreate, ranges, requestSummary, + workbenchSummary, requestsError, requestsLoading, reloadRequests, diff --git a/web/src/composables/useApprovalInbox.js b/web/src/composables/useApprovalInbox.js new file mode 100644 index 0000000..41325a0 --- /dev/null +++ b/web/src/composables/useApprovalInbox.js @@ -0,0 +1,152 @@ +import { computed, ref, watch } from 'vue' + +import { fetchApprovalExpenseClaims } from '../services/reimbursements.js' +import { canAccessAppView } from '../utils/accessControl.js' +import { resolvePendingClaimIds } from '../utils/approvalInbox.js' +import { useSystemState } from './useSystemState.js' + +const pendingClaimIds = ref([]) +const viewedClaimIds = ref([]) +let refreshTimer = null + +function buildStorageKey(userId) { + return `x-financial.approval-viewed:${userId}` +} + +function loadViewedClaimIds(userId) { + if (!userId) { + return [] + } + + try { + const raw = localStorage.getItem(buildStorageKey(userId)) + const parsed = raw ? JSON.parse(raw) : [] + return Array.isArray(parsed) + ? parsed.map((item) => String(item || '').trim()).filter(Boolean) + : [] + } catch { + return [] + } +} + +function saveViewedClaimIds(userId, claimIds) { + if (!userId) { + return + } + + localStorage.setItem(buildStorageKey(userId), JSON.stringify(claimIds)) +} + +function pruneViewedClaimIds(viewedIds, pendingIds) { + const pendingSet = new Set(pendingIds) + return viewedIds.filter((claimId) => pendingSet.has(claimId)) +} + +function syncPendingState(pendingIds, userId) { + pendingClaimIds.value = pendingIds + const pruned = pruneViewedClaimIds(viewedClaimIds.value, pendingIds) + if (pruned.length !== viewedClaimIds.value.length) { + viewedClaimIds.value = pruned + saveViewedClaimIds(userId, pruned) + } +} + +export function useApprovalInbox() { + const { currentUser } = useSystemState() + + const userKey = computed(() => { + const user = currentUser.value || {} + return String(user.username || user.name || 'anonymous').trim() || 'anonymous' + }) + + const unreadCount = computed(() => { + const viewedSet = new Set(viewedClaimIds.value) + return pendingClaimIds.value.filter((claimId) => !viewedSet.has(claimId)).length + }) + + const badgeLabel = computed(() => { + const count = unreadCount.value + if (count <= 0) { + return '' + } + + return count > 99 ? '99+' : String(count) + }) + + function markClaimViewed(claimId) { + const normalizedId = String(claimId || '').trim() + if (!normalizedId || !pendingClaimIds.value.includes(normalizedId)) { + return + } + + if (viewedClaimIds.value.includes(normalizedId)) { + return + } + + const nextViewed = [...viewedClaimIds.value, normalizedId] + viewedClaimIds.value = nextViewed + saveViewedClaimIds(userKey.value, nextViewed) + } + + function syncPendingClaimIds(claimIds) { + if (!canAccessAppView(currentUser.value, 'approval')) { + pendingClaimIds.value = [] + return + } + + const pendingIds = Array.isArray(claimIds) + ? claimIds.map((item) => String(item || '').trim()).filter(Boolean) + : [] + + syncPendingState(pendingIds, userKey.value) + } + + async function refreshApprovalInbox() { + const user = currentUser.value + if (!user || !canAccessAppView(user, 'approval')) { + pendingClaimIds.value = [] + return + } + + try { + const payload = await fetchApprovalExpenseClaims() + syncPendingClaimIds(resolvePendingClaimIds(payload, user)) + } catch { + pendingClaimIds.value = [] + } + } + + function startApprovalInboxPolling(intervalMs = 45000) { + stopApprovalInboxPolling() + refreshTimer = window.setInterval(() => { + void refreshApprovalInbox() + }, intervalMs) + } + + function stopApprovalInboxPolling() { + if (refreshTimer) { + window.clearInterval(refreshTimer) + refreshTimer = null + } + } + + watch( + userKey, + (nextUserKey) => { + viewedClaimIds.value = loadViewedClaimIds(nextUserKey) + void refreshApprovalInbox() + }, + { immediate: true } + ) + + return { + pendingClaimIds, + unreadCount, + badgeLabel, + markClaimViewed, + syncPendingClaimIds, + refreshApprovalInbox, + startApprovalInboxPolling, + stopApprovalInboxPolling + } +} diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js index d560347..b748943 100644 --- a/web/src/composables/useRequests.js +++ b/web/src/composables/useRequests.js @@ -66,6 +66,33 @@ function formatDateTime(value) { return `${formatDate(nextDate)} ${hours}:${minutes}` } +function formatDurationFrom(value, now = Date.now()) { + const startAt = toDate(value) + if (!startAt) { + return '' + } + + const diffMs = Math.max(0, Number(now) - startAt.getTime()) + const totalMinutes = Math.floor(diffMs / (60 * 1000)) + if (totalMinutes < 1) { + return '刚刚' + } + + const days = Math.floor(totalMinutes / (24 * 60)) + const hours = Math.floor((totalMinutes % (24 * 60)) / 60) + const minutes = totalMinutes % 60 + + if (days > 0) { + return hours > 0 ? `${days}天${hours}小时` : `${days}天` + } + + if (hours > 0) { + return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时` + } + + return `${minutes}分钟` +} + function formatAmount(value) { return new Intl.NumberFormat('zh-CN', { style: 'currency', @@ -239,7 +266,147 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) { return 2 } -function buildProgressSteps(approvalMeta, workflowNode) { +function normalizeText(value) { + return String(value || '').trim() +} + +function getRiskFlags(claim) { + return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [] +} + +function getLatestEvent(events) { + const sortedEvents = events + .filter((item) => item && typeof item === 'object') + .map((item) => ({ ...item, eventDate: toDate(item.created_at || item.createdAt) })) + .filter((item) => item.eventDate) + .sort((a, b) => a.eventDate.getTime() - b.eventDate.getTime()) + + return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null +} + +function findApprovalEventForStep(claim, label) { + const stepLabel = normalizeText(label) + const events = getRiskFlags(claim).filter((flag) => { + if (!flag || typeof flag !== 'object') { + return false + } + + const source = normalizeText(flag.source) + if (!['manual_approval', 'finance_approval'].includes(source)) { + return false + } + + const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage) + const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage) + + if (stepLabel === '直属领导审批') { + return ( + previousStage.includes('直属领导') + || previousStage.includes('领导审批') + || nextStage.includes('财务') + ) + } + + if (stepLabel === '财务审批') { + return ( + previousStage.includes('财务') + || nextStage.includes('归档') + || nextStage.includes('入账') + || nextStage.includes('完成') + ) + } + + return false + }) + + return getLatestEvent(events) +} + +function findLatestReturnEvent(claim) { + return getLatestEvent( + getRiskFlags(claim).filter((flag) => ( + flag + && typeof flag === 'object' + && normalizeText(flag.source) === 'manual_return' + )) + ) +} + +function buildProgressStepMeta(time, detail = '', title = '') { + return { + time, + detail, + title: title || [time, detail].filter(Boolean).join(' ') + } +} + +function buildCompletedStepMeta(claim, label) { + const stepLabel = normalizeText(label) + const employeeName = normalizeText(claim?.employee_name) || '申请人' + + if (stepLabel === '保存草稿') { + const createdAt = formatDateTime(claim?.created_at) + return buildProgressStepMeta(`${employeeName}创建`, createdAt) + } + + if (stepLabel === '待提交') { + const submittedAt = formatDateTime(claim?.submitted_at) + return buildProgressStepMeta(`${employeeName}提交`, submittedAt) + } + + if (stepLabel === 'AI预审') { + const reviewedAt = formatDateTime(claim?.submitted_at || claim?.updated_at) + return buildProgressStepMeta('AI预审通过', reviewedAt) + } + + if (stepLabel === '直属领导审批' || stepLabel === '财务审批') { + const approvalEvent = findApprovalEventForStep(claim, stepLabel) + if (approvalEvent) { + const operator = normalizeText(approvalEvent.operator) || (stepLabel === '财务审批' ? '财务' : '审批人') + const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt) + return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim()) + } + + if (stepLabel === '财务审批') { + const updatedAt = formatDateTime(claim?.updated_at) + return buildProgressStepMeta('财务通过', updatedAt, `财务审批通过 ${updatedAt}`.trim()) + } + } + + if (stepLabel === '归档入账') { + const archivedAt = formatDateTime(claim?.updated_at) + return buildProgressStepMeta('归档入账', archivedAt) + } + + return buildProgressStepMeta('已完成') +} + +function resolveCurrentStepStartedAt(claim, label) { + const stepLabel = normalizeText(label) + if (stepLabel === '保存草稿') { + return claim?.created_at + } + if (stepLabel === '待提交') { + const returnEvent = findLatestReturnEvent(claim) + return returnEvent?.created_at || returnEvent?.createdAt || claim?.updated_at || claim?.created_at + } + if (stepLabel === 'AI预审') { + return claim?.updated_at || claim?.submitted_at || claim?.created_at + } + if (stepLabel === '直属领导审批') { + return claim?.submitted_at || claim?.updated_at || claim?.created_at + } + if (stepLabel === '财务审批') { + const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批') + return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at + } + if (stepLabel === '归档入账') { + return claim?.updated_at || claim?.submitted_at + } + return '' +} + +function buildProgressSteps(approvalMeta, workflowNode, claim = {}) { const currentIndex = resolveProgressCurrentIndex(approvalMeta, workflowNode) const currentTime = approvalMeta.key === 'completed' @@ -252,10 +419,13 @@ function buildProgressSteps(approvalMeta, workflowNode) { return REIMBURSEMENT_PROGRESS_LABELS.map((label, index) => { if (approvalMeta.key === 'completed') { + const stepMeta = buildCompletedStepMeta(claim, label) return { index: index + 1, label, - time: '已完成', + time: stepMeta.time, + detail: stepMeta.detail, + title: stepMeta.title, done: true, active: true, current: false @@ -263,10 +433,13 @@ function buildProgressSteps(approvalMeta, workflowNode) { } if (index < currentIndex) { + const stepMeta = buildCompletedStepMeta(claim, label) return { index: index + 1, label, - time: '已完成', + time: stepMeta.time, + detail: stepMeta.detail, + title: stepMeta.title, done: true, active: true, current: false @@ -274,10 +447,13 @@ function buildProgressSteps(approvalMeta, workflowNode) { } if (index === currentIndex) { + const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label)) return { index: index + 1, label, - time: currentTime, + time: stayDuration ? `停留 ${stayDuration}` : currentTime, + detail: '', + title: stayDuration ? `当前${label}已停留 ${stayDuration}` : currentTime, done: false, active: true, current: true @@ -288,6 +464,8 @@ function buildProgressSteps(approvalMeta, workflowNode) { index: index + 1, label, time: '待处理', + detail: '', + title: '待处理', done: false, active: false, current: false @@ -315,6 +493,7 @@ function buildExpenseItems(claim, riskSummary) { id: String(item?.id || `${claim?.id || 'claim'}-item-${index}`), time: formatDate(item?.item_date) || '待补充', itemDate: formatDate(item?.item_date) || '', + filledAt: formatDateTime(item?.created_at) || '待同步', itemType, itemReason, itemLocation, @@ -328,8 +507,8 @@ function buildExpenseItems(claim, riskSummary) { amount: itemAmountDisplay, status: attachments.length ? '已识别' : '待补充', tone: attachments.length ? 'ok' : 'bad', - attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '未上传', - attachmentHint: attachments.length ? attachments[0] : '支持上传 JPG、PNG、PDF,未上传也可先保存草稿', + attachmentStatus: attachments.length ? '已关联票据' : '未上传', + attachmentHint: attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据', attachmentTone: attachments.length ? 'ok' : 'missing', attachments, riskLabel: riskSummary === '无' ? '无' : '待关注', @@ -394,7 +573,7 @@ export function mapExpenseClaimToRequest(claim) { : `共 ${expenseItems.length} 条费用明细,待补充票据`) : '暂无费用明细', note: String(claim?.reason || '').trim(), - progressSteps: buildProgressSteps(approvalMeta, workflowNode), + progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim), expenseItems } } diff --git a/web/src/services/reimbursements.js b/web/src/services/reimbursements.js index 00741d7..9c6ef09 100644 --- a/web/src/services/reimbursements.js +++ b/web/src/services/reimbursements.js @@ -4,6 +4,10 @@ export function fetchExpenseClaims() { return apiRequest('/reimbursements/claims') } +export function fetchApprovalExpenseClaims() { + return apiRequest('/reimbursements/claims/approvals') +} + export function fetchExpenseClaimDetail(claimId) { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`) } @@ -94,6 +98,13 @@ export function returnExpenseClaim(claimId, payload = {}) { }) } +export function approveExpenseClaim(claimId, payload = {}) { + return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/approve`, { + method: 'POST', + body: JSON.stringify(payload) + }) +} + export function deleteExpenseClaim(claimId) { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, { method: 'DELETE' diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index 47a0cd9..7e34978 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -20,6 +20,7 @@ const VIEW_ROLE_RULES = { settings: ['manager'] } const CLAIM_MANAGER_ROLE_CODES = new Set(['finance', 'executive']) +const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver']) function normalizedRoleCodes(user) { if (!user) { @@ -51,6 +52,14 @@ export function canManageExpenseClaims(user) { return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode)) } +export function canReturnExpenseClaims(user) { + if (Boolean(user?.isAdmin)) { + return true + } + + return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode)) +} + export function canAccessAppView(user, viewId) { if (!viewId || !user) { return false diff --git a/web/src/utils/approvalInbox.js b/web/src/utils/approvalInbox.js new file mode 100644 index 0000000..102019b --- /dev/null +++ b/web/src/utils/approvalInbox.js @@ -0,0 +1,40 @@ +import { mapExpenseClaimToRequest } from '../composables/useRequests.js' +import { canManageExpenseClaims } from './accessControl.js' + +export function canProcessApprovalRequest(request, currentUser) { + const node = String(request?.workflowNode || '').trim() + const currentName = String(currentUser?.name || '').trim() + const applicantName = String(request?.person || request?.employeeName || '').trim() + + if (currentName && applicantName && currentName === applicantName) { + return false + } + + if (canManageExpenseClaims(currentUser)) { + return true + } + + return ( + node.includes('直属领导') + || node.includes('领导审批') + || node.includes('部门负责人') + || node.includes('负责人审批') + ) +} + +export function listPendingApprovalRequests(claimsPayload, currentUser) { + if (!Array.isArray(claimsPayload)) { + return [] + } + + return claimsPayload + .map((item) => mapExpenseClaimToRequest(item)) + .filter((item) => item.approvalKey === 'in_progress') + .filter((item) => canProcessApprovalRequest(item, currentUser)) +} + +export function resolvePendingClaimIds(claimsPayload, currentUser) { + return listPendingApprovalRequests(claimsPayload, currentUser) + .map((item) => String(item.claimId || '').trim()) + .filter(Boolean) +} diff --git a/web/src/utils/reimbursementTextInference.js b/web/src/utils/reimbursementTextInference.js new file mode 100644 index 0000000..84acdc2 --- /dev/null +++ b/web/src/utils/reimbursementTextInference.js @@ -0,0 +1,188 @@ +const DEFAULT_SESSION_TYPE_EXPENSE = 'expense' +const DEFAULT_SESSION_TYPE_KNOWLEDGE = 'knowledge' + +const DEFAULT_INTENT_LABELS = { + query: '查询', + explain: '解释', + compare: '对比', + risk_check: '风险检查', + draft: '草稿生成', + operate: '动作请求' +} + +const DEFAULT_SCENARIO_LABELS = { + expense: '报销', + accounts_receivable: '应收', + accounts_payable: '应付', + knowledge: '知识', + unknown: '通用' +} + +const DEFAULT_EXPENSE_TYPE_LABELS = { + travel: '差旅费', + hotel: '住宿费', + transport: '交通费', + meal: '餐费', + meeting: '会务费', + entertainment: '业务招待费', + office: '办公费', + training: '培训费', + communication: '通讯费', + welfare: '福利费', + other: '其他费用' +} + +export const TRANSPORT_KEYWORD_PATTERN = /交通|出行|打车|网约车|出租车|滴滴|车费|乘车|用车|叫车|约车|的士|车票|车资|地铁|公交|停车|过路费|通行费/ + +const FLOW_INTENT_KEYWORDS = { + draft: ['报销', '报账', '草稿', '生成', '提交', '申请', '请走报销'], + query: ['查询', '查一下', '多少', '明细', '统计'], + risk_check: ['风险', '异常', '重复', '超标'], + explain: ['为什么', '依据', '规则', '怎么'] +} + +function normalizeCompactText(value) { + return String(value || '').trim().replace(/\s+/g, '') +} + +function resolveExpenseTypeLabel(type, fallbackLabel = '', expenseTypeLabels = DEFAULT_EXPENSE_TYPE_LABELS) { + const normalized = String(type || '').trim() + return expenseTypeLabels[normalized] || String(fallbackLabel || '').trim() || expenseTypeLabels.other +} + +function resolveSemanticExpenseTypeLabel(semanticParse, expenseTypeLabels = DEFAULT_EXPENSE_TYPE_LABELS) { + const entities = Array.isArray(semanticParse?.entities_json) ? semanticParse.entities_json : [] + const expenseTypeEntity = entities.find((item) => String(item?.type || '').trim() === 'expense_type') + if (expenseTypeEntity) { + return resolveExpenseTypeLabel( + String(expenseTypeEntity.normalized_value || '').trim(), + String(expenseTypeEntity.value || '').trim(), + expenseTypeLabels + ) + } + + return resolveExpenseTypeLabel( + String(semanticParse?.expense_type || semanticParse?.expense_type_code || '').trim(), + String(semanticParse?.expense_type_label || '').trim(), + expenseTypeLabels + ) +} + +export function inferLocalFlowCandidates(rawText) { + const text = String(rawText || '').trim() + const compact = normalizeCompactText(text) + + let time = '' + const explicitTimeMatch = text.match(/发生时间[::]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/) + if (explicitTimeMatch?.[1]) { + time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-') + } else { + const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/) + if (dateMatch?.[1]) { + time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-') + } else if (/今天|今日/.test(compact)) { + time = '今天' + } else if (/昨天|昨日/.test(compact)) { + time = '昨天' + } else if (/前天/.test(compact)) { + time = '前天' + } + } + + let amount = '' + const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/) + if (amountMatch?.[1]) { + const numericValue = Number(amountMatch[1]) + if (Number.isFinite(numericValue)) { + amount = Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元` + } + } + + let event = '' + let expenseType = '' + if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) { + event = '请客户吃饭' + expenseType = '业务招待费' + } else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) { + event = '出差行程' + expenseType = '差旅费' + } else if (TRANSPORT_KEYWORD_PATTERN.test(compact)) { + event = '交通出行' + expenseType = '交通费' + } else if (/住宿|酒店|宾馆/.test(compact)) { + event = '住宿报销' + expenseType = '住宿费' + } else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) { + event = '餐饮用餐' + expenseType = '餐费' + } + + return { + time, + amount, + event, + expenseType + } +} + +export function buildLocalIntentPreview(rawText, sessionType = DEFAULT_SESSION_TYPE_EXPENSE, options = {}) { + if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) { + return '初步识别为财务知识问答,正在准备检索范围' + } + + const compact = normalizeCompactText(rawText) + const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS + const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) => + keywords.some((keyword) => compact.includes(keyword)) + )?.[0] || 'draft' + const intentLabel = intentLabels[intentKey] || DEFAULT_INTENT_LABELS[intentKey] || '处理' + const candidates = inferLocalFlowCandidates(rawText) + const expenseTypeText = candidates.expenseType ? `,费用类型为${candidates.expenseType}` : '' + return `初步识别为报销场景,准备进入${intentLabel}${expenseTypeText}` +} + +export function buildLocalExtractionProgressMessages(rawText, options = {}) { + const candidates = inferLocalFlowCandidates(rawText) + const messages = [] + + messages.push('正在提取发生时间...') + messages.push( + candidates.time + ? `发现发生时间 ${candidates.time},继续提取金额...` + : '暂未定位到明确时间,继续提取金额...' + ) + messages.push( + candidates.amount + ? `发现金额 ${candidates.amount},继续识别事件类型...` + : '暂未定位到明确金额,继续识别事件类型...' + ) + + if (candidates.event || candidates.expenseType) { + const eventParts = [candidates.event, candidates.expenseType].filter(Boolean) + messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`) + } else { + messages.push('正在识别事件类型和费用分类...') + } + + const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件' + messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`) + + return messages +} + +export function summarizeSemanticIntentDetail(semanticParse, options = {}) { + if (!semanticParse || typeof semanticParse !== 'object') { + return options.fallbackText || '意图识别完成' + } + + const scenarioLabels = options.scenarioLabels || DEFAULT_SCENARIO_LABELS + const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS + const expenseTypeLabels = options.expenseTypeLabels || DEFAULT_EXPENSE_TYPE_LABELS + const scenarioLabel = scenarioLabels[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用' + const intentLabel = intentLabels[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理' + const expenseTypeLabel = resolveSemanticExpenseTypeLabel(semanticParse, expenseTypeLabels) + const expenseTypeText = expenseTypeLabel && expenseTypeLabel !== expenseTypeLabels.other + ? `,费用类型为${expenseTypeLabel}` + : '' + return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}${expenseTypeText}` +} diff --git a/web/src/utils/requestViewModel.js b/web/src/utils/requestViewModel.js index c650c6e..ceae2af 100644 --- a/web/src/utils/requestViewModel.js +++ b/web/src/utils/requestViewModel.js @@ -194,7 +194,13 @@ export function normalizeRequestForUi(request) { const sceneTarget = String(request.sceneTarget || request.location || request.city || request.entity || '').trim() || '待补充' const occurredDisplay = String(request.occurredDisplay || request.period || request.occurredAt || '').trim() || '待补充' const applyTime = String(request.applyTime || parseRequestDateFromId(request.id) || '').trim() || '待补充' - const workflowNode = String(request.workflowNode || request.node || '').trim() || '待提交' + const workflowNode = String( + request.workflowNode + || request.node + || request.approval_stage + || request.approvalStage + || '' + ).trim() || '待提交' const secondaryStatusValue = String(request.secondaryStatusValue || request.travel || '').trim() || (detailVariant === 'travel' ? '待安排行程' : '待补充票据') diff --git a/web/src/utils/workbenchIconAssets.js b/web/src/utils/workbenchIconAssets.js new file mode 100644 index 0000000..b8cc837 --- /dev/null +++ b/web/src/utils/workbenchIconAssets.js @@ -0,0 +1,22 @@ +import briefcaseIcon from '../assets/workbench-icons/outline-briefcase.svg?raw' +import documentTextIcon from '../assets/workbench-icons/outline-document-text.svg?raw' +import paperAirplaneIcon from '../assets/workbench-icons/outline-paper-airplane.svg?raw' +import shoppingBagIcon from '../assets/workbench-icons/outline-shopping-bag.svg?raw' +import truckIcon from '../assets/workbench-icons/outline-truck.svg?raw' +import usersIcon from '../assets/workbench-icons/outline-users.svg?raw' + +function prepareHeroiconMarkup(svgRaw) { + return String(svgRaw || '') + .replace(/]*)>/i, '') + .replace(/\sdata-slot="[^"]*"/g, '') + .replace(/\saria-hidden="[^"]*"/g, '') +} + +export const workbenchIconMap = { + hospitality: { markup: prepareHeroiconMarkup(usersIcon), style: 'outline' }, + travelDraft: { markup: prepareHeroiconMarkup(briefcaseIcon), style: 'outline' }, + receipts: { markup: prepareHeroiconMarkup(documentTextIcon), style: 'outline' }, + flight: { markup: prepareHeroiconMarkup(paperAirplaneIcon), style: 'outline' }, + transport: { markup: prepareHeroiconMarkup(truckIcon), style: 'outline' }, + procurement: { markup: prepareHeroiconMarkup(shoppingBagIcon), style: 'outline' } +} diff --git a/web/src/utils/workbenchSummary.js b/web/src/utils/workbenchSummary.js new file mode 100644 index 0000000..ebff830 --- /dev/null +++ b/web/src/utils/workbenchSummary.js @@ -0,0 +1,82 @@ +function parseNumber(value) { + const nextValue = Number(value) + return Number.isFinite(nextValue) ? nextValue : 0 +} + +function toDate(value) { + if (!value) { + return null + } + + const nextDate = new Date(value) + return Number.isNaN(nextDate.getTime()) ? null : nextDate +} + +function isCurrentMonth(dateValue) { + const date = toDate(dateValue) + if (!date) { + return false + } + + const now = new Date() + return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() +} + +function resolveClaimDate(request) { + return request?.submittedAt || request?.createdAt || request?.occurredAt || '' +} + +export function belongsToCurrentUser(request, currentUser) { + const person = String(request?.person || request?.employeeName || '').trim() + if (!person) { + return false + } + + const names = [ + String(currentUser?.name || '').trim(), + String(currentUser?.username || '').trim() + ].filter(Boolean) + + return names.some((name) => name === person) +} + +export function hasHighRiskFlag(request) { + const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : [] + + if (riskFlags.some((item) => String(item?.severity || '').trim().toLowerCase() === 'high')) { + return true + } + + const summary = String(request?.riskSummary || '').trim() + return summary.includes('高') +} + +function formatCurrency(value) { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY', + minimumFractionDigits: 0, + maximumFractionDigits: Number.isInteger(value) ? 0 : 2 + }).format(parseNumber(value)) +} + +export function buildWorkbenchSummary(requests, currentUser) { + const ownedRequests = Array.isArray(requests) + ? requests.filter((item) => belongsToCurrentUser(item, currentUser)) + : [] + + const monthlyClaims = ownedRequests.filter((item) => isCurrentMonth(resolveClaimDate(item))) + + const monthlyCount = monthlyClaims.length + const monthlyAmount = monthlyClaims.reduce((sum, item) => sum + parseNumber(item.amount), 0) + const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length + const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length + + return { + monthlyCount, + monthlyAmount, + monthlyAmountLabel: formatCurrency(monthlyAmount), + returnCount, + highRiskCount + } +} diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index 2024f80..7ef71b3 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -1,200 +1,202 @@ -
- - -
+
+ + +
- - - + + -
- + - - - + - + - - - - - - - + + + + + + + + +
- -
- + +
+ - +function handleLogout() { + logout('manual') +} + diff --git a/web/src/views/ApprovalCenterView.vue b/web/src/views/ApprovalCenterView.vue index 9aed38e..93021b3 100644 --- a/web/src/views/ApprovalCenterView.vue +++ b/web/src/views/ApprovalCenterView.vue @@ -1,429 +1,15 @@ diff --git a/web/src/views/EmployeeManagementView.vue b/web/src/views/EmployeeManagementView.vue index 039c79d..7d15018 100644 --- a/web/src/views/EmployeeManagementView.vue +++ b/web/src/views/EmployeeManagementView.vue @@ -287,12 +287,10 @@ class="history-row" > {{ item.action }} -
- {{ item.owner }} - {{ - formatEmployeeHistoryTime(item.time || item.occurredAt) - }} -
+ {{ item.owner }} + {{ + formatEmployeeHistoryTime(item.time || item.occurredAt) + }}

暂无变更记录 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" + > + + + +

-
- 部门{{ profile.department }} - 职级{{ profile.grade }} - 直属上司{{ profile.manager }} +
+
+ + 部门 + {{ profile.department }} + + + 直属上司 + {{ profile.manager }} + +
+
+ + 职级 + {{ profile.grade }} + + + 岗位 + {{ profile.position }} + +
@@ -59,8 +76,11 @@ - {{ step.label }} - {{ step.time }} +
+ {{ step.label }} + {{ step.time }} + {{ step.detail }} +
@@ -73,16 +93,16 @@

费用明细

- {{ isTravelRequest ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与系统校验。' }} + {{ isTravelRequest ? '按出行时间逐笔核对票据与差旅规则。' : '按业务发生时间逐笔核对票据、用途说明与 AI 识别结果。' }}

-