diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index 727f5d2..bb616ae 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -18,6 +18,7 @@ from app.schemas.reimbursement import ( ExpenseClaimItemUpdate, ExpenseClaimRead, ExpenseClaimReturnPayload, + ExpenseClaimUpdate, ReimbursementCreate, ReimbursementRead, TravelReimbursementCalculatorRequest, @@ -115,6 +116,43 @@ def get_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) - return claim +@router.patch( + "/claims/{claim_id}", + response_model=ExpenseClaimRead, + summary="更新草稿报销单", + description="更新草稿待提交报销单的主说明等草稿字段。", + responses={ + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "报销单不存在。", + }, + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "报销单状态不允许更新。", + }, + }, +) +def update_expense_claim( + claim_id: str, + payload: ExpenseClaimUpdate, + db: DbSession, + current_user: CurrentUser, +) -> ExpenseClaimRead: + service = ExpenseClaimService(db) + try: + claim = service.update_claim( + claim_id=claim_id, + payload=payload, + current_user=current_user, + ) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + if claim is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") + return claim + + @router.patch( "/claims/{claim_id}/items/{item_id}", response_model=ExpenseClaimRead, diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index 97e206c..5d4ae22 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -113,6 +113,10 @@ class ExpenseClaimItemCreate(BaseModel): invoice_id: str | None = None +class ExpenseClaimUpdate(BaseModel): + reason: str | None = Field(default=None, max_length=500) + + class ExpenseClaimRead(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 9e66b7a..94dce02 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -27,7 +27,12 @@ from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.organization import OrganizationUnit from app.schemas.ontology import OntologyEntity, OntologyParseResult -from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate +from app.schemas.reimbursement import ( + ExpenseClaimItemCreate, + ExpenseClaimItemUpdate, + ExpenseClaimUpdate, + TravelReimbursementCalculatorRequest, +) from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY from app.services.agent_foundation import AgentFoundationService @@ -42,10 +47,15 @@ from app.services.expense_rule_runtime import ( ) from app.services.ocr import OcrService -EXPENSE_TYPE_LABELS = { - "travel": "差旅", - "hotel": "住宿", - "transport": "交通", +EXPENSE_TYPE_LABELS = { + "travel": "差旅", + "train_ticket": "火车票", + "flight_ticket": "机票", + "hotel_ticket": "住宿票", + "ride_ticket": "乘车", + "travel_allowance": "出差补贴", + "hotel": "住宿", + "transport": "交通", "meal": "餐费", "meeting": "会务", "entertainment": "招待", @@ -60,8 +70,45 @@ APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"} CLAIM_DELETE_ROLE_CODES = {"executive"} MAX_DRAFT_CLAIMS_PER_USER = 3 EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned") +SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"} +TRAVEL_DETAIL_ITEM_TYPES = { + "train_ticket", + "flight_ticket", + "hotel_ticket", + "ride_ticket", + "travel_allowance", +} +DOCUMENT_TYPE_ITEM_TYPE_MAP = { + "train_ticket": "train_ticket", + "flight_itinerary": "flight_ticket", + "hotel_invoice": "hotel_ticket", + "taxi_receipt": "ride_ticket", + "transport_receipt": "ride_ticket", +} +DOCUMENT_FACT_ITEM_TYPES = {"train_ticket", "flight_ticket", "hotel_ticket", "ride_ticket"} +DOCUMENT_ROUTE_TEXT_PATTERN = re.compile( + r"([A-Za-z0-9\u4e00-\u9fa5()()·]{2,40})\s*(?:至|到|→|->|—|–|-)\s*" + r"([A-Za-z0-9\u4e00-\u9fa5()()·]{2,40})" +) +DOCUMENT_ROUTE_ORIGIN_LABELS = {"起点", "上车", "上车地点", "上车地址", "出发", "出发地", "出发站", "始发站", "乘车起点"} +DOCUMENT_ROUTE_DESTINATION_LABELS = { + "终点", + "下车", + "下车地点", + "下车地址", + "到达", + "到达地", + "到达站", + "目的地", + "乘车终点", +} +GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES = {"", "other", "travel", "transport", "hotel"} LOCATION_REQUIRED_EXPENSE_TYPES = { "travel", + "train_ticket", + "flight_ticket", + "hotel_ticket", + "ride_ticket", "meeting", "entertainment", } @@ -109,9 +156,14 @@ EXPENSE_SCENE_KEYWORDS = { "training": ("培训", "课程", "讲师", "教材", "学费", "认证"), } -EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = { - "travel": {"travel", "hotel", "transport", "meal"}, - "hotel": {"hotel"}, +EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = { + "travel": {"travel", "hotel", "transport", "meal"}, + "train_ticket": {"travel"}, + "flight_ticket": {"travel"}, + "hotel_ticket": {"hotel"}, + "ride_ticket": {"transport"}, + "travel_allowance": set(), + "hotel": {"hotel"}, "transport": {"transport", "travel"}, "meal": {"meal", "entertainment"}, "entertainment": {"entertainment", "meal"}, @@ -343,23 +395,55 @@ class ExpenseClaimService: ) stmt = self._apply_claim_scope(stmt, current_user, include_approval_scope=True) return self.db.scalar(stmt) - - def update_claim_item( - self, - *, + + def update_claim( + self, + *, + claim_id: str, + payload: ExpenseClaimUpdate, + current_user: CurrentUserContext, + ) -> ExpenseClaim | None: + claim = self.get_claim(claim_id, current_user) + if claim is None: + return None + + self._ensure_draft_pending_claim(claim) + before_json = self._serialize_claim(claim) + + if payload.reason is not None: + claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充" + + self.db.commit() + self.db.refresh(claim) + + self.audit_service.log_action( + actor=current_user.name or current_user.username, + action="expense_claim.update", + resource_type="expense_claim", + resource_id=claim.id, + before_json=before_json, + after_json=self._serialize_claim(claim), + ) + + return claim + + def update_claim_item( + self, + *, claim_id: str, item_id: str, payload: ExpenseClaimItemUpdate, current_user: CurrentUserContext, ) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) - if claim is None: - return None - - self._ensure_draft_claim(claim) - item = next((entry for entry in claim.items if entry.id == item_id), None) - if item is None: - raise LookupError("Item not found") + if claim is None: + return None + + self._ensure_draft_claim(claim) + item = next((entry for entry in claim.items if entry.id == item_id), None) + if item is None: + raise LookupError("Item not found") + self._ensure_mutable_claim_item(item) before_json = self._serialize_claim(claim) @@ -407,12 +491,12 @@ class ExpenseClaimService: current_user: CurrentUserContext, ) -> ExpenseClaim | None: claim = self.get_claim(claim_id, current_user) - if claim is None: - return None - - self._ensure_draft_claim(claim) - before_json = self._serialize_claim(claim) - payload = payload or ExpenseClaimItemCreate() + if claim is None: + return None + + self._ensure_draft_claim(claim) + before_json = self._serialize_claim(claim) + payload = payload or ExpenseClaimItemCreate() occurred_at = claim.occurred_at if claim.occurred_at is not None else datetime.now(UTC) item_amount = Decimal("0.00") @@ -509,11 +593,12 @@ class ExpenseClaimService: item_id=item_id, current_user=current_user, ) - if claim is None: - return None - - self._ensure_draft_claim(claim) - normalized_name = self._normalize_attachment_filename(filename) + if claim is None: + return None + + self._ensure_draft_claim(claim) + self._ensure_mutable_claim_item(item) + normalized_name = self._normalize_attachment_filename(filename) if not content: raise ValueError("上传文件不能为空。") @@ -547,11 +632,20 @@ class ExpenseClaimService: ocr_document = documents[0] ocr_status = "recognized" document_info = self._build_attachment_document_info(ocr_document) + self._backfill_item_type_from_attachment( + item=item, + document_info=document_info, + ) self._backfill_item_amount_from_attachment( item=item, document=ocr_document, document_info=document_info, ) + self._backfill_item_reason_from_attachment( + item=item, + document=ocr_document, + document_info=document_info, + ) requirement_check = self._build_attachment_requirement_check( item=item, document_info=document_info, @@ -694,11 +788,12 @@ class ExpenseClaimService: item_id=item_id, current_user=current_user, ) - if claim is None: - return None - - self._ensure_draft_claim(claim) - before_json = self._serialize_claim(claim) + if claim is None: + return None + + self._ensure_draft_claim(claim) + self._ensure_mutable_claim_item(item) + before_json = self._serialize_claim(claim) previous_name = self._resolve_attachment_display_name(item.invoice_id) self._delete_item_attachment_files(item) item.invoice_id = None @@ -1234,15 +1329,18 @@ class ExpenseClaimService: self.db.flush() if context_documents or attachment_names: - document_specs = self._build_context_item_specs( - context_documents=context_documents, - attachment_names=attachment_names, - occurred_at=final_occurred_at, - expense_type=final_expense_type, - amount=final_amount, - reason=final_reason, - location=final_location, - ) + document_specs = self._build_context_item_specs( + context_documents=context_documents, + attachment_names=attachment_names, + occurred_at=final_occurred_at, + expense_type=final_expense_type, + amount=final_amount, + reason=final_reason, + location=final_location, + context_json=context_json, + employee_grade=str(employee.grade or "").strip() if employee is not None else "", + user_id=user_id, + ) else: document_specs = [] @@ -1486,28 +1584,31 @@ class ExpenseClaimService: ) return normalized - def _build_context_item_specs( - self, - *, - context_documents: list[dict[str, Any]], - attachment_names: list[str], - occurred_at: datetime, - expense_type: str, - amount: Decimal, - reason: str, - location: str, - ) -> list[dict[str, Any]]: - specs: list[dict[str, Any]] = [] - if context_documents: - for document in context_documents: + def _build_context_item_specs( + self, + *, + context_documents: list[dict[str, Any]], + attachment_names: list[str], + occurred_at: datetime, + expense_type: str, + amount: Decimal, + reason: str, + location: str, + context_json: dict[str, Any], + employee_grade: str | None = None, + user_id: str = "", + ) -> list[dict[str, Any]]: + specs: list[dict[str, Any]] = [] + if context_documents: + for document in context_documents: specs.append( { - "item_date": self._resolve_document_item_date(document, fallback=occurred_at.date()), - "item_type": self._resolve_document_item_type(document, fallback=expense_type), - "item_reason": reason, - "item_location": location, - "item_amount": self._resolve_document_item_amount(document), - "invoice_id": str(document.get("filename") or "").strip() or None, + "item_date": self._resolve_document_item_date(document, fallback=occurred_at.date()), + "item_type": self._resolve_document_item_type(document, fallback=expense_type), + "item_reason": self._resolve_document_item_reason(document, fallback=reason), + "item_location": location, + "item_amount": self._resolve_document_item_amount(document), + "invoice_id": str(document.get("filename") or "").strip() or None, } ) elif attachment_names: @@ -1535,13 +1636,191 @@ class ExpenseClaimService: if remaining > Decimal("0.00"): missing_specs[0]["item_amount"] = remaining - for spec in specs: - if spec.get("item_amount") is None: - spec["item_amount"] = Decimal("0.00") - - return specs - - def _replace_claim_items( + for spec in specs: + if spec.get("item_amount") is None: + spec["item_amount"] = Decimal("0.00") + + allowance_spec = self._build_travel_allowance_item_spec( + context_documents=context_documents, + specs=specs, + occurred_at=occurred_at, + expense_type=expense_type, + location=location, + context_json=context_json, + employee_grade=employee_grade, + user_id=user_id, + ) + if allowance_spec is not None: + specs = [spec for spec in specs if str(spec.get("item_type") or "").strip() != "travel_allowance"] + specs.append(allowance_spec) + + return specs + + def _build_travel_allowance_item_spec( + self, + *, + context_documents: list[dict[str, Any]], + specs: list[dict[str, Any]], + occurred_at: datetime, + expense_type: str, + location: str, + context_json: dict[str, Any], + employee_grade: str | None, + user_id: str, + ) -> dict[str, Any] | None: + if not self._should_add_travel_allowance_item( + expense_type=expense_type, + context_documents=context_documents, + context_json=context_json, + ): + return None + + grade = str(employee_grade or context_json.get("grade") or "").strip() + if not grade: + return None + + days, _, end_date = self._resolve_travel_allowance_days( + context_json=context_json, + occurred_at=occurred_at, + ) + allowance_location = self._resolve_travel_allowance_location( + location=location, + context_documents=context_documents, + ) + if days < 1 or not allowance_location: + return None + + try: + from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService + + result = TravelReimbursementCalculatorService(self.db).calculate( + TravelReimbursementCalculatorRequest( + days=days, + location=allowance_location, + grade=grade, + ), + CurrentUserContext( + username=user_id, + name="", + role_codes=[], + is_admin=False, + ), + ) + except ValueError: + return None + + allowance_amount = Decimal(result.allowance_amount or Decimal("0.00")).quantize(Decimal("0.01")) + allowance_rate = Decimal(result.total_allowance_rate or Decimal("0.00")).quantize(Decimal("0.01")) + if allowance_amount <= Decimal("0.00") or allowance_rate <= Decimal("0.00"): + return None + + return { + "item_date": end_date, + "item_type": "travel_allowance", + "item_reason": ( + f"系统自动计算出差补贴:{result.matched_city},{days}天," + f"{allowance_rate:.2f}元/天" + ), + "item_location": str(result.allowance_region or allowance_location).strip(), + "item_amount": allowance_amount, + "invoice_id": None, + } + + @staticmethod + def _should_add_travel_allowance_item( + *, + expense_type: str, + context_documents: list[dict[str, Any]], + context_json: dict[str, Any], + ) -> bool: + normalized_expense_type = str(expense_type or "").strip().lower() + if normalized_expense_type == "travel": + return True + + review_form_values = context_json.get("review_form_values") + if isinstance(review_form_values, dict): + review_type = str( + review_form_values.get("expense_type") + or review_form_values.get("scene_label") + or review_form_values.get("reason_value") + or "" + ) + if any(keyword in review_type for keyword in ("差旅", "出差")): + return True + + for document in context_documents: + document_type = str(document.get("document_type") or "").strip() + scene_code = str(document.get("scene_code") or "").strip() + if document_type in {"train_ticket", "flight_itinerary", "hotel_invoice"} or scene_code == "travel": + return True + return False + + def _resolve_travel_allowance_days( + self, + *, + context_json: dict[str, Any], + occurred_at: datetime, + ) -> tuple[int, date, date]: + start_date = occurred_at.date() + end_date = start_date + + business_time_context = context_json.get("business_time_context") + if isinstance(business_time_context, dict): + start_date = self._parse_iso_date_or_default(business_time_context.get("start_date"), start_date) + end_date = self._parse_iso_date_or_default(business_time_context.get("end_date"), start_date) + else: + review_form_values = context_json.get("review_form_values") + if isinstance(review_form_values, dict): + time_text = str( + review_form_values.get("time_range") + or review_form_values.get("business_time") + or review_form_values.get("occurred_date") + or "" + ).strip() + matched_dates = re.findall(r"\d{4}-\d{2}-\d{2}", time_text) + if matched_dates: + start_date = self._parse_iso_date_or_default(matched_dates[0], start_date) + end_date = self._parse_iso_date_or_default(matched_dates[-1], start_date) + + if end_date < start_date: + end_date = start_date + days = (end_date - start_date).days + 1 + return max(1, days), start_date, end_date + + @staticmethod + def _parse_iso_date_or_default(value: Any, fallback: date) -> date: + try: + return date.fromisoformat(str(value or "").strip()) + except ValueError: + return fallback + + @staticmethod + def _resolve_travel_allowance_location( + *, + location: str, + context_documents: list[dict[str, Any]], + ) -> str: + normalized_location = str(location or "").strip() + if normalized_location and normalized_location not in {"待补充", "未知", "暂无"}: + return normalized_location + + for document in context_documents: + for field in list(document.get("document_fields") or []): + if not isinstance(field, dict): + continue + key = str(field.get("key") or "").strip().lower() + label = str(field.get("label") or "").strip() + value = str(field.get("value") or "").strip() + if key == "route" or "行程" in label: + separators = ("-", "至", "→", "->") + for separator in separators: + if separator in value: + return value.split(separator)[-1].strip() + if key in {"destination", "arrival_city"} or label in {"目的地", "到达城市"}: + return value + return "" + + def _replace_claim_items( self, *, claim: ExpenseClaim, @@ -1565,18 +1844,28 @@ class ExpenseClaimService: item.item_reason = spec["item_reason"] item.item_location = spec["item_location"] item.item_amount = spec["item_amount"] - item.invoice_id = self._merge_attachment_reference(item.invoice_id, spec["invoice_id"]) + item.invoice_id = ( + None + if str(spec.get("item_type") or "").strip() in SYSTEM_GENERATED_ITEM_TYPES + else self._merge_attachment_reference(item.invoice_id, spec["invoice_id"]) + ) for stale_item in existing_items[len(item_specs) :]: claim.items.remove(stale_item) self.db.delete(stale_item) - def _append_document_items( - self, - *, - claim: ExpenseClaim, - item_specs: list[dict[str, Any]], - ) -> None: + def _append_document_items( + self, + *, + claim: ExpenseClaim, + item_specs: list[dict[str, Any]], + ) -> None: + system_specs = [ + spec for spec in item_specs if str(spec.get("item_type") or "").strip() in SYSTEM_GENERATED_ITEM_TYPES + ] + normal_specs = [ + spec for spec in item_specs if str(spec.get("item_type") or "").strip() not in SYSTEM_GENERATED_ITEM_TYPES + ] existing_invoice_ids = { str(item.invoice_id or "").strip() for item in claim.items @@ -1587,7 +1876,7 @@ class ExpenseClaimService: for item in claim.items if str(item.invoice_id or "").strip() } - for spec in item_specs: + for spec in normal_specs: invoice_id = str(spec.get("invoice_id") or "").strip() invoice_name = self._resolve_attachment_display_name(invoice_id) if invoice_id and (invoice_id in existing_invoice_ids or invoice_name in existing_invoice_names): @@ -1607,15 +1896,40 @@ class ExpenseClaimService: if invoice_id: existing_invoice_ids.add(invoice_id) existing_invoice_names.add(invoice_name) + + if system_specs: + existing_system_items = [ + item for item in list(claim.items) if str(item.item_type or "").strip() in SYSTEM_GENERATED_ITEM_TYPES + ] + for stale_item in existing_system_items: + claim.items.remove(stale_item) + self.db.delete(stale_item) + for spec in system_specs: + claim.items.append( + ExpenseClaimItem( + claim_id=claim.id, + item_date=spec["item_date"], + item_type=spec["item_type"], + item_reason=spec["item_reason"], + item_location=spec["item_location"], + item_amount=spec["item_amount"], + invoice_id=spec["invoice_id"], + ) + ) + self.db.add(claim.items[-1]) - def _resolve_document_item_type(self, document: dict[str, Any], *, fallback: str) -> str: - scene_code = str(document.get("scene_code") or "").strip() - if scene_code in {"travel", "hotel", "transport", "meal", "office", "meeting", "training"}: - return scene_code - - document_type = str(document.get("document_type") or "").strip() - if document_type in {"flight_itinerary", "train_ticket"}: - return "travel" + def _resolve_document_item_type(self, document: dict[str, Any], *, fallback: str) -> str: + document_type = str(document.get("document_type") or "").strip() + mapped_type = DOCUMENT_TYPE_ITEM_TYPE_MAP.get(document_type) + if mapped_type: + return mapped_type + + scene_code = str(document.get("scene_code") or "").strip() + if scene_code in {"travel", "hotel", "transport", "meal", "office", "meeting", "training"}: + return scene_code + + if document_type in {"flight_itinerary", "train_ticket"}: + return "travel" if document_type in {"taxi_receipt", "parking_toll_receipt", "transport_receipt"}: return "transport" if document_type == "hotel_invoice": @@ -1639,12 +1953,212 @@ class ExpenseClaimService: if "会务" in scene_label or "会议" in scene_label: return "meeting" if "培训" in scene_label: - return "training" - return fallback or "other" - - def _resolve_document_item_amount(self, document: dict[str, Any]) -> Decimal | None: - for field in list(document.get("document_fields") or []): - if not isinstance(field, dict): + return "training" + return fallback or "other" + + def _resolve_document_item_reason(self, document: dict[str, Any], *, fallback: str) -> str: + document_type = str(document.get("document_type") or "").strip().lower() + item_type = self._resolve_document_item_type(document, fallback="") + + if document_type in {"train_ticket", "flight_itinerary"} or item_type in {"train_ticket", "flight_ticket"}: + route = self._resolve_document_route_value(document) + trip_no = self._resolve_document_fact_field( + document, + keys={"trip_no", "flight_no", "train_no"}, + labels={"车次", "航班"}, + ) + if route and trip_no: + return f"{self._format_document_route(route)}({trip_no})" + if route: + return self._format_document_route(route) + + if document_type in {"taxi_receipt", "transport_receipt"} or item_type == "ride_ticket": + route = self._resolve_document_route_value(document) + if route: + return self._format_document_route(route) + + if document_type == "hotel_invoice" or item_type == "hotel_ticket": + merchant = self._resolve_document_fact_field( + document, + keys={"merchant_name", "merchant", "seller_name", "vendor_name", "hotel_name"}, + labels={"商户", "酒店", "宾馆", "销售方", "开票方"}, + ) + stay_range = self._resolve_document_stay_range(document) + if merchant and stay_range: + return f"{merchant},{stay_range}" + if merchant: + return merchant + if stay_range: + return stay_range + + merchant = self._resolve_document_fact_field( + document, + keys={"merchant_name", "merchant", "seller_name", "vendor_name"}, + labels={"商户", "销售方", "开票方", "收款方"}, + ) + if merchant: + return merchant + + summary = str(document.get("summary") or "").strip() + return summary or fallback or "" + + def _resolve_document_route_value(self, document: dict[str, Any]) -> str: + route = self._resolve_document_fact_field( + document, + keys={"route", "trip_route"}, + labels={"行程", "路线"}, + ) + if route: + return route + + origin = self._resolve_document_fact_field( + document, + keys={ + "origin", + "from", + "from_city", + "departure", + "departure_city", + "start", + "start_location", + "start_address", + "pickup_location", + "pickup_address", + "boarding_station", + }, + labels=DOCUMENT_ROUTE_ORIGIN_LABELS, + ) + destination = self._resolve_document_fact_field( + document, + keys={ + "destination", + "to", + "to_city", + "arrival", + "arrival_city", + "end", + "end_location", + "end_address", + "dropoff_location", + "dropoff_address", + "alighting_station", + }, + labels=DOCUMENT_ROUTE_DESTINATION_LABELS, + ) + if origin and destination: + return f"{origin}-{destination}" + + text = " ".join( + [ + str(document.get("summary") or "").strip(), + str(document.get("text") or "").strip(), + ] + ).strip() + text_route = self._extract_document_route_from_text(text) + if text_route: + return text_route + + text_origin = self._extract_document_labeled_text_value(text, DOCUMENT_ROUTE_ORIGIN_LABELS) + text_destination = self._extract_document_labeled_text_value(text, DOCUMENT_ROUTE_DESTINATION_LABELS) + if text_origin and text_destination: + return f"{text_origin}-{text_destination}" + return "" + + @staticmethod + def _resolve_document_fact_field( + document: dict[str, Any], + *, + keys: set[str], + labels: set[str], + ) -> str: + raw_fields = document.get("document_fields") + if not isinstance(raw_fields, list): + raw_fields = document.get("fields") + if not isinstance(raw_fields, list): + return "" + + normalized_keys = {str(key or "").strip().lower().replace("_", "") for key in keys} + for field in raw_fields: + if not isinstance(field, dict): + continue + field_key = str(field.get("key") or "").strip().lower().replace("_", "") + label = str(field.get("label") or "").replace(" ", "") + value = str(field.get("value") or "").strip() + if not value: + continue + if field_key in normalized_keys or any(token in label for token in labels): + return value + return "" + + @staticmethod + def _format_document_route(route: str) -> str: + normalized = ( + str(route or "") + .strip() + .replace("->", "-") + .replace("→", "-") + .replace("—", "-") + .replace("–", "-") + .replace("至", "-") + .replace("到", "-") + ) + if "-" not in normalized: + return str(route or "").strip() + origin, destination = [part.strip() for part in normalized.split("-", 1)] + origin = origin.removeprefix("从").strip() + destination = destination.removeprefix("至").removeprefix("到").strip() + if not origin or not destination or origin == destination: + return str(route or "").strip() + return f"从{origin}到{destination}" + + @staticmethod + def _extract_document_route_from_text(text: str) -> str: + match = DOCUMENT_ROUTE_TEXT_PATTERN.search(str(text or "")) + if not match: + return "" + origin = str(match.group(1) or "").strip() + destination = str(match.group(2) or "").strip() + if not origin or not destination or origin == destination: + return "" + return f"{origin}-{destination}" + + @staticmethod + def _extract_document_labeled_text_value(text: str, labels: set[str]) -> str: + for label in sorted(labels, key=len, reverse=True): + pattern = re.compile( + rf"{re.escape(label)}[::\s]*" + r"([A-Za-z0-9\u4e00-\u9fa5()()·\-路街道号弄区县市省园桥站机场中心]{2,50})" + ) + match = pattern.search(str(text or "")) + if match: + return str(match.group(1) or "").strip() + return "" + + def _resolve_document_stay_range(self, document: dict[str, Any]) -> str: + check_in = self._resolve_document_fact_field( + document, + keys={"check_in", "checkin", "arrival_date", "start_date"}, + labels={"入住", "入住日期", "到店", "开始日期"}, + ) + check_out = self._resolve_document_fact_field( + document, + keys={"check_out", "checkout", "departure_date", "end_date"}, + labels={"离店", "退房", "离店日期", "结束日期"}, + ) + if check_in and check_out: + return f"{check_in}至{check_out}" + nights = self._resolve_document_fact_field( + document, + keys={"nights", "night_count", "room_nights"}, + labels={"间夜", "晚数", "入住天数"}, + ) + if nights: + return f"{nights}晚" + return "" + + def _resolve_document_item_amount(self, document: dict[str, Any]) -> Decimal | None: + for field in list(document.get("document_fields") or []): + if not isinstance(field, dict): continue key = str(field.get("key") or "").strip().lower().replace("_", "") label = str(field.get("label") or "").replace(" ", "") @@ -2575,6 +3089,21 @@ class ExpenseClaimService: "fields": normalized_fields, } + def _backfill_item_type_from_attachment( + self, + *, + item: ExpenseClaimItem, + document_info: dict[str, Any], + ) -> None: + current_type = str(item.item_type or "").strip().lower() + if current_type not in GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES: + return + + document_type = str(document_info.get("document_type") or "").strip() + mapped_type = DOCUMENT_TYPE_ITEM_TYPE_MAP.get(document_type) + if mapped_type: + item.item_type = mapped_type + def _backfill_item_amount_from_attachment( self, *, @@ -2596,6 +3125,27 @@ class ExpenseClaimService: if amount is not None and amount > Decimal("0.00"): item.item_amount = amount + def _backfill_item_reason_from_attachment( + self, + *, + item: ExpenseClaimItem, + document: Any, + document_info: dict[str, Any], + ) -> None: + reason = self._resolve_document_item_reason( + { + "document_type": str(document_info.get("document_type") or "").strip(), + "scene_code": str(document_info.get("scene_code") or "").strip(), + "scene_label": str(document_info.get("scene_label") or "").strip(), + "document_fields": document_info.get("fields") or [], + "summary": str(getattr(document, "summary", "") or ""), + "text": str(getattr(document, "text", "") or ""), + }, + fallback=str(item.item_reason or "").strip(), + ) + if reason: + item.item_reason = reason + def _build_attachment_requirement_check( self, *, @@ -3063,6 +3613,17 @@ class ExpenseClaimService: if not self._is_editable_claim_status(claim.status): raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。") + @staticmethod + def _ensure_draft_pending_claim(claim: ExpenseClaim) -> None: + status = str(claim.status or "").strip().lower() + if status != "draft": + raise ValueError("只有草稿待提交状态的报销单才允许编辑附加说明。") + + @staticmethod + def _ensure_mutable_claim_item(item: ExpenseClaimItem) -> None: + if str(item.item_type or "").strip().lower() in SYSTEM_GENERATED_ITEM_TYPES: + raise ValueError("系统自动计算的费用明细不可手动修改。") + def _delete_submitted_claim_assistant_sessions(self, claim_id: str | None) -> None: from app.services.agent_conversations import AgentConversationService @@ -4531,10 +5092,16 @@ class ExpenseClaimService: primary_item.item_date.day, tzinfo=UTC, ) - claim.expense_type = str(primary_item.item_type or claim.expense_type or "other").strip() or "other" - claim.reason = ( - self._normalize_optional_text(primary_item.item_reason, fallback=claim.reason or "待补充") or "待补充" - ) + claim.expense_type = self._resolve_claim_expense_type_from_items( + ordered_items, + fallback=str(primary_item.item_type or claim.expense_type or "other").strip() or "other", + ) + primary_item_type = str(primary_item.item_type or "").strip() + if primary_item_type not in DOCUMENT_FACT_ITEM_TYPES: + claim.reason = ( + self._normalize_optional_text(primary_item.item_reason, fallback=claim.reason or "待补充") + or "待补充" + ) claim.location = ( self._normalize_optional_text(primary_item.item_location, fallback=claim.location or "待补充") or "待补充" @@ -4543,8 +5110,20 @@ class ExpenseClaimService: claim, self._build_claim_attachment_risk_flags(ordered_items), ) - if str(claim.status or "").strip().lower() == "draft": - claim.approval_stage = "待提交" + if str(claim.status or "").strip().lower() == "draft": + claim.approval_stage = "待提交" + + @staticmethod + def _resolve_claim_expense_type_from_items( + items: list[ExpenseClaimItem], + *, + fallback: str, + ) -> str: + fallback_type = str(fallback or "").strip() or "other" + item_types = {str(item.item_type or "").strip().lower() for item in items} + if item_types & TRAVEL_DETAIL_ITEM_TYPES: + return "travel" + return fallback_type def _refresh_item_attachment_analysis(self, item: ExpenseClaimItem) -> None: file_path = self._resolve_attachment_path(item.invoice_id) diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index ff7fe8a..5d6a822 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -9,10 +9,12 @@ from typing import Any from sqlalchemy import or_, select from sqlalchemy.orm import Session, selectinload +from app.api.deps import CurrentUserContext from app.core.agent_enums import AgentAssetStatus, AgentAssetType from app.models.employee import Employee from app.models.financial_record import ExpenseClaim from app.schemas.agent_asset import AgentAssetListItem +from app.schemas.reimbursement import TravelReimbursementCalculatorRequest from app.schemas.user_agent import ( UserAgentCitation, UserAgentDraftPayload, @@ -37,6 +39,7 @@ 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 +from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService SCENARIO_LABELS = { "expense": "报销", @@ -187,6 +190,7 @@ DOCUMENT_AMOUNT_PATTERN = re.compile( ) DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)") TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN = re.compile(r"(\d+)\s*(?:晚|间夜)") +TRAVEL_ROUTE_PATTERN = re.compile(r"([\u4e00-\u9fa5]{2,12})\s*(?:至|→|->|-|—)\s*([\u4e00-\u9fa5]{2,12})") SOURCE_LABELS = { "user_text": "用户描述", @@ -1900,6 +1904,11 @@ class UserAgentService: ocr_documents=ocr_documents, claim_groups=claim_groups, ) + travel_receipt_state = self._build_travel_receipt_state( + payload, + document_cards=document_cards, + claim_groups=claim_groups, + ) missing_slot_keys = self._resolve_review_missing_slot_keys( payload, slot_cards=slot_cards, @@ -1911,10 +1920,11 @@ class UserAgentService: document_cards=document_cards, claim_groups=claim_groups, ) + risk_briefs.extend(self._build_travel_receipt_briefs(travel_receipt_state)) association_choice_pending = self._is_review_association_choice_pending(payload) can_proceed = ( False - if association_choice_pending or submission_blocked + if association_choice_pending or submission_blocked or travel_receipt_state.get("blocks_next_step") else self._can_proceed_review( payload, missing_slot_keys=missing_slot_keys, @@ -1943,7 +1953,15 @@ class UserAgentService: risk_briefs=risk_briefs, can_proceed=can_proceed, document_cards=document_cards, + travel_receipt_state=travel_receipt_state, ) + missing_slot_labels = [SLOT_LABELS.get(key, key) for key in missing_slot_keys] + missing_slot_labels.extend( + str(item) + for item in travel_receipt_state.get("required_missing_labels", []) + if str(item).strip() + ) + missing_slot_labels = list(dict.fromkeys(missing_slot_labels)) return UserAgentReviewPayload( intent_summary=intent_summary, @@ -1951,7 +1969,7 @@ class UserAgentService: scenario=payload.ontology.scenario, intent=payload.ontology.intent, can_proceed=can_proceed, - missing_slots=[SLOT_LABELS.get(key, key) for key in missing_slot_keys], + missing_slots=missing_slot_labels, risk_briefs=risk_briefs, slot_cards=slot_cards, document_cards=document_cards, @@ -2649,6 +2667,230 @@ class UserAgentService: return True return any(keyword in message_context for keyword in ("差旅", "出差", "机票", "火车", "高铁", "酒店", "住宿")) + def _build_travel_receipt_state( + self, + payload: UserAgentRequest, + *, + document_cards: list[UserAgentReviewDocumentCard], + claim_groups: list[UserAgentReviewClaimGroup], + ) -> dict[str, Any]: + empty_state: dict[str, Any] = { + "is_travel_context": False, + "has_long_distance_ticket": False, + "ticket_type_label": "", + "ticket_amount": Decimal("0.00"), + "destination": "", + "days": 1, + "has_hotel_invoice": False, + "has_local_transport": False, + "required_missing_labels": [], + "optional_missing_labels": [], + "blocks_next_step": False, + } + if not document_cards or not self._is_travel_review_context(payload, document_cards, claim_groups): + return empty_state + + long_distance_cards = [card for card in document_cards if self._is_long_distance_travel_card(card)] + if not long_distance_cards: + return { + **empty_state, + "is_travel_context": True, + } + + has_hotel_invoice = any(self._is_review_hotel_card(card) for card in document_cards) + has_local_transport = any(self._is_local_transport_receipt_card(card) for card in document_cards) + required_missing_labels = [] if has_hotel_invoice else ["酒店的报销票据待上传(必须)"] + optional_missing_labels = [] if has_local_transport else ["市内交通/乘车票据可继续上传(非必须)"] + ticket_amount = sum( + (self._extract_amount_decimal_from_card(card) or Decimal("0.00")) + for card in long_distance_cards + ).quantize(Decimal("0.01")) + + return { + **empty_state, + "is_travel_context": True, + "has_long_distance_ticket": True, + "ticket_type_label": self._resolve_travel_ticket_type_label(long_distance_cards), + "ticket_amount": ticket_amount, + "destination": self._resolve_travel_receipt_destination(payload, long_distance_cards), + "days": self._resolve_travel_receipt_days(payload, long_distance_cards), + "has_hotel_invoice": has_hotel_invoice, + "has_local_transport": has_local_transport, + "required_missing_labels": required_missing_labels, + "optional_missing_labels": optional_missing_labels, + "blocks_next_step": bool(required_missing_labels), + } + + @staticmethod + def _is_long_distance_travel_card(card: UserAgentReviewDocumentCard) -> bool: + document_type = str(card.document_type or "").strip().lower() + return document_type in {"train_ticket", "flight_itinerary"} + + @staticmethod + def _is_local_transport_receipt_card(card: UserAgentReviewDocumentCard) -> bool: + document_type = str(card.document_type or "").strip().lower() + suggested_type = str(card.suggested_expense_type or "").strip().lower() + return document_type in {"taxi_receipt", "parking_toll_receipt", "transport_receipt"} or ( + suggested_type == "transport" and document_type not in {"train_ticket", "flight_itinerary"} + ) + + @staticmethod + def _resolve_travel_ticket_type_label(cards: list[UserAgentReviewDocumentCard]) -> str: + labels: list[str] = [] + for card in cards: + document_type = str(card.document_type or "").strip().lower() + if document_type == "train_ticket" and "火车" not in labels: + labels.append("火车") + if document_type == "flight_itinerary" and "飞机" not in labels: + labels.append("飞机") + return "/".join(labels) if labels else "交通" + + def _resolve_travel_receipt_destination( + self, + payload: UserAgentRequest, + long_distance_cards: list[UserAgentReviewDocumentCard], + ) -> str: + for card in long_distance_cards: + for field in card.fields: + if str(field.label or "").strip() not in {"行程", "路线"}: + continue + destination = self._extract_travel_destination_from_route(field.value) + if destination: + return self._normalize_travel_destination(destination) + + card_text = self._build_review_document_card_text(card) + route_match = TRAVEL_ROUTE_PATTERN.search(card_text) + if route_match: + return self._normalize_travel_destination(route_match.group(2)) + + location = self._resolve_location_value(payload) + if location: + return self._normalize_travel_destination(location) + return "" + + @staticmethod + def _extract_travel_destination_from_route(value: str) -> str: + route_text = str(value or "").strip() + if not route_text: + return "" + route_match = TRAVEL_ROUTE_PATTERN.search(route_text) + if route_match: + return route_match.group(2).strip() + parts = [ + item.strip() + for item in re.split(r"\s*(?:至|到|→|->|-|—|~|~)\s*", route_text) + if item.strip() + ] + return parts[-1] if len(parts) >= 2 else "" + + def _normalize_travel_destination(self, value: str) -> str: + candidate = re.sub( + r"(?:火车站|高铁站|动车站|车站|站|机场|航站楼)$", + "", + str(value or "").strip(), + ) + if not candidate: + return "" + try: + policy = ExpenseRuleRuntimeService(self.db).load_catalog().travel_policy + except Exception: + policy = None + if policy is not None: + policy_city = self._extract_policy_city_from_text(candidate, policy) + if policy_city: + return policy_city + return candidate + + def _resolve_travel_receipt_days( + self, + payload: UserAgentRequest, + long_distance_cards: list[UserAgentReviewDocumentCard], + ) -> int: + dates: list[datetime] = [] + for card in long_distance_cards: + card_text = self._build_review_document_card_text(card) + dates.extend(self._extract_dates_from_text(card_text)) + + if dates: + return max(1, (max(dates).date() - min(dates).date()).days + 1) + + start_date = self._parse_date_text(payload.ontology.time_range.start_date or "") + end_date = self._parse_date_text(payload.ontology.time_range.end_date or "") + if start_date and end_date: + return max(1, (end_date.date() - start_date.date()).days + 1) + return 1 + + @staticmethod + def _extract_dates_from_text(text: str) -> list[datetime]: + dates: list[datetime] = [] + for match in DATE_TEXT_PATTERN.finditer(str(text or "")): + parsed = UserAgentService._parse_date_text(match.group(1)) + if parsed is not None: + dates.append(parsed) + return dates + + @staticmethod + def _parse_date_text(value: str) -> datetime | None: + raw_value = str(value or "").strip() + if not raw_value: + return None + normalized = ( + raw_value.replace("年", "-") + .replace("月", "-") + .replace("/", "-") + .replace("日", "") + .strip() + ) + parts = [part for part in normalized.split("-") if part] + if len(parts) != 3: + return None + try: + year, month, day = (int(part) for part in parts) + return datetime(year, month, day) + except ValueError: + return None + + def _build_travel_receipt_briefs( + self, + travel_receipt_state: dict[str, Any], + ) -> list[UserAgentReviewRiskBrief]: + if not travel_receipt_state.get("has_long_distance_ticket"): + return [] + + required_labels = [ + str(item).strip() + for item in travel_receipt_state.get("required_missing_labels", []) + if str(item).strip() + ] + optional_labels = [ + str(item).strip() + for item in travel_receipt_state.get("optional_missing_labels", []) + if str(item).strip() + ] + if not required_labels and not optional_labels: + return [] + + content_parts = [*required_labels, *optional_labels] + required_text = ";".join(required_labels) + optional_text = ";".join(optional_labels) + return [ + UserAgentReviewRiskBrief( + title="差旅票据待补充", + level="warning" if required_labels else "info", + content=";".join(content_parts), + detail=( + "系统已识别到长途交通票据,会按差旅报销口径核对住宿、交通等票据完整性。" + + (f"当前必须补充:{required_text}。" if required_text else "") + + (f"当前还可以补充:{optional_text}。" if optional_text else "") + ), + suggestion=( + "请先补充酒店住宿发票或住宿清单;在补齐前只能保存为草稿。" + if required_labels + else "如还有市内交通、打车、地铁或停车等乘车票据,可以继续上传;没有也可以进入下一步或保存草稿。" + ), + ) + ] + def _resolve_review_travel_allowance_standard( self, policy: RuntimeTravelPolicy, @@ -3008,7 +3250,7 @@ class UserAgentService: if draft_payload is not None and draft_payload.claim_no and not can_proceed: primary_action.description = f"保存后会生成草稿 {draft_payload.claim_no},后续仍可继续补充。" - return [ + actions = [ UserAgentReviewAction( label="取消", action_type="cancel_review", @@ -3021,8 +3263,18 @@ class UserAgentService: description="打开结构化模板,按已识别字段逐项修改。", emphasis="secondary", ), - primary_action, ] + if can_proceed: + actions.append( + UserAgentReviewAction( + label="保存为草稿", + action_type="save_draft", + description="先暂存当前已识别信息,稍后仍可从个人报销继续补充或提交。", + emphasis="secondary", + ) + ) + actions.append(primary_action) + return actions def _build_review_intent_summary( self, @@ -3086,20 +3338,22 @@ class UserAgentService: return "已按您当前确认的信息保存为草稿。后续您可以继续补充缺失项,或修改识别结果后再继续提交。" if review_action == "link_to_existing_draft": document_count = self._resolve_review_document_count(payload) + followup_copy = self._build_review_action_followup_copy(review_payload) if draft_payload is not None and draft_payload.claim_no: return ( f"已将本次上传的 {document_count} 张票据关联到草稿 {draft_payload.claim_no}。" - "您可以继续补充识别字段,确认无误后再提交审批。" + f"{followup_copy or '您可以继续补充识别字段,确认无误后再提交审批。'}" ) - return "已将本次上传的票据关联到现有草稿。您可以继续补充识别字段,确认无误后再提交审批。" + return f"已将本次上传的票据关联到现有草稿。{followup_copy or '您可以继续补充识别字段,确认无误后再提交审批。'}" if review_action == "create_new_claim_from_documents": document_count = self._resolve_review_document_count(payload) + followup_copy = self._build_review_action_followup_copy(review_payload) if draft_payload is not None and draft_payload.claim_no: return ( f"已按当前上传的 {document_count} 张票据新建报销草稿 {draft_payload.claim_no}。" - "您可以继续补充识别字段,确认无误后再提交审批。" + f"{followup_copy or '您可以继续补充识别字段,确认无误后再提交审批。'}" ) - return "已按当前上传票据新建报销草稿。您可以继续补充识别字段,确认无误后再提交审批。" + return f"已按当前上传票据新建报销草稿。{followup_copy or '您可以继续补充识别字段,确认无误后再提交审批。'}" if review_action == "next_step": if draft_payload is not None and draft_payload.status == "submitted": stage_text = draft_payload.approval_stage or "审批中" @@ -3135,6 +3389,7 @@ class UserAgentService: risk_briefs: list[UserAgentReviewRiskBrief], can_proceed: bool, document_cards: list[UserAgentReviewDocumentCard], + travel_receipt_state: dict[str, Any] | None = None, ) -> str: if self._is_review_association_choice_pending(payload): claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip() @@ -3157,13 +3412,30 @@ class UserAgentService: "请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。" ) + travel_message = self._build_travel_receipt_guidance_message( + payload, + travel_receipt_state=travel_receipt_state or {}, + can_proceed=can_proceed, + ) + if travel_message: + return travel_message + + missing_labels = self._resolve_review_missing_slot_labels(slot_cards) + if travel_receipt_state: + missing_labels.extend( + str(item) + for item in travel_receipt_state.get("required_missing_labels", []) + if str(item).strip() + ) + missing_labels = list(dict.fromkeys(missing_labels)) + review_payload = UserAgentReviewPayload( intent_summary="", body_message="", scenario=payload.ontology.scenario, intent=payload.ontology.intent, can_proceed=can_proceed, - missing_slots=self._resolve_review_missing_slot_labels(slot_cards), + missing_slots=missing_labels, risk_briefs=risk_briefs, slot_cards=slot_cards, document_cards=[], @@ -3176,6 +3448,155 @@ class UserAgentService: f"{self._build_review_guidance_copy(review_payload, mention_save_draft=not can_proceed)}" ) + @staticmethod + def _build_review_action_followup_copy(review_payload: UserAgentReviewPayload) -> str: + missing_slots = [str(item).strip() for item in review_payload.missing_slots if str(item).strip()] + receipt_briefs = [ + item + for item in review_payload.risk_briefs + if "差旅票据待补充" in str(item.title or "") + ] + if missing_slots: + return f"当前仍有 {'、'.join(missing_slots)},暂时只能保存为草稿,补齐后再继续下一步。" + if receipt_briefs: + return "当前必需票据已具备;如还有市内交通、打车、地铁或停车等乘车票据,可以继续上传,也可以继续下一步或保存草稿。" + if review_payload.can_proceed: + return "当前信息已较完整,您可以继续下一步,也可以先保存为草稿。" + return "" + + def _build_travel_receipt_guidance_message( + self, + payload: UserAgentRequest, + *, + travel_receipt_state: dict[str, Any], + can_proceed: bool, + ) -> str: + review_action = str(payload.context_json.get("review_action") or "").strip() + if review_action or not travel_receipt_state.get("has_long_distance_ticket"): + return "" + + employee = self._resolve_employee_profile(payload) + user_name = ( + str(employee.name).strip() + if employee is not None and employee.name + else str(payload.context_json.get("name") or payload.user_id or "同事").strip() + ) + destination = str(travel_receipt_state.get("destination") or "待确认").strip() + days = max(1, int(travel_receipt_state.get("days") or 1)) + ticket_type_label = str(travel_receipt_state.get("ticket_type_label") or "交通").strip() + ticket_amount = self._coerce_decimal_money(travel_receipt_state.get("ticket_amount")) + + required_labels = [ + str(item).strip() + for item in travel_receipt_state.get("required_missing_labels", []) + if str(item).strip() + ] + optional_labels = [ + str(item).strip() + for item in travel_receipt_state.get("optional_missing_labels", []) + if str(item).strip() + ] + + lines = [ + f"您好:{user_name},根据您提交的票据信息,您可能出差的地点为 {destination},天数为:{days} 天。", + f"根据票据,您现在提交的是{ticket_type_label}票,一共金额为:{self._format_decimal_money(ticket_amount)} 元。", + ] + + provide_items: list[str] = [] + if required_labels: + provide_items.append("1. 酒店住宿发票/住宿清单(必须,当前待上传)") + if optional_labels: + provide_items.append(f"{len(provide_items) + 1}. 市内交通/乘车票据(非必须,如打车、地铁、停车等)") + if provide_items: + lines.append("根据公司相关报销制度,您还可以继续提供:\n" + "\n".join(provide_items)) + else: + lines.append("根据公司相关报销制度,当前核心票据已较完整,无需继续上传票据。") + + if required_labels: + lines.append("酒店票据仍缺失,所以暂时不能继续下一步;您可以先保存为草稿,补齐后再提交。") + elif can_proceed and optional_labels: + lines.append("当前必需票据已具备;如暂时没有乘车票据,也可以继续下一步,或先保存为草稿。") + elif can_proceed: + lines.append("当前信息已较完整,确认无误后可以继续下一步,也可以先保存为草稿。") + + estimate_copy = self._build_travel_receipt_estimate_copy( + payload, + travel_receipt_state=travel_receipt_state, + ) + if estimate_copy: + lines.append(estimate_copy) + return "\n".join(line for line in lines if line) + + def _build_travel_receipt_estimate_copy( + self, + payload: UserAgentRequest, + *, + travel_receipt_state: dict[str, Any], + ) -> str: + destination = str(travel_receipt_state.get("destination") or "").strip() + days = max(1, int(travel_receipt_state.get("days") or 1)) + ticket_type_label = str(travel_receipt_state.get("ticket_type_label") or "交通").strip() + ticket_amount = self._coerce_decimal_money(travel_receipt_state.get("ticket_amount")) + employee = self._resolve_employee_profile(payload) + grade = self._resolve_review_employee_grade(payload, employee=employee) + + if not destination or not grade: + return ( + "根据公司差旅费报销依据," + f"您的职级为:{grade or '待确认'},去{destination or '出差地点待确认'}," + f"当前可确认的{ticket_type_label}票据金额为:{self._format_decimal_money(ticket_amount)} 元;" + "住宿和补贴金额需补齐职级或地点后再核算。" + ) + + current_user = CurrentUserContext( + username=str(payload.user_id or payload.context_json.get("name") or "anonymous").strip() or "anonymous", + name=str(payload.context_json.get("name") or payload.user_id or "anonymous").strip() or "anonymous", + role_codes=[ + str(item).strip() + for item in list(payload.context_json.get("role_codes") or []) + if str(item).strip() + ], + is_admin=bool(payload.context_json.get("is_admin")), + department_name=str(payload.context_json.get("department_name") or payload.context_json.get("department") or "").strip(), + ) + try: + calculation = TravelReimbursementCalculatorService(self.db).calculate( + TravelReimbursementCalculatorRequest(days=days, location=destination, grade=grade), + current_user, + ) + except Exception: + return ( + "根据公司差旅费报销依据," + f"您的职级为:{grade},去{destination},当前可确认的{ticket_type_label}票据金额为:" + f"{self._format_decimal_money(ticket_amount)} 元;住宿和补贴标准暂时无法自动测算,请以规则中心最新差旅标准为准。" + ) + + total_amount = ( + ticket_amount + + self._coerce_decimal_money(calculation.hotel_amount) + + self._coerce_decimal_money(calculation.allowance_amount) + ).quantize(Decimal("0.01")) + return ( + "根据公司差旅费报销依据," + f"您的职级为:{calculation.grade},去{calculation.matched_city or destination}," + "报销费用核算约为:" + f"已提交{ticket_type_label} {self._format_decimal_money(ticket_amount)} 元 + " + f"住宿标准 {self._format_decimal_money(calculation.hotel_rate)} 元/天 × {calculation.days} 天 + " + f"出差补贴 {self._format_decimal_money(calculation.total_allowance_rate)} 元/天 × {calculation.days} 天 = " + f"{self._format_decimal_money(total_amount)} 元。" + ) + + @staticmethod + def _coerce_decimal_money(value: Any) -> Decimal: + try: + return Decimal(str(value or "0")).quantize(Decimal("0.01")) + except (InvalidOperation, ValueError): + return Decimal("0.00") + + @staticmethod + def _format_decimal_money(value: Any) -> str: + return f"{UserAgentService._coerce_decimal_money(value):.2f}" + @staticmethod def _resolve_review_missing_slot_labels( slot_cards: list[UserAgentReviewSlotCard], @@ -4076,16 +4497,11 @@ class UserAgentService: merchant_value = "" for document in ocr_documents: - if str(document.get("document_type") or "").strip().lower() != "hotel_invoice": + if not self._is_hotel_document_item(document): 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, @@ -4407,6 +4823,8 @@ class UserAgentService: label=display_label, value=value, ) + if display_label == "商户/酒店" and not self._is_hotel_document_item(item): + continue if display_label and normalized_value: normalized_fields.setdefault(display_label, normalized_value) @@ -4418,7 +4836,7 @@ class UserAgentService: if date_match and "时间" not in normalized_fields: normalized_fields["时间"] = date_match.group(1) - merchant = self._extract_document_merchant_name_from_text(text) + merchant = self._extract_document_merchant_name_from_text(text) if self._is_hotel_document_item(item) else "" if merchant and "商户/酒店" not in normalized_fields: normalized_fields["商户/酒店"] = merchant return normalized_fields @@ -4484,9 +4902,25 @@ class UserAgentService: merchant = str(fields.get("商户/酒店") or "").strip() if merchant: return merchant + if not self._is_hotel_document_item(item): + return "" text = " ".join([str(item.get("summary") or ""), str(item.get("text") or "")]).strip() return self._extract_document_merchant_name_from_text(text) + @staticmethod + def _is_hotel_document_item(item: dict[str, object]) -> bool: + document_type = str(item.get("document_type") or "").strip().lower() + scene_code = str(item.get("scene_code") or "").strip().lower() + scene_label = str(item.get("scene_label") or "").strip() + suggested_expense_type = str(item.get("suggested_expense_type") or "").strip().lower() + return ( + document_type == "hotel_invoice" + or scene_code == "hotel" + or suggested_expense_type == "hotel" + or "住宿" in scene_label + or "酒店" in scene_label + ) + @staticmethod def _extract_document_merchant_name_from_text(text: str) -> str: for keyword in ("酒店", "宾馆", "饭店", "酒楼", "餐厅", "航空", "铁路", "滴滴"): diff --git a/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf b/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf deleted file mode 100644 index d2aad17..0000000 Binary files a/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf and /dev/null differ diff --git a/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf.meta.json b/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf.meta.json deleted file mode 100644 index e44c696..0000000 --- a/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf.meta.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "file_name": "发票_3_京S98876.pdf", - "storage_key": "193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.pdf", - "media_type": "application/pdf", - "size_bytes": 61170, - "uploaded_at": "2026-05-20T12:25:49.243144+00:00", - "previewable": true, - "preview_kind": "image", - "preview_storage_key": "193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.preview.png", - "preview_media_type": "image/png", - "preview_file_name": "发票_3_京S98876.preview.png", - "analysis": { - "severity": "pass", - "label": "AI提示符合条件", - "headline": "AI提示:附件符合基础校验条件", - "summary": "已识别到票据类型和关键字段,且符合当前费用场景的附件要求。", - "points": [ - "票据类型:已识别为增值税发票。", - "附件类型要求:当前费用项目为交通费,已识别为增值税发票,符合当前交通费场景的附件要求。", - "金额字段:已识别到与当前明细接近的金额 121.54 元。" - ], - "suggestion": "建议继续核对报销分类、费用说明和业务场景是否一致。" - }, - "document_info": { - "document_type": "vat_invoice", - "document_type_label": "增值税发票", - "scene_code": "other", - "scene_label": "通用发票", - "fields": [ - { - "key": "amount", - "label": "金额", - "value": "121.54元" - }, - { - "key": "date", - "label": "日期", - "value": "2026-03-04" - }, - { - "key": "merchant_name", - "label": "商户", - "value": "信息" - }, - { - "key": "invoice_number", - "label": "票据号码", - "value": "26427004426998871533" - } - ] - }, - "requirement_check": { - "matches": true, - "current_expense_type": "transport", - "current_expense_type_label": "交通费", - "allowed_scene_labels": [ - "交通" - ], - "allowed_document_type_labels": [ - "停车/通行费票据", - "一般收据/凭证", - "出租车/网约车票据", - "增值税发票" - ], - "recognized_scene_code": "other", - "recognized_scene_label": "通用发票", - "recognized_document_type": "vat_invoice", - "recognized_document_type_label": "增值税发票", - "mismatch_severity": "high", - "rule_code": "rule.expense.scene_submission_standard", - "rule_name": "报销场景提交与附件标准", - "message": "当前费用项目为交通费,已识别为增值税发票,符合当前交通费场景的附件要求。" - }, - "ocr_status": "recognized", - "ocr_error": "", - "ocr_text": "发票号码:26427004426998871533\n旅普发票)\n电子发票\n开票日期:2026年03月04日\n购买方信息\n名称:北京京能电力股份有限公司\n销售方信息\n名称:北京小桔科技有限公司\n统一社会信用代码/纳税人识别1:110000717734559Y\n统一社会信用代码/纳税人识别1:110108MA00293G5X\n项目名称\n单价\n数量\n金额\n税率/征收率\n税额\n*运输服务*客运服务费\n118.00\n1\n118.00\n3%\n3.54\n合\n计\n¥118.00\n¥3.54\n出行人\n有效身份证件号\n出行日期\n出发地\n到达地\n等级\n交通工具\n类\n2026-03-04\n小汤山酒店\n林萃花园南门\n网约车\n价税合计(大写)\n壹佰贰拾壹圆伍角肆分\n(小写)¥121.54\n购方开户银行:-;\n银行账号:-;\n备注\n销方开户银行:中国建设银行北京中关村支行;\n银行账号:11001006500059041897;\n开票人:系统自动开票", - "ocr_summary": "发票号码:26427004426998871533;旅普发票);电子发票", - "ocr_avg_score": 0.9825071743194093, - "ocr_line_count": 47, - "ocr_classification_source": "rule", - "ocr_classification_confidence": 0.74, - "ocr_classification_evidence": [ - "发票号码", - "价税合计", - "电子发票" - ], - "ocr_warnings": [] -} \ No newline at end of file diff --git a/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.preview.png b/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.preview.png deleted file mode 100644 index 1b02f70..0000000 Binary files a/server/storage/expense_claims/193e6c44-29f7-4ac9-9d64-c57ddfd186c0/f8e2b572-9f9f-472f-957c-cb49c5e1e283/发票_3_京S98876.preview.png and /dev/null 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/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/3d643ccb-cfb5-48c5-8037-39dbe1fa87e4/2月20_武汉-上海.pdf similarity index 100% rename from server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf rename to server/storage/expense_claims/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/3d643ccb-cfb5-48c5-8037-39dbe1fa87e4/2月20_武汉-上海.pdf 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/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/3d643ccb-cfb5-48c5-8037-39dbe1fa87e4/2月20_武汉-上海.pdf.meta.json similarity index 76% rename from server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf.meta.json rename to server/storage/expense_claims/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/3d643ccb-cfb5-48c5-8037-39dbe1fa87e4/2月20_武汉-上海.pdf.meta.json index 496127b..d25173f 100644 --- 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/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/3d643ccb-cfb5-48c5-8037-39dbe1fa87e4/2月20_武汉-上海.pdf.meta.json @@ -1,12 +1,12 @@ { "file_name": "2月20_武汉-上海.pdf", - "storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.pdf", + "storage_key": "3754b9c8-e0f0-4d88-a24c-d52c7620be2c/3d643ccb-cfb5-48c5-8037-39dbe1fa87e4/2月20_武汉-上海.pdf", "media_type": "application/pdf", "size_bytes": 24995, - "uploaded_at": "2026-05-20T13:48:21.652497+00:00", + "uploaded_at": "2026-05-21T01:54:55.627221+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_storage_key": "3754b9c8-e0f0-4d88-a24c-d52c7620be2c/3d643ccb-cfb5-48c5-8037-39dbe1fa87e4/2月20_武汉-上海.preview.png", "preview_media_type": "image/png", "preview_file_name": "2月20_武汉-上海.preview.png", "analysis": { @@ -15,7 +15,7 @@ "headline": "AI提示:附件存在明显待整改项", "summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。", "points": [ - "用途字段:用户填写用途“业务发生时间:2026-02-20 至 2026”与票据内容不一致,当前附件更像交通相关材料。" + "用途字段:用户填写用途“至 2026-02-23,支撑上海电力项目部署,”与票据内容不一致,当前附件更像交通相关材料。" ], "suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。" }, @@ -54,15 +54,10 @@ }, "requirement_check": { "matches": true, - "current_expense_type": "travel", - "current_expense_type_label": "差旅费", - "allowed_scene_labels": [ - "差旅" - ], - "allowed_document_type_labels": [ - "机票/航班行程单", - "火车/高铁票" - ], + "current_expense_type": "train_ticket", + "current_expense_type_label": "火车票", + "allowed_scene_labels": [], + "allowed_document_type_labels": [], "recognized_scene_code": "travel", "recognized_scene_label": "差旅票据", "recognized_document_type": "train_ticket", @@ -70,7 +65,7 @@ "mismatch_severity": "high", "rule_code": "rule.expense.scene_submission_standard", "rule_name": "报销场景提交与附件标准", - "message": "当前费用项目为差旅费,已识别为火车/高铁票,符合当前差旅费场景的附件要求。" + "message": "当前费用项目为火车票,已识别为火车/高铁票。" }, "ocr_status": "recognized", "ocr_error": "", diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.preview.png b/server/storage/expense_claims/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/3d643ccb-cfb5-48c5-8037-39dbe1fa87e4/2月20_武汉-上海.preview.png similarity index 100% rename from server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/62fe16f9-ad9f-4f96-948f-0b31a427a81d/2月20_武汉-上海.preview.png rename to server/storage/expense_claims/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/3d643ccb-cfb5-48c5-8037-39dbe1fa87e4/2月20_武汉-上海.preview.png 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/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/a8d8e56b-8e0c-4feb-9371-1e3cd71ce25b/2月23_上海-武汉.pdf similarity index 100% rename from server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf rename to server/storage/expense_claims/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/a8d8e56b-8e0c-4feb-9371-1e3cd71ce25b/2月23_上海-武汉.pdf 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/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/a8d8e56b-8e0c-4feb-9371-1e3cd71ce25b/2月23_上海-武汉.pdf.meta.json similarity index 76% rename from server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf.meta.json rename to server/storage/expense_claims/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/a8d8e56b-8e0c-4feb-9371-1e3cd71ce25b/2月23_上海-武汉.pdf.meta.json index b2e8892..8eff481 100644 --- 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/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/a8d8e56b-8e0c-4feb-9371-1e3cd71ce25b/2月23_上海-武汉.pdf.meta.json @@ -1,12 +1,12 @@ { "file_name": "2月23_上海-武汉.pdf", - "storage_key": "08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.pdf", + "storage_key": "3754b9c8-e0f0-4d88-a24c-d52c7620be2c/a8d8e56b-8e0c-4feb-9371-1e3cd71ce25b/2月23_上海-武汉.pdf", "media_type": "application/pdf", "size_bytes": 24940, - "uploaded_at": "2026-05-20T13:48:38.616319+00:00", + "uploaded_at": "2026-05-21T01:55:11.468967+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_storage_key": "3754b9c8-e0f0-4d88-a24c-d52c7620be2c/a8d8e56b-8e0c-4feb-9371-1e3cd71ce25b/2月23_上海-武汉.preview.png", "preview_media_type": "image/png", "preview_file_name": "2月23_上海-武汉.preview.png", "analysis": { @@ -15,7 +15,7 @@ "headline": "AI提示:附件存在明显待整改项", "summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。", "points": [ - "用途字段:用户填写用途“业务发生时间:2026-02-20 至 2026”与票据内容不一致,当前附件更像交通相关材料。" + "用途字段:用户填写用途“至 2026-02-23,支撑上海电力项目部署,”与票据内容不一致,当前附件更像交通相关材料。" ], "suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。" }, @@ -54,15 +54,10 @@ }, "requirement_check": { "matches": true, - "current_expense_type": "travel", - "current_expense_type_label": "差旅费", - "allowed_scene_labels": [ - "差旅" - ], - "allowed_document_type_labels": [ - "机票/航班行程单", - "火车/高铁票" - ], + "current_expense_type": "train_ticket", + "current_expense_type_label": "火车票", + "allowed_scene_labels": [], + "allowed_document_type_labels": [], "recognized_scene_code": "travel", "recognized_scene_label": "差旅票据", "recognized_document_type": "train_ticket", @@ -70,7 +65,7 @@ "mismatch_severity": "high", "rule_code": "rule.expense.scene_submission_standard", "rule_name": "报销场景提交与附件标准", - "message": "当前费用项目为差旅费,已识别为火车/高铁票,符合当前差旅费场景的附件要求。" + "message": "当前费用项目为火车票,已识别为火车/高铁票。" }, "ocr_status": "recognized", "ocr_error": "", diff --git a/server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.preview.png b/server/storage/expense_claims/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/a8d8e56b-8e0c-4feb-9371-1e3cd71ce25b/2月23_上海-武汉.preview.png similarity index 100% rename from server/storage/expense_claims/08f51e80-512e-498e-bcd6-50ca5d0becfc/1170b632-ad59-46c0-9876-7230d9d97e30/2月23_上海-武汉.preview.png rename to server/storage/expense_claims/3754b9c8-e0f0-4d88-a24c-d52c7620be2c/a8d8e56b-8e0c-4feb-9371-1e3cd71ce25b/2月23_上海-武汉.preview.png diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 2f25967..892e9df 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -15,7 +15,7 @@ from app.models.financial_record import ExpenseClaim, ExpenseClaimItem 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.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate from app.services.agent_conversations import AgentConversationService from app.services.expense_claims import ExpenseClaimService from app.services.ontology import SemanticOntologyService @@ -405,6 +405,92 @@ def test_upsert_draft_from_ontology_supports_link_or_create_for_multi_documents( assert float(new_claim.amount) == 50.5 +def test_upsert_travel_draft_uses_ticket_item_types_and_auto_allowance() -> None: + user_id = "travel-allowance@example.com" + + with build_session() as db: + employee = Employee( + employee_no="E5010", + name="差旅员工", + email=user_id, + grade="P4", + ) + db.add(employee) + db.commit() + + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我去北京出差 3 天,上传了火车票,帮我生成差旅费报销草稿", + user_id=user_id, + ) + ) + result = ExpenseClaimService(db).upsert_draft_from_ontology( + run_id=ontology.run_id, + user_id=user_id, + message="我去北京出差 3 天,上传了火车票,帮我生成差旅费报销草稿", + ontology=ontology, + context_json={ + "name": "差旅员工", + "grade": "P4", + "attachment_names": ["train-ticket.png"], + "attachment_count": 1, + "review_form_values": { + "expense_type": "差旅费", + "location": "北京", + "time_range": "2026-05-13 至 2026-05-15", + }, + "business_time_context": { + "mode": "range", + "start_date": "2026-05-13", + "end_date": "2026-05-15", + "display_value": "2026-05-13 至 2026-05-15", + }, + "ocr_documents": [ + { + "filename": "train-ticket.png", + "document_type": "train_ticket", + "document_type_label": "火车/高铁票", + "scene_code": "travel", + "scene_label": "差旅费", + "summary": "中国铁路电子客票 广州南-北京南 二等座 票价 354 元", + "text": "中国铁路电子客票 广州南-北京南 二等座 票价:¥354.00", + "document_fields": [ + {"key": "amount", "label": "票价", "value": "¥354.00"}, + {"key": "route", "label": "行程", "value": "广州南-北京南"}, + ], + } + ], + }, + ) + + claim = db.get(ExpenseClaim, result["claim_id"]) + assert claim is not None + assert claim.expense_type == "travel" + assert claim.invoice_count == 1 + assert len(claim.items) == 2 + train_item = next(item for item in claim.items if item.item_type == "train_ticket") + allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance") + assert train_item.item_amount == Decimal("354.00") + assert train_item.item_reason == "从广州南到北京南" + assert allowance_item.item_amount == Decimal("300.00") + assert allowance_item.invoice_id is None + assert allowance_item.is_system_generated is True + assert claim.amount == Decimal("654.00") + + with pytest.raises(ValueError, match="系统自动计算"): + ExpenseClaimService(db).update_claim_item( + claim_id=claim.id, + item_id=allowance_item.id, + payload=ExpenseClaimItemUpdate(item_amount=Decimal("1.00")), + current_user=CurrentUserContext( + username=user_id, + name="差旅员工", + role_codes=[], + is_admin=False, + ), + ) + + def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_events() -> None: user_id = "returned-owner@example.com" return_flag = { @@ -635,6 +721,42 @@ def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() -> assert new_item.invoice_id is None +def test_update_claim_reason_only_allows_draft_pending_submission() -> None: + current_user = CurrentUserContext( + username="emp-1", + name="张三", + role_codes=[], + is_admin=False, + ) + + with build_session() as db: + claim = build_claim(expense_type="travel", location="北京") + db.add(claim) + db.commit() + + service = ExpenseClaimService(db) + updated = service.update_claim( + claim_id=claim.id, + payload=ExpenseClaimUpdate(reason="去北京客户现场出差,处理项目验收事项"), + current_user=current_user, + ) + + assert updated is not None + assert updated.reason == "去北京客户现场出差,处理项目验收事项" + + claim.status = "submitted" + claim.submitted_at = datetime(2026, 5, 14, tzinfo=UTC) + claim.approval_stage = "直属领导审批" + db.commit() + + with pytest.raises(ValueError, match="草稿待提交"): + service.update_claim( + claim_id=claim.id, + payload=ExpenseClaimUpdate(reason="提交后不能改"), + current_user=current_user, + ) + + def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path) -> None: current_user = CurrentUserContext( username="emp-1", @@ -785,6 +907,8 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p assert updated["claim_amount"] == Decimal("354.00") db.refresh(claim) assert claim.items[0].item_amount == Decimal("354.00") + assert claim.items[0].item_type == "train_ticket" + assert claim.items[0].item_reason == "从广州南到北京南" assert claim.amount == Decimal("354.00") uploaded_meta = service.get_claim_item_attachment_meta( claim_id=claim.id, @@ -799,6 +923,75 @@ def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_p ) +def test_upload_ride_receipt_backfills_item_reason_from_addresses(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="ride-receipt.png", + media_type="image/png", + text="滴滴出行订单 起点:深圳北站 终点:腾讯滨海大厦 实付金额:42.00元", + summary="滴滴出行乘车票据,深圳北站到腾讯滨海大厦,金额 42 元。", + avg_score=0.98, + line_count=1, + page_count=1, + document_type="taxi_receipt", + document_type_label="出租车/网约车票据", + scene_code="transport", + scene_label="交通票据", + document_fields=[ + {"key": "start_location", "label": "起点", "value": "深圳北站"}, + {"key": "end_location", "label": "终点", "value": "腾讯滨海大厦"}, + {"key": "amount", "label": "实付金额", "value": "42.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="transport", location="深圳") + claim.amount = Decimal("0.00") + claim.invoice_count = 0 + claim.items[0].item_type = "transport" + claim.items[0].item_reason = "打车报销" + 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="ride-receipt.png", + content=b"fake-image-bytes", + media_type="image/png", + current_user=current_user, + ) + + assert updated is not None + db.refresh(claim) + assert claim.items[0].item_type == "ride_ticket" + assert claim.items[0].item_reason == "从深圳北站到腾讯滨海大厦" + assert claim.items[0].item_amount == Decimal("42.00") + assert claim.amount == Decimal("42.00") + + def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None: current_user = CurrentUserContext( username="emp-1", diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index d407c9a..6c63ccb 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -1315,6 +1315,230 @@ def test_user_agent_review_payload_prefers_hotel_invoice_for_hotel_name() -> Non assert slot_map["merchant_name"].value == "北京中心酒店" +def test_user_agent_review_payload_does_not_fill_hotel_name_from_train_ticket() -> 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": "train_ticket", + "scene_code": "travel", + "scene_label": "差旅票据", + "summary": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 金额 560 元 中国铁路", + "text": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 票价 ¥560.00 中国铁路祝您旅途愉快", + "avg_score": 0.95, + "document_fields": [ + {"key": "amount", "label": "金额", "value": "560"}, + {"key": "route", "label": "行程", "value": "广州南-北京南"}, + {"key": "date", "label": "业务发生时间", "value": "2026-03-04"}, + {"key": "merchant_name", "label": "商户", "value": "中国铁路"}, + ], + "warnings": [], + }, + ], + } + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=query, + user_id="pytest-train-only-hotel-name@example.com", + context_json=context, + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest-train-only-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 == "" + assert "酒店/商户" not in response.review_payload.missing_slots + assert "酒店的报销票据待上传(必须)" in response.review_payload.missing_slots + assert response.review_payload.can_proceed is False + assert [item.action_type for item in response.review_payload.confirmation_actions if item.emphasis == "primary"] == [ + "save_draft" + ] + assert "继续下一步" not in [item.label for item in response.review_payload.confirmation_actions] + assert "酒店住宿发票/住宿清单(必须,当前待上传)" in response.answer + assert "市内交通/乘车票据(非必须" in response.answer + assert "只能保存为草稿" in response.answer or "保存为草稿" in response.answer + assert "您的职级为:P4" in response.answer + assert "去北京" in response.answer + assert "已提交火车 560.00 元" in response.answer + field_labels = [ + field.label + for card in response.review_payload.document_cards + for field in card.fields + ] + assert "商户/酒店" not in field_labels + + +def test_user_agent_review_payload_allows_next_step_when_only_optional_ride_receipt_is_missing() -> None: + session_factory = build_session_factory() + with session_factory() as db: + query = "我去北京出差,上传了火车票和酒店票,帮我生成差旅费报销草稿" + context = { + "name": "张三", + "grade": "P4", + "review_form_values": {"occurred_date": "2026-03-04"}, + "attachment_names": ["北京南站火车票.png", "北京酒店发票.png"], + "attachment_count": 2, + "ocr_documents": [ + { + "filename": "北京南站火车票.png", + "document_type": "train_ticket", + "scene_code": "travel", + "scene_label": "差旅票据", + "summary": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 金额 560 元", + "text": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 票价 ¥560.00", + "avg_score": 0.95, + "document_fields": [ + {"key": "amount", "label": "金额", "value": "560"}, + {"key": "route", "label": "行程", "value": "广州南-北京南"}, + ], + "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-optional-ride@example.com", + context_json=context, + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest-travel-optional-ride@example.com", + message=query, + ontology=ontology, + context_json=context, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + assert response.review_payload.can_proceed is True + assert response.review_payload.missing_slots == [] + receipt_brief = next(item for item in response.review_payload.risk_briefs if item.title == "差旅票据待补充") + assert receipt_brief.level == "info" + assert "市内交通/乘车票据可继续上传(非必须)" in receipt_brief.content + assert "酒店的报销票据待上传(必须)" not in receipt_brief.content + action_types = [item.action_type for item in response.review_payload.confirmation_actions] + assert "save_draft" in action_types + assert "next_step" in action_types + assert "市内交通/乘车票据(非必须" in response.answer + assert "也可以继续下一步" in response.answer + + +def test_user_agent_review_payload_allows_next_step_after_required_travel_receipts_are_complete() -> None: + session_factory = build_session_factory() + with session_factory() as db: + query = "我去北京出差,上传了火车票、酒店票和打车票,帮我生成差旅费报销草稿" + context = { + "name": "张三", + "grade": "P4", + "review_form_values": {"occurred_date": "2026-03-04"}, + "attachment_names": ["北京南站火车票.png", "北京酒店发票.png", "北京打车票.png"], + "attachment_count": 3, + "ocr_documents": [ + { + "filename": "北京南站火车票.png", + "document_type": "train_ticket", + "scene_code": "travel", + "scene_label": "差旅票据", + "summary": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 金额 560 元", + "text": "电子发票(铁路电子客票) 2026-03-04 广州南至北京南 G123 二等座 票价 ¥560.00", + "avg_score": 0.95, + "document_fields": [ + {"key": "amount", "label": "金额", "value": "560"}, + {"key": "route", "label": "行程", "value": "广州南-北京南"}, + {"key": "date", "label": "日期", "value": "2026-03-04"}, + ], + "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": [], + }, + { + "filename": "北京打车票.png", + "document_type": "taxi_receipt", + "summary": "北京网约车 打车票 支付金额 32 元", + "text": "北京网约车 打车票 支付金额 32 元", + "avg_score": 0.94, + "document_fields": [ + {"key": "amount", "label": "支付金额", "value": "32"}, + ], + "warnings": [], + }, + ], + } + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query=query, + user_id="pytest-travel-complete@example.com", + context_json=context, + ) + ) + + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest-travel-complete@example.com", + message=query, + ontology=ontology, + context_json=context, + tool_payload={"draft_only": True}, + ) + ) + + assert response.review_payload is not None + assert response.review_payload.can_proceed is True + assert response.review_payload.missing_slots == [] + action_types = [item.action_type for item in response.review_payload.confirmation_actions] + assert "save_draft" in action_types + assert "next_step" in action_types + assert not any(item.title == "差旅票据待补充" for item in response.review_payload.risk_briefs) + assert "无需继续上传票据" in response.answer + assert "当前信息已较完整" in response.answer + + def test_user_agent_review_payload_prechecks_taxi_amount_against_rule_standard() -> None: session_factory = build_session_factory() with session_factory() as db: diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index a83cfde..741c20e 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -561,6 +561,46 @@ white-space: pre-wrap; } +.detail-note.readonly { + background: #f8fafc; + border-color: #e2e8f0; +} + +.detail-note-editor { + display: grid; + gap: 10px; +} + +.detail-note-editor textarea { + min-height: 92px; + border-color: rgba(16, 185, 129, .28); + background: #fff; +} + +.detail-note-editor textarea:focus { + border-color: #10b981; + box-shadow: 0 0 0 3px rgba(16, 185, 129, .12); + outline: none; +} + +.detail-note-editor-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.detail-note-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex-shrink: 0; + gap: 8px; +} + .leader-approval-card { border-color: rgba(5, 150, 105, .18); background: linear-gradient(180deg, #ffffff 0%, #f7fdfb 100%); @@ -633,6 +673,15 @@ background: #fbfefd; } +.detail-expense-table tbody tr.system-generated-row td { + background: #f0fdf4; + border-bottom-color: #bbf7d0; +} + +.detail-expense-table tbody tr.system-generated-row:hover td { + background: #ecfdf5; +} + .detail-expense-table .col-time { width: 11%; } .detail-expense-table .col-filled-at { width: 15%; } .detail-expense-table .col-type { width: 13%; } @@ -756,6 +805,36 @@ color: #ea580c; } +.over-tag.system { + background: #dcfce7; + color: #047857; +} + +.expense-total-under-table { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + margin-top: 12px; + padding: 12px 14px; + border: 1px solid #d1fae5; + border-radius: 8px; + background: #f0fdf4; + color: #0f766e; +} + +.expense-total-under-table span { + color: #475569; + font-size: 12px; + font-weight: 800; +} + +.expense-total-under-table strong { + color: #047857; + font-size: 17px; + font-weight: 900; +} + .attachment-action-group { display: inline-flex; align-items: center; @@ -932,6 +1011,36 @@ min-width: 128px; } +.system-row-lock { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + min-height: 28px; + padding: 0 9px; + border-radius: 8px; + background: #dcfce7; + color: #047857; + font-size: 11px; + font-weight: 850; + white-space: nowrap; +} + +.system-attachment-note { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + min-height: 28px; + padding: 0 9px; + border-radius: 8px; + background: #ecfdf5; + color: #047857; + font-size: 11px; + font-weight: 850; + white-space: nowrap; +} + .row-action-group { display: flex; flex-wrap: wrap; @@ -1332,8 +1441,9 @@ } .validation-card { - border: 1px solid #e6f0eb; - background: linear-gradient(180deg, #fcfffd 0%, #f7fbf9 100%); + border: 1px solid #e5e7eb; + background: #ffffff; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); } .validation-head { @@ -1341,11 +1451,14 @@ align-items: flex-start; justify-content: space-between; gap: 12px; - margin-bottom: 8px; + margin-bottom: 10px; } .validation-head h3 { margin-bottom: 4px; + color: #0f172a; + font-size: 15px; + font-weight: 800; } .validation-head p { @@ -1356,28 +1469,32 @@ } .validation-pill { - min-height: 26px; + min-height: 24px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; - font-size: 12px; + border: 1px solid transparent; + font-size: 11px; font-weight: 800; } .validation-pill.ready { - background: #dcfce7; - color: #047857; + background: #f0fdf4; + border-color: #bbf7d0; + color: #166534; } .validation-pill.pending { background: #fff7ed; + border-color: #fed7aa; color: #c2410c; } .validation-pill.warning { background: #fef2f2; - color: #dc2626; + border-color: #fecaca; + color: #b91c1c; } .validation-summary { @@ -1387,29 +1504,155 @@ line-height: 1.6; } +.validation-sections { + display: grid; + gap: 18px; + margin-top: 16px; +} + +.validation-section { + display: grid; + gap: 10px; + padding-top: 14px; + border-top: 1px solid #e5e7eb; +} + +.validation-section:first-child { + padding-top: 0; + border-top: none; +} + +.validation-section-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + color: #0f172a; + font-size: 13px; + font-weight: 800; + line-height: 1.4; +} + +.validation-section-title::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 999px; + background: #10b981; + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12); +} + +.validation-section--risk .validation-section-title { + color: #b91c1c; +} + +.validation-section--risk .validation-section-title::before { + background: #ef4444; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); +} + .validation-list { display: grid; gap: 6px; - margin-top: 12px; - padding-left: 18px; - color: #b45309; + margin: 0; + padding: 0 0 0 18px; + color: #0f766e; font-size: 13px; line-height: 1.55; } -.risk-advice-list { - display: grid; - gap: 12px; - margin-top: 14px; +.validation-list li::marker { + color: #14b8a6; } -.risk-advice-card { +.validation-section--risk .risk-advice-list { display: grid; gap: 10px; - padding: 14px; - border: 1px solid #fee2e2; + margin-top: 0; +} + +.validation-section--risk .risk-advice-card { + display: grid; + gap: 8px; + padding: 12px 12px 11px; + border: 1px solid #e5e7eb; + border-radius: 10px; + background: #ffffff; + box-shadow: 0 1px 1px rgba(15, 23, 42, 0.03); +} + +.validation-section--risk .risk-advice-card.medium { + border-color: #f3e8d9; + background: #fffcf7; +} + +.validation-section--risk .risk-advice-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.validation-section--risk .risk-advice-card-head span { + min-height: 20px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + background: #fef2f2; + color: #b91c1c; + font-size: 10px; + font-weight: 800; + white-space: nowrap; +} + +.validation-section--risk .risk-advice-card.medium .risk-advice-card-head span { + background: #fff7ed; + color: #c2410c; +} + +.validation-section--risk .risk-advice-card-head strong { + min-width: 0; + color: #0f172a; + font-size: 12px; + line-height: 1.4; + text-align: right; +} + +.validation-section--risk .risk-advice-point { + margin: 0; + color: #334155; + font-size: 13px; + line-height: 1.5; +} + +.validation-section--risk .risk-advice-meta { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; +} + +.validation-section--risk .risk-advice-meta > div { + min-width: 0; + display: grid; + gap: 4px; + padding: 8px 9px; border-radius: 8px; - background: #fffafa; + background: #f8fafc; +} + +.validation-section--risk .risk-advice-meta span { + color: #64748b; + font-size: 10px; + font-weight: 800; +} + +.validation-section--risk .risk-advice-meta ul, +.validation-section--risk .risk-advice-meta p { + margin: 0; + color: #334155; + font-size: 11px; + line-height: 1.5; } .risk-advice-card.medium { diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js index 841523b..f6e26a0 100644 --- a/web/src/composables/useRequests.js +++ b/web/src/composables/useRequests.js @@ -4,6 +4,11 @@ import { fetchExpenseClaims } from '../services/reimbursements.js' const EXPENSE_TYPE_LABELS = { travel: '差旅费', + train_ticket: '火车票', + flight_ticket: '机票', + hotel_ticket: '住宿票', + ride_ticket: '乘车', + travel_allowance: '出差补贴', entertainment: '业务招待费', office: '办公费', meeting: '会务费', @@ -16,10 +21,17 @@ const EXPENSE_TYPE_LABELS = { const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ 'travel', + 'train_ticket', + 'flight_ticket', + 'hotel_ticket', + 'ride_ticket', 'meeting', 'entertainment' ]) +const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance']) +const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket']) + const REIMBURSEMENT_PROGRESS_LABELS = [ '创建单据', '待提交', @@ -123,6 +135,57 @@ function resolveLocationDisplay(location, typeCode) { return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填' } +function resolveExpenseItemViewId(item, index, claim) { + return String(item?.id || `${claim?.id || 'claim'}-item-${index}`) +} + +function buildTravelTimeLabelMap(items, claim) { + const travelItems = items + .map((item, index) => { + const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type) + return { + id: resolveExpenseItemViewId(item, index, claim), + index, + itemType, + itemDate: formatDate(item?.item_date), + isSystemGenerated: Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) + } + }) + .filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType)) + .sort((left, right) => { + const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || '')) + return dateCompare || left.index - right.index + }) + + const labels = new Map() + travelItems.forEach((item, index) => { + if (index === 0) { + labels.set(item.id, '出发时间') + } else if (index === travelItems.length - 1) { + labels.set(item.id, '返回时间') + } else { + labels.set(item.id, '中转时间') + } + }) + return labels +} + +function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, claim, travelTimeLabelMap }) { + if (isSystemGenerated) { + return '系统自动计算' + } + if (travelTimeLabelMap?.has(id)) { + return travelTimeLabelMap.get(id) + } + if (itemType === 'ride_ticket') { + return '乘车时间' + } + if (itemType === 'hotel_ticket') { + return '住宿时间' + } + return claim?.expense_type === 'travel' ? '出行时间' : '业务发生时间' +} + function resolveAttachmentDisplayName(value) { const normalized = String(value || '').trim() if (!normalized) { @@ -498,11 +561,20 @@ function buildExpenseItems(claim, riskSummary) { return [] } - return claim.items.map((item, index) => { + const sortedItems = [...claim.items].sort((left, right) => { + const leftType = normalizeExpenseType(left?.item_type) + const rightType = normalizeExpenseType(right?.item_type) + return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType)) + }) + const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim) + + return sortedItems.map((item, index) => { const invoiceId = String(item?.invoice_id || '').trim() const attachmentName = resolveAttachmentDisplayName(invoiceId) const attachments = invoiceId ? [attachmentName || invoiceId] : [] const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type) + const isSystemGenerated = Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) + const id = resolveExpenseItemViewId(item, index, claim) const itemTypeLabel = resolveTypeLabel(itemType) const itemLocation = String(item?.item_location || '').trim() const itemReason = String(item?.item_reason || '').trim() @@ -510,7 +582,7 @@ function buildExpenseItems(claim, riskSummary) { const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充' return { - id: String(item?.id || `${claim?.id || 'claim'}-item-${index}`), + id, time: formatDate(item?.item_date) || '待补充', itemDate: formatDate(item?.item_date) || '', filledAt: formatDateTime(item?.created_at) || '待同步', @@ -519,17 +591,24 @@ function buildExpenseItems(claim, riskSummary) { itemLocation, itemAmount, invoiceId, - dayLabel: claim?.expense_type === 'travel' ? `第 ${index + 1} 项` : '业务发生项', + isSystemGenerated, + dayLabel: resolveExpenseTimeLabel({ + id, + itemType, + isSystemGenerated, + claim, + travelTimeLabelMap + }), name: itemTypeLabel, category: itemTypeLabel, desc: itemReason || '待补充', detail: resolveLocationDisplay(itemLocation, itemType), amount: itemAmountDisplay, - status: attachments.length ? '已识别' : '待补充', - tone: attachments.length ? 'ok' : 'bad', - attachmentStatus: attachments.length ? '已关联票据' : '未上传', - attachmentHint: attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据', - attachmentTone: attachments.length ? 'ok' : 'missing', + status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充', + tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad', + attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传', + attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据', + attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing', attachments, riskLabel: riskSummary === '无' ? '无' : '待关注', riskText: riskSummary === '无' ? '' : riskSummary, diff --git a/web/src/services/reimbursements.js b/web/src/services/reimbursements.js index a4773d3..5fbfece 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 updateExpenseClaim(claimId, payload = {}) { + return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }) +} + export function calculateTravelReimbursement(payload = {}) { return apiRequest('/reimbursements/travel-calculator', { method: 'POST', diff --git a/web/src/utils/requestViewModel.js b/web/src/utils/requestViewModel.js index ebb5199..d689972 100644 --- a/web/src/utils/requestViewModel.js +++ b/web/src/utils/requestViewModel.js @@ -5,6 +5,36 @@ const REQUEST_TYPE_META = { tone: 'travel', secondaryStatusLabel: '行程状态' }, + train_ticket: { + label: '火车票', + detailVariant: 'travel', + tone: 'travel', + secondaryStatusLabel: '行程状态' + }, + flight_ticket: { + label: '机票', + detailVariant: 'travel', + tone: 'travel', + secondaryStatusLabel: '行程状态' + }, + hotel_ticket: { + label: '住宿票', + detailVariant: 'travel', + tone: 'travel', + secondaryStatusLabel: '票据状态' + }, + ride_ticket: { + label: '乘车', + detailVariant: 'travel', + tone: 'travel', + secondaryStatusLabel: '票据状态' + }, + travel_allowance: { + label: '出差补贴', + detailVariant: 'travel', + tone: 'travel', + secondaryStatusLabel: '系统计算' + }, entertainment: { label: '业务招待费', detailVariant: 'general', diff --git a/web/src/views/TravelReimbursementCreateView.vue b/web/src/views/TravelReimbursementCreateView.vue index c2924c3..20dceb1 100644 --- a/web/src/views/TravelReimbursementCreateView.vue +++ b/web/src/views/TravelReimbursementCreateView.vue @@ -352,6 +352,7 @@ + + + + +
{{ detailNote }}
+ +
@@ -129,7 +169,7 @@