diff --git a/docker-compose.yml b/docker-compose.yml index 7a0f87a..608dec7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,10 +30,24 @@ services: - /bin/sh - -lc - > - apt-get update && - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends - python3 python3-pip python3-venv && - mkdir -p /run/sshd && /usr/sbin/sshd && + apt-get update && + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends + python3 python3-pip python3-venv fontconfig fonts-noto-cjk fonts-noto-cjk-extra && + printf '%s\n' + '' + '' + '' + ' SimSunNoto Serif CJK SC' + ' NSimSunNoto Serif CJK SC' + ' KaiTiNoto Serif CJK SC' + ' FangSongNoto Serif CJK SC' + ' SimHeiNoto Sans CJK SC' + ' DengXianNoto Sans CJK SC' + ' Microsoft YaHeiNoto Sans CJK SC' + '' + > /etc/fonts/local.conf && + fc-cache -f && + mkdir -p /run/sshd && /usr/sbin/sshd && printf '%s\n' 'cd /app >/dev/null 2>&1 || true' > /etc/profile.d/zz-x-financial-app-dir.sh && chmod 644 /etc/profile.d/zz-x-financial-app-dir.sh && touch /root/.bashrc /root/.profile && diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index 42af4f0..727f5d2 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -20,9 +20,12 @@ from app.schemas.reimbursement import ( ExpenseClaimReturnPayload, ReimbursementCreate, ReimbursementRead, + TravelReimbursementCalculatorRequest, + TravelReimbursementCalculatorResponse, ) from app.services.expense_claims import ExpenseClaimService from app.services.reimbursement import ReimbursementService +from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService router = APIRouter() DbSession = Annotated[Session, Depends(get_db)] @@ -50,6 +53,29 @@ def create_reimbursement(payload: ReimbursementCreate, db: DbSession) -> Reimbur return ReimbursementService(db).create_reimbursement(payload) +@router.post( + "/travel-calculator", + response_model=TravelReimbursementCalculatorResponse, + summary="差旅报销标准测算", + description="根据规则中心的差旅报销表、当前员工职级、出差天数与地点测算住宿和补贴参考金额。", + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "测算入参或规则匹配失败。", + } + }, +) +def calculate_travel_reimbursement( + payload: TravelReimbursementCalculatorRequest, + db: DbSession, + current_user: CurrentUser, +) -> TravelReimbursementCalculatorResponse: + try: + return TravelReimbursementCalculatorService(db).calculate(payload, current_user) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + @router.get( "/claims", response_model=list[ExpenseClaimRead], @@ -463,8 +489,8 @@ def return_expense_claim( @router.post( "/claims/{claim_id}/approve", response_model=ExpenseClaimRead, - summary="直属领导审批通过报销单", - description="当前审批人确认报销信息无误后,将报销单从直属领导审批流转到财务审批。", + summary="审批通过报销单", + description="直属领导审批通过后流转到财务审批;财务终审通过后进入归档入账。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, @@ -497,7 +523,7 @@ def approve_expense_claim( "/claims/{claim_id}", response_model=ExpenseClaimActionResponse, summary="删除报销单", - description="普通用户仅可删除草稿或待补充报销单;财务人员和高级管理人员可删除可见报销单。", + description="申请人仅可删除自己的草稿、待补充或退回单据;高级管理人员可删除可见单据,财务人员没有删除权限。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, diff --git a/server/src/app/models/financial_record.py b/server/src/app/models/financial_record.py index 5607b26..19468df 100644 --- a/server/src/app/models/financial_record.py +++ b/server/src/app/models/financial_record.py @@ -93,6 +93,10 @@ class ExpenseClaimItem(Base): claim = relationship("ExpenseClaim", back_populates="items") + @property + def is_system_generated(self) -> bool: + return str(self.item_type or "").strip().lower() in {"travel_allowance"} + class AccountsReceivableRecord(Base): __tablename__ = "accounts_receivable" diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index 06f9ec8..97e206c 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -41,6 +41,7 @@ class ExpenseClaimItemRead(BaseModel): item_location: str item_amount: Decimal invoice_id: str | None + is_system_generated: bool = False created_at: datetime updated_at: datetime @@ -157,11 +158,41 @@ class ExpenseClaimApprovalPayload(BaseModel): opinion: str | None = Field(default=None, max_length=500) +class TravelReimbursementCalculatorRequest(BaseModel): + days: int = Field(ge=1, le=365) + location: str = Field(min_length=1, max_length=120) + grade: str | None = Field(default=None, max_length=30) + + +class TravelReimbursementCalculatorResponse(BaseModel): + days: int + location: str + matched_city: str + city_tier: str + grade: str + grade_band: str + grade_band_label: str + hotel_rate: Decimal + hotel_amount: Decimal + allowance_region: str + meal_allowance_rate: Decimal + basic_allowance_rate: Decimal + total_allowance_rate: Decimal + allowance_amount: Decimal + total_amount: Decimal + rule_name: str + rule_version: str + formula_text: str + summary_text: str + + class ExpenseClaimAttachmentActionResponse(BaseModel): message: str claim_id: str item_id: str invoice_id: str | None = None + item_amount: Decimal | None = None + claim_amount: Decimal | None = None attachment: ExpenseClaimAttachmentRead | None = None diff --git a/server/src/app/services/agent_conversations.py b/server/src/app/services/agent_conversations.py index 45843ed..9bc93ad 100644 --- a/server/src/app/services/agent_conversations.py +++ b/server/src/app/services/agent_conversations.py @@ -39,6 +39,7 @@ class AgentConversationService: normalized_id = str(conversation_id or "").strip() normalized_user_id = str(user_id or "").strip() or None incoming_session_type = str(context_json.get("session_type") or "").strip() or "expense" + incoming_draft_claim_id = self._resolve_draft_claim_id(context_json) conversation = self.get_conversation(normalized_id) if normalized_id else None if conversation is not None and conversation.user_id != normalized_user_id: normalized_id = "" @@ -56,6 +57,7 @@ class AgentConversationService: source=source, entry_source=str(context_json.get("entry_source") or "").strip() or None, title=self._resolve_title(context_json), + draft_claim_id=incoming_draft_claim_id or None, state_json=self._extract_state_json(context_json), ) self.db.add(conversation) @@ -69,6 +71,8 @@ class AgentConversationService: conversation.entry_source = str(context_json.get("entry_source") or "").strip() or None if not conversation.title: conversation.title = self._resolve_title(context_json) + if incoming_draft_claim_id: + conversation.draft_claim_id = incoming_draft_claim_id conversation.state_json = self._merge_state_json( conversation.state_json, self._extract_state_json(context_json), @@ -354,6 +358,38 @@ class AgentConversationService: self.db.commit() return len(conversations) + def delete_conversations_for_draft_claim( + self, + *, + claim_id: str | None, + source: str | None = "user_message", + session_type: str | None = "expense", + ) -> int: + normalized_claim_id = str(claim_id or "").strip() + if not normalized_claim_id: + return 0 + + stmt = select(AgentConversation).where(AgentConversation.draft_claim_id == normalized_claim_id) + if source: + stmt = stmt.where(AgentConversation.source == source) + conversations = list(self.db.scalars(stmt).all()) + normalized_session_type = str(session_type or "").strip() + if normalized_session_type: + conversations = [ + conversation + for conversation in conversations + if (str((conversation.state_json or {}).get("session_type") or "").strip() or "expense") + == normalized_session_type + ] + if not conversations: + return 0 + + for conversation in conversations: + self.db.delete(conversation) + + self.db.commit() + return len(conversations) + def delete_conversation( self, *, @@ -478,11 +514,28 @@ class AgentConversationService: continue state_json[key] = value - draft_claim_id = str(context_json.get("draft_claim_id") or "").strip() + draft_claim_id = AgentConversationService._resolve_draft_claim_id(context_json) if draft_claim_id: state_json["draft_claim_id"] = draft_claim_id return state_json + @staticmethod + def _resolve_draft_claim_id(context_json: dict[str, Any]) -> str: + draft_claim_id = str((context_json or {}).get("draft_claim_id") or "").strip() + if draft_claim_id: + return draft_claim_id + + request_context = (context_json or {}).get("request_context") + if isinstance(request_context, dict): + return str( + request_context.get("claim_id") + or request_context.get("claimId") + or request_context.get("draft_claim_id") + or request_context.get("draftClaimId") + or "" + ).strip() + return "" + @staticmethod def _merge_state_json( current_state: dict[str, Any] | None, diff --git a/server/src/app/services/document_intelligence.py b/server/src/app/services/document_intelligence.py index 4f116a8..e36e42e 100644 --- a/server/src/app/services/document_intelligence.py +++ b/server/src/app/services/document_intelligence.py @@ -86,7 +86,7 @@ DOCUMENT_RULES: tuple[DocumentRule, ...] = ( scene_code="travel", scene_label="差旅票据", expense_type="travel", - keywords=("高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座"), + keywords=("铁路电子客票", "电子客票", "高铁", "火车", "动车", "铁路", "车次", "检票", "二等座", "一等座", "票价"), score_bias=0.32, ), DocumentRule( diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index ca917c8..9e66b7a 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -57,6 +57,7 @@ EXPENSE_TYPE_LABELS = { PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"} APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"} +CLAIM_DELETE_ROLE_CODES = {"executive"} MAX_DRAFT_CLAIMS_PER_USER = 3 EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned") LOCATION_REQUIRED_EXPENSE_TYPES = { @@ -542,14 +543,19 @@ class ExpenseClaimService: [(normalized_name, content, media_type or "application/octet-stream")] ) documents = list(ocr_result.documents or []) - if documents: - ocr_document = documents[0] - ocr_status = "recognized" - document_info = self._build_attachment_document_info(ocr_document) - requirement_check = self._build_attachment_requirement_check( - item=item, - document_info=document_info, - ) + if documents: + ocr_document = documents[0] + ocr_status = "recognized" + document_info = self._build_attachment_document_info(ocr_document) + self._backfill_item_amount_from_attachment( + item=item, + document=ocr_document, + document_info=document_info, + ) + requirement_check = self._build_attachment_requirement_check( + item=item, + document_info=document_info, + ) attachment_analysis = self._build_attachment_analysis( document=ocr_document, item=item, @@ -615,13 +621,15 @@ class ExpenseClaimService: after_json=self._serialize_claim(claim), ) - return { - "message": f"{normalized_name} 已上传并关联到当前费用明细。", - "claim_id": claim.id, - "item_id": item.id, - "invoice_id": item.invoice_id, - "attachment": self._build_attachment_payload(item), - } + return { + "message": f"{normalized_name} 已上传并关联到当前费用明细。", + "claim_id": claim.id, + "item_id": item.id, + "invoice_id": item.invoice_id, + "item_amount": item.item_amount, + "claim_amount": claim.amount, + "attachment": self._build_attachment_payload(item), + } def get_claim_item_attachment_meta( self, @@ -739,16 +747,18 @@ class ExpenseClaimService: self.db.commit() self.db.refresh(claim) - self.audit_service.log_action( - actor=current_user.name or current_user.username, - action="expense_claim.submit", - resource_type="expense_claim", - resource_id=claim.id, - before_json=before_json, - after_json=self._serialize_claim(claim), - ) - - return claim + self.audit_service.log_action( + actor=current_user.name or current_user.username, + action="expense_claim.submit", + resource_type="expense_claim", + resource_id=claim.id, + before_json=before_json, + after_json=self._serialize_claim(claim), + ) + if str(claim.status or "").strip().lower() == "submitted": + self._delete_submitted_claim_assistant_sessions(claim.id) + + return claim def save_or_submit_from_ontology( self, @@ -858,8 +868,10 @@ class ExpenseClaimService: if claim is None: return None - if not self._has_privileged_claim_access(current_user): + if not self._has_claim_delete_access(current_user): self._ensure_draft_claim(claim) + if not self._is_claim_owned_by_current_user(claim, current_user): + raise ValueError("只有高级管理人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。") before_json = self._serialize_claim(claim) resource_id = claim.id @@ -903,7 +915,7 @@ class ExpenseClaimService: raise ValueError("已完成单据不允许退回。") before_json = self._serialize_claim(claim) - operator = current_user.name or current_user.username + operator = self._resolve_current_user_display_name(current_user) 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) @@ -987,29 +999,43 @@ class ExpenseClaimService: 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("当前节点不是直属领导审批,不能执行领导审批通过。") + if previous_stage == "直属领导审批": + if not self._can_approve_claim(current_user, claim): + raise ValueError("只有当前直属领导审批人可以审批通过该报销单。") + approval_source = "manual_approval" + event_type = "expense_claim_approval" + label = "领导审批通过" + next_status = "submitted" + next_stage = "财务审批" + default_message = "{operator} 已审批通过,流转至{next_stage}。" + elif previous_stage == "财务审批": + if not self._can_approve_claim(current_user, claim): + raise ValueError("只有财务人员可以完成财务终审。") + approval_source = "finance_approval" + event_type = "expense_claim_finance_approval" + label = "财务审核通过" + next_status = "approved" + next_stage = "归档入账" + default_message = "{operator} 已完成财务审核,进入归档入账。" + else: + raise ValueError("当前节点不支持审批通过。") before_json = self._serialize_claim(claim) - operator = current_user.name or current_user.username - leader_opinion = str(opinion or "").strip() - next_stage = "财务审批" + operator = self._resolve_current_user_display_name(current_user) + approval_opinion = str(opinion or "").strip() approval_flag = { - "source": "manual_approval", - "event_type": "expense_claim_approval", + "source": approval_source, + "event_type": event_type, "approval_event_id": str(uuid.uuid4()), "severity": "info", - "label": "领导审批通过", - "message": leader_opinion or f"{operator} 已审批通过,流转至{next_stage}。", - "opinion": leader_opinion, + "label": label, + "message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage), + "opinion": approval_opinion, "operator": operator, "operator_username": current_user.username, "operator_role_codes": [ @@ -1024,7 +1050,7 @@ class ExpenseClaimService: "created_at": datetime.now(UTC).isoformat(), } - claim.status = "submitted" + claim.status = next_status claim.approval_stage = next_stage if claim.submitted_at is None: claim.submitted_at = datetime.now(UTC) @@ -2205,16 +2231,89 @@ class ExpenseClaimService: meta_path = self._attachment_meta_path(file_path) meta_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - def _read_attachment_meta(self, file_path: Path) -> dict[str, Any]: - meta_path = self._attachment_meta_path(file_path) - if not meta_path.exists(): - return {} + def _read_attachment_meta(self, file_path: Path) -> dict[str, Any]: + meta_path = self._attachment_meta_path(file_path) + if not meta_path.exists(): + return {} try: payload = json.loads(meta_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): - return {} - return payload if isinstance(payload, dict) else {} + return {} + return payload if isinstance(payload, dict) else {} + + def _repair_pdf_text_layer_metadata_if_needed( + self, + *, + file_path: Path, + metadata: dict[str, Any], + item: ExpenseClaimItem | None = None, + ) -> dict[str, Any]: + if not metadata: + return metadata + + media_type = str(metadata.get("media_type") or self._resolve_attachment_media_type(file_path.name)).strip() + if media_type != "application/pdf": + return metadata + + ocr_text = str(metadata.get("ocr_text") or "") + ocr_summary = str(metadata.get("ocr_summary") or "") + if OcrService._placeholder_ratio(f"{ocr_summary}\n{ocr_text}") < 0.12: + return metadata + + text_layer = OcrService(self.db)._extract_pdf_text_layer(file_path) + repaired_text, used_text_layer = OcrService._choose_document_text( + ocr_text=ocr_text, + text_layer=text_layer, + ) + if not used_text_layer or not repaired_text: + return metadata + + repaired_summary = OcrService._summarize_text(repaired_text) + document = SimpleNamespace( + filename=str(metadata.get("file_name") or file_path.name), + text=repaired_text, + summary=repaired_summary, + avg_score=float(metadata.get("ocr_avg_score") or 0.0), + line_count=int(metadata.get("ocr_line_count") or 0), + document_type="", + document_type_label="", + scene_code="", + scene_label="", + document_fields=[], + warnings=[str(value) for value in list(metadata.get("ocr_warnings") or []) if str(value).strip()], + ) + document_info = self._build_attachment_document_info(document) + document.document_type = document_info.get("document_type", "") + document.document_type_label = document_info.get("document_type_label", "") + document.scene_code = document_info.get("scene_code", "") + document.scene_label = document_info.get("scene_label", "") + document.document_fields = list(document_info.get("fields") or []) + + metadata["ocr_text"] = repaired_text + metadata["ocr_summary"] = repaired_summary + metadata["document_info"] = document_info + metadata["previewable"] = True + metadata["preview_kind"] = "pdf" + metadata["preview_storage_key"] = str(metadata.get("storage_key") or self._to_attachment_storage_key(file_path)) + metadata["preview_media_type"] = "application/pdf" + metadata["preview_file_name"] = str(metadata.get("file_name") or file_path.name) + + if item is not None: + requirement_check = self._build_attachment_requirement_check( + item=item, + document_info=document_info, + ) + metadata["requirement_check"] = requirement_check + metadata["analysis"] = self._build_attachment_analysis( + document=document, + item=item, + document_info=document_info, + requirement_check=requirement_check, + ) + + self._write_attachment_meta(file_path, metadata) + return metadata def _build_attachment_preview_meta( self, @@ -2262,12 +2361,17 @@ class ExpenseClaimService: "preview_file_name": "", } - def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]: - file_path, media_type, filename = self._resolve_item_attachment_content(item) - metadata = self._read_attachment_meta(file_path) - preview_storage_key = str(metadata.get("preview_storage_key") or "").strip() - preview_file_name = str(metadata.get("preview_file_name") or "").strip() - preview_media_type = str(metadata.get("preview_media_type") or "").strip() + def _resolve_item_attachment_preview_content(self, item: ExpenseClaimItem) -> tuple[Path, str, str]: + file_path, media_type, filename = self._resolve_item_attachment_content(item) + metadata = self._read_attachment_meta(file_path) + metadata = self._repair_pdf_text_layer_metadata_if_needed( + file_path=file_path, + metadata=metadata, + item=item, + ) + preview_storage_key = str(metadata.get("preview_storage_key") or "").strip() + preview_file_name = str(metadata.get("preview_file_name") or "").strip() + preview_media_type = str(metadata.get("preview_media_type") or "").strip() if preview_storage_key: preview_path = self._resolve_attachment_path(preview_storage_key) @@ -2284,10 +2388,15 @@ class ExpenseClaimService: raise FileNotFoundError("Attachment preview not found") - def _build_attachment_payload(self, item: ExpenseClaimItem) -> dict[str, Any]: - file_path, media_type, filename = self._resolve_item_attachment_content(item) - metadata = self._read_attachment_meta(file_path) - uploaded_at_value = metadata.get("uploaded_at") + def _build_attachment_payload(self, item: ExpenseClaimItem) -> dict[str, Any]: + file_path, media_type, filename = self._resolve_item_attachment_content(item) + metadata = self._read_attachment_meta(file_path) + metadata = self._repair_pdf_text_layer_metadata_if_needed( + file_path=file_path, + metadata=metadata, + item=item, + ) + uploaded_at_value = metadata.get("uploaded_at") uploaded_at = None if isinstance(uploaded_at_value, str) and uploaded_at_value.strip(): try: @@ -2402,11 +2511,11 @@ class ExpenseClaimService: return normalized_next - def _build_attachment_document_info(self, document: Any) -> dict[str, Any]: - insight = build_document_insight( - filename=str(getattr(document, "filename", "") or ""), - summary=str(getattr(document, "summary", "") or ""), - text=str(getattr(document, "text", "") or ""), + def _build_attachment_document_info(self, document: Any) -> dict[str, Any]: + insight = build_document_insight( + filename=str(getattr(document, "filename", "") or ""), + summary=str(getattr(document, "summary", "") or ""), + text=str(getattr(document, "text", "") or ""), ) raw_fields = list(getattr(document, "document_fields", []) or []) normalized_fields: list[dict[str, str]] = [] @@ -2463,14 +2572,35 @@ class ExpenseClaimService: "document_type_label": document_type_label, "scene_code": scene_code, "scene_label": scene_label, - "fields": normalized_fields, - } - - def _build_attachment_requirement_check( - self, - *, - item: ExpenseClaimItem, - document_info: dict[str, Any], + "fields": normalized_fields, + } + + def _backfill_item_amount_from_attachment( + self, + *, + item: ExpenseClaimItem, + document: Any, + document_info: dict[str, Any], + ) -> None: + current_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01")) + if current_amount > Decimal("0.00"): + return + + amount = self._resolve_document_item_amount( + { + "document_fields": document_info.get("fields") or [], + "summary": str(getattr(document, "summary", "") or ""), + "text": str(getattr(document, "text", "") or ""), + } + ) + if amount is not None and amount > Decimal("0.00"): + item.item_amount = amount + + def _build_attachment_requirement_check( + self, + *, + item: ExpenseClaimItem, + document_info: dict[str, Any], ) -> dict[str, Any]: expense_type = str(item.item_type or "").strip().lower() or "other" policy = self._get_expense_scene_policy(expense_type) @@ -2932,8 +3062,17 @@ class ExpenseClaimService: 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]: + + def _delete_submitted_claim_assistant_sessions(self, claim_id: str | None) -> None: + from app.services.agent_conversations import AgentConversationService + + AgentConversationService(self.db).delete_conversations_for_draft_claim( + claim_id=claim_id, + source="user_message", + session_type="expense", + ) + + def _run_ai_submission_review(self, claim: ExpenseClaim) -> dict[str, Any]: base_flags = list(claim.risk_flags_json or []) attachment_flags = [ flag @@ -4593,7 +4732,7 @@ class ExpenseClaimService: return str(expense_type or "").strip().lower() in LOCATION_REQUIRED_EXPENSE_TYPES return bool(policy.location_required) - @staticmethod + @staticmethod def _has_privileged_claim_access(current_user: CurrentUserContext) -> bool: if current_user.is_admin: return True @@ -4604,6 +4743,17 @@ class ExpenseClaimService: } return bool(role_codes & PRIVILEGED_CLAIM_ROLE_CODES) + @staticmethod + def _has_claim_delete_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 & CLAIM_DELETE_ROLE_CODES) + def _can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: if self._has_privileged_claim_access(current_user): return True @@ -4636,7 +4786,41 @@ class ExpenseClaimService: 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) + stage = str(claim.approval_stage or "").strip() + if stage == "直属领导审批": + return self._is_current_direct_manager_approver(current_user, claim) + if stage == "财务审批": + role_codes = self._normalize_role_codes(current_user) + return current_user.is_admin or "finance" in role_codes + return False + + def _is_current_direct_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool: + 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 @staticmethod def _normalize_role_codes(current_user: CurrentUserContext) -> set[str]: @@ -4654,6 +4838,44 @@ class ExpenseClaimService: ] ) + def _resolve_current_user_display_name(self, current_user: CurrentUserContext) -> str: + current_employee = self._resolve_current_employee(current_user) + if current_employee is not None and str(current_employee.name or "").strip(): + return str(current_employee.name).strip() + + for candidate in (current_user.name, current_user.username): + normalized = str(candidate or "").strip() + if normalized and not self._is_email_like(normalized): + return normalized + + return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous" + + def _is_claim_owned_by_current_user(self, claim: ExpenseClaim, current_user: CurrentUserContext) -> bool: + current_employee = self._resolve_current_employee(current_user) + if current_employee is not None: + if str(claim.employee_id or "").strip() == current_employee.id: + return True + identity_values = { + str(current_employee.name or "").strip(), + str(current_employee.email or "").strip(), + str(current_employee.employee_no or "").strip(), + } + else: + identity_values = set() + + identity_values.update( + { + str(current_user.username or "").strip(), + str(current_user.name or "").strip(), + } + ) + identity_values.discard("") + return str(claim.employee_name or "").strip() in identity_values + + @staticmethod + def _is_email_like(value: str) -> bool: + return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", str(value or "").strip())) + def _resolve_claim_employee_for_backfill(self, claim: ExpenseClaim) -> Employee | None: if claim.employee is not None: employee = self.db.scalar( @@ -4850,8 +5072,14 @@ class ExpenseClaimService: return conditions def _apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any: - if self._has_privileged_claim_access(current_user): + role_codes = self._normalize_role_codes(current_user) + if current_user.is_admin or "executive" in role_codes: return stmt.where(ExpenseClaim.status == "submitted") + if "finance" in role_codes: + return stmt.where( + ExpenseClaim.status == "submitted", + ExpenseClaim.approval_stage == "财务审批", + ) conditions = self._build_approval_claim_conditions(current_user) if not conditions: diff --git a/server/src/app/services/expense_rule_runtime.py b/server/src/app/services/expense_rule_runtime.py index a96a249..7bc8609 100644 --- a/server/src/app/services/expense_rule_runtime.py +++ b/server/src/app/services/expense_rule_runtime.py @@ -6,12 +6,17 @@ from dataclasses import dataclass, field from decimal import Decimal from typing import Any, Literal +from openpyxl import load_workbook from pydantic import BaseModel, Field, ValidationError from sqlalchemy import select from sqlalchemy.orm import Session from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType from app.models.agent_asset import AgentAsset, AgentAssetVersion +from app.services.agent_asset_spreadsheet import ( + COMPANY_TRAVEL_EXPENSE_RULE_CODE, + AgentAssetSpreadsheetManager, +) EXPENSE_RULE_CODE_BLOCK_PATTERN = re.compile(r"```expense-rule\s*(\{.*?\})\s*```", re.DOTALL) @@ -351,6 +356,11 @@ class TravelPolicyConfig(BaseModel): band_labels: dict[str, str] = Field(default_factory=dict) city_tiers: dict[str, str] = Field(default_factory=dict) hotel_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict) + hotel_city_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict) + allowance_limits: dict[str, dict[str, Decimal]] = Field(default_factory=dict) + standard_rule_code: str = "" + standard_rule_name: str = "" + standard_rule_version: str = "" transport_limits: dict[str, dict[str, int]] = Field(default_factory=dict) flight_classes: list[TravelClassConfig] = Field(default_factory=list) train_classes: list[TravelClassConfig] = Field(default_factory=list) @@ -576,17 +586,35 @@ class ExpenseRuleRuntimeService: ).all() ) if not assets: - return catalog + assets = [] + asset_ids = {asset.id for asset in assets} + travel_spreadsheet_asset = self.db.scalar( + select(AgentAsset) + .where(AgentAsset.asset_type == AgentAssetType.RULE.value) + .where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value) + .where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE) + .order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc()) + .limit(1) + ) + if travel_spreadsheet_asset is not None and travel_spreadsheet_asset.id not in asset_ids: + assets.append(travel_spreadsheet_asset) + + spreadsheet_assets: list[tuple[AgentAsset, AgentAssetVersion]] = [] for asset in assets: version = self._get_current_version(asset) if version is None: continue + is_travel_spreadsheet_asset = ( + str(asset.code or "").strip() == COMPANY_TRAVEL_EXPENSE_RULE_CODE + and str((asset.config_json or {}).get("detail_mode") or "").strip() == "spreadsheet" + ) runtime_payload = self._extract_runtime_payload( markdown_content=str(version.content or ""), config_json=asset.config_json, ) if not isinstance(runtime_payload, dict): + spreadsheet_assets.append((asset, version)) continue self._apply_runtime_payload( catalog, @@ -594,6 +622,15 @@ class ExpenseRuleRuntimeService: asset=asset, version=version, ) + if is_travel_spreadsheet_asset: + spreadsheet_assets.append((asset, version)) + + for asset, version in spreadsheet_assets: + self._apply_spreadsheet_runtime_payload( + catalog, + asset=asset, + version=version, + ) return catalog @@ -658,3 +695,406 @@ class ExpenseRuleRuntimeService: ) except ValidationError: return + + def _apply_spreadsheet_runtime_payload( + self, + catalog: ExpenseRuleCatalog, + *, + asset: AgentAsset, + version: AgentAssetVersion, + ) -> None: + if str(asset.code or "").strip() != COMPANY_TRAVEL_EXPENSE_RULE_CODE: + return + if str((asset.config_json or {}).get("detail_mode") or "").strip() != "spreadsheet": + return + + manager = AgentAssetSpreadsheetManager() + metadata = manager.parse_version_markdown(str(version.content or "")) + rule_document = (asset.config_json or {}).get("rule_document") + if not isinstance(rule_document, dict): + rule_document = {} + storage_key = str(metadata.storage_key if metadata is not None else "").strip() + if storage_key: + try: + workbook_path = manager.resolve_storage_path(storage_key) + except FileNotFoundError: + workbook_path = None + if workbook_path is not None and not workbook_path.exists(): + workbook_path = None + else: + workbook_path = None + + if workbook_path is None: + fallback_storage_key = str(rule_document.get("storage_key") or "").strip() + if not fallback_storage_key: + return + try: + workbook_path = manager.resolve_storage_path(fallback_storage_key) + except FileNotFoundError: + return + if not workbook_path.exists(): + return + + try: + workbook = load_workbook( + workbook_path, + read_only=True, + data_only=True, + ) + except (FileNotFoundError, OSError): + return + + try: + standards = self._extract_travel_amount_standards_from_workbook(workbook) + hotel_city_limits = self._extract_hotel_city_limits_from_workbook(workbook) + allowance_limits = self._extract_travel_allowance_limits_from_workbook(workbook) + transport_limits = self._extract_transport_class_limits_from_workbook(workbook) + finally: + workbook.close() + + standard_rule_version = str( + rule_document.get("asset_version") or asset.current_version or version.version + ).strip() + if (hotel_city_limits or allowance_limits or transport_limits) and catalog.travel_policy is not None: + payload = catalog.travel_policy.model_dump() + payload["standard_rule_code"] = asset.code + payload["standard_rule_name"] = asset.name + payload["standard_rule_version"] = standard_rule_version + if hotel_city_limits: + payload["hotel_city_limits"] = { + **payload.get("hotel_city_limits", {}), + **hotel_city_limits, + } + if allowance_limits: + payload["allowance_limits"] = { + **payload.get("allowance_limits", {}), + **allowance_limits, + } + if transport_limits: + payload["transport_limits"] = { + **payload.get("transport_limits", {}), + **transport_limits, + } + catalog.travel_policy = RuntimeTravelPolicy(**payload) + + for expense_type, amount in standards.items(): + current = catalog.scene_policies.get(expense_type) + if current is None: + continue + limit_attr = "item_amount_limit" if expense_type == "transport" else "claim_amount_limit" + base_limit = getattr(current, limit_attr, None) + next_limit = self._replace_amount_limit_warn_amount( + base_limit, + amount=amount, + metric_label=self._spreadsheet_metric_label(expense_type), + ) + payload = current.model_dump() + payload["rule_code"] = asset.code + payload["rule_name"] = asset.name + payload["rule_version"] = standard_rule_version + payload[limit_attr] = next_limit.model_dump() + catalog.scene_policies[expense_type] = ExpenseScenePolicy(**payload) + + @staticmethod + def _extract_travel_amount_standards_from_workbook(workbook: Any) -> dict[str, Decimal]: + standards: dict[str, Decimal] = {} + for sheet in workbook.worksheets: + rows = list(sheet.iter_rows(values_only=True)) + if not rows: + continue + header_index = -1 + category_index = -1 + standard_index = -1 + for index, row in enumerate(rows[:8]): + values = [str(value or "").strip() for value in row] + if "费用分类" in values and "报销标准" in values: + header_index = index + category_index = values.index("费用分类") + standard_index = values.index("报销标准") + break + if header_index < 0: + continue + + for row in rows[header_index + 1 :]: + category = str(row[category_index] or "").strip() if len(row) > category_index else "" + standard_text = str(row[standard_index] or "").strip() if len(row) > standard_index else "" + amount = ExpenseRuleRuntimeService._extract_first_standard_amount(standard_text) + if not category or amount is None: + continue + normalized_type = ExpenseRuleRuntimeService._map_spreadsheet_category_to_expense_type(category) + if normalized_type: + standards[normalized_type] = amount + return standards + + @staticmethod + def _extract_hotel_city_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]: + city_limits: dict[str, dict[str, Decimal]] = {} + for sheet in workbook.worksheets: + rows = list(sheet.iter_rows(values_only=True)) + if not rows: + continue + + header_index = -1 + city_index = -1 + band_indexes: dict[str, int] = {} + for index, row in enumerate(rows[:10]): + values = [str(value or "").strip() for value in row] + for candidate in ("地区(城市)", "城市", "地区"): + if candidate in values: + city_index = values.index(candidate) + break + if city_index < 0: + continue + for column_index, header in enumerate(values): + compact = re.sub(r"\s+", "", header) + if any(keyword in compact for keyword in ("P1-P3", "其他员工")): + band_indexes["junior"] = column_index + if any(keyword in compact for keyword in ("P4-P6", "基层经理", "中层经理")): + band_indexes["mid"] = column_index + band_indexes["senior"] = column_index + if any(keyword in compact for keyword in ("P7", "高层经理", "公司级管理")): + band_indexes["manager"] = column_index + band_indexes["executive"] = column_index + if band_indexes: + header_index = index + break + + if header_index < 0: + continue + + for row in rows[header_index + 1 :]: + raw_city = str(row[city_index] or "").strip() if len(row) > city_index else "" + cities = ExpenseRuleRuntimeService._extract_city_names_from_cell(raw_city) + if not cities: + continue + for city in cities: + city_entry = city_limits.setdefault(city, {}) + for band, column_index in band_indexes.items(): + amount = ExpenseRuleRuntimeService._coerce_decimal_cell( + row[column_index] if len(row) > column_index else None + ) + if amount is not None: + city_entry[band] = amount + return city_limits + + @staticmethod + def _extract_travel_allowance_limits_from_workbook(workbook: Any) -> dict[str, dict[str, Decimal]]: + allowance_limits: dict[str, dict[str, Decimal]] = {} + for sheet in workbook.worksheets: + rows = list(sheet.iter_rows(values_only=True)) + if not rows: + continue + + header_index = -1 + type_index = -1 + region_indexes: dict[str, int] = {} + for index, row in enumerate(rows[:10]): + values = [str(value or "").strip() for value in row] + if "补助类型" not in values: + continue + header_index = index + type_index = values.index("补助类型") + for column_index, header in enumerate(values): + if column_index <= type_index: + continue + normalized = str(header or "").strip() + if not normalized or normalized == "项目": + continue + region_indexes[normalized] = column_index + break + + if header_index < 0 or type_index < 0 or not region_indexes: + continue + + for row in rows[header_index + 1 :]: + raw_type = str(row[type_index] or "").strip() if len(row) > type_index else "" + allowance_key = ExpenseRuleRuntimeService._map_allowance_type_to_key(raw_type) + if not allowance_key: + continue + + entry: dict[str, Decimal] = {} + for region_label, column_index in region_indexes.items(): + amount = ExpenseRuleRuntimeService._coerce_decimal_cell( + row[column_index] if len(row) > column_index else None + ) + if amount is not None: + entry[region_label] = amount + if entry: + allowance_limits[allowance_key] = entry + return allowance_limits + + @staticmethod + def _map_allowance_type_to_key(value: str) -> str: + normalized = re.sub(r"\s+", "", str(value or "")) + if "伙食" in normalized or "餐" in normalized: + return "meal" + if "基本" in normalized: + return "basic" + if "合计" in normalized or "总计" in normalized: + return "total" + return "" + + @staticmethod + def _extract_transport_class_limits_from_workbook(workbook: Any) -> dict[str, dict[str, int]]: + limits: dict[str, dict[str, int]] = {} + for sheet in workbook.worksheets: + rows = list(sheet.iter_rows(values_only=True)) + if not rows: + continue + + employee_index = -1 + flight_index = -1 + train_index = -1 + for row_index, row in enumerate(rows[:10]): + values = [str(value or "").strip() for value in row] + if "员工职级" in values: + employee_index = values.index("员工职级") + for next_row in rows[row_index + 1 : row_index + 4]: + next_values = [str(value or "").strip() for value in next_row] + if "飞机" in next_values: + flight_index = next_values.index("飞机") + if "火车" in next_values: + train_index = next_values.index("火车") + if flight_index >= 0 and train_index >= 0: + break + break + + if employee_index < 0 or (flight_index < 0 and train_index < 0): + continue + + for row in rows: + employee_text = str(row[employee_index] or "").strip() if len(row) > employee_index else "" + bands = ExpenseRuleRuntimeService._map_transport_grade_row_to_bands(employee_text) + if not bands: + continue + flight_level = ( + ExpenseRuleRuntimeService._transport_class_level_for_text( + row[flight_index] if len(row) > flight_index else None, + kind="flight", + ) + if flight_index >= 0 + else None + ) + train_level = ( + ExpenseRuleRuntimeService._transport_class_level_for_text( + row[train_index] if len(row) > train_index else None, + kind="train", + ) + if train_index >= 0 + else None + ) + for band in bands: + entry = limits.setdefault(band, {}) + if flight_level is not None: + entry["flight"] = flight_level + if train_level is not None: + entry["train"] = train_level + return limits + + @staticmethod + def _map_transport_grade_row_to_bands(value: str) -> list[str]: + normalized = re.sub(r"\s+", "", str(value or "").upper()) + if not normalized or normalized.startswith("注"): + return [] + bands: list[str] = [] + if any(keyword in normalized for keyword in ("P1", "P2", "P3", "P4", "其他员工", "基层经理", "P4及以下")): + bands.extend(["junior", "mid"]) + if any(keyword in normalized for keyword in ("P5", "P6", "P7", "P5及以上", "中层经理", "高层经理", "公司级")): + bands.extend(["mid", "senior", "manager", "executive"]) + return list(dict.fromkeys(bands)) + + @staticmethod + def _transport_class_level_for_text(value: Any, *, kind: str) -> int | None: + normalized = re.sub(r"\s+", "", str(value or "")) + if not normalized: + return None + if kind == "flight": + if any(keyword in normalized for keyword in ("头等舱",)): + return 4 + if any(keyword in normalized for keyword in ("公务舱", "商务舱")): + return 3 + if any(keyword in normalized for keyword in ("超级经济舱", "高端经济舱", "明珠经济舱")): + return 2 + if "经济舱" in normalized: + return 1 + if kind == "train": + if "商务座" in normalized: + return 3 + if any(keyword in normalized for keyword in ("一等座", "软卧")): + return 2 + if any(keyword in normalized for keyword in ("二等座", "二等软座", "硬卧", "硬座", "硬席")): + return 1 + return None + + @staticmethod + def _extract_city_names_from_cell(value: str) -> list[str]: + normalized = re.sub(r"[;;,,、/]+", "、", str(value or "").strip()) + if not normalized: + return [] + names: list[str] = [] + for part in normalized.split("、"): + cleaned = re.sub(r"\s+", "", part) + cleaned = re.sub(r"[((].*?[))]", "", cleaned) + if not cleaned or any(keyword in cleaned for keyword in ("不含", "中心城区", "新区")): + continue + if len(cleaned) <= 12: + names.append(cleaned) + return list(dict.fromkeys(names)) + + @staticmethod + def _coerce_decimal_cell(value: Any) -> Decimal | None: + if value is None: + return None + try: + return Decimal(str(value).strip()).quantize(Decimal("0.01")) + except (ArithmeticError, ValueError): + return None + + @staticmethod + def _extract_first_standard_amount(text: str) -> Decimal | None: + match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)\s*/\s*(?:天|人|晚|次|笔)", str(text or "")) + if match is None: + match = re.search(r"([0-9]+(?:\.[0-9]{1,2})?)", str(text or "")) + if match is None: + return None + try: + return Decimal(match.group(1)).quantize(Decimal("0.01")) + except (ArithmeticError, ValueError): + return None + + @staticmethod + def _map_spreadsheet_category_to_expense_type(category: str) -> str: + normalized = re.sub(r"\s+", "", str(category or "")) + if any(keyword in normalized for keyword in ("市内交通", "打车", "网约车", "出租车")): + return "transport" + if "招待" in normalized and "餐" in normalized: + return "entertainment" + if "餐补" in normalized or normalized == "餐费": + return "meal" + return "" + + @staticmethod + def _spreadsheet_metric_label(expense_type: str) -> str: + return { + "transport": "单笔交通金额", + "meal": "差旅餐补金额", + "entertainment": "人均招待餐费", + }.get(expense_type, "金额") + + @staticmethod + def _replace_amount_limit_warn_amount( + base_limit: AmountLimitConfig | None, + *, + amount: Decimal, + metric_label: str, + ) -> AmountLimitConfig: + if base_limit is None: + return AmountLimitConfig( + warn_amount=amount, + block_amount=None, + metric_label=metric_label, + ) + payload = base_limit.model_dump() + payload["warn_amount"] = amount + payload["metric_label"] = metric_label + return AmountLimitConfig(**payload) diff --git a/server/src/app/services/ocr.py b/server/src/app/services/ocr.py index c2e38cf..e975c72 100644 --- a/server/src/app/services/ocr.py +++ b/server/src/app/services/ocr.py @@ -2,6 +2,7 @@ from __future__ import annotations import base64 import json +import re import shutil import subprocess from dataclasses import dataclass, field @@ -27,6 +28,7 @@ class PreparedOcrInput: page_index: int | None = None preview_kind: str = "" preview_data_url: str = "" + text_layer: str = "" @dataclass(slots=True) @@ -38,6 +40,7 @@ class AggregatedOcrDocument: model: str = "PP-OCRv5_mobile" summary_fragments: list[str] = field(default_factory=list) text_fragments: list[str] = field(default_factory=list) + text_layer_fragments: list[str] = field(default_factory=list) score_values: list[float] = field(default_factory=list) warnings: list[str] = field(default_factory=list) lines: list[OcrRecognizeLineRead] = field(default_factory=list) @@ -112,12 +115,14 @@ class OcrService: if suffix == ".pdf": try: + text_layer = self._extract_pdf_text_layer(temp_path) prepared_inputs.extend( self._prepare_pdf_inputs( pdf_path=temp_path, filename=normalized_name, media_type=resolved_media_type, cleanup_paths=cleanup_paths, + text_layer=text_layer, ) ) except RuntimeError as exc: @@ -261,6 +266,7 @@ class OcrService: filename: str, media_type: str, cleanup_paths: list[Path], + text_layer: str = "", ) -> list[PreparedOcrInput]: output_dir = pdf_path.with_suffix("") output_dir.mkdir(parents=True, exist_ok=True) @@ -283,10 +289,33 @@ class OcrService: page_index=page_index, preview_kind="image" if page_index == 0 else "", preview_data_url=preview_data_url if page_index == 0 else "", + text_layer=text_layer if page_index == 0 else "", ) ) return descriptors + def _extract_pdf_text_layer(self, pdf_path: Path) -> str: + try: + completed = subprocess.run( + [ + "pdftotext", + "-layout", + str(pdf_path), + "-", + ], + capture_output=True, + text=True, + timeout=self.settings.ocr_timeout_seconds, + check=False, + ) + except (OSError, subprocess.SubprocessError, UnicodeError): + return "" + + if completed.returncode != 0: + return "" + + return self._normalize_extracted_text(completed.stdout) + def _convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]: prefix = output_dir / "page" completed = subprocess.run( @@ -367,6 +396,8 @@ class OcrService: aggregated.preview_kind = descriptor.preview_kind if descriptor.preview_data_url and not aggregated.preview_data_url: aggregated.preview_data_url = descriptor.preview_data_url + if descriptor.text_layer and descriptor.text_layer not in aggregated.text_layer_fragments: + aggregated.text_layer_fragments.append(descriptor.text_layer) page_summary = str(payload.get("summary", "") or "").strip() if page_summary: @@ -401,6 +432,20 @@ class OcrService: aggregated = aggregated_by_source.get(source_key) if aggregated is None: first_descriptor = descriptors[0] + text_layer = self._collect_descriptor_text_layer(descriptors) + if text_layer: + fallback = AggregatedOcrDocument( + filename=first_descriptor.filename, + media_type=first_descriptor.media_type, + source_key=first_descriptor.source_key, + page_count=max(1, len(descriptors)), + preview_kind=first_descriptor.preview_kind, + preview_data_url=first_descriptor.preview_data_url, + warnings=["OCR worker 未返回该文件的识别结果,已使用 PDF 文本层。"], + ) + fallback.text_layer_fragments.append(text_layer) + documents.append(self._finalize_document(fallback)) + continue documents.append( OcrRecognizeDocumentRead( filename=first_descriptor.filename, @@ -416,6 +461,13 @@ class OcrService: return documents + @staticmethod + def _collect_descriptor_text_layer(descriptors: list[PreparedOcrInput]) -> str: + for descriptor in descriptors: + if descriptor.text_layer: + return descriptor.text_layer + return "" + @staticmethod def _build_lines( items: list[dict], @@ -451,13 +503,26 @@ class OcrService: return summary def _finalize_document(self, aggregated: AggregatedOcrDocument) -> OcrRecognizeDocumentRead: - full_text = "\n".join(fragment for fragment in aggregated.text_fragments if fragment).strip() + ocr_text = "\n".join(fragment for fragment in aggregated.text_fragments if fragment).strip() + text_layer = "\n".join(fragment for fragment in aggregated.text_layer_fragments if fragment).strip() + full_text, used_text_layer = self._choose_document_text(ocr_text=ocr_text, text_layer=text_layer) summary = self._truncate_summary(aggregated.summary_fragments or aggregated.text_fragments) + if used_text_layer or self._placeholder_ratio(summary) >= 0.12: + summary = self._summarize_text(full_text) + preview_kind = aggregated.preview_kind + preview_data_url = aggregated.preview_data_url + if ( + used_text_layer + and aggregated.media_type == "application/pdf" + and self._placeholder_ratio(ocr_text) >= 0.12 + ): + preview_kind = "" + preview_data_url = "" insight = self.document_intelligence_service.build_document_insight( filename=aggregated.filename, summary=summary, text=full_text, - preview_data_url=aggregated.preview_data_url, + preview_data_url=preview_data_url, ) warnings = list(aggregated.warnings) for warning in insight.warnings: @@ -493,8 +558,8 @@ class OcrService: ) for field in insight.fields ], - preview_kind=aggregated.preview_kind, - preview_data_url=aggregated.preview_data_url, + preview_kind=preview_kind, + preview_data_url=preview_data_url, warnings=warnings, lines=sorted( aggregated.lines, @@ -502,6 +567,45 @@ class OcrService: ), ) + @classmethod + def _choose_document_text(cls, *, ocr_text: str, text_layer: str) -> tuple[str, bool]: + normalized_ocr_text = cls._normalize_extracted_text(ocr_text) + normalized_text_layer = cls._normalize_extracted_text(text_layer) + if not normalized_text_layer: + return normalized_ocr_text, False + if not normalized_ocr_text: + return normalized_text_layer, True + if cls._placeholder_ratio(normalized_ocr_text) >= 0.12 and cls._meaningful_char_count(normalized_text_layer) >= 8: + return normalized_text_layer, True + if cls._meaningful_char_count(normalized_text_layer) > cls._meaningful_char_count(normalized_ocr_text) * 1.3: + return normalized_text_layer, True + return normalized_ocr_text, False + + @staticmethod + def _normalize_extracted_text(value: str) -> str: + lines = [re.sub(r"[ \t]+", " ", line).strip() for line in str(value or "").replace("\r", "\n").split("\n")] + return "\n".join(line for line in lines if line).strip() + + @staticmethod + def _summarize_text(value: str) -> str: + lines = [line.strip() for line in str(value or "").splitlines() if line.strip()] + summary = ";".join(lines[:3]) + if len(summary) > 180: + return f"{summary[:177]}..." + return summary + + @staticmethod + def _meaningful_char_count(value: str) -> int: + return len(re.findall(r"[0-9A-Za-z\u4e00-\u9fff]", str(value or ""))) + + @staticmethod + def _placeholder_ratio(value: str) -> float: + chars = [char for char in str(value or "") if not char.isspace()] + if not chars: + return 0.0 + placeholder_count = sum(1 for char in chars if char in {"□", "�"}) + return placeholder_count / len(chars) + @staticmethod def _cleanup_temp_paths(paths: list[Path]) -> None: for path in reversed(paths): diff --git a/server/src/app/services/travel_reimbursement_calculator.py b/server/src/app/services/travel_reimbursement_calculator.py new file mode 100644 index 0000000..951f350 --- /dev/null +++ b/server/src/app/services/travel_reimbursement_calculator.py @@ -0,0 +1,593 @@ +from __future__ import annotations + +import re +from decimal import Decimal + +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.core.agent_enums import AgentAssetType +from app.models.employee import Employee +from app.schemas.reimbursement import ( + TravelReimbursementCalculatorRequest, + TravelReimbursementCalculatorResponse, +) +from app.services.agent_assets import AgentAssetService +from app.services.expense_claims import ExpenseClaimService +from app.services.expense_rule_runtime import RuntimeTravelPolicy, ExpenseRuleRuntimeService + +OTHER_REGION_LOCATION_KEYWORDS = { + "河北", + "石家庄", + "唐山", + "秦皇岛", + "邯郸", + "邢台", + "保定", + "张家口", + "承德", + "沧州", + "廊坊", + "衡水", + "山西", + "太原", + "大同", + "长治", + "晋城", + "晋中", + "运城", + "临汾", + "吕梁", + "内蒙古", + "呼和浩特", + "包头", + "赤峰", + "通辽", + "鄂尔多斯", + "辽宁", + "鞍山", + "抚顺", + "本溪", + "丹东", + "锦州", + "营口", + "盘锦", + "吉林", + "长春", + "吉林市", + "四平", + "通化", + "白山", + "松原", + "延边", + "黑龙江", + "哈尔滨", + "齐齐哈尔", + "牡丹江", + "佳木斯", + "大庆", + "江苏", + "常州", + "南通", + "连云港", + "淮安", + "盐城", + "扬州", + "镇江", + "泰州", + "宿迁", + "浙江", + "温州", + "嘉兴", + "湖州", + "绍兴", + "金华", + "衢州", + "舟山", + "台州", + "丽水", + "安徽", + "芜湖", + "蚌埠", + "淮南", + "马鞍山", + "淮北", + "铜陵", + "安庆", + "黄山", + "滁州", + "阜阳", + "宿州", + "六安", + "亳州", + "池州", + "宣城", + "福建", + "泉州", + "漳州", + "莆田", + "三明", + "南平", + "龙岩", + "宁德", + "江西", + "南昌", + "景德镇", + "萍乡", + "九江", + "新余", + "鹰潭", + "赣州", + "吉安", + "宜春", + "抚州", + "上饶", + "山东", + "淄博", + "枣庄", + "东营", + "烟台", + "潍坊", + "济宁", + "泰安", + "威海", + "日照", + "临沂", + "德州", + "聊城", + "滨州", + "菏泽", + "河南", + "洛阳", + "开封", + "平顶山", + "安阳", + "鹤壁", + "新乡", + "焦作", + "濮阳", + "许昌", + "漯河", + "三门峡", + "南阳", + "商丘", + "信阳", + "周口", + "驻马店", + "湖北", + "黄石", + "十堰", + "宜昌", + "襄阳", + "鄂州", + "荆门", + "孝感", + "荆州", + "黄冈", + "咸宁", + "随州", + "恩施", + "湖南", + "株洲", + "湘潭", + "衡阳", + "邵阳", + "岳阳", + "常德", + "张家界", + "益阳", + "郴州", + "永州", + "怀化", + "娄底", + "湘西", + "广东", + "惠州", + "江门", + "湛江", + "茂名", + "肇庆", + "梅州", + "汕尾", + "河源", + "阳江", + "清远", + "潮州", + "揭阳", + "云浮", + "广西", + "南宁", + "柳州", + "桂林", + "梧州", + "北海", + "防城港", + "钦州", + "贵港", + "玉林", + "百色", + "贺州", + "河池", + "来宾", + "崇左", + "海南", + "儋州", + "四川", + "自贡", + "攀枝花", + "泸州", + "德阳", + "绵阳", + "广元", + "遂宁", + "内江", + "乐山", + "南充", + "眉山", + "宜宾", + "广安", + "达州", + "雅安", + "巴中", + "资阳", + "阿坝", + "甘孜", + "凉山", + "贵州", + "贵阳", + "遵义", + "六盘水", + "安顺", + "毕节", + "铜仁", + "黔东南", + "黔南", + "黔西南", + "云南", + "曲靖", + "玉溪", + "保山", + "昭通", + "丽江", + "普洱", + "临沧", + "楚雄", + "红河", + "文山", + "西双版纳", + "大理", + "德宏", + "怒江", + "迪庆", + "陕西", + "宝鸡", + "咸阳", + "铜川", + "渭南", + "延安", + "汉中", + "榆林", + "安康", + "商洛", + "甘肃", + "兰州", + "嘉峪关", + "金昌", + "白银", + "天水", + "武威", + "张掖", + "平凉", + "酒泉", + "庆阳", + "定西", + "陇南", + "临夏", + "甘南", + "青海", + "西宁", + "海东", + "海北", + "黄南", + "海南州", + "果洛", + "玉树", + "海西", + "宁夏", + "银川", + "石嘴山", + "吴忠", + "固原", + "中卫", +} + +OTHER_REGION_PROVINCE_KEYWORDS = { + "河北", + "山西", + "内蒙古", + "辽宁", + "吉林", + "黑龙江", + "江苏", + "浙江", + "安徽", + "福建", + "江西", + "山东", + "河南", + "湖北", + "湖南", + "广东", + "广西", + "海南", + "四川", + "贵州", + "云南", + "陕西", + "甘肃", + "青海", + "宁夏", + "新疆", + "西藏", + "台湾", + "香港", + "澳门", +} + +AMBIGUOUS_PROVINCE_CITY_NAMES = {"吉林"} + + +class TravelReimbursementCalculatorService: + def __init__(self, db: Session) -> None: + self.db = db + + def calculate( + self, + payload: TravelReimbursementCalculatorRequest, + current_user: CurrentUserContext, + ) -> TravelReimbursementCalculatorResponse: + days = max(1, int(payload.days)) + location = str(payload.location or "").strip() + if not location: + raise ValueError("请先填写出差地点。") + + policy = self._load_travel_policy() + grade = self._resolve_grade(payload.grade, current_user) + if not grade: + raise ValueError("未识别到当前员工职级,请在个人信息中维护职级后再计算。") + + grade_band = ExpenseClaimService._resolve_travel_policy_band(grade) + if not grade_band: + raise ValueError(f"当前职级 {grade} 暂未匹配到差旅报销档位。") + + matched_city = self._resolve_city(location, policy) + matched_other_region = "" if matched_city else self._resolve_other_region(location) + if not matched_city and not matched_other_region: + raise ValueError(f"出差地点“{location}”未识别为有效出差地区,请按真实省市或规则表地点重新填写。") + city_tier = policy.city_tiers.get(matched_city, "tier_3") if matched_city else "tier_3" + hotel_rate = self._resolve_hotel_rate(policy, grade_band, matched_city, city_tier) + allowance_region = self._resolve_allowance_region(location, matched_city or matched_other_region) + meal_rate = self._resolve_allowance_rate(policy, "meal", allowance_region) + basic_rate = self._resolve_allowance_rate(policy, "basic", allowance_region) + total_allowance_rate = self._resolve_total_allowance_rate(policy, allowance_region, meal_rate, basic_rate) + + hotel_amount = hotel_rate * Decimal(days) + allowance_amount = total_allowance_rate * Decimal(days) + total_amount = hotel_amount + allowance_amount + band_label = policy.band_labels.get(grade_band, grade_band) + rule_name = policy.standard_rule_name or policy.rule_name or "公司差旅费报销规则" + rule_version = policy.standard_rule_version or policy.rule_version or "" + display_city = matched_city or self._format_other_region_display(matched_other_region) + formula_text = ( + f"住宿 {self._format_money(hotel_rate)} × {days} 天 + " + f"补贴 {self._format_money(total_allowance_rate)} × {days} 天 = " + f"{self._format_money(total_amount)}" + ) + summary_text = ( + f"按《{rule_name}》{f'({rule_version})' if rule_version else ''}测算:" + f"当前职级 {grade} 对应 {band_label} 档,出差地点“{location}”匹配为“{display_city}”," + f"住宿标准 {self._format_money(hotel_rate)} 元/天,补贴区域为“{allowance_region}”," + f"补贴标准 {self._format_money(total_allowance_rate)} 元/天" + f"(伙食 {self._format_money(meal_rate)} + 基本 {self._format_money(basic_rate)})。" + f"按 {days} 天计算,住宿合计 {self._format_money(hotel_amount)} 元," + f"补贴合计 {self._format_money(allowance_amount)} 元," + f"参考可报销总金额为 {self._format_money(total_amount)} 元。" + ) + + return TravelReimbursementCalculatorResponse( + days=days, + location=location, + matched_city=display_city, + city_tier=city_tier, + grade=grade, + grade_band=grade_band, + grade_band_label=band_label, + hotel_rate=hotel_rate, + hotel_amount=hotel_amount, + allowance_region=allowance_region, + meal_allowance_rate=meal_rate, + basic_allowance_rate=basic_rate, + total_allowance_rate=total_allowance_rate, + allowance_amount=allowance_amount, + total_amount=total_amount, + rule_name=rule_name, + rule_version=rule_version, + formula_text=formula_text, + summary_text=summary_text, + ) + + def _load_travel_policy(self) -> RuntimeTravelPolicy: + AgentAssetService(self.db).list_assets(asset_type=AgentAssetType.RULE.value) + policy = ExpenseRuleRuntimeService(self.db).load_catalog().travel_policy + if policy is None: + raise ValueError("规则中心暂未配置差旅报销规则。") + return policy + + def _resolve_grade( + self, + grade: str | None, + current_user: CurrentUserContext, + ) -> str: + normalized_grade = str(grade or "").strip() + if normalized_grade: + return normalized_grade + + employee = self._resolve_current_employee(current_user) + if employee is not None and str(employee.grade or "").strip(): + return str(employee.grade).strip() + return "" + + @staticmethod + def _resolve_other_region(location: str) -> str: + normalized = re.sub(r"\s+", "", str(location or "").strip()) + if not normalized: + return "" + if any(keyword in normalized for keyword in ("国外", "境外", "海外")): + return "国外" + for keyword in ("香港", "澳门", "台湾", "港澳台", "西藏", "拉萨", "新疆", "乌鲁木齐"): + if keyword in normalized: + return keyword + city_matches = [] + province_matches = [] + for keyword in OTHER_REGION_LOCATION_KEYWORDS: + if not keyword or keyword not in normalized: + continue + if keyword in OTHER_REGION_PROVINCE_KEYWORDS: + province_matches.append(keyword) + else: + city_matches.append(keyword) + candidates = city_matches or province_matches + if candidates: + return sorted(candidates, key=len, reverse=True)[0] + return "" + + @staticmethod + def _format_other_region_display(region: str) -> str: + normalized = str(region or "").strip() + if not normalized: + return "" + if normalized in {"国外", "香港", "澳门", "台湾", "港澳台", "西藏", "拉萨", "新疆", "乌鲁木齐"}: + return normalized + return f"{normalized}(其他地区)" + + def _resolve_current_employee(self, current_user: CurrentUserContext) -> Employee | None: + candidates = [ + str(current_user.username or "").strip(), + str(current_user.name or "").strip(), + ] + normalized_candidates = [ + item + for item in dict.fromkeys(candidate for candidate in candidates if candidate) + if item + ] + if not normalized_candidates: + return None + + for candidate in normalized_candidates: + employee = self.db.scalar( + select(Employee) + .where( + or_( + func.lower(Employee.email) == candidate.lower(), + func.lower(Employee.employee_no) == candidate.lower(), + ) + ) + .limit(1) + ) + if employee is not None: + return employee + + for candidate in normalized_candidates: + matches = list( + self.db.scalars( + select(Employee) + .where(Employee.name == candidate) + .limit(2) + ).all() + ) + if len(matches) == 1: + return matches[0] + return None + + @staticmethod + def _resolve_city(location: str, policy: RuntimeTravelPolicy) -> str: + normalized = str(location or "").strip() + if not normalized: + return "" + city_names = set(policy.city_tiers.keys()) + city_names.update(policy.hotel_city_limits.keys()) + for city in sorted(city_names, key=lambda item: len(item), reverse=True): + if city in AMBIGUOUS_PROVINCE_CITY_NAMES and normalized != city and f"{city}市" not in normalized: + continue + if city and city in normalized: + return city + compact = re.sub(r"(省|市|区|县|自治州|特别行政区)$", "", normalized) + for city in sorted(city_names, key=lambda item: len(item), reverse=True): + if city in AMBIGUOUS_PROVINCE_CITY_NAMES and compact != city and f"{city}市" not in normalized: + continue + if city and city in compact: + return city + return "" + + @staticmethod + def _resolve_hotel_rate( + policy: RuntimeTravelPolicy, + grade_band: str, + matched_city: str, + city_tier: str, + ) -> Decimal: + city_limits = policy.hotel_city_limits.get(matched_city, {}) if matched_city else {} + if city_limits.get(grade_band) is not None: + return Decimal(city_limits[grade_band]) + + band_limits = policy.hotel_limits.get(grade_band, {}) + if band_limits.get(city_tier) is not None: + return Decimal(band_limits[city_tier]) + if band_limits.get("tier_3") is not None: + return Decimal(band_limits["tier_3"]) + return Decimal("0") + + @staticmethod + def _resolve_allowance_region(location: str, matched_city: str) -> str: + text = f"{location} {matched_city}".strip() + if any(keyword in text for keyword in ("国外", "境外", "海外")): + return "国外" + if any(keyword in text for keyword in ("香港", "澳门", "台湾", "港澳台")): + return "港澳台" + if "乌鲁木齐" in text: + return "新疆-乌鲁木齐" + if "新疆" in text: + return "新疆-其他" + if "西藏" in text or "拉萨" in text: + return "西藏" + if any(keyword in text for keyword in ("北京", "上海", "天津", "重庆", "深圳", "珠海", "汕头", "厦门")): + return "直辖市/特区" + return "其他地区" + + @staticmethod + def _resolve_allowance_rate(policy: RuntimeTravelPolicy, allowance_key: str, region: str) -> Decimal: + limits = policy.allowance_limits.get(allowance_key, {}) + if limits.get(region) is not None: + return Decimal(limits[region]) + if limits.get("其他地区") is not None: + return Decimal(limits["其他地区"]) + return Decimal("0") + + def _resolve_total_allowance_rate( + self, + policy: RuntimeTravelPolicy, + region: str, + meal_rate: Decimal, + basic_rate: Decimal, + ) -> Decimal: + total_limits = policy.allowance_limits.get("total", {}) + if total_limits.get(region) is not None: + return Decimal(total_limits[region]) + if total_limits.get("其他地区") is not None: + return Decimal(total_limits["其他地区"]) + return meal_rate + basic_rate + + @staticmethod + def _format_money(value: Decimal | int | float | str) -> str: + return f"{Decimal(str(value)).quantize(Decimal('0.01'))}" diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index c110368..ff7fe8a 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -34,6 +34,7 @@ from app.schemas.user_agent import ( from app.services.agent_assets import AgentAssetService from app.services.agent_foundation import AgentFoundationService from app.services.expense_claims import ExpenseClaimService +from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check from app.services.runtime_chat import RuntimeChatService @@ -185,6 +186,7 @@ DOCUMENT_AMOUNT_PATTERN = re.compile( r"[::\s¥¥人民币]*([0-9]+(?:[.,][0-9]{1,2})?)" ) DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)") +TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN = re.compile(r"(\d+)\s*(?:晚|间夜)") SOURCE_LABELS = { "user_text": "用户描述", @@ -197,6 +199,8 @@ SOURCE_LABELS = { "system": "系统判断", } +DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ("历史报销画像", "用户画像", "制度注意事项", "制度注意") + SCENE_REQUIRED_SLOT_KEYS = { "hotel": {"merchant_name"}, "meeting": {"location"}, @@ -2193,8 +2197,8 @@ class UserAgentService: for reason in self._resolve_submission_blocked_reasons(payload): briefs.append( UserAgentReviewRiskBrief( - title="AI预审未通过", - level="high", + title="提交风险提示", + level=self._resolve_submission_blocked_risk_level(reason), content=reason, detail=( "该项属于提交审批前的阻断条件。系统会先要求补齐基础字段、附件或业务说明," @@ -2204,6 +2208,14 @@ class UserAgentService: ) ) + briefs.extend( + self._build_travel_policy_precheck_briefs( + payload, + document_cards=document_cards, + claim_groups=claim_groups, + ) + ) + employee = self._resolve_employee_profile(payload) employee_name = ( str(employee.name).strip() @@ -2211,7 +2223,10 @@ class UserAgentService: else self._collect_entity_values(payload).get("employee_name") or str(payload.context_json.get("name") or "").strip() ) - if employee_name: + current_amount = self._resolve_amount_value(payload) or sum( + self._extract_amount_from_card(card) for card in document_cards + ) + if employee_name and current_amount > 0: since = datetime.now(UTC) - timedelta(days=90) claim_identity_conditions = [ExpenseClaim.employee_name == employee_name] if employee is not None: @@ -2228,57 +2243,27 @@ class UserAgentService: stmt = select(ExpenseClaim).where(or_(*claim_identity_conditions), ExpenseClaim.occurred_at >= since) recent_claims = list(self.db.scalars(stmt).all()) if recent_claims: - risky_count = sum(1 for item in recent_claims if item.risk_flags_json) - draft_count = sum(1 for item in recent_claims if item.status == "draft") - briefs.append( - UserAgentReviewRiskBrief( - title="历史报销画像", - level="info", - content=( - f"{employee_name} 最近 90 天共有 {len(recent_claims)} 笔报销," - f"其中 {risky_count} 笔带风险标记,{draft_count} 笔仍处于草稿态。" - ), - detail=( - "该画像来自员工近 90 天报销记录,用于辅助判断是否存在频繁草稿、" - "历史风险或异常重复报销倾向,不会单独阻断审批。" - ), - suggestion="如历史记录中存在风险标记,本次提交时建议主动补充业务背景和票据说明。", - ) + duplicate_count = sum( + 1 + for item in recent_claims + if abs(float(item.amount) - current_amount) < 0.01 ) - current_amount = self._resolve_amount_value(payload) - if current_amount > 0: - duplicate_count = sum( - 1 - for item in recent_claims - if abs(float(item.amount) - current_amount) < 0.01 - ) - if duplicate_count: - briefs.append( - UserAgentReviewRiskBrief( - title="金额重复预警", - level="warning", - content=( - f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录," - "提交前建议核对是否为重复报销或拆分不当。" - ), - detail=( - "系统将当前金额与近 90 天历史报销金额进行比对。金额完全一致不一定违规," - "但在交通、餐饮、办公采购等场景中可能提示重复票据或拆分报销。" - ), - suggestion="核对历史单据与当前票据是否对应同一业务;如不是重复,请在事由中说明差异。", - ) + if duplicate_count: + briefs.append( + UserAgentReviewRiskBrief( + title="金额重复预警", + level="warning", + content=( + f"近 90 天发现 {duplicate_count} 笔金额相同的报销记录," + "提交前建议核对是否为重复报销或拆分不当。" + ), + detail=( + "系统将当前金额与近 90 天历史报销金额进行比对。金额完全一致不一定违规," + "但在交通、餐饮、办公采购等场景中可能提示重复票据或拆分报销。" + ), + suggestion="核对历史单据与当前票据是否对应同一业务;如不是重复,请在事由中说明差异。", ) - - if citations: - briefs.append( - UserAgentReviewRiskBrief( - title="制度注意事项", - level="info", - content=citations[0].excerpt or f"请先核对 {citations[0].title} 的制度要求。", - detail=f"本条来自规则或知识库引用:{citations[0].title}。提交前应确认当前单据符合该条口径。", - suggestion="如当前场景与制度口径存在差异,请补充审批说明或选择更准确的报销分类。", - ) - ) + ) warning_count = sum(len(item.warnings) for item in document_cards) if warning_count: @@ -2296,14 +2281,635 @@ class UserAgentService: briefs.append( UserAgentReviewRiskBrief( title="建议拆单", - level="high", + level="warning", content=f"系统检测到 {len(claim_groups)} 类费用场景,建议拆成多张报销单后再提交。", detail="同一批附件中包含多类费用场景时,混在一张报销单里会影响规则匹配、附件核验和审批归口。", suggestion="按费用场景拆成多张报销单,分别确认金额、事由和附件归属。", ) ) - return briefs[:4] + return self._filter_deprecated_review_risk_briefs(briefs) + + @staticmethod + def _resolve_submission_blocked_risk_level(reason: str) -> str: + normalized = re.sub(r"\s+", "", str(reason or "")) + amount_keywords = ("金额", "超标", "费用", "价款", "票面金额", "单价", "合计") + return "high" if any(keyword in normalized for keyword in amount_keywords) else "warning" + + @staticmethod + def _filter_deprecated_review_risk_briefs( + briefs: list[UserAgentReviewRiskBrief], + ) -> list[UserAgentReviewRiskBrief]: + filtered: list[UserAgentReviewRiskBrief] = [] + for brief in briefs: + title = str(brief.title or "").strip() + if any(keyword in title for keyword in DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS): + continue + filtered.append(brief) + return filtered + + def _build_travel_policy_precheck_briefs( + self, + payload: UserAgentRequest, + *, + document_cards: list[UserAgentReviewDocumentCard], + claim_groups: list[UserAgentReviewClaimGroup], + ) -> list[UserAgentReviewRiskBrief]: + if not document_cards or not self._is_travel_review_context(payload, document_cards, claim_groups): + return [] + + rule_catalog = ExpenseRuleRuntimeService(self.db).load_catalog() + policy = rule_catalog.travel_policy + if policy is None: + return [] + + employee = self._resolve_employee_profile(payload) + grade = self._resolve_review_employee_grade(payload, employee=employee) + grade_band = ExpenseClaimService._resolve_travel_policy_band(grade) + band_label = policy.band_labels.get(grade_band or "", grade or "当前职级") + declared_city = self._resolve_declared_travel_city(payload, policy) + reason_corpus = self._build_review_reason_corpus(payload) + has_exception_note = self._text_contains_any(reason_corpus, policy.standard_exception_keywords) + standard_rule_name = str(getattr(policy, "standard_rule_name", "") or policy.rule_name) + standard_rule_version = str(getattr(policy, "standard_rule_version", "") or policy.rule_version) + + briefs: list[UserAgentReviewRiskBrief] = [] + amount_measurement_lines: list[str] = [] + seen_keys: set[str] = set() + + def append_once(key: str, brief: UserAgentReviewRiskBrief) -> None: + if key in seen_keys: + return + seen_keys.add(key) + briefs.append(brief) + + for card in document_cards: + document_type = str(card.document_type or "").strip().lower() + suggested_type = str(card.suggested_expense_type or "").strip().lower() + card_text = self._build_review_document_card_text(card) + document_type_label = resolve_document_type_label(document_type) + amount = self._extract_amount_decimal_from_card(card) + + if self._is_review_hotel_card(card): + hotel_city = self._extract_policy_city_from_text(card_text, policy) or declared_city + city_tier = policy.city_tiers.get(hotel_city, "tier_3") + city_tier_label = self._format_travel_city_tier(city_tier) + + if amount is None: + amount_measurement_lines.append( + f"{card.filename}:识别为{document_type_label},但未识别到可核算金额,无法完成住宿差标测算。" + ) + append_once( + f"hotel-amount-missing-{card.index}", + UserAgentReviewRiskBrief( + title="住宿金额待补充", + level="warning", + content=f"{card.filename} 已识别为{document_type_label},但未识别到可核算的住宿金额。", + detail=( + f"依据《{standard_rule_name}》({standard_rule_version}),住宿票据需要按员工职级、城市级别和每晚金额进行差标核算。" + "当前票据缺少金额,系统无法判断是否超出差旅标准。" + ), + suggestion="请在票据识别结果中补充或更正住宿金额,再继续核对报销单。", + ), + ) + continue + + if grade_band is None: + amount_measurement_lines.append( + f"{card.filename}:识别住宿金额 {amount:.2f} 元,但缺少员工职级,无法匹配住宿标准。" + ) + append_once( + f"hotel-grade-missing-{card.index}", + UserAgentReviewRiskBrief( + title="职级信息待确认", + level="warning", + content=f"{card.filename} 已识别住宿金额 {amount:.2f} 元,但当前员工职级缺失,无法匹配住宿标准。", + detail=( + f"依据《{standard_rule_name}》({standard_rule_version}),住宿标准按职级档位和城市级别配置。" + "当前未能识别员工职级,因此无法完成创建前差标核算。" + ), + suggestion="请确认员工档案或页面上下文中的职级信息,再重新进行差旅规则预检。", + ), + ) + continue + + cap = self._resolve_review_hotel_cap( + policy, + grade_band=grade_band, + city=hotel_city, + city_tier=city_tier, + ) + if cap <= Decimal("0.00"): + continue + night_count = self._extract_review_hotel_night_count(card) + nightly_amount = (amount / Decimal(max(night_count, 1))).quantize(Decimal("0.01")) + amount_measurement_lines.append( + f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元," + f"按 {night_count} 晚折算 {nightly_amount:.2f} 元/晚;" + f"适用标准为 {band_label}{city_tier_label} {cap:.2f} 元/晚," + f"{'超出标准' if nightly_amount > cap else '测算通过'}。" + ) + if nightly_amount <= cap: + continue + + basis = ( + f"依据《{standard_rule_name}》({standard_rule_version}),{band_label} 在{city_tier_label}" + f"住宿标准为 {cap:.2f} 元/晚;{card.filename} 识别为{document_type_label}," + f"金额 {amount:.2f} 元,按 {night_count} 晚折算约 {nightly_amount:.2f} 元/晚。" + ) + append_once( + f"hotel-over-limit-{card.index}", + UserAgentReviewRiskBrief( + title="住宿超标待说明" if not has_exception_note else "住宿超标提醒", + level="high", + content=( + f"{card.filename} 住宿金额约 {nightly_amount:.2f} 元/晚," + f"超过 {band_label} {city_tier_label}标准 {cap:.2f} 元/晚。" + ), + detail=( + basis + + ( + "当前未识别到超标说明,创建单据前需要先补充原因。" + if not has_exception_note + else "当前已识别到例外说明,后续仍需审批人重点复核。" + ) + ), + suggestion="补充超标说明、协议酒店满房/会议高峰等原因,或调整住宿金额后再继续。", + ), + ) + continue + + if document_type == "meal_receipt": + allowance = self._resolve_review_travel_allowance_standard( + policy, + declared_city=declared_city, + card_text=card_text, + ) + if allowance is not None: + region_label, standard_amount = allowance + if amount is None: + amount_measurement_lines.append( + f"{card.filename}:识别为{document_type_label},但未识别到可核算金额,无法按{region_label}伙食补助标准测算。" + ) + append_once( + f"travel-meal-amount-missing-{card.index}", + UserAgentReviewRiskBrief( + title="差旅餐饮金额待补充", + level="high", + content=f"{card.filename} 已识别为{document_type_label},但未识别到可核算金额。", + detail=( + f"依据《{standard_rule_name}》({standard_rule_version}),差旅餐饮票据优先按出差补助标准中的伙食补助进行测算。" + f"当前匹配区域为{region_label},但票据缺少金额,系统无法判断是否超出补助标准。" + ), + suggestion="请在票据识别结果中补充或更正餐饮金额,再继续创建报销单。", + ), + ) + continue + + amount_measurement_lines.append( + f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;" + f"适用《{standard_rule_name}》{region_label}伙食补助标准 {standard_amount:.2f} 元/天," + f"{'超出标准' if amount > standard_amount else '测算通过'}。" + ) + if amount > standard_amount: + append_once( + f"travel-meal-allowance-over-limit-{card.index}", + UserAgentReviewRiskBrief( + title="差旅餐饮金额超出伙食补助标准", + level="high", + content=( + f"{card.filename} 识别金额 {amount:.2f} 元," + f"超过{region_label}伙食补助标准 {standard_amount:.2f} 元/天。" + ), + detail=( + f"依据《{standard_rule_name}》({standard_rule_version})的出差补助标准," + f"{region_label}伙食补助为 {standard_amount:.2f} 元/天;" + f"当前票据类型识别为{document_type_label},识别金额 {amount:.2f} 元。" + "首轮上传阶段按单张票据先行测算,后续可结合出差天数和实际餐补口径复核。" + ), + suggestion="如该票据属于差旅餐补,请调整金额或补充超标/拆分说明;如属于业务招待或普通餐费,请改为对应费用类型后再提交。", + ), + ) + continue + + scene_code = self._resolve_review_amount_scene_code(card, payload) + scene_policy = rule_catalog.get_scene_policy(scene_code) + scene_limit = self._resolve_review_scene_amount_limit(scene_policy) + if scene_policy is not None and scene_limit is not None: + metric_label = str(getattr(scene_limit, "metric_label", "") or scene_policy.label or "金额").strip() + standard_amount = self._resolve_scene_standard_amount(scene_limit) + if amount is None: + amount_measurement_lines.append( + f"{card.filename}:识别为{document_type_label},但未识别到可核算金额,无法按{metric_label}测算。" + ) + append_once( + f"{scene_code}-amount-missing-{card.index}", + UserAgentReviewRiskBrief( + title=f"{scene_policy.label}金额待补充", + level="warning", + content=f"{card.filename} 已识别为{document_type_label},但未识别到可核算金额。", + detail=( + f"依据《{scene_policy.rule_name}》({scene_policy.rule_version})," + f"{scene_policy.label}需要按{metric_label}进行金额审核。当前票据缺少金额,系统无法判断是否合规。" + ), + suggestion="请在票据识别结果中补充或更正金额,再继续核对报销单。", + ), + ) + continue + + if standard_amount is not None: + amount_measurement_lines.append( + f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;" + f"适用《{scene_policy.rule_name}》{metric_label}标准 {standard_amount:.2f} 元," + f"{'超出标准' if amount > standard_amount else '测算通过'}。" + ) + + amount_risk = self._evaluate_review_scene_amount( + amount=amount, + limit_config=scene_limit, + reason_text=reason_corpus, + ) + if amount_risk is not None: + severity, threshold = amount_risk + append_once( + f"{scene_code}-amount-over-limit-{card.index}", + UserAgentReviewRiskBrief( + title=f"{scene_policy.label}金额超标待说明", + level="high" if severity == "high" else "warning", + content=( + f"{card.filename} 识别金额 {amount:.2f} 元," + f"超过{metric_label}标准 {threshold:.2f} 元。" + ), + detail=( + f"依据《{scene_policy.rule_name}》({scene_policy.rule_version})," + f"{scene_policy.label}按{metric_label}审核,当前票据类型识别为{document_type_label}," + f"识别金额 {amount:.2f} 元,标准阈值 {threshold:.2f} 元。" + ), + suggestion="请补充超标原因或拆分到更准确的费用类型;如属于例外场景,请在事由中写明业务背景。", + ), + ) + continue + + transport_class = self._detect_review_transport_class(card, policy) + if transport_class and grade_band is not None: + transport_kind, class_label, class_level = transport_class + allowed_level = policy.transport_limits.get(grade_band, {}).get(transport_kind) + if allowed_level is not None and class_level > allowed_level: + append_once( + f"transport-class-over-limit-{card.index}-{class_label}", + UserAgentReviewRiskBrief( + title="交通舱位超标待说明" if not has_exception_note else "交通舱位超标提醒", + level="warning", + content=f"{card.filename} 识别为 {class_label},{band_label} 当前默认不可报销该舱位/席别。", + detail=( + f"依据《{standard_rule_name}》({standard_rule_version}),{band_label} 的交通席别标准" + f"未覆盖 {class_label};票据类型识别为{document_type_label}。" + + ( + "当前未识别到例外说明,创建单据前需要补充原因。" + if not has_exception_note + else "当前已识别到例外说明,后续仍需审批人重点复核。" + ) + ), + suggestion="补充无直达、临时改签、行程变更等例外说明,或更换为符合标准的票据。", + ), + ) + continue + + if document_type == "meal_receipt" and self._is_travel_review_context(payload, document_cards, claim_groups): + if amount is not None: + amount_measurement_lines.append( + f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;需确认按餐补、餐费或业务招待口径归口。" + ) + append_once( + f"travel-meal-card-{card.index}", + UserAgentReviewRiskBrief( + title="差旅餐饮票据待归口", + level="warning", + content=f"{card.filename} 已识别为餐饮票据,当前差旅报销单需要确认是否允许并入差旅费用。", + detail=( + f"依据《{standard_rule_name}》({standard_rule_version})的差旅票据预检口径,系统优先核算交通、住宿等差旅核心票据。" + "餐饮票据可能需要按餐费或业务招待场景拆分,并补充同行人员或客户信息。" + ), + suggestion="如属于差旅餐补,请补充制度允许口径;如属于招待或普通餐费,建议拆成对应费用类型单据。", + ), + ) + continue + + if suggested_type in {"travel", "hotel", "transport"} and document_type in {"other", "travel_ticket"}: + append_once( + f"travel-type-uncertain-{card.index}", + UserAgentReviewRiskBrief( + title="差旅票据类型待确认", + level="warning", + content=f"{card.filename} 归入差旅场景,但票据类型仍需确认。", + detail=( + f"依据《{standard_rule_name}》({standard_rule_version}),差旅预检需要先明确票据是机票、火车票、住宿票据、打车票等," + "再匹配对应的金额或舱位规则。当前类型识别不够稳定。" + ), + suggestion="请在附件识别结果中更正票据类型,或重新上传更清晰的附件后再继续。", + ), + ) + + if amount_measurement_lines: + briefs.insert( + 0, + UserAgentReviewRiskBrief( + title="附件金额测算结果", + level="info", + content="系统已根据首轮上传附件识别金额,并匹配当前可执行的报销标准进行测算。", + detail=";".join(dict.fromkeys(amount_measurement_lines)), + suggestion="如测算结果超标,请补充超标说明、调整金额或更正票据类型后再继续。", + ), + ) + + return briefs + + def _is_travel_review_context( + self, + payload: UserAgentRequest, + document_cards: list[UserAgentReviewDocumentCard], + claim_groups: list[UserAgentReviewClaimGroup], + ) -> bool: + entity_expense_type = self._collect_entity_values(payload).get("expense_type_code", "") + review_form_values = self._resolve_review_form_values(payload) + form_expense_type = str(review_form_values.get("expense_type") or "").strip() + message_context = " ".join( + [ + str(payload.message or ""), + str(payload.context_json.get("user_input_text") or ""), + str(payload.context_json.get("expense_type") or ""), + form_expense_type, + ] + ) + if entity_expense_type in {"travel", "hotel", "transport"}: + return True + if any(group.group_code == "travel" or group.expense_type in {"travel", "hotel", "transport"} for group in claim_groups): + return True + if any(card.suggested_expense_type in {"travel", "hotel", "transport"} for card in document_cards): + return True + return any(keyword in message_context for keyword in ("差旅", "出差", "机票", "火车", "高铁", "酒店", "住宿")) + + def _resolve_review_travel_allowance_standard( + self, + policy: RuntimeTravelPolicy, + *, + declared_city: str, + card_text: str, + ) -> tuple[str, Decimal] | None: + meal_limits = getattr(policy, "allowance_limits", {}).get("meal", {}) + if not meal_limits: + return None + + region_label = self._resolve_review_travel_allowance_region( + " ".join([declared_city or "", card_text or ""]) + ) + amount = meal_limits.get(region_label) + if amount is None and region_label != "其他地区": + amount = meal_limits.get("其他地区") + region_label = "其他地区" + if amount is None: + return None + return region_label, Decimal(amount).quantize(Decimal("0.01")) + + @staticmethod + def _resolve_review_travel_allowance_region(text: str) -> str: + normalized = re.sub(r"\s+", "", str(text or "")) + if not normalized: + return "其他地区" + if any(keyword in normalized for keyword in ("境外", "国外", "海外")): + return "国外" + if any(keyword in normalized for keyword in ("香港", "澳门", "台湾", "港澳台")): + return "港澳台" + if "乌鲁木齐" in normalized: + return "新疆-乌鲁木齐" + if "新疆" in normalized: + return "新疆-其他" + if any(keyword in normalized for keyword in ("西藏", "拉萨")): + return "西藏" + if any(keyword in normalized for keyword in ("北京", "上海", "天津", "重庆", "深圳", "珠海", "汕头", "厦门")): + return "直辖市/特区" + return "其他地区" + + def _resolve_review_amount_scene_code( + self, + card: UserAgentReviewDocumentCard, + payload: UserAgentRequest, + ) -> str: + document_type = str(card.document_type or "").strip().lower() + suggested_type = str(card.suggested_expense_type or "").strip().lower() + if document_type in {"taxi_receipt", "parking_toll_receipt", "transport_receipt"}: + return "transport" + if document_type == "meal_receipt": + entity_values = self._collect_entity_values(payload) + if suggested_type == "entertainment" or entity_values.get("expense_type_code") == "entertainment": + return "entertainment" + return "meal" + if document_type == "hotel_invoice" or suggested_type == "hotel": + return "hotel" + if suggested_type in { + "travel", + "transport", + "meal", + "entertainment", + "office", + "meeting", + "training", + "communication", + "welfare", + "other", + }: + return suggested_type + return self._collect_entity_values(payload).get("expense_type_code") or "other" + + @staticmethod + def _resolve_review_scene_amount_limit(scene_policy: Any | None) -> Any | None: + if scene_policy is None: + return None + return getattr(scene_policy, "item_amount_limit", None) or getattr(scene_policy, "claim_amount_limit", None) + + @staticmethod + def _resolve_scene_standard_amount(limit_config: Any | None) -> Decimal | None: + if limit_config is None: + return None + warn_amount = getattr(limit_config, "warn_amount", None) + block_amount = getattr(limit_config, "block_amount", None) + amount = warn_amount if warn_amount is not None else block_amount + if amount is None: + return None + try: + return Decimal(amount).quantize(Decimal("0.01")) + except (InvalidOperation, ValueError): + return None + + @staticmethod + def _evaluate_review_scene_amount( + *, + amount: Decimal, + limit_config: Any, + reason_text: str, + ) -> tuple[str, Decimal] | None: + block_amount = getattr(limit_config, "block_amount", None) + warn_amount = getattr(limit_config, "warn_amount", None) + exception_keywords = list(getattr(limit_config, "exception_keywords", []) or []) + has_exception = UserAgentService._text_contains_any(reason_text, exception_keywords) + + if block_amount is not None and amount > Decimal(block_amount): + return ("high", Decimal(block_amount).quantize(Decimal("0.01"))) + if warn_amount is not None and amount > Decimal(warn_amount): + return ("high", Decimal(warn_amount).quantize(Decimal("0.01"))) + return None + + def _resolve_review_employee_grade(self, payload: UserAgentRequest, *, employee: Employee | None) -> str: + if employee is not None and employee.grade: + return str(employee.grade).strip() + review_form_values = self._resolve_review_form_values(payload) + for source in ( + review_form_values, + payload.context_json, + payload.tool_payload, + ): + for key in ("employee_grade", "grade", "user_grade", "position_grade"): + value = str(source.get(key) or "").strip() if isinstance(source, dict) else "" + if value: + return value + return "" + + def _build_review_reason_corpus(self, payload: UserAgentRequest) -> str: + review_form_values = self._resolve_review_form_values(payload) + parts = [ + str(payload.message or ""), + str(payload.context_json.get("user_input_text") or ""), + str(review_form_values.get("reason") or ""), + str(review_form_values.get("business_reason") or ""), + str(review_form_values.get("location") or ""), + str(review_form_values.get("business_location") or ""), + ] + return "\n".join(part.strip() for part in parts if part and part.strip()) + + def _resolve_declared_travel_city(self, payload: UserAgentRequest, policy: RuntimeTravelPolicy) -> str: + review_form_values = self._resolve_review_form_values(payload) + candidates = [ + str(review_form_values.get("business_location") or ""), + str(review_form_values.get("location") or ""), + self._resolve_location_value(payload), + str(payload.message or ""), + ] + for candidate in candidates: + city = self._extract_policy_city_from_text(candidate, policy) + if city: + return city + return "" + + @staticmethod + def _build_review_document_card_text(card: UserAgentReviewDocumentCard) -> str: + field_text = " ".join(f"{field.label}:{field.value}" for field in card.fields) + return " ".join( + [ + str(card.filename or ""), + str(card.document_type or ""), + str(card.scene_label or ""), + str(card.summary or ""), + field_text, + ] + ).strip() + + @staticmethod + def _is_review_hotel_card(card: UserAgentReviewDocumentCard) -> bool: + document_type = str(card.document_type or "").strip().lower() + suggested_type = str(card.suggested_expense_type or "").strip().lower() + scene_label = str(card.scene_label or "").strip() + return document_type == "hotel_invoice" or suggested_type == "hotel" or "住宿" in scene_label + + @staticmethod + def _extract_amount_decimal_from_card(card: UserAgentReviewDocumentCard) -> Decimal | None: + for field in card.fields: + if field.label != "金额": + continue + normalized = str(field.value or "").replace("元", "").replace("¥", "").replace("¥", "").replace(",", "").strip() + try: + amount = Decimal(normalized).quantize(Decimal("0.01")) + except (InvalidOperation, ValueError): + continue + if amount > Decimal("0.00"): + return amount + return None + + @staticmethod + def _extract_review_hotel_night_count(card: UserAgentReviewDocumentCard) -> int: + text = f"{card.summary or ''} {' '.join(f'{field.label}:{field.value}' for field in card.fields)}" + match = TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN.search(text) + if not match: + return 1 + try: + return max(1, int(match.group(1))) + except (TypeError, ValueError): + return 1 + + @staticmethod + def _extract_policy_city_from_text(text: str, policy: RuntimeTravelPolicy) -> str: + normalized = str(text or "").strip() + if not normalized: + return "" + city_names = set(policy.city_tiers.keys()) + city_names.update(getattr(policy, "hotel_city_limits", {}).keys()) + for city in sorted(city_names, key=lambda item: len(item), reverse=True): + if city in normalized: + return city + return "" + + @staticmethod + def _format_travel_city_tier(city_tier: str) -> str: + return { + "tier_1": "一线城市", + "tier_2": "重点城市", + "tier_3": "其他城市", + }.get(str(city_tier or "").strip(), "当前城市") + + @staticmethod + def _resolve_review_hotel_cap( + policy: RuntimeTravelPolicy, + *, + grade_band: str, + city: str, + city_tier: str, + ) -> Decimal: + normalized_city = str(city or "").strip() + if normalized_city and getattr(policy, "hotel_city_limits", None): + city_limits = policy.hotel_city_limits.get(normalized_city, {}) + city_cap = city_limits.get(grade_band) + if city_cap is not None: + return Decimal(city_cap).quantize(Decimal("0.01")) + return Decimal(policy.hotel_limits.get(grade_band, {}).get(city_tier, Decimal("0.00"))).quantize( + Decimal("0.01") + ) + + def _detect_review_transport_class( + self, + card: UserAgentReviewDocumentCard, + policy: RuntimeTravelPolicy, + ) -> tuple[str, str, int] | None: + document_type = str(card.document_type or "").strip().lower() + text = re.sub(r"\s+", "", self._build_review_document_card_text(card)) + if not text: + return None + + if document_type == "flight_itinerary" or any(keyword in text for keyword in ("机票", "航班", "登机牌")): + for config in policy.flight_classes: + label = str(config.keyword or "").strip() + if label and label in text: + return "flight", label, int(config.level) + + if document_type == "train_ticket" or any(keyword in text for keyword in ("火车", "高铁", "动车", "铁路")): + for config in policy.train_classes: + label = str(config.keyword or "").strip() + if label and label in text: + return "train", label, int(config.level) + return None + + @staticmethod + def _text_contains_any(text: str, keywords: list[str] | tuple[str, ...]) -> bool: + compact = re.sub(r"\s+", "", str(text or "")) + return bool(compact) and any(str(keyword or "").strip() and str(keyword).strip() in compact for keyword in keywords) @staticmethod def _resolve_submission_blocked_reasons(payload: UserAgentRequest) -> list[str]: @@ -2543,6 +3149,14 @@ class UserAgentService: "系统检测到你已有可用草稿,请先选择关联到现有草稿,或单独建立一张新的报销单。" ) + blocked_reasons = self._resolve_submission_blocked_reasons(payload) + if blocked_reasons: + reason_text = ";".join(dict.fromkeys(reason.strip("。;;") for reason in blocked_reasons if reason)) + return ( + f"AI预审未通过:{reason_text}。" + "请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。" + ) + review_payload = UserAgentReviewPayload( intent_summary="", body_message="", @@ -3460,7 +4074,18 @@ class UserAgentService: evidence="来源于用户修改后的结构化表单。", ) - merchant_value = self._extract_document_merchant_name(ocr_documents[0]) if ocr_documents else "" + merchant_value = "" + for document in ocr_documents: + if str(document.get("document_type") or "").strip().lower() != "hotel_invoice": + continue + merchant_value = self._extract_document_merchant_name(document) + if merchant_value: + break + if not merchant_value: + for document in ocr_documents: + merchant_value = self._extract_document_merchant_name(document) + if merchant_value: + break if merchant_value: return self._build_slot_value( value=merchant_value, diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf new file mode 100644 index 0000000..d516ecb Binary files /dev/null and b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf differ diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf.meta.json b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf.meta.json new file mode 100644 index 0000000..b2e8892 --- /dev/null +++ b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf.meta.json @@ -0,0 +1,90 @@ +{ + "file_name": "2月23_上海-武汉.pdf", + "storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf", + "media_type": "application/pdf", + "size_bytes": 24940, + "uploaded_at": "2026-05-20T13:48:38.616319+00:00", + "previewable": true, + "preview_kind": "image", + "preview_storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.preview.png", + "preview_media_type": "image/png", + "preview_file_name": "2月23_上海-武汉.preview.png", + "analysis": { + "severity": "medium", + "label": "中风险", + "headline": "AI提示:附件存在明显待整改项", + "summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。", + "points": [ + "用途字段:用户填写用途“业务发生时间:2026-02-20 至 2026”与票据内容不一致,当前附件更像交通相关材料。" + ], + "suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。" + }, + "document_info": { + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "日期", + "value": "2026-05-18" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26319166100006175398" + }, + { + "key": "route", + "label": "行程", + "value": "上海-武汉" + } + ] + }, + "requirement_check": { + "matches": true, + "current_expense_type": "travel", + "current_expense_type_label": "差旅费", + "allowed_scene_labels": [ + "差旅" + ], + "allowed_document_type_labels": [ + "机票/航班行程单", + "火车/高铁票" + ], + "recognized_scene_code": "travel", + "recognized_scene_label": "差旅票据", + "recognized_document_type": "train_ticket", + "recognized_document_type_label": "火车/高铁票", + "mismatch_severity": "high", + "rule_code": "rule.expense.scene_submission_standard", + "rule_name": "报销场景提交与附件标准", + "message": "当前费用项目为差旅费,已识别为火车/高铁票,符合当前差旅费场景的附件要求。" + }, + "ocr_status": "recognized", + "ocr_error": "", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "ocr_summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9620026834309101, + "ocr_line_count": 24, + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "ocr_warnings": [] +} \ No newline at end of file diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.preview.png b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.preview.png new file mode 100644 index 0000000..099413e Binary files /dev/null and b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.preview.png differ diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf new file mode 100644 index 0000000..b2207b8 Binary files /dev/null and b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf differ diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf.meta.json b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf.meta.json new file mode 100644 index 0000000..496127b --- /dev/null +++ b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf.meta.json @@ -0,0 +1,90 @@ +{ + "file_name": "2月20_武汉-上海.pdf", + "storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf", + "media_type": "application/pdf", + "size_bytes": 24995, + "uploaded_at": "2026-05-20T13:48:21.652497+00:00", + "previewable": true, + "preview_kind": "image", + "preview_storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.preview.png", + "preview_media_type": "image/png", + "preview_file_name": "2月20_武汉-上海.preview.png", + "analysis": { + "severity": "medium", + "label": "中风险", + "headline": "AI提示:附件存在明显待整改项", + "summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。", + "points": [ + "用途字段:用户填写用途“业务发生时间:2026-02-20 至 2026”与票据内容不一致,当前附件更像交通相关材料。" + ], + "suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。" + }, + "document_info": { + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅票据", + "fields": [ + { + "key": "amount", + "label": "金额", + "value": "354元" + }, + { + "key": "date", + "label": "日期", + "value": "2026-05-18" + }, + { + "key": "merchant_name", + "label": "商户", + "value": "中国铁路" + }, + { + "key": "invoice_number", + "label": "票据号码", + "value": "26429165800002785705" + }, + { + "key": "route", + "label": "行程", + "value": "武汉-上海" + } + ] + }, + "requirement_check": { + "matches": true, + "current_expense_type": "travel", + "current_expense_type_label": "差旅费", + "allowed_scene_labels": [ + "差旅" + ], + "allowed_document_type_labels": [ + "机票/航班行程单", + "火车/高铁票" + ], + "recognized_scene_code": "travel", + "recognized_scene_label": "差旅票据", + "recognized_document_type": "train_ticket", + "recognized_document_type_label": "火车/高铁票", + "mismatch_severity": "high", + "rule_code": "rule.expense.scene_submission_standard", + "rule_name": "报销场景提交与附件标准", + "message": "当前费用项目为差旅费,已识别为火车/高铁票,符合当前差旅费场景的附件要求。" + }, + "ocr_status": "recognized", + "ocr_error": "", + "ocr_text": "电子发票\n(铁路电子客票)\n州\n国家税务总局\n发票号码:26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价:¥354.00\n4201061987****1615\n曹笑竹\n电子客票号:6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码:\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", + "ocr_summary": "电子发票;(铁路电子客票);州", + "ocr_avg_score": 0.9580968717734019, + "ocr_line_count": 24, + "ocr_classification_source": "rule", + "ocr_classification_confidence": 0.88, + "ocr_classification_evidence": [ + "铁路电子客票", + "电子客票", + "铁路", + "二等座" + ], + "ocr_warnings": [] +} \ No newline at end of file diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.preview.png b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.preview.png new file mode 100644 index 0000000..0bdfb91 Binary files /dev/null and b/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.preview.png differ diff --git a/server/storage/font-test-after-install.png b/server/storage/font-test-after-install.png new file mode 100644 index 0000000..099413e Binary files /dev/null and b/server/storage/font-test-after-install.png differ diff --git a/server/storage/knowledge/.index.json b/server/storage/knowledge/.index.json index 9e38834..4f7185f 100644 --- a/server/storage/knowledge/.index.json +++ b/server/storage/knowledge/.index.json @@ -20,7 +20,7 @@ "ingest_document_name": "远光《公司支出管理办法(2024)》.pdf", "ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00", "ingest_document_sha256": "67a74538bce0dec71ccbb947256cc2c9c0e672d148de49406b967ae1379dbece", - "ingest_agent_run_id": "run_8b0ead1e3c734a53" + "ingest_agent_run_id": "run_3a0b0ecb941b4c8e" }, { "id": "a8f8465df08e455ebe133351721d49f8", @@ -36,12 +36,12 @@ "uploaded_by": "admin", "version_number": 1, "ingest_status": 4, - "ingest_status_updated_at": "2026-05-19T16:00:57.418443+00:00", + "ingest_status_updated_at": "2026-05-20T16:00:02.515903+00:00", "ingest_completed_at": "", "ingest_document_name": "", "ingest_document_updated_at": "", "ingest_document_sha256": "", - "ingest_agent_run_id": "run_57f2d8727aaa4374" + "ingest_agent_run_id": "run_3a0b0ecb941b4c8e" } ] } \ No newline at end of file diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py index caf0ff4..a5b75fc 100644 --- a/server/tests/test_agent_asset_service.py +++ b/server/tests/test_agent_asset_service.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest from openpyxl import Workbook, load_workbook -from sqlalchemy import create_engine +from sqlalchemy import create_engine, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool @@ -24,11 +24,14 @@ from app.core.agent_enums import ( ) from app.core.config import SERVER_DIR from app.db.base import Base +from app.models.agent_asset import AgentAsset +from app.models.employee import Employee from app.schemas.agent_asset import ( AgentAssetCreate, AgentAssetReviewCreate, AgentAssetVersionCreate, ) +from app.schemas.reimbursement import TravelReimbursementCalculatorRequest from app.services.agent_asset_spreadsheet import ( COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, @@ -41,6 +44,7 @@ from app.services.agent_runs import AgentRunService from app.services.audit import AuditLogService from app.services.expense_rule_runtime import ExpenseRuleRuntimeService from app.services.settings import OnlyOfficeRuntimeConfig +from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService @pytest.fixture(autouse=True) @@ -618,6 +622,126 @@ def test_agent_asset_service_returns_travel_policy_rule_detail() -> None: assert "住宿标准、飞机舱位和火车席别" in str(detail.current_version_content) +def test_expense_rule_runtime_reads_amount_standards_from_travel_spreadsheet() -> None: + with build_session() as db: + AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value) + travel_spreadsheet_rule = db.scalar( + select(AgentAsset).where(AgentAsset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE) + ) + assert travel_spreadsheet_rule is not None + travel_spreadsheet_rule.status = AgentAssetStatus.REVIEW.value + db.commit() + + catalog = ExpenseRuleRuntimeService(db).load_catalog() + + assert catalog.travel_policy is not None + assert catalog.travel_policy.standard_rule_code == COMPANY_TRAVEL_EXPENSE_RULE_CODE + assert catalog.travel_policy.standard_rule_name == "公司差旅费报销规则" + assert catalog.travel_policy.hotel_city_limits["北京"]["mid"] == 450 + assert catalog.travel_policy.hotel_city_limits["北京"]["junior"] == 450 + assert catalog.travel_policy.hotel_city_limits["北京"]["manager"] == 500 + assert catalog.travel_policy.allowance_limits["meal"]["直辖市/特区"] == 65 + assert catalog.travel_policy.allowance_limits["meal"]["其他地区"] == 55 + assert catalog.travel_policy.allowance_limits["total"]["其他地区"] == 90 + assert catalog.travel_policy.transport_limits["senior"]["flight"] == 1 + assert catalog.travel_policy.transport_limits["executive"]["train"] == 1 + + +def test_travel_reimbursement_calculator_uses_finance_spreadsheet_amounts() -> None: + with build_session() as db: + db.add( + Employee( + employee_no="E9001", + name="测试员工", + email="traveler@example.com", + position="产品经理", + grade="P4", + ) + ) + db.commit() + + result = TravelReimbursementCalculatorService(db).calculate( + TravelReimbursementCalculatorRequest(days=3, location="北京市朝阳区"), + CurrentUserContext( + username="traveler@example.com", + name="测试员工", + role_codes=[], + is_admin=False, + ), + ) + + assert result.rule_name == "公司差旅费报销规则" + assert result.grade == "P4" + assert result.grade_band == "mid" + assert result.matched_city == "北京" + assert result.hotel_rate == 450 + assert result.hotel_amount == 1350 + assert result.allowance_region == "直辖市/特区" + assert result.total_allowance_rate == 100 + assert result.allowance_amount == 300 + assert result.total_amount == 1650 + assert "住宿 450.00 × 3 天 + 补贴 100.00 × 3 天 = 1650.00" == result.formula_text + assert "参考可报销总金额为 1650.00 元" in result.summary_text + + +def test_travel_reimbursement_calculator_uses_other_region_for_known_unlisted_location() -> None: + with build_session() as db: + db.add( + Employee( + employee_no="E9002", + name="其他地区员工", + email="other-region@example.com", + position="产品经理", + grade="P4", + ) + ) + db.commit() + + result = TravelReimbursementCalculatorService(db).calculate( + TravelReimbursementCalculatorRequest(days=2, location="吉林延边"), + CurrentUserContext( + username="other-region@example.com", + name="其他地区员工", + role_codes=[], + is_admin=False, + ), + ) + + assert result.matched_city == "延边(其他地区)" + assert result.city_tier == "tier_3" + assert result.hotel_rate == 380 + assert result.hotel_amount == 760 + assert result.allowance_region == "其他地区" + assert result.total_allowance_rate == 90 + assert result.allowance_amount == 180 + assert result.total_amount == 940 + + +def test_travel_reimbursement_calculator_rejects_unrecognized_location() -> None: + with build_session() as db: + db.add( + Employee( + employee_no="E9003", + name="无效地点员工", + email="invalid-location@example.com", + position="产品经理", + grade="P4", + ) + ) + db.commit() + + with pytest.raises(ValueError, match="未识别为有效出差地区"): + TravelReimbursementCalculatorService(db).calculate( + TravelReimbursementCalculatorRequest(days=2, location="背景"), + CurrentUserContext( + username="invalid-location@example.com", + name="无效地点员工", + role_codes=[], + is_admin=False, + ), + ) + + def test_agent_run_service_lists_seeded_trace_data() -> None: with build_session() as db: service = AgentRunService(db) diff --git a/server/tests/test_document_intelligence.py b/server/tests/test_document_intelligence.py index 69c4b26..514c644 100644 --- a/server/tests/test_document_intelligence.py +++ b/server/tests/test_document_intelligence.py @@ -51,6 +51,27 @@ def test_document_intelligence_extracts_larger_decimal_amount_from_multiple_cand assert any(field.label == "金额" and field.value == "13.4元" for field in insight.fields) +def test_document_intelligence_prefers_train_ticket_for_railway_e_ticket_invoice_text() -> None: + insight = build_document_insight( + filename="铁路电子客票.pdf", + summary="电子发票(铁路电子客票)", + text=( + "电子发票(铁路电子客票)\n" + "发票号码:26319166100006175398\n" + "上海虹桥站\n" + "武汉站\n" + "G456\n" + "二等座\n" + "票价:¥354.00" + ), + ) + + assert insight.document_type == "train_ticket" + assert insight.document_type_label == "火车/高铁票" + assert insight.scene_code == "travel" + assert any(field.label == "金额" and field.value == "354元" for field in insight.fields) + + def test_document_intelligence_service_keeps_rule_fields_without_model_correction() -> None: engine = create_engine( "sqlite+pysqlite:///:memory:", diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index beedf1d..2f25967 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -16,6 +16,7 @@ from app.models.organization import OrganizationUnit from app.schemas.ontology import OntologyParseRequest from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate +from app.services.agent_conversations import AgentConversationService from app.services.expense_claims import ExpenseClaimService from app.services.ontology import SemanticOntologyService from app.services.ocr import OcrService @@ -722,6 +723,82 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path) assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"]) +def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_path) -> None: + current_user = CurrentUserContext( + username="emp-1", + name="张三", + role_codes=[], + is_admin=False, + ) + + def fake_recognize( + self, + files: list[tuple[str, bytes, str | None]], + ) -> OcrRecognizeBatchRead: + return OcrRecognizeBatchRead( + total_file_count=1, + success_count=1, + documents=[ + OcrRecognizeDocumentRead( + filename="train-ticket.png", + media_type="image/png", + text="中国铁路电子客票 广州南-北京南 二等座 票价:¥354.00", + summary="铁路电子客票,票价 354 元。", + avg_score=0.98, + line_count=1, + page_count=1, + document_type="train_ticket", + document_type_label="火车/高铁票", + scene_code="travel", + scene_label="差旅费", + document_fields=[ + {"key": "fare", "label": "票价", "value": "¥354.00"}, + ], + ) + ], + ) + + monkeypatch.setattr(OcrService, "recognize_files", fake_recognize) + monkeypatch.setattr(ExpenseClaimService, "_get_attachment_storage_root", lambda self: tmp_path) + + with build_session() as db: + claim = build_claim(expense_type="travel", location="北京") + claim.amount = Decimal("0.00") + claim.invoice_count = 0 + claim.items[0].item_amount = Decimal("0.00") + claim.items[0].invoice_id = None + db.add(claim) + db.commit() + + service = ExpenseClaimService(db) + updated = service.upload_claim_item_attachment( + claim_id=claim.id, + item_id=claim.items[0].id, + filename="train-ticket.png", + content=b"fake-image-bytes", + media_type="image/png", + current_user=current_user, + ) + + assert updated is not None + assert updated["item_amount"] == Decimal("354.00") + assert updated["claim_amount"] == Decimal("354.00") + db.refresh(claim) + assert claim.items[0].item_amount == Decimal("354.00") + assert claim.amount == Decimal("354.00") + uploaded_meta = service.get_claim_item_attachment_meta( + claim_id=claim.id, + item_id=claim.items[0].id, + current_user=current_user, + ) + assert uploaded_meta is not None + assert uploaded_meta["document_info"]["document_type"] == "train_ticket" + assert any( + field["label"] == "票价" and field["value"] == "¥354.00" + for field in uploaded_meta["document_info"]["fields"] + ) + + def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None: current_user = CurrentUserContext( username="emp-1", @@ -1502,7 +1579,7 @@ def test_list_claims_allows_executive_to_view_all_records() -> None: assert {claim.claim_no for claim in claims} == {"EXP-EXE-101", "EXP-EXE-102"} -def test_privileged_user_can_return_and_delete_submitted_claim() -> None: +def test_finance_can_return_but_cannot_delete_submitted_claim() -> None: current_user = CurrentUserContext( username="finance@example.com", name="财务", @@ -1545,10 +1622,46 @@ def test_privileged_user_can_return_and_delete_submitted_claim() -> None: for flag in returned.risk_flags_json ) - deleted = service.delete_claim(claim_id, current_user) + with pytest.raises(ValueError, match="只有高级管理人员可以删除"): + service.delete_claim(claim_id, current_user) + + assert db.get(ExpenseClaim, claim_id) is not None + + +def test_executive_can_delete_submitted_claim() -> None: + current_user = CurrentUserContext( + username="executive-delete@example.com", + name="高管", + role_codes=["executive"], + is_admin=False, + ) + + with build_session() as db: + claim = ExpenseClaim( + claim_no="EXP-DEL-EXEC-101", + employee_name="张三", + department_name="市场部", + project_code="PRJ-A", + expense_type="travel", + reason="差旅报销", + location="上海", + amount=Decimal("120.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC), + status="submitted", + approval_stage="财务审批", + risk_flags_json=[], + ) + db.add(claim) + db.commit() + claim_id = claim.id + + deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user) assert deleted is not None - assert deleted.claim_no == "EXP-RET-101" + assert deleted.claim_no == "EXP-DEL-EXEC-101" assert db.get(ExpenseClaim, claim_id) is None @@ -1675,6 +1788,56 @@ def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> Non ) +def test_finance_can_approve_claim_to_archive_stage() -> None: + current_user = CurrentUserContext( + username="finance-approve@example.com", + name="财务复核", + role_codes=["finance"], + is_admin=False, + ) + + with build_session() as db: + claim = ExpenseClaim( + claim_no="EXP-FIN-APP-201", + 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 == "approved" + assert approved.approval_stage == "归档入账" + assert any( + isinstance(flag, dict) + and flag.get("source") == "finance_approval" + and flag.get("event_type") == "expense_claim_finance_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", @@ -1836,6 +1999,16 @@ def test_submit_returned_claim_preserves_manual_return_events() -> None: claim.risk_flags_json = [return_flag] db.add_all([manager, employee, claim]) db.commit() + conversation = AgentConversationService(db).get_or_create_conversation( + conversation_id=None, + user_id=current_user.username, + source="user_message", + context_json={ + "session_type": "expense", + "draft_claim_id": claim.id, + }, + ) + conversation_id = conversation.conversation_id submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user) @@ -1848,6 +2021,7 @@ def test_submit_returned_claim_preserves_manual_return_events() -> None: and flag.get("return_event_id") == "return-event-submit" for flag in list(submitted.risk_flags_json or []) ) + assert AgentConversationService(db).get_conversation(conversation_id) is None def test_manager_personal_claims_exclude_subordinate_pending_approval_claims() -> None: @@ -2001,3 +2175,57 @@ def test_list_approval_claims_allows_direct_manager_to_view_pending_claims_for_a assert len(claims) == 1 assert claims[0].claim_no == "EXP-MGR-201" + + +def test_list_approval_claims_limits_finance_to_finance_stage_claims() -> None: + current_user = CurrentUserContext( + username="finance-approval-list@example.com", + name="财务", + role_codes=["finance"], + is_admin=False, + ) + + with build_session() as db: + db.add_all( + [ + ExpenseClaim( + claim_no="EXP-FIN-LIST-201", + employee_name="张三", + department_name="市场部", + project_code="PRJ-FIN", + 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=[], + ), + ExpenseClaim( + claim_no="EXP-FIN-LIST-202", + employee_name="李四", + department_name="销售部", + project_code="PRJ-FIN", + expense_type="meal", + reason="财务待审", + location="杭州", + amount=Decimal("188.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 12, 12, 0, tzinfo=UTC), + submitted_at=datetime(2026, 5, 12, 13, 0, tzinfo=UTC), + status="submitted", + approval_stage="财务审批", + risk_flags_json=[], + ), + ] + ) + db.commit() + + claims = ExpenseClaimService(db).list_approval_claims(current_user) + + assert [claim.claim_no for claim in claims] == ["EXP-FIN-LIST-202"] diff --git a/server/tests/test_ocr_service.py b/server/tests/test_ocr_service.py index 8141050..0717b8d 100644 --- a/server/tests/test_ocr_service.py +++ b/server/tests/test_ocr_service.py @@ -177,3 +177,80 @@ def test_ocr_service_converts_pdf_to_images_and_returns_image_preview( assert any(field.label == "车次/航班" and field.value == "G1234" for field in recognized.document_fields) assert recognized.lines[0].page_index == 0 assert recognized.lines[1].page_index == 1 + + +def test_ocr_service_prefers_pdf_text_layer_when_rendered_ocr_is_placeholder_heavy( + monkeypatch, + tmp_path: Path, +) -> None: + def fake_convert_pdf_to_images(self, *, pdf_path: Path, output_dir: Path) -> list[Path]: + page = output_dir / "page-1.png" + page.write_bytes(b"fake-page") + return [page] + + def fake_invoke_worker( + self, + *, + python_bin: str, + worker_path: str, + input_paths: list[Path], + ) -> dict: + return { + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "documents": [ + { + "input_path": str(input_paths[0]), + "engine": "paddleocr_mobile", + "model": "PP-OCRv5_mobile", + "text": "□□□□□□\n□□□□:26319166100006175398\nG456\n□□:□354.00", + "summary": "□□□□□□;□□□□:26319166100006175398", + "avg_score": 0.88, + "line_count": 4, + "page_count": 1, + "warnings": [], + "lines": [ + { + "text": "□□□□□□", + "score": 0.88, + "box": [[1, 2], [10, 2], [10, 8], [1, 8]], + } + ], + } + ], + } + + monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage")) + monkeypatch.setattr(OcrService, "_resolve_python_bin", lambda self: "python") + monkeypatch.setattr(OcrService, "_resolve_worker_path", lambda self: "worker.py") + monkeypatch.setattr(OcrService, "_convert_pdf_to_images", fake_convert_pdf_to_images) + monkeypatch.setattr(OcrService, "_invoke_worker", fake_invoke_worker) + monkeypatch.setattr( + OcrService, + "_extract_pdf_text_layer", + lambda self, pdf_path: ( + "电子发票(铁路电子客票)\n" + "发票号码:26319166100006175398\n" + "上海虹桥站\n" + "武汉站\n" + "G456\n" + "票价:¥354.00" + ), + ) + get_settings.cache_clear() + try: + result = OcrService().recognize_files( + [ + ("train-ticket.pdf", b"%PDF-1.4 fake", "application/pdf"), + ] + ) + finally: + get_settings.cache_clear() + + recognized = result.documents[0] + assert "电子发票(铁路电子客票)" in recognized.text + assert "上海虹桥站" in recognized.text + assert "□□□□" not in recognized.summary + assert recognized.document_type == "train_ticket" + assert recognized.preview_kind == "" + assert recognized.preview_data_url == "" diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py index fdbc00f..3969803 100644 --- a/server/tests/test_orchestrator_review_flow.py +++ b/server/tests/test_orchestrator_review_flow.py @@ -11,6 +11,7 @@ from app.db.base import Base from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.schemas.orchestrator import OrchestratorRequest +from app.services.agent_conversations import AgentConversationService from app.services.orchestrator import OrchestratorService @@ -96,6 +97,8 @@ def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload( assert claim.status == "submitted" assert claim.approval_stage == "直属领导审批" assert claim.submitted_at is not None + assert response.conversation_id + assert AgentConversationService(db).get_conversation(response.conversation_id) is None def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action( @@ -165,6 +168,8 @@ def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action( assert response.status == "succeeded" assert result["draft_payload"]["status"] == "draft" + assert response.conversation_id + assert AgentConversationService(db).get_conversation(response.conversation_id) is not None assert "AI预审暂未通过" in result["answer"] assert "所属部门未完善" in result["answer"] assert "next_step" not in actions diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index 5693478..b5138db 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -345,9 +345,17 @@ def test_approve_claim_endpoint_routes_direct_manager_claim_to_finance_review() assert any( item["source"] == "manual_approval" and item["opinion"] == "情况属实,同意报销。" + and item["operator"] == "李经理" and item["next_approval_stage"] == "财务审批" for item in payload["risk_flags_json"] ) + approval_events = [ + item + for item in payload["risk_flags_json"] + if item["source"] == "manual_approval" + ] + assert approval_events[0]["operator"] == "李经理" + assert "manager-approve-api@example.com" not in approval_events[0]["message"] def test_claim_item_pdf_attachment_preview_returns_generated_image(monkeypatch, tmp_path) -> None: diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index 70c3afd..d407c9a 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -1,16 +1,21 @@ -from __future__ import annotations - -from datetime import UTC, datetime, timedelta - -from sqlalchemy import create_engine -from sqlalchemy.orm import Session, sessionmaker -from sqlalchemy.pool import StaticPool - -from app.db.base import Base -from app.schemas.ontology import OntologyParseRequest -from app.schemas.user_agent import UserAgentRequest -from app.services.ontology import SemanticOntologyService -from app.services.user_agent import UserAgentService +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim +from app.core.agent_enums import AgentAssetType +from app.schemas.ontology import OntologyParseRequest +from app.schemas.user_agent import UserAgentCitation, UserAgentRequest, UserAgentReviewRiskBrief +from app.services.agent_assets import AgentAssetService +from app.services.ontology import SemanticOntologyService +from app.services.user_agent import UserAgentService def build_session_factory() -> sessionmaker[Session]: @@ -1096,11 +1101,11 @@ def test_user_agent_prefers_larger_decimal_amount_from_ocr_text_candidates() -> assert slot_map["amount"].value == "13.40元" -def test_user_agent_review_payload_keeps_document_preview_data() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( +def test_user_agent_review_payload_keeps_document_preview_data() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( query="我上传了打车票据,帮我生成报销草稿", user_id="pytest", context_json={ @@ -1147,15 +1152,465 @@ def test_user_agent_review_payload_keeps_document_preview_data() -> None: ) assert response.review_payload is not None - assert response.review_payload.document_cards[0].preview_kind == "image" - assert response.review_payload.document_cards[0].preview_data_url.startswith("data:image/png;base64,") - - -def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None: - session_factory = build_session_factory() - with session_factory() as db: - ontology = SemanticOntologyService(db).parse( - OntologyParseRequest( + assert response.review_payload.document_cards[0].preview_kind == "image" + assert response.review_payload.document_cards[0].preview_data_url.startswith("data:image/png;base64,") + + +def test_user_agent_review_payload_prechecks_travel_receipts_against_policy_and_hides_old_briefs( + monkeypatch, +) -> None: + session_factory = build_session_factory() + with session_factory() as db: + employee = Employee( + employee_no="E-TRAVEL-001", + name="张三", + email="pytest-travel@example.com", + position="实施顾问", + grade="P4", + ) + db.add(employee) + db.flush() + db.add( + ExpenseClaim( + claim_no="EXP-HISTORY-001", + employee_id=employee.id, + employee_name=employee.name, + department_name="交付部", + expense_type="travel", + reason="历史差旅记录", + location="北京", + amount=Decimal("680.00"), + invoice_count=1, + occurred_at=datetime.now(UTC) - timedelta(days=7), + status="draft", + risk_flags_json=[{"label": "历史风险"}], + ) + ) + db.commit() + + query = "我去北京出差住酒店,上传了北京酒店发票,帮我生成差旅费报销草稿" + context = { + "name": "张三", + "attachment_names": ["北京酒店发票.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "北京酒店发票.png", + "document_type": "hotel_invoice", + "summary": "北京中心酒店 住宿 1 晚 金额 680 元", + "text": "北京中心酒店 住宿 1 晚 金额 680 元", + "avg_score": 0.96, + "document_fields": [ + {"key": "amount", "label": "金额", "value": "680"}, + {"key": "merchant", "label": "酒店", "value": "北京中心酒店"}, + ], + "warnings": [], + } + ], + } + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=query, + user_id="pytest-travel@example.com", + context_json=context, + ) + ) + service = UserAgentService(db) + monkeypatch.setattr( + service, + "_build_citations", + lambda payload: [ + UserAgentCitation( + source_type="rule", + code="rule.expense.travel_risk_control_standard", + title="差旅报销风险管控制度", + version="v1.1.0", + excerpt="住宿费按职级和城市分级限额执行。", + ) + ], + ) + + response = service.respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest-travel@example.com", + message=query, + ontology=ontology, + context_json=context, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + titles = [item.title for item in response.review_payload.risk_briefs] + assert "历史报销画像" not in titles + assert "制度注意事项" not in titles + hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title) + combined = f"{hotel_brief.title}\n{hotel_brief.content}\n{hotel_brief.detail}\n{hotel_brief.suggestion}" + assert "北京酒店发票.png" in combined + assert "P4-P5" in combined + assert "680.00" in combined + assert "450.00" in combined + assert "公司差旅费报销规则" in combined + assert "补充超标说明" in combined + slot_map = {item.key: item for item in response.review_payload.slot_cards} + assert slot_map["merchant_name"].value == "北京中心酒店" + + +def test_user_agent_review_payload_prefers_hotel_invoice_for_hotel_name() -> None: + session_factory = build_session_factory() + with session_factory() as db: + query = "我去北京出差,上传了火车票和酒店发票,帮我生成差旅费报销草稿" + context = { + "name": "张三", + "attachment_names": ["北京南站火车票.png", "北京中心酒店发票.png"], + "attachment_count": 2, + "ocr_documents": [ + { + "filename": "北京南站火车票.png", + "document_type": "train_ticket", + "summary": "广州南至北京南 高铁二等座 金额 560 元", + "text": "广州南至北京南 高铁二等座 金额 560 元", + "avg_score": 0.95, + "document_fields": [ + {"key": "amount", "label": "金额", "value": "560"}, + ], + "warnings": [], + }, + { + "filename": "北京中心酒店发票.png", + "document_type": "hotel_invoice", + "summary": "北京中心酒店 住宿 1 晚 金额 450 元", + "text": "北京中心酒店 住宿 1 晚 金额 450 元", + "avg_score": 0.96, + "document_fields": [ + {"key": "amount", "label": "金额", "value": "450"}, + {"key": "merchant", "label": "酒店名称", "value": "北京中心酒店"}, + ], + "warnings": [], + }, + ], + } + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=query, + user_id="pytest-travel-hotel-name@example.com", + context_json=context, + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest-travel-hotel-name@example.com", + message=query, + ontology=ontology, + context_json=context, + 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["merchant_name"].value == "北京中心酒店" + + +def test_user_agent_review_payload_prechecks_taxi_amount_against_rule_standard() -> None: + session_factory = build_session_factory() + with session_factory() as db: + query = "我去北京出差,上传了一张打车票,帮我生成差旅费报销草稿" + context = { + "name": "张三", + "grade": "P4", + "attachment_names": ["北京打车票.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "北京打车票.png", + "document_type": "taxi_receipt", + "summary": "北京网约车 打车票 支付金额 360 元", + "text": "北京网约车 打车票 支付金额 360 元", + "avg_score": 0.95, + "document_fields": [ + {"key": "amount", "label": "支付金额", "value": "360"}, + ], + "warnings": [], + } + ], + } + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=query, + user_id="pytest", + context_json=context, + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message=query, + ontology=ontology, + context_json=context, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + amount_brief = next(item for item in response.review_payload.risk_briefs if "交通费金额超标" in item.title) + combined = f"{amount_brief.title}\n{amount_brief.content}\n{amount_brief.detail}\n{amount_brief.suggestion}" + assert "北京打车票.png" in combined + assert "360.00" in combined + assert "300.00" in combined + assert "单笔交通金额" in combined + assert "报销场景提交与附件标准" in combined + assert amount_brief.level == "high" + assert any(item.title == "附件金额测算结果" for item in response.review_payload.risk_briefs) + + +def test_user_agent_review_payload_uses_finance_spreadsheet_hotel_amount_standard() -> None: + session_factory = build_session_factory() + with session_factory() as db: + AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value) + employee = Employee( + employee_no="E-TRAVEL-XLSX-001", + name="测算员工", + email="pytest-travel-xlsx@example.com", + position="基层经理", + grade="P4", + ) + db.add(employee) + db.commit() + + query = "测算员工去北京出差住宿,上传了北京酒店发票,帮我生成差旅费报销草稿" + context = { + "name": "测算员工", + "attachment_names": ["北京酒店发票.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "北京酒店发票.png", + "document_type": "hotel_invoice", + "summary": "北京酒店 住宿 1 晚 金额 480 元", + "text": "北京酒店 住宿 1 晚 金额 480 元", + "avg_score": 0.96, + "document_fields": [ + {"key": "amount", "label": "金额", "value": "480"}, + ], + "warnings": [], + } + ], + } + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=query, + user_id="pytest-travel-xlsx@example.com", + context_json=context, + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest-travel-xlsx@example.com", + message=query, + ontology=ontology, + context_json=context, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title) + combined = f"{hotel_brief.content}\n{hotel_brief.detail}" + assert "480.00" in combined + assert "450.00" in combined + assert "公司差旅费报销规则" in combined + + +def test_user_agent_review_payload_uses_spreadsheet_city_hotel_standard_not_default_tier() -> None: + session_factory = build_session_factory() + with session_factory() as db: + AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value) + + query = "我去张家口出差住宿,上传了张家口酒店发票,帮我生成差旅费报销草稿" + context = { + "name": "张三", + "grade": "P4", + "attachment_names": ["张家口酒店发票.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "张家口酒店发票.png", + "document_type": "hotel_invoice", + "summary": "张家口酒店 住宿 1 晚 金额 320 元", + "text": "张家口酒店 住宿 1 晚 金额 320 元", + "avg_score": 0.96, + "document_fields": [ + {"key": "amount", "label": "金额", "value": "320"}, + ], + "warnings": [], + } + ], + } + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=query, + user_id="pytest-travel-city@example.com", + context_json=context, + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest-travel-city@example.com", + message=query, + ontology=ontology, + context_json=context, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + hotel_brief = next(item for item in response.review_payload.risk_briefs if "住宿超标" in item.title) + combined = f"{hotel_brief.content}\n{hotel_brief.detail}" + assert "320.00" in combined + assert "300.00" in combined + assert "公司差旅费报销规则" in combined + + +def test_user_agent_review_payload_uses_finance_spreadsheet_meal_allowance_standard() -> None: + session_factory = build_session_factory() + with session_factory() as db: + AgentAssetService(db).list_assets(asset_type=AgentAssetType.RULE.value) + + query = "我去北京出差,上传了一张餐饮发票,帮我生成差旅费报销草稿" + context = { + "name": "张三", + "grade": "P4", + "attachment_names": ["北京餐饮发票.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "北京餐饮发票.png", + "document_type": "meal_receipt", + "summary": "北京餐饮发票 金额 90 元", + "text": "北京餐饮发票 金额 90 元", + "avg_score": 0.96, + "document_fields": [ + {"key": "amount", "label": "金额", "value": "90"}, + ], + "warnings": [], + } + ], + } + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=query, + user_id="pytest-travel-meal@example.com", + context_json=context, + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest-travel-meal@example.com", + message=query, + ontology=ontology, + context_json=context, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + meal_brief = next(item for item in response.review_payload.risk_briefs if "伙食补助标准" in item.title) + combined = f"{meal_brief.title}\n{meal_brief.content}\n{meal_brief.detail}\n{meal_brief.suggestion}" + assert "北京餐饮发票.png" in combined + assert "90.00" in combined + assert "65.00" in combined + assert "直辖市/特区" in combined + assert "公司差旅费报销规则" in combined + assert meal_brief.level == "high" + measurement = next(item for item in response.review_payload.risk_briefs if item.title == "附件金额测算结果") + assert "伙食补助标准 65.00" in measurement.detail + + +def test_user_agent_filters_deprecated_review_risk_briefs() -> None: + filtered = UserAgentService._filter_deprecated_review_risk_briefs( + [ + UserAgentReviewRiskBrief(title="历史报销画像", level="info", content="旧画像"), + UserAgentReviewRiskBrief(title="用户画像", level="info", content="旧画像"), + UserAgentReviewRiskBrief(title="制度注意事项", level="info", content="旧制度提示"), + UserAgentReviewRiskBrief(title="住宿超标待说明", level="high", content="保留"), + ] + ) + + assert [item.title for item in filtered] == ["住宿超标待说明"] + + +def test_user_agent_submission_blocked_risk_level_only_marks_amount_reasons_high() -> None: + assert UserAgentService._resolve_submission_blocked_risk_level("住宿金额超出当前职级差标") == "high" + assert UserAgentService._resolve_submission_blocked_risk_level("缺少直属领导或参与人员信息") == "warning" + + +def test_user_agent_review_payload_shows_ai_precheck_failure_in_main_message() -> None: + session_factory = build_session_factory() + with session_factory() as db: + query = "我去北京出差住酒店,帮我生成差旅费报销草稿并进入下一步提交" + context = { + "name": "张三", + "attachment_names": ["北京酒店发票.png"], + "attachment_count": 1, + "ocr_documents": [ + { + "filename": "北京酒店发票.png", + "document_type": "hotel_invoice", + "summary": "北京酒店 住宿 1 晚 金额 680 元", + "text": "北京酒店 住宿 1 晚 金额 680 元", + "avg_score": 0.94, + } + ], + } + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=query, + user_id="pytest", + context_json=context, + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message=query, + ontology=ontology, + context_json=context, + tool_payload={ + "submission_blocked": True, + "submission_blocked_reasons": ["住宿金额超出当前职级差标,且未补充超标说明。"], + }, + ) + ) + + assert response.review_payload is not None + assert response.answer == response.review_payload.body_message + assert response.answer.startswith("AI预审未通过:住宿金额超出当前职级差标") + assert "整改后再继续提交" in response.answer + assert response.review_payload.can_proceed is False + blocked_brief = next(item for item in response.review_payload.risk_briefs if item.title == "提交风险提示") + assert blocked_brief.level == "high" + assert not any(item.title == "AI预审未通过" for item in response.review_payload.risk_briefs) + + +def test_user_agent_prompts_existing_draft_association_choice_for_multi_documents() -> None: + session_factory = build_session_factory() + with session_factory() as db: + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( query="我上传了两张票据,帮我生成报销草稿", user_id="pytest", ) diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view.css b/web/src/assets/styles/views/travel-reimbursement-create-view.css index 5850952..5788ccb 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view.css @@ -813,6 +813,24 @@ color: #1d4ed8; } +.message-meta-chip.high { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; +} + +.message-meta-chip.medium { + background: #fffbeb; + color: #b45309; + border: 1px solid #fde68a; +} + +.message-meta-chip.low { + background: #eff6ff; + color: #1d4ed8; + border: 1px solid #bfdbfe; +} + .risk-chip, .message-risk-chip { background: #fff1f2; @@ -1262,6 +1280,10 @@ position: relative; } +.travel-calculator-anchor { + position: relative; +} + .tool-btn.composer-side-btn.active { border-color: rgba(59, 130, 246, 0.42); background: rgba(239, 246, 255, 0.96); @@ -1286,6 +1308,84 @@ 0 4px 12px rgba(15, 23, 42, 0.06); } +.travel-calculator-popover { + position: absolute; + bottom: calc(100% + 10px); + left: 0; + z-index: 30; + width: min(300px, calc(100vw - 48px)); + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid rgba(203, 213, 225, 0.92); + border-radius: 16px; + background: rgba(255, 255, 255, 0.98); + box-shadow: + 0 18px 40px rgba(15, 23, 42, 0.16), + 0 4px 12px rgba(15, 23, 42, 0.06); +} + +.travel-calculator-mini-head { + display: grid; + gap: 3px; +} + +.travel-calculator-mini-head strong { + color: #0f172a; + font-size: 13px; + font-weight: 900; +} + +.travel-calculator-mini-head span { + color: #64748b; + font-size: 11px; + font-weight: 750; +} + +.travel-calculator-form { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 8px; +} + +.travel-calculator-field { + display: grid; + gap: 6px; + min-width: 0; +} + +.travel-calculator-field span { + color: #64748b; + font-size: 11px; + font-weight: 800; +} + +.travel-calculator-field input { + width: 100%; + min-height: 36px; + padding: 0 10px; + border: 1px solid rgba(203, 213, 225, 0.92); + border-radius: 10px; + background: #fff; + color: #0f172a; + font-size: 12px; + font-weight: 700; +} + +.travel-calculator-field input:focus { + border-color: rgba(59, 130, 246, 0.46); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + outline: none; +} + +.travel-calculator-error { + margin: 0; + color: #dc2626; + font-size: 11px; + font-weight: 750; + line-height: 1.5; +} + .composer-date-mode-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1984,6 +2084,11 @@ transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease; } +.review-side-metric-card.wide { + grid-column: 1 / -1; + min-height: 104px; +} + .review-side-metric-card.invalid { border-color: rgba(239, 68, 68, 0.34); background: rgba(254, 242, 242, 0.72); @@ -2038,6 +2143,14 @@ font-weight: 700; } +.review-inline-textarea { + min-height: 82px; + padding: 9px 10px; + resize: vertical; + line-height: 1.55; + font-family: inherit; +} + .review-inline-input.invalid { border-color: rgba(239, 68, 68, 0.4); color: #b91c1c; @@ -2225,16 +2338,6 @@ background: linear-gradient(180deg, rgba(255, 255, 255, 0.84) 0%, rgba(255, 249, 238, 0.8) 100%); } -.review-side-risk-score { - color: #f97316; - font-size: 13px; - font-weight: 900; -} - -.review-side-risk-score.empty { - color: #94a3b8; -} - .review-side-risk-summary { margin: 0; color: #334155; @@ -2281,7 +2384,7 @@ font-size: 16px; } -.review-side-risk-item.warning .review-side-risk-icon { +.review-side-risk-item.medium .review-side-risk-icon { background: rgba(245, 158, 11, 0.14); color: #b45309; } @@ -2291,6 +2394,11 @@ color: #dc2626; } +.review-side-risk-item.low .review-side-risk-icon { + background: rgba(14, 165, 233, 0.12); + color: #0284c7; +} + .review-side-risk-copy { min-width: 0; display: grid; @@ -4201,93 +4309,6 @@ flex: 1 1 168px; } -.review-risk-detail-modal { - width: min(560px, calc(100vw - 40px)); - max-height: min(760px, calc(100vh - 48px)); - display: grid; - grid-template-rows: auto minmax(0, 1fr); - overflow: hidden; - border-radius: 24px; - border: 1px solid #e7eef6; - background: - radial-gradient(circle at top right, rgba(245, 158, 11, 0.10), transparent 28%), - linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%); - box-shadow: - 0 24px 80px rgba(15, 23, 42, 0.22), - 0 2px 12px rgba(15, 23, 42, 0.08); -} - -.review-risk-detail-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - padding: 22px 24px 18px; - border-bottom: 1px solid #eef2f7; -} - -.review-risk-detail-head h3 { - margin: 12px 0 0; - color: #0f172a; - font-size: 21px; - font-weight: 900; - line-height: 1.35; -} - -.review-risk-detail-body { - min-height: 0; - display: grid; - gap: 14px; - padding: 18px 24px 24px; - overflow-y: auto; -} - -.review-risk-detail-level { - width: fit-content; - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 30px; - padding: 0 11px; - border-radius: 999px; - background: rgba(14, 165, 233, 0.12); - color: #0284c7; - font-size: 12px; - font-weight: 900; -} - -.review-risk-detail-level.warning { - background: rgba(245, 158, 11, 0.14); - color: #b45309; -} - -.review-risk-detail-level.high { - background: rgba(239, 68, 68, 0.12); - color: #dc2626; -} - -.review-risk-detail-section { - display: grid; - gap: 8px; - padding: 14px; - border: 1px solid rgba(226, 232, 240, 0.92); - border-radius: 16px; - background: rgba(255, 255, 255, 0.72); -} - -.review-risk-detail-section strong { - color: #0f172a; - font-size: 13px; - font-weight: 900; -} - -.review-risk-detail-section p { - margin: 0; - color: #475569; - font-size: 13px; - line-height: 1.7; -} - .review-edit-modal { max-height: min(860px, calc(100vh - 48px)); display: grid; @@ -4723,6 +4744,10 @@ min-height: 32px; } + .travel-calculator-form { + grid-template-columns: 1fr; + } + .dialog-toolbar { padding: 16px 16px 12px; } diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js index b748943..841523b 100644 --- a/web/src/composables/useRequests.js +++ b/web/src/composables/useRequests.js @@ -21,7 +21,7 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ ]) const REIMBURSEMENT_PROGRESS_LABELS = [ - '保存草稿', + '创建单据', '待提交', 'AI预审', '直属领导审批', @@ -270,6 +270,21 @@ function normalizeText(value) { return String(value || '').trim() } +function isEmailLike(value) { + return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value)) +} + +function resolveDisplayName(...values) { + for (const value of values) { + const normalized = normalizeText(value) + if (normalized && !isEmailLike(normalized)) { + return normalized + } + } + + return '' +} + function getRiskFlags(claim) { return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [] } @@ -344,7 +359,7 @@ function buildCompletedStepMeta(claim, label) { const stepLabel = normalizeText(label) const employeeName = normalizeText(claim?.employee_name) || '申请人' - if (stepLabel === '保存草稿') { + if (stepLabel === '创建单据') { const createdAt = formatDateTime(claim?.created_at) return buildProgressStepMeta(`${employeeName}创建`, createdAt) } @@ -362,7 +377,12 @@ function buildCompletedStepMeta(claim, label) { if (stepLabel === '直属领导审批' || stepLabel === '财务审批') { const approvalEvent = findApprovalEventForStep(claim, stepLabel) if (approvalEvent) { - const operator = normalizeText(approvalEvent.operator) || (stepLabel === '财务审批' ? '财务' : '审批人') + const operator = resolveDisplayName( + approvalEvent.operator, + approvalEvent.operator_name, + approvalEvent.operatorName, + stepLabel === '直属领导审批' ? claim?.manager_name : '' + ) || (stepLabel === '财务审批' ? '财务' : '直属领导') const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt) return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim()) } @@ -383,7 +403,7 @@ function buildCompletedStepMeta(claim, label) { function resolveCurrentStepStartedAt(claim, label) { const stepLabel = normalizeText(label) - if (stepLabel === '保存草稿') { + if (stepLabel === '创建单据') { return claim?.created_at } if (stepLabel === '待提交') { @@ -539,7 +559,7 @@ export function mapExpenseClaimToRequest(claim) { employeeName: String(claim?.employee_name || '').trim() || '待补充', employeePosition: String(claim?.employee_position || '').trim(), employeeGrade: String(claim?.employee_grade || '').trim(), - managerName: String(claim?.manager_name || '').trim(), + managerName: resolveDisplayName(claim?.manager_name), roleLabels: Array.isArray(claim?.role_labels) ? claim.role_labels.filter(Boolean) : [], entity: '', typeCode, diff --git a/web/src/services/reimbursements.js b/web/src/services/reimbursements.js index 9c6ef09..a4773d3 100644 --- a/web/src/services/reimbursements.js +++ b/web/src/services/reimbursements.js @@ -12,6 +12,13 @@ export function fetchExpenseClaimDetail(claimId) { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`) } +export function calculateTravelReimbursement(payload = {}) { + return apiRequest('/reimbursements/travel-calculator', { + method: 'POST', + body: JSON.stringify(payload) + }) +} + export function createExpenseClaimItem(claimId, payload = {}) { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/items`, { method: 'POST', diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index 7e34978..5d3325e 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -19,8 +19,9 @@ const VIEW_ROLE_RULES = { employees: ['manager'], settings: ['manager'] } -const CLAIM_MANAGER_ROLE_CODES = new Set(['finance', 'executive']) +const CLAIM_MANAGER_ROLE_CODES = new Set(['executive']) const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver']) +const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver']) function normalizedRoleCodes(user) { if (!user) { @@ -60,6 +61,14 @@ export function canReturnExpenseClaims(user) { return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode)) } +export function canApproveLeaderExpenseClaims(user) { + if (Boolean(user?.isAdmin)) { + return true + } + + return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_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 index 102019b..961ad78 100644 --- a/web/src/utils/approvalInbox.js +++ b/web/src/utils/approvalInbox.js @@ -1,5 +1,9 @@ import { mapExpenseClaimToRequest } from '../composables/useRequests.js' -import { canManageExpenseClaims } from './accessControl.js' +import { + canApproveLeaderExpenseClaims, + canManageExpenseClaims, + isFinanceUser +} from './accessControl.js' export function canProcessApprovalRequest(request, currentUser) { const node = String(request?.workflowNode || '').trim() @@ -14,12 +18,18 @@ export function canProcessApprovalRequest(request, currentUser) { return true } - return ( + if (isFinanceUser(currentUser) && node.includes('财务')) { + return true + } + + const isLeaderApprovalNode = ( node.includes('直属领导') || node.includes('领导审批') || node.includes('部门负责人') || node.includes('负责人审批') ) + + return canApproveLeaderExpenseClaims(currentUser) && isLeaderApprovalNode } export function listPendingApprovalRequests(claimsPayload, currentUser) { diff --git a/web/src/utils/requestViewModel.js b/web/src/utils/requestViewModel.js index ceae2af..ebb5199 100644 --- a/web/src/utils/requestViewModel.js +++ b/web/src/utils/requestViewModel.js @@ -181,6 +181,21 @@ function normalizeRoleLabels(value) { return text ? [text] : [] } +function isEmailLike(value) { + return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(value || '').trim()) +} + +function resolveDisplayName(...values) { + for (const value of values) { + const normalized = String(value || '').trim() + if (normalized && !isEmailLike(normalized)) { + return normalized + } + } + + return '' +} + export function normalizeRequestForUi(request) { if (!request) { return null @@ -255,7 +270,12 @@ export function normalizeRequestForUi(request) { String(request.profilePosition || request.employeePosition || request.employee_position || request.position || '').trim() || '待补充', profileGrade: String(request.profileGrade || request.employeeGrade || request.employee_grade || request.grade || '').trim() || '待补充', - profileManager: String(request.profileManager || request.managerName || request.manager_name || request.manager || '').trim() || '待补充', + profileManager: resolveDisplayName( + request.profileManager, + request.managerName, + request.manager_name, + request.manager + ) || '待补充', roleLabels, profileAvatar: String(request.person || request.applicant || request.employeeName || '申').trim().slice(0, 1) || '申' diff --git a/web/src/views/TravelReimbursementCreateView.vue b/web/src/views/TravelReimbursementCreateView.vue index 694b3a9..c2924c3 100644 --- a/web/src/views/TravelReimbursementCreateView.vue +++ b/web/src/views/TravelReimbursementCreateView.vue @@ -121,7 +121,14 @@
- {{ item }} + + {{ item }} +
@@ -548,6 +555,72 @@
+
+ + +
@@ -783,7 +856,8 @@ :class="{ editable: item.editor, editing: reviewInlineEditorKey === item.key, - invalid: Boolean(reviewInlineErrors[item.key]) + invalid: Boolean(reviewInlineErrors[item.key]), + wide: item.wide }" @click="openInlineReviewEditor(item.key)" > @@ -831,6 +905,19 @@ @keydown.enter.prevent="commitInlineReviewEditor" /> + @@ -1222,41 +1305,6 @@ @confirm="confirmCancelReview" /> - -
-
-
-
- {{ reviewRiskDetailDialog.item?.sourceLabel || 'AI预审' }} -

{{ reviewRiskDetailDialog.item?.title || '风险提示' }}

-
- -
- -
-
- - {{ reviewRiskDetailDialog.item?.levelLabel || '提示' }} -
-
- 提示情况 -

{{ reviewRiskDetailDialog.item?.summary }}

-
-
- 详细解释 -

{{ reviewRiskDetailDialog.item?.detail }}

-
-
- 处理建议 -

{{ reviewRiskDetailDialog.item?.suggestion }}

-
-
-
-
-
-
diff --git a/web/src/views/TravelRequestDetailView.vue b/web/src/views/TravelRequestDetailView.vue index dc67f0e..6d880b6 100644 --- a/web/src/views/TravelRequestDetailView.vue +++ b/web/src/views/TravelRequestDetailView.vue @@ -375,15 +375,15 @@
-

领导意见

+

{{ approvalOpinionTitle }}

- 审批通过后将流转至财务审批。 + {{ approvalOpinionHint }} {{ leaderOpinion.length }}/500
@@ -620,10 +620,10 @@
下一节点 - 财务审批 + {{ approvalNextStage }}
- 领导意见 + {{ approvalOpinionTitle }} {{ leaderOpinion.trim() || '未填写' }}
diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index c9959b2..fb20314 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -15,6 +15,7 @@ import { TRANSPORT_KEYWORD_PATTERN } from '../../utils/reimbursementTextInference.js' import { + calculateTravelReimbursement, fetchExpenseClaimAttachmentAsset, fetchExpenseClaimDetail, fetchExpenseClaimItemAttachmentMeta, @@ -55,15 +56,15 @@ const REVIEW_RISK_LEVEL_META = { icon: 'mdi mdi-alert-octagon-outline', suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。' }, - warning: { - label: '需关注', + medium: { + label: '中风险', icon: 'mdi mdi-alert-circle-outline', suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。' }, - info: { - label: '提示', + low: { + label: '低风险', icon: 'mdi mdi-information-outline', - suggestion: '该项主要用于辅助判断,可结合当前单据情况继续核对。' + suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。' } } @@ -310,6 +311,7 @@ const FLOW_MISSING_SLOT_LABELS = { participants: '参与人员', attachments: '票据附件' } +const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意'] let messageSeed = 0 function nowTime() { @@ -1317,6 +1319,7 @@ function createEmptyInlineReviewState() { return { occurred_date: '', amount: '', + transport_type: '', scene_label: '', reason_value: '', customer_name: '', @@ -1330,6 +1333,67 @@ function createEmptyInlineReviewState() { } } +function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) { + const expenseType = resolveExpenseTypeCode( + inlineState?.expense_type || + buildReviewSlotMap(reviewPayload).expense_type?.normalized_value || + buildReviewSlotMap(reviewPayload).expense_type?.value || + '' + ) + if (['travel', 'hotel', 'transport'].includes(expenseType)) { + return true + } + + return (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []).some((item) => { + const documentType = String(item?.document_type || '').trim().toLowerCase() + const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '') + return ( + ['flight_itinerary', 'train_ticket', 'hotel_invoice', 'taxi_receipt', 'transport_receipt'].includes(documentType) || + ['travel', 'hotel', 'transport'].includes(suggestedType) + ) + }) +} + +function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + const labels = [] + + const appendLabel = (label) => { + if (label && !labels.includes(label)) { + labels.push(label) + } + } + + for (const item of documents) { + const documentType = String(item?.document_type || '').trim().toLowerCase() + const text = [ + item?.filename, + item?.summary, + item?.scene_label, + item?.suggested_expense_type, + ...(Array.isArray(item?.fields) ? item.fields.map((field) => `${field?.label || ''}${field?.value || ''}`) : []) + ].join(' ') + const compact = text.replace(/\s+/g, '') + + if (documentType === 'flight_itinerary' || /飞机|机票|航班|登机牌/.test(compact)) { + appendLabel('飞机') + } else if (documentType === 'train_ticket' || /火车|高铁|动车|铁路|车票/.test(compact)) { + appendLabel('火车/高铁') + } else if (documentType === 'taxi_receipt' || /打车|网约车|出租车|滴滴|的士/.test(compact)) { + appendLabel('打车/网约车') + } + } + + const fallback = String(fallbackText || '').replace(/\s+/g, '') + if (!labels.length) { + if (/飞机|机票|航班/.test(fallback)) appendLabel('飞机') + if (/火车|高铁|动车|铁路/.test(fallback)) appendLabel('火车/高铁') + if (/打车|网约车|出租车|滴滴|的士/.test(fallback)) appendLabel('打车/网约车') + } + + return labels.join('、') +} + function buildClientTimeContext() { const now = new Date() const locale = @@ -1434,7 +1498,11 @@ function resolveReviewMissingSlotCards(reviewPayload) { } function resolveReviewRiskBriefs(reviewPayload) { - return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : [] + if (!Array.isArray(reviewPayload?.risk_briefs)) return [] + return reviewPayload.risk_briefs.filter((item) => { + const title = String(item?.title || '').trim() + return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword)) + }) } function formatConfidenceLabel(value) { @@ -1792,7 +1860,7 @@ function buildReviewAlertChips(reviewPayload) { chips.push({ key: item.key, label: buildReviewAlertLabel(item.key, expenseTypeLabel), - tone: item.key === 'attachments' ? 'danger' : 'warning' + tone: 'warning' }) } @@ -1830,7 +1898,7 @@ function buildReviewTodoItems(reviewPayload) { title: config.title || item.label, hint: item.hint || config.hint || `请补充${item.label}`, status: config.status || '待补充', - tone: item.key === 'attachments' ? 'danger' : 'warning' + tone: 'warning' } }) } @@ -2075,6 +2143,9 @@ function buildInlineReviewState(reviewPayload) { editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || '' ).trim() const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType) + const transportType = String( + editFieldMap.transport_type?.value || resolveReviewTravelTransportType(reviewPayload, reasonValue) + ).trim() return { occurred_date: String( @@ -2083,6 +2154,7 @@ function buildInlineReviewState(reviewPayload) { amount: normalizeAmountValue( String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim() ), + transport_type: transportType, scene_label: sceneLabel, reason_value: sceneLabel === REVIEW_SCENE_OTHER_OPTION @@ -2129,6 +2201,56 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi : totalAttachmentCount > 0 ? `已上传 ${totalAttachmentCount} 份` : buildReviewAttachmentStatus(reviewPayload) + if (isTravelReviewPayload(reviewPayload, inlineState)) { + return [ + { + key: 'occurred_date', + label: '发生时间', + value: String(inlineState.occurred_date || '').trim() || '待补充', + icon: 'mdi mdi-calendar-month-outline', + editor: 'date', + modelKey: 'occurred_date', + placeholder: `例如 ${DATE_INPUT_FORMAT}` + }, + { + key: 'amount', + label: '金额', + value: formatAmountDisplay(inlineState.amount) || '待补充', + icon: 'mdi mdi-cash', + editor: 'amount', + modelKey: 'amount', + placeholder: '例如 200.00' + }, + { + key: 'transport_type', + label: '交通类型', + value: String(inlineState.transport_type || '').trim() || '待确认', + icon: 'mdi mdi-train-car', + editor: 'text', + modelKey: 'transport_type', + placeholder: '例如 火车/高铁、飞机' + }, + { + key: 'hotel_name', + label: '酒店名称', + value: String(inlineState.merchant_name || '').trim() || '待补充', + icon: 'mdi mdi-bed-outline', + editor: 'text', + modelKey: 'merchant_name', + placeholder: '请输入酒店名称' + }, + { + key: 'travel_purpose', + label: '出差事宜', + value: String(inlineState.reason_value || '').trim() || '待补充', + icon: 'mdi mdi-briefcase-edit-outline', + editor: 'textarea', + modelKey: 'reason_value', + placeholder: '请填写本次出差的具体工作内容或业务意图', + wide: true + } + ] + } const cards = [ { key: 'occurred_date', @@ -2319,14 +2441,6 @@ function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInli ) } -function buildReviewRiskScore(reviewPayload) { - const score = Number(reviewPayload?.risk_score) - if (!Number.isFinite(score) || score <= 0) { - return null - } - return Math.max(0, Math.min(100, Math.round(score))) -} - function buildMissingRiskLine(slotKey, expenseTypeLabel = '') { if (slotKey === 'customer_name') { return expenseTypeLabel === '业务招待费' @@ -2353,17 +2467,30 @@ function buildMissingRiskLine(slotKey, expenseTypeLabel = '') { function buildReviewRiskSummary(reviewPayload) { if (resolveReviewRiskBriefs(reviewPayload).length) { - return '当前识别到了合规提醒,提交前建议逐项核对。' + return '当前识别到了风险提示,点击任一风险点会在主对话中展开规则依据和整改建议。' } - return '当前版本暂未生成风险评分结果。' + return '当前没有需要额外处理的结构化风险点。' } function normalizeReviewRiskLevel(level) { const normalized = String(level || '').trim().toLowerCase() if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high' - if (normalized === 'warn' || normalized === 'medium') return 'warning' - if (normalized === 'high' || normalized === 'warning' || normalized === 'info') return normalized - return 'info' + if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium' + if (normalized === 'info' || normalized === 'notice' || normalized === 'low') return 'low' + if (normalized === 'high') return normalized + return 'low' +} + +function normalizeReviewRiskTitle(title, fallbackTitle) { + const normalized = String(title || '').trim() + const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示' + if (!normalized) return fallback + const cleaned = normalized + .replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示') + .replace(/(高风险|中风险|低风险)/g, '') + .replace(/^[::\-—\s]+|[::\-—\s]+$/g, '') + .trim() + return cleaned || fallback } function buildReviewRiskItems(reviewPayload) { @@ -2374,9 +2501,9 @@ function buildReviewRiskItems(reviewPayload) { const detail = String(brief?.detail || '').trim() const suggestion = String(brief?.suggestion || '').trim() const level = normalizeReviewRiskLevel(brief?.level) - const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.info + const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示' - const normalizedTitle = title || fallbackTitle + const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle) const summary = content || normalizedTitle if (!normalizedTitle && !summary) return null @@ -2389,12 +2516,30 @@ function buildReviewRiskItems(reviewPayload) { level, levelLabel: meta.label, icon: meta.icon, - sourceLabel: title === '历史报销画像' ? '历史记录' : 'AI预审', + sourceLabel: meta.label, suggestion: suggestion || meta.suggestion } }) .filter(Boolean) - .slice(0, 6) +} + +function buildReviewRiskConversationText(item) { + const title = String(item?.title || '风险提示').trim() + const summary = String(item?.summary || '').trim() + const detail = String(item?.detail || '').trim() + const suggestion = String(item?.suggestion || '').trim() + const lines = [`${title}`] + + if (summary) { + lines.push('', `风险点:${summary}`) + } + if (detail && detail !== summary) { + lines.push('', `规则依据:${detail}`) + } + if (suggestion) { + lines.push('', `修改建议:${suggestion}`) + } + return lines.join('\n') } function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) { @@ -2489,6 +2634,7 @@ function normalizeInlineReviewComparableState(state) { return { occurred_date: String(source.occurred_date || '').trim(), amount: String(source.amount || '').trim(), + transport_type: String(source.transport_type || '').trim(), scene_label: String(source.scene_label || '').trim(), reason_value: String(source.reason_value || '').trim(), customer_name: String(source.customer_name || '').trim(), @@ -2512,6 +2658,9 @@ function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = []) if (base.amount !== next.amount) { lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`) } + if (base.transport_type !== next.transport_type) { + lines.push(`交通类型 ${next.transport_type || '待确认'}`) + } if (base.scene_label !== next.scene_label) { lines.push(`场景 ${next.scene_label || '待补充'}`) } @@ -2543,6 +2692,7 @@ function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = []) const fieldConfigs = [ { key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' }, { key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' }, + { key: 'transport_type', label: '交通类型', format: (value) => value || '待确认' }, { key: 'scene_label', label: '场景', format: (value) => value || '待补充' }, { key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' }, { key: 'location', label: '业务地点', format: (value) => value || '待补充' }, @@ -2611,6 +2761,7 @@ function mergeInlineReviewFields(baseFields, inlineState) { const merged = cloneReviewEditFields(baseFields) const updateMap = { expense_type: inlineState.expense_type, + transport_type: inlineState.transport_type, occurred_date: inlineState.occurred_date, amount: inlineState.amount, customer_name: inlineState.customer_name, @@ -2699,7 +2850,7 @@ function buildReviewRiskHint(reviewPayload) { if (!riskBriefs.length) { return '' } - return '这些是我根据当前场景和历史记录给你的提醒,提交前建议顺手核对一下。' + return '这些是我根据当前单据信息、票据识别结果和规则口径给出的风险提示,提交前建议顺手核对一下。' } function buildReviewActionHint(reviewPayload) { @@ -2839,6 +2990,14 @@ export default { const composerRangeEndDate = ref(formatDateInputValue()) const composerBusinessTimeTags = ref([]) const composerBusinessTimeDraftTouched = ref(false) + const travelCalculatorOpen = ref(false) + const travelCalculatorBusy = ref(false) + const travelCalculatorError = ref('') + const travelCalculatorResult = ref(null) + const travelCalculatorForm = ref({ + days: '1', + location: '' + }) const attachedFiles = ref([]) const composerFilesExpanded = ref(false) const submitting = ref(false) @@ -2882,10 +3041,6 @@ export default { const activeReviewDocumentIndex = ref(0) const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW) const insightPanelCollapsed = ref(false) - const reviewRiskDetailDialog = ref({ - open: false, - item: null - }) const documentPreviewDialog = ref({ open: false, filename: '', @@ -2921,6 +3076,11 @@ export default { && composerRangeStartDate.value <= composerRangeEndDate.value ) }) + const travelCalculatorCanSubmit = computed(() => + !travelCalculatorBusy.value + && Number(travelCalculatorForm.value.days) >= 1 + && Boolean(String(travelCalculatorForm.value.location || '').trim()) + ) const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE) const completedFlowStepCount = computed( () => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length @@ -3040,10 +3200,9 @@ export default { ).length > 0 ) const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value)) - const reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value)) const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value)) const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value)) - const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length) + const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length) const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0) const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value) const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0) @@ -3301,7 +3460,9 @@ export default { activeReviewDocumentIndex.value = nextDocumentDrafts.length ? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1) : 0 - reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW + reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length + ? REVIEW_DRAWER_MODE_RISK + : REVIEW_DRAWER_MODE_REVIEW reviewInlinePendingFiles.value = [] reviewInlineEditorKey.value = '' reviewInlineErrors.value = {} @@ -3975,6 +4136,9 @@ export default { function toggleComposerDatePicker() { composerDatePickerOpen.value = !composerDatePickerOpen.value + if (composerDatePickerOpen.value) { + travelCalculatorOpen.value = false + } } function closeComposerDatePicker() { @@ -3998,13 +4162,21 @@ export default { } function handleComposerDatePickerOutside(event) { - if (!composerDatePickerOpen.value) { + if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) { return } if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) { return } - composerDatePickerOpen.value = false + if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) { + return + } + if (composerDatePickerOpen.value) { + composerDatePickerOpen.value = false + } + if (travelCalculatorOpen.value && !travelCalculatorBusy.value) { + travelCalculatorOpen.value = false + } } async function applyComposerDateSelection() { @@ -4026,6 +4198,142 @@ export default { composerTextareaRef.value?.focus() } + function resolveTravelCalculatorInitialDays() { + const businessTimeContext = buildComposerBusinessTimeContext() + if (!businessTimeContext) { + return 1 + } + const startDate = businessTimeContext.start_date + const endDate = businessTimeContext.end_date || startDate + if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) { + return 1 + } + const startAt = Date.parse(`${startDate}T00:00:00Z`) + const endAt = Date.parse(`${endDate}T00:00:00Z`) + if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) { + return 1 + } + return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1) + } + + function resolveTravelCalculatorInitialLocation() { + const slotMap = buildReviewSlotMap(activeReviewPayload.value) + const candidates = [ + reviewInlineForm.value.location, + slotMap.business_location?.normalized_value, + slotMap.business_location?.value, + slotMap.location?.normalized_value, + slotMap.location?.value, + currentUser.value?.location + ] + return String(candidates.find((item) => String(item || '').trim()) || '').trim() + } + + function openTravelCalculator() { + closeComposerDatePicker() + travelCalculatorError.value = '' + travelCalculatorResult.value = null + travelCalculatorForm.value = { + days: String(resolveTravelCalculatorInitialDays()), + location: resolveTravelCalculatorInitialLocation() + } + travelCalculatorOpen.value = true + } + + function toggleTravelCalculator() { + if (travelCalculatorOpen.value) { + closeTravelCalculator() + return + } + openTravelCalculator() + } + + function closeTravelCalculator() { + if (travelCalculatorBusy.value) { + return + } + travelCalculatorOpen.value = false + } + + function formatTravelCalculatorMoney(value) { + const amount = Number(value) + if (!Number.isFinite(amount)) { + return String(value || '0') + } + return amount.toFixed(2) + } + + function buildTravelCalculatorResultText(result) { + const days = Number(result?.days) || 1 + const location = String(result?.location || '').trim() || '未填写地点' + const matchedCity = String(result?.matched_city || location).trim() + const grade = String(result?.grade || '').trim() || '当前职级' + const gradeBandLabel = String(result?.grade_band_label || result?.grade_band || '').trim() || '对应档位' + const allowanceRegion = String(result?.allowance_region || '').trim() || '默认区域' + const ruleName = String(result?.rule_name || '').trim() || '公司差旅费报销规则' + const ruleVersion = String(result?.rule_version || '').trim() + const hotelRate = formatTravelCalculatorMoney(result?.hotel_rate) + const hotelAmount = formatTravelCalculatorMoney(result?.hotel_amount) + const mealRate = formatTravelCalculatorMoney(result?.meal_allowance_rate) + const basicRate = formatTravelCalculatorMoney(result?.basic_allowance_rate) + const allowanceRate = formatTravelCalculatorMoney(result?.total_allowance_rate) + const allowanceAmount = formatTravelCalculatorMoney(result?.allowance_amount) + const totalAmount = formatTravelCalculatorMoney(result?.total_amount) + const ruleVersionText = ruleVersion ? `(${ruleVersion})` : '' + const user = currentUser.value || {} + const displayName = String(user.name || user.display_name || user.username || '').trim() + const greeting = displayName ? `您好,${displayName},` : '您好,' + + return [ + `${greeting}根据您输入的地点和天数,我匹配到您要出差的地区为:**${matchedCity}**,出差天数为:**${days} 天**,我根据公司的报销文件给您预估金额如下:`, + '', + `**参考可报销合计:${totalAmount} 元**`, + '', + '| 项目 | 标准口径 | 天数 | 小计 |', + '| --- | --- | ---: | ---: |', + `| 住宿费 | ${matchedCity} / ${grade}(${gradeBandLabel})标准:${hotelRate} 元/天 | ${days} | ${hotelAmount} 元 |`, + `| 出差补贴 | ${allowanceRegion}:伙食 ${mealRate} 元 + 基本 ${basicRate} 元 = ${allowanceRate} 元/天 | ${days} | ${allowanceAmount} 元 |`, + '', + '**计算过程**', + `1. 住宿费:${hotelRate} × ${days} = ${hotelAmount} 元`, + `2. 出差补贴:(${mealRate} + ${basicRate}) × ${days} = ${allowanceRate} × ${days} = ${allowanceAmount} 元`, + `3. 合计:${hotelAmount} + ${allowanceAmount} = ${totalAmount} 元`, + '', + `**规则依据**:${ruleName}${ruleVersionText}。出差地点“${location}”匹配为“${matchedCity}”,当前职级“${grade}”匹配“${gradeBandLabel}”档。`, + '', + '这个结果是提交前的规则测算参考,最终仍以实际票据、审批意见和财务复核口径为准。' + ].join('\n') + } + + async function submitTravelCalculator() { + if (!travelCalculatorCanSubmit.value) { + travelCalculatorError.value = '请填写出差天数和地点后再计算。' + return + } + + travelCalculatorBusy.value = true + travelCalculatorError.value = '' + try { + const user = currentUser.value || {} + const payload = await calculateTravelReimbursement({ + days: Math.max(1, Number.parseInt(String(travelCalculatorForm.value.days || '1'), 10) || 1), + location: String(travelCalculatorForm.value.location || '').trim(), + grade: String(user.grade || '').trim() + }) + travelCalculatorResult.value = payload + messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload), [], { + meta: ['差旅计算器'], + metaTone: 'low' + })) + travelCalculatorOpen.value = false + nextTick(scrollToBottom) + } catch (error) { + travelCalculatorError.value = error?.message || '差旅金额测算失败,请稍后重试。' + } finally { + travelCalculatorBusy.value = false + } + } + function rememberFilePreviews(filePreviews) { reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews) } @@ -4378,6 +4686,7 @@ export default { ...reviewInlineForm.value, occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(), amount: String(reviewInlineForm.value.amount || '').trim(), + transport_type: String(reviewInlineForm.value.transport_type || '').trim(), customer_name: String(reviewInlineForm.value.customer_name || '').trim(), location: String(reviewInlineForm.value.location || '').trim(), merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(), @@ -4473,19 +4782,13 @@ export default { }) } - function openReviewRiskDetail(item) { + function appendReviewRiskBriefToConversation(item) { if (!item) return - reviewRiskDetailDialog.value = { - open: true, - item - } - } - - function closeReviewRiskDetail() { - reviewRiskDetailDialog.value = { - ...reviewRiskDetailDialog.value, - open: false - } + messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item), [], { + meta: [item.sourceLabel || item.levelLabel || '风险提示'], + metaTone: item.level || 'low' + })) + nextTick(scrollToBottom) } function goReviewDocument(direction) { @@ -5267,11 +5570,9 @@ export default { REVIEW_OTHER_CATEGORY_OPTIONS, workbenchVisible, reviewPanelConfidence, - reviewRiskScore, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, - reviewRiskDetailDialog, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, @@ -5281,6 +5582,12 @@ export default { reviewCancelDialogOpen, reviewEditDialogOpen, uploadDecisionDialogOpen, + travelCalculatorOpen, + travelCalculatorBusy, + travelCalculatorError, + travelCalculatorResult, + travelCalculatorForm, + travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, @@ -5331,6 +5638,10 @@ export default { resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, + openTravelCalculator, + toggleTravelCalculator, + closeTravelCalculator, + submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, @@ -5357,8 +5668,7 @@ export default { selectReviewCategory, selectReviewOtherCategory, queryDraftByClaimNo, - openReviewRiskDetail, - closeReviewRiskDetail, + appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, diff --git a/web/src/views/scripts/TravelRequestDetailView.js b/web/src/views/scripts/TravelRequestDetailView.js index fc21e00..931646a 100644 --- a/web/src/views/scripts/TravelRequestDetailView.js +++ b/web/src/views/scripts/TravelRequestDetailView.js @@ -17,7 +17,12 @@ import { uploadExpenseClaimItemAttachment, updateExpenseClaimItem } from '../../services/reimbursements.js' -import { canManageExpenseClaims, canReturnExpenseClaims } from '../../utils/accessControl.js' +import { + canApproveLeaderExpenseClaims, + canManageExpenseClaims, + canReturnExpenseClaims, + isFinanceUser +} from '../../utils/accessControl.js' import { normalizeRequestForUi } from '../../utils/requestViewModel.js' import { buildAiAdviceViewModel, @@ -82,7 +87,7 @@ function resolveLocationDisplay(value, expenseType) { function buildFallbackProgressSteps() { return [ - { index: 1, label: '保存草稿', time: '已完成', done: true, active: true }, + { index: 1, label: '创建单据', time: '已完成', done: true, active: true }, { index: 2, label: '待提交', time: '进行中', active: true, current: true }, { index: 3, label: 'AI预审', time: '待处理' }, { index: 4, label: '直属领导审批', time: '待处理' }, @@ -486,20 +491,51 @@ export default { const node = String(request.value.node || request.value.approvalStage || '').trim() return node === '直属领导审批' }) - const showLeaderApprovalPanel = computed(() => - Boolean(props.approvalMode) - && request.value.approvalKey === 'in_progress' - && isDirectManagerApprovalStage.value - && Boolean(request.value.claimId) - ) + const isFinanceApprovalStage = computed(() => { + const node = String(request.value.node || request.value.approvalStage || '').trim() + return node === '财务审批' + }) const canReturnRequest = computed(() => canReturnExpenseClaims(currentUser.value) && request.value.approvalKey === 'in_progress' && Boolean(request.value.claimId) ) const canApproveRequest = computed(() => - showLeaderApprovalPanel.value - && canReturnExpenseClaims(currentUser.value) + Boolean(props.approvalMode) + && request.value.approvalKey === 'in_progress' + && Boolean(request.value.claimId) + && ( + ( + isDirectManagerApprovalStage.value + && canApproveLeaderExpenseClaims(currentUser.value) + ) + || ( + isFinanceApprovalStage.value + && isFinanceUser(currentUser.value) + ) + ) + ) + const showLeaderApprovalPanel = computed(() => canApproveRequest.value) + const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见')) + const approvalOpinionPlaceholder = computed(() => + isFinanceApprovalStage.value + ? '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。' + : '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。' + ) + const approvalOpinionHint = computed(() => + isFinanceApprovalStage.value ? '审核通过后将进入归档入账。' : '审批通过后将流转至财务审批。' + ) + const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批')) + const approvalConfirmDescription = computed(() => + isFinanceApprovalStage.value + ? '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。' + : '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。' + ) + const approvalNextStage = computed(() => (isFinanceApprovalStage.value ? '归档入账' : '财务审批')) + const approvalSuccessToast = computed(() => + isFinanceApprovalStage.value + ? `${request.value.id} 已完成财务终审,进入归档入账。` + : `${request.value.id} 已审批通过,流转至财务审批。` ) const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据')) const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`) @@ -564,7 +600,7 @@ export default { }, { key: 'date', - label: '日期', + label: '单据申请日期', value: request.value.applyTime || request.value.occurredDisplay, icon: 'mdi mdi-calendar-month-outline', valueClass: '' @@ -1011,12 +1047,23 @@ export default { try { const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file) expenseAttachmentMeta[item.id] = payload?.attachment || null - applyLocalExpenseItemPatch(item.id, { + const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount) + const itemPatch = { invoiceId: String(payload?.invoice_id || '').trim(), attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim() + } + if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) { + itemPatch.itemAmount = recognizedItemAmount + itemPatch.amount = formatCurrency(recognizedItemAmount) + } + applyLocalExpenseItemPatch(item.id, { + ...itemPatch }) if (editingExpenseId.value === item.id) { expenseEditor.invoiceId = String(payload?.invoice_id || '').trim() + if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) { + expenseEditor.itemAmount = String(recognizedItemAmount) + } } emit('request-updated', { claimId: request.value.claimId }) @@ -1322,7 +1369,7 @@ export default { } if (!canApproveRequest.value) { - toast('当前节点不支持领导审批通过。') + toast('当前节点暂不支持审批通过。') return } @@ -1345,7 +1392,7 @@ export default { } if (!canApproveRequest.value) { - toast('当前节点不支持领导审批通过。') + toast('当前节点暂不支持审批通过。') approveConfirmDialogOpen.value = false return } @@ -1357,7 +1404,7 @@ export default { }) approveConfirmDialogOpen.value = false leaderOpinion.value = '' - toast(`${request.value.id} 已审批通过,流转至财务审批。`) + toast(approvalSuccessToast.value) emit('request-updated', { claimId: request.value.claimId }) } catch (error) { toast(error?.message || '审批通过失败,请稍后重试。') @@ -1396,6 +1443,12 @@ export default { attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, + approvalConfirmBadge, + approvalConfirmDescription, + approvalNextStage, + approvalOpinionHint, + approvalOpinionPlaceholder, + approvalOpinionTitle, canDeleteRequest, canManageCurrentClaim, canNavigateAttachmentPreview, diff --git a/web/tests/accessControl.test.mjs b/web/tests/accessControl.test.mjs index bc7fc6a..5c88b24 100644 --- a/web/tests/accessControl.test.mjs +++ b/web/tests/accessControl.test.mjs @@ -1,7 +1,12 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { canManageExpenseClaims, canReturnExpenseClaims } from '../src/utils/accessControl.js' +import { + canApproveLeaderExpenseClaims, + canManageExpenseClaims, + canReturnExpenseClaims +} from '../src/utils/accessControl.js' +import { canProcessApprovalRequest } from '../src/utils/approvalInbox.js' test('direct approvers can return claims without receiving delete permissions', () => { const managerUser = { roleCodes: ['manager'] } @@ -9,13 +14,42 @@ test('direct approvers can return claims without receiving delete permissions', assert.equal(canReturnExpenseClaims(managerUser), true) assert.equal(canReturnExpenseClaims(approverUser), true) + assert.equal(canApproveLeaderExpenseClaims(managerUser), true) + assert.equal(canApproveLeaderExpenseClaims(approverUser), true) assert.equal(canManageExpenseClaims(managerUser), false) assert.equal(canManageExpenseClaims(approverUser), false) }) -test('finance and executives can return and manage claims', () => { +test('finance can return and final approve, but only executives can manage delete permissions', () => { assert.equal(canReturnExpenseClaims({ roleCodes: ['finance'] }), true) - assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), true) + assert.equal(canApproveLeaderExpenseClaims({ roleCodes: ['finance'] }), false) + assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), false) assert.equal(canReturnExpenseClaims({ roleCodes: ['executive'] }), true) assert.equal(canManageExpenseClaims({ roleCodes: ['executive'] }), true) }) + +test('finance approval inbox only processes finance-stage requests', () => { + const financeUser = { roleCodes: ['finance'], name: '财务' } + + assert.equal( + canProcessApprovalRequest({ workflowNode: '财务审批', person: '张三' }, financeUser), + true + ) + assert.equal( + canProcessApprovalRequest({ workflowNode: '直属领导审批', person: '张三' }, financeUser), + false + ) +}) + +test('users with both finance and manager roles can process both relevant stages', () => { + const financeManagerUser = { roleCodes: ['finance', 'manager'], name: '李经理' } + + assert.equal( + canProcessApprovalRequest({ workflowNode: '财务审批', person: '张三' }, financeManagerUser), + true + ) + assert.equal( + canProcessApprovalRequest({ workflowNode: '直属领导审批', person: '张三' }, financeManagerUser), + true + ) +}) diff --git a/web/tests/requestProgressSteps.test.mjs b/web/tests/requestProgressSteps.test.mjs index c394a05..84a18c2 100644 --- a/web/tests/requestProgressSteps.test.mjs +++ b/web/tests/requestProgressSteps.test.mjs @@ -39,7 +39,9 @@ test('progress steps show approval operator time and current stay duration', () const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批') const financeStep = request.progressSteps.find((step) => step.label === '财务审批') const aiStep = request.progressSteps.find((step) => step.label === 'AI预审') + const firstStep = request.progressSteps[0] + assert.equal(firstStep.label, '创建单据') assert.equal(leaderStep.time, '李经理通过') assert.match(leaderStep.detail, /2026-05-20/) assert.match(leaderStep.title, /李经理审批通过/) @@ -52,6 +54,96 @@ test('progress steps show approval operator time and current stay duration', () } }) +test('progress steps do not expose approver email when manager name is available', () => { + const originalNow = Date.now + Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime() + + try { + const request = mapExpenseClaimToRequest({ + id: 'claim-email-operator', + claim_no: 'EXP-202605-003', + employee_name: '张三', + department_name: '市场部', + manager_name: '李经理', + expense_type: 'transport', + reason: '交通报销', + location: '上海', + amount: 88, + invoice_count: 1, + occurred_at: '2026-05-20T01:00:00.000Z', + submitted_at: '2026-05-20T02:00:00.000Z', + created_at: '2026-05-20T01:30:00.000Z', + updated_at: '2026-05-20T03:30:00.000Z', + status: 'submitted', + approval_stage: '财务审批', + risk_flags_json: [ + { + source: 'manual_approval', + operator: 'manager@example.com', + operator_username: 'manager@example.com', + previous_approval_stage: '直属领导审批', + next_approval_stage: '财务审批', + created_at: '2026-05-20T03:30:00.000Z' + } + ], + items: [] + }) + + const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批') + + assert.equal(leaderStep.time, '李经理通过') + assert.ok(!leaderStep.title.includes('manager@example.com')) + } finally { + Date.now = originalNow + } +}) + +test('completed finance approval marks finance and archive progress steps', () => { + const request = mapExpenseClaimToRequest({ + id: 'claim-finance-completed', + claim_no: 'EXP-202605-004', + employee_name: '张三', + department_name: '市场部', + expense_type: 'transport', + reason: '交通报销', + location: '上海', + amount: 88, + invoice_count: 1, + occurred_at: '2026-05-20T01:00:00.000Z', + submitted_at: '2026-05-20T02:00:00.000Z', + created_at: '2026-05-20T01:30:00.000Z', + updated_at: '2026-05-20T04:00:00.000Z', + status: 'approved', + approval_stage: '归档入账', + risk_flags_json: [ + { + source: 'manual_approval', + operator: '李经理', + previous_approval_stage: '直属领导审批', + next_approval_stage: '财务审批', + created_at: '2026-05-20T03:00:00.000Z' + }, + { + source: 'finance_approval', + operator: '财务复核', + previous_approval_stage: '财务审批', + next_approval_stage: '归档入账', + created_at: '2026-05-20T04:00:00.000Z' + } + ], + items: [] + }) + + const financeStep = request.progressSteps.find((step) => step.label === '财务审批') + const archiveStep = request.progressSteps.find((step) => step.label === '归档入账') + + assert.equal(request.workflowNode, '归档入账') + assert.equal(financeStep.time, '财务复核通过') + assert.match(financeStep.detail, /2026-05-20/) + assert.equal(archiveStep.time, '归档入账') + assert.equal(archiveStep.done, true) +}) + test('current direct manager step shows how long the claim has stayed there', () => { const originalNow = Date.now Date.now = () => new Date('2026-05-20T05:15:00.000Z').getTime() diff --git a/web/tests/requestViewModel.test.mjs b/web/tests/requestViewModel.test.mjs index 79c9d99..e666de2 100644 --- a/web/tests/requestViewModel.test.mjs +++ b/web/tests/requestViewModel.test.mjs @@ -31,3 +31,17 @@ test('normalizes returned backend claims as editable pending submission', () => assert.equal(request.approvalStatus, '待提交') assert.equal(request.node, '待提交') }) + +test('does not show manager email as direct supervisor name', () => { + const request = normalizeRequestForUi({ + id: 'EXP-202605-003', + claim_id: 'claim-3', + status: 'submitted', + approval_stage: '直属领导审批', + expense_type: 'transport', + amount: 66, + manager_name: 'manager@example.com' + }) + + assert.equal(request.profileManager, '待补充') +}) diff --git a/web/tests/travel-reimbursement-review-drawer-switch.test.mjs b/web/tests/travel-reimbursement-review-drawer-switch.test.mjs index 42c487a..7971f79 100644 --- a/web/tests/travel-reimbursement-review-drawer-switch.test.mjs +++ b/web/tests/travel-reimbursement-review-drawer-switch.test.mjs @@ -11,6 +11,10 @@ const createViewScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)), 'utf8' ) +const reimbursementService = readFileSync( + fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)), + 'utf8' +) test('review drawer tools expose the default review tab before conditional document and risk tabs', () => { assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/) @@ -35,3 +39,74 @@ test('review drawer tool buttons switch modes instead of toggling the active mod assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_RISK\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/) assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/) }) + +test('review risk drawer lists risk briefs without score and posts details into the conversation', () => { + const riskItemsBlock = createViewScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nfunction buildReviewRiskConversationText/) + assert.ok(riskItemsBlock, 'risk item builder should be present') + + assert.doesNotMatch(createViewTemplate, /review-side-risk-score/) + assert.doesNotMatch(createViewTemplate, /风险评分/) + assert.doesNotMatch(createViewTemplate, /暂无风险评分/) + assert.doesNotMatch(createViewScript, /function buildReviewRiskScore/) + assert.doesNotMatch(createViewScript, /const reviewRiskScore/) + assert.doesNotMatch(riskItemsBlock[0], /\.slice\(0,\s*6\)/) + assert.match(createViewScript, /const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = \[[\s\S]*'历史报销画像'[\s\S]*'制度注意事项'/) + assert.match( + createViewScript, + /function resolveReviewRiskBriefs\(reviewPayload\) \{[\s\S]*DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS\.some/ + ) + + assert.match( + createViewTemplate, + /class="review-side-risk-item"[\s\S]*@click="appendReviewRiskBriefToConversation\(item\)"/ + ) + assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/) + assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/) + assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/) + assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/) + assert.match(createViewScript, /function normalizeReviewRiskTitle/) + assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/) + assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/) + assert.match(createViewScript, /sourceLabel:\s*meta\.label/) + assert.doesNotMatch(createViewScript, /normalizedTitle\.includes\('AI预审'\)/) + assert.match(createViewScript, /metaTone:\s*item\.level \|\| 'low'/) + assert.doesNotMatch(createViewTemplate, /@click="openReviewRiskDetail\(item\)"/) + assert.doesNotMatch(createViewTemplate, /review-risk-detail-modal/) + assert.doesNotMatch(createViewScript, /reviewRiskDetailDialog/) + assert.doesNotMatch(createViewScript, /function openReviewRiskDetail/) + + assert.match( + createViewScript, + /function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/ + ) +}) + +test('review payload with risks opens risk drawer and travel overview uses travel-specific fields', () => { + assert.match( + createViewScript, + /reviewDrawerMode\.value = resolveReviewRiskBriefs\(payload\)\.length[\s\S]*\? REVIEW_DRAWER_MODE_RISK[\s\S]*: REVIEW_DRAWER_MODE_REVIEW/ + ) + assert.match(createViewScript, /function isTravelReviewPayload\(reviewPayload/) + assert.match(createViewScript, /function resolveReviewTravelTransportType\(reviewPayload/) + assert.match(createViewScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/) + assert.match(createViewScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/) + assert.match(createViewScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/) + assert.match(createViewTemplate, /item\.editor === 'textarea'[\s\S]*