feat: 细化差旅票据费用明细分类并自动计算出差补贴
将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类 型,根据票据字段自动生成行程/事由描述,结合规则引擎自 动计算出差补贴金额,前端适配费用明细编辑和差旅票据审 核交互,补充单元测试覆盖。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ("酒店", "宾馆", "饭店", "酒楼", "餐厅", "航空", "铁路", "滴滴"):
|
||||
|
||||
Binary file not shown.
@@ -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": []
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 143 KiB |
@@ -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": "",
|
||||
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
@@ -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": "",
|
||||
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -352,6 +352,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="shouldShowReviewUploadButton(message.reviewPayload)"
|
||||
type="button"
|
||||
class="review-footer-btn"
|
||||
:disabled="submitting || reviewActionBusy"
|
||||
|
||||
@@ -88,6 +88,46 @@
|
||||
|
||||
<div class="detail-grid">
|
||||
<section class="detail-left">
|
||||
<article class="detail-card panel">
|
||||
<div class="detail-card-head">
|
||||
<div>
|
||||
<h3>附加说明</h3>
|
||||
<p>用于说明本次出差或办事目的,例如去哪里、拜访谁、处理什么事项。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canEditDetailNote" class="detail-note-editor">
|
||||
<textarea
|
||||
v-model="detailNoteEditor"
|
||||
maxlength="500"
|
||||
placeholder="例如:去北京客户现场出差,拜访 XX 客户并处理项目验收事项"
|
||||
aria-label="附加说明"
|
||||
></textarea>
|
||||
<div class="detail-note-editor-meta">
|
||||
<span>仅草稿待提交状态可编辑,提交后将作为明确说明展示。</span>
|
||||
<div class="detail-note-actions">
|
||||
<button
|
||||
v-if="detailNoteDirty"
|
||||
class="inline-action"
|
||||
type="button"
|
||||
:disabled="savingDetailNote"
|
||||
@click="resetDetailNote"
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
<button
|
||||
class="inline-action primary"
|
||||
type="button"
|
||||
:disabled="!detailNoteDirty || savingDetailNote"
|
||||
@click="saveDetailNote"
|
||||
>
|
||||
{{ savingDetailNote ? '保存中' : '保存说明' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="detail-note readonly">{{ detailNote }}</div>
|
||||
</article>
|
||||
|
||||
<article class="detail-card panel">
|
||||
<div class="detail-card-head">
|
||||
<div>
|
||||
@@ -129,7 +169,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="item in expenseItems" :key="item.id">
|
||||
<tr>
|
||||
<tr :class="{ 'system-generated-row': item.isSystemGenerated }">
|
||||
<td class="expense-time col-time">
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor">
|
||||
@@ -200,8 +240,8 @@
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor editor-stack">
|
||||
<div class="attachment-action-group">
|
||||
<button
|
||||
v-if="isEditableRequest && !item.invoiceId"
|
||||
<button
|
||||
v-if="isEditableRequest && !item.invoiceId && !item.isSystemGenerated"
|
||||
class="icon-action upload"
|
||||
type="button"
|
||||
title="上传单据"
|
||||
@@ -221,8 +261,8 @@
|
||||
>
|
||||
<i class="mdi mdi-eye-outline"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="isEditableRequest && item.invoiceId"
|
||||
<button
|
||||
v-if="isEditableRequest && item.invoiceId && !item.isSystemGenerated"
|
||||
class="icon-action danger"
|
||||
type="button"
|
||||
title="删除附件"
|
||||
@@ -236,9 +276,13 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="attachment-action-group">
|
||||
<div v-if="item.isSystemGenerated" class="system-attachment-note">
|
||||
<i class="mdi mdi-calculator-variant-outline"></i>
|
||||
<span>无需附件</span>
|
||||
</div>
|
||||
<div v-else class="attachment-action-group">
|
||||
<button
|
||||
v-if="isEditableRequest && !item.invoiceId"
|
||||
v-if="isEditableRequest && !item.invoiceId && !item.isSystemGenerated"
|
||||
class="icon-action upload"
|
||||
type="button"
|
||||
title="上传单据"
|
||||
@@ -259,7 +303,7 @@
|
||||
<i class="mdi mdi-eye-outline"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="isEditableRequest && item.invoiceId"
|
||||
v-if="isEditableRequest && item.invoiceId && !item.isSystemGenerated"
|
||||
class="icon-action danger"
|
||||
type="button"
|
||||
title="删除附件"
|
||||
@@ -273,7 +317,11 @@
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="isEditableRequest" class="expense-action-cell col-action">
|
||||
<div v-if="editingExpenseId === item.id" class="row-action-group">
|
||||
<div v-if="item.isSystemGenerated" class="system-row-lock">
|
||||
<i class="mdi mdi-lock-outline"></i>
|
||||
<span>系统计算</span>
|
||||
</div>
|
||||
<div v-else-if="editingExpenseId === item.id" class="row-action-group">
|
||||
<button
|
||||
class="inline-action primary"
|
||||
type="button"
|
||||
@@ -328,6 +376,10 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="expenseItems.length" class="expense-total-under-table">
|
||||
<span>金额合计</span>
|
||||
<strong>{{ expenseTotal }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article v-if="isEditableRequest" class="detail-card panel validation-card">
|
||||
@@ -339,39 +391,43 @@
|
||||
<span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span>
|
||||
</div>
|
||||
<p class="validation-summary">{{ aiAdvice.summary }}</p>
|
||||
<div v-if="aiAdvice.riskCards.length" class="risk-advice-list">
|
||||
<article
|
||||
v-for="card in aiAdvice.riskCards"
|
||||
:key="card.id"
|
||||
:class="['risk-advice-card', card.tone]"
|
||||
<div v-if="aiAdvice.sections.length" class="validation-sections">
|
||||
<section
|
||||
v-for="section in aiAdvice.sections"
|
||||
:key="section.kind"
|
||||
:class="['validation-section', `validation-section--${section.kind}`]"
|
||||
>
|
||||
<div class="risk-advice-card-head">
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.title }}</strong>
|
||||
<h4 class="validation-section-title">{{ section.title }}</h4>
|
||||
<ul v-if="section.kind === 'completion'" class="validation-list">
|
||||
<li v-for="item in section.items" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
<div v-else class="risk-advice-list">
|
||||
<article
|
||||
v-for="card in section.items"
|
||||
:key="card.id"
|
||||
:class="['risk-advice-card', card.tone]"
|
||||
>
|
||||
<div class="risk-advice-card-head">
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.title }}</strong>
|
||||
</div>
|
||||
<p class="risk-advice-point">{{ card.risk }}</p>
|
||||
<div class="risk-advice-meta">
|
||||
<div>
|
||||
<span>规则依据</span>
|
||||
<ul>
|
||||
<li v-for="basis in card.ruleBasis" :key="basis">{{ basis }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span>修改建议</span>
|
||||
<p>{{ card.suggestion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p class="risk-advice-point">{{ card.risk }}</p>
|
||||
<div class="risk-advice-meta">
|
||||
<div>
|
||||
<span>规则依据</span>
|
||||
<ul>
|
||||
<li v-for="basis in card.ruleBasis" :key="basis">{{ basis }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span>修改建议</span>
|
||||
<p>{{ card.suggestion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
<ul v-if="aiAdvice.items.length" class="validation-list">
|
||||
<li v-for="item in aiAdvice.items" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="detail-card panel">
|
||||
<h3>附加说明</h3>
|
||||
<div class="detail-note">{{ detailNote }}</div>
|
||||
</article>
|
||||
|
||||
<article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card">
|
||||
|
||||
@@ -1497,6 +1497,20 @@ function resolveReviewMissingSlotCards(reviewPayload) {
|
||||
: []
|
||||
}
|
||||
|
||||
function resolveReviewExtraMissingLabels(reviewPayload) {
|
||||
const labels = Array.isArray(reviewPayload?.missing_slots)
|
||||
? reviewPayload.missing_slots.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: []
|
||||
if (!labels.length) return []
|
||||
|
||||
const slotLabels = new Set(
|
||||
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : [])
|
||||
.map((item) => String(item?.label || item?.key || '').trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
return labels.filter((label) => !slotLabels.has(label))
|
||||
}
|
||||
|
||||
function resolveReviewRiskBriefs(reviewPayload) {
|
||||
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
|
||||
return reviewPayload.risk_briefs.filter((item) => {
|
||||
@@ -1762,7 +1776,7 @@ function buildExpenseQueryHint(queryPayload) {
|
||||
}
|
||||
|
||||
function countReviewPendingItems(reviewPayload) {
|
||||
return resolveReviewMissingSlotCards(reviewPayload).length
|
||||
return resolveReviewMissingSlotCards(reviewPayload).length + resolveReviewExtraMissingLabels(reviewPayload).length
|
||||
}
|
||||
|
||||
function countReviewRiskItems(reviewPayload) {
|
||||
@@ -1825,12 +1839,12 @@ function shouldOpenReviewDisclosure(reviewPayload) {
|
||||
}
|
||||
|
||||
function buildReviewTodoSectionTitle(reviewPayload) {
|
||||
return resolveReviewMissingSlotCards(reviewPayload).length ? '待补充内容' : '已识别信息'
|
||||
return countReviewPendingItems(reviewPayload) ? '待补充内容' : '已识别信息'
|
||||
}
|
||||
|
||||
function buildReviewTodoSectionMeta(reviewPayload) {
|
||||
const count = buildReviewTodoItems(reviewPayload).length
|
||||
if (resolveReviewMissingSlotCards(reviewPayload).length) {
|
||||
if (countReviewPendingItems(reviewPayload)) {
|
||||
return count ? `${count} 项` : '待确认'
|
||||
}
|
||||
return count ? `${count} 项` : '已齐全'
|
||||
@@ -1864,6 +1878,17 @@ function buildReviewAlertChips(reviewPayload) {
|
||||
})
|
||||
}
|
||||
|
||||
if (chips.length < 3) {
|
||||
for (const label of resolveReviewExtraMissingLabels(reviewPayload)) {
|
||||
chips.push({
|
||||
key: label,
|
||||
label,
|
||||
tone: 'warning'
|
||||
})
|
||||
if (chips.length >= 3) break
|
||||
}
|
||||
}
|
||||
|
||||
if (chips.length < 3) {
|
||||
for (const risk of resolveReviewRiskBriefs(reviewPayload)) {
|
||||
if (chips.some((item) => item.label === risk.title)) continue
|
||||
@@ -1889,8 +1914,10 @@ function buildReviewAlertChips(reviewPayload) {
|
||||
|
||||
function buildReviewTodoItems(reviewPayload) {
|
||||
const missingItems = resolveReviewMissingSlotCards(reviewPayload)
|
||||
if (missingItems.length) {
|
||||
return missingItems.map((item) => {
|
||||
const extraMissingLabels = resolveReviewExtraMissingLabels(reviewPayload)
|
||||
if (missingItems.length || extraMissingLabels.length) {
|
||||
return [
|
||||
...missingItems.map((item) => {
|
||||
const config = REVIEW_SLOT_CONFIG[item.key] || {}
|
||||
return {
|
||||
key: item.key,
|
||||
@@ -1900,7 +1927,18 @@ function buildReviewTodoItems(reviewPayload) {
|
||||
status: config.status || '待补充',
|
||||
tone: 'warning'
|
||||
}
|
||||
})
|
||||
}),
|
||||
...extraMissingLabels.map((label, index) => ({
|
||||
key: `extra-missing-${index}-${label}`,
|
||||
icon: label.includes('酒店') || label.includes('住宿') ? 'mdi mdi-bed-outline' : 'mdi mdi-file-alert-outline',
|
||||
title: label,
|
||||
hint: label.includes('必须')
|
||||
? '该票据属于当前差旅提交的必备材料,补齐后才能继续下一步。'
|
||||
: '可以继续补充该材料;如暂时没有,也可以按当前信息处理。',
|
||||
status: label.includes('必须') ? '必须补齐' : '可选补充',
|
||||
tone: 'warning'
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
return resolveReviewRecognizedSlotCards(reviewPayload)
|
||||
@@ -2571,8 +2609,18 @@ function buildLocallySyncedReviewActions(reviewPayload, canProceed) {
|
||||
return actions
|
||||
}
|
||||
|
||||
const syncedActions = actions.filter((item) => String(item?.action_type || '').trim() !== 'next_step')
|
||||
if (!syncedActions.some((item) => String(item?.action_type || '').trim() === 'save_draft')) {
|
||||
syncedActions.push({
|
||||
label: '保存为草稿',
|
||||
action_type: 'save_draft',
|
||||
description: '先暂存当前已识别信息,稍后仍可继续补充或提交。',
|
||||
emphasis: 'secondary'
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
...actions.filter((item) => !['save_draft', 'next_step'].includes(String(item?.action_type || '').trim())),
|
||||
...syncedActions,
|
||||
{
|
||||
label: '继续下一步',
|
||||
action_type: 'next_step',
|
||||
@@ -2607,12 +2655,17 @@ function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmpt
|
||||
const missingSlots = nextSlotCards
|
||||
.filter((slot) => slot.required && slot.status === 'missing')
|
||||
.map((slot) => slot.label || slot.key)
|
||||
const canProceed = missingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true)
|
||||
const extraMissingSlots = resolveReviewExtraMissingLabels({
|
||||
...reviewPayload,
|
||||
slot_cards: nextSlotCards
|
||||
})
|
||||
const allMissingSlots = [...missingSlots, ...extraMissingSlots]
|
||||
const canProceed = allMissingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true)
|
||||
|
||||
return {
|
||||
...reviewPayload,
|
||||
can_proceed: canProceed,
|
||||
missing_slots: missingSlots,
|
||||
missing_slots: allMissingSlots,
|
||||
slot_cards: nextSlotCards,
|
||||
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
|
||||
}
|
||||
@@ -2821,22 +2874,24 @@ function buildReviewDocumentSummaries(reviewPayload) {
|
||||
}
|
||||
|
||||
function buildReviewDecisionHint(reviewPayload) {
|
||||
const missingSlots = resolveReviewMissingSlotCards(reviewPayload)
|
||||
const pendingCount = countReviewPendingItems(reviewPayload)
|
||||
const riskBriefs = resolveReviewRiskBriefs(reviewPayload)
|
||||
if (reviewPayload?.can_proceed) {
|
||||
if (shouldShowReviewUploadButton(reviewPayload)) {
|
||||
return '必需信息已整理好;如还有非必需票据可以继续上传,也可以直接进入下一步或保存草稿。'
|
||||
}
|
||||
return riskBriefs.length
|
||||
? `我已经把信息整理好了。你可以直接进入下一步,提交前再看一下下方 ${riskBriefs.length} 条提醒。`
|
||||
: '我已经把信息整理好了。你确认无误后,可以直接进入下一步。'
|
||||
}
|
||||
if (missingSlots.length) {
|
||||
return `我先完成了当前这轮识别,还差 ${missingSlots.length} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。`
|
||||
if (pendingCount) {
|
||||
return `我先完成了当前这轮识别,还差 ${pendingCount} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。`
|
||||
}
|
||||
return '如果你觉得识别结果有偏差,点“修改识别信息”直接校正,我会按新内容重新识别。'
|
||||
}
|
||||
|
||||
function buildReviewMissingHint(reviewPayload) {
|
||||
const missingSlots = resolveReviewMissingSlotCards(reviewPayload)
|
||||
if (!missingSlots.length) {
|
||||
if (!countReviewPendingItems(reviewPayload)) {
|
||||
return ''
|
||||
}
|
||||
if (reviewPayload?.can_proceed) {
|
||||
@@ -2860,8 +2915,19 @@ function buildReviewActionHint(reviewPayload) {
|
||||
return '如果现在信息还不完整,可以先保存草稿;识别错了就点“修改识别信息”。'
|
||||
}
|
||||
|
||||
function shouldShowReviewUploadButton(reviewPayload) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
if (!documents.length) return true
|
||||
if (countReviewPendingItems(reviewPayload)) return true
|
||||
|
||||
return resolveReviewRiskBriefs(reviewPayload).some((brief) => {
|
||||
const text = `${brief?.title || ''} ${brief?.content || ''} ${brief?.suggestion || ''}`
|
||||
return /差旅票据待补充|待上传|可继续上传|可继续提供/.test(text)
|
||||
})
|
||||
}
|
||||
|
||||
function buildReviewStatusTag(reviewPayload) {
|
||||
const missingCount = resolveReviewMissingSlotCards(reviewPayload).length
|
||||
const missingCount = countReviewPendingItems(reviewPayload)
|
||||
if (reviewPayload?.can_proceed) {
|
||||
return '可继续处理'
|
||||
}
|
||||
@@ -5607,6 +5673,7 @@ export default {
|
||||
buildReviewTodoSectionMeta,
|
||||
buildReviewAlertChips,
|
||||
buildReviewTodoItems,
|
||||
shouldShowReviewUploadButton,
|
||||
resolveReviewSubmitActions,
|
||||
resolveReviewPrimaryAction,
|
||||
resolveReviewEditAction,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
returnExpenseClaim,
|
||||
submitExpenseClaim,
|
||||
uploadExpenseClaimItemAttachment,
|
||||
updateExpenseClaim,
|
||||
updateExpenseClaimItem
|
||||
} from '../../services/reimbursements.js'
|
||||
import {
|
||||
@@ -32,6 +33,10 @@ import {
|
||||
|
||||
const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
{ value: 'train_ticket', label: '火车票' },
|
||||
{ value: 'flight_ticket', label: '机票' },
|
||||
{ value: 'hotel_ticket', label: '住宿票' },
|
||||
{ value: 'ride_ticket', label: '乘车' },
|
||||
{ value: 'entertainment', label: '业务招待费' },
|
||||
{ value: 'office', label: '办公费' },
|
||||
{ value: 'meeting', label: '会务费' },
|
||||
@@ -39,15 +44,23 @@ const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'hotel', label: '住宿费' },
|
||||
{ value: 'transport', label: '交通费' },
|
||||
{ value: 'meal', label: '餐费' },
|
||||
{ value: 'travel_allowance', label: '出差补贴' },
|
||||
{ value: 'other', label: '其他费用' }
|
||||
]
|
||||
|
||||
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'])
|
||||
|
||||
function parseCurrency(value) {
|
||||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
||||
}
|
||||
@@ -69,6 +82,11 @@ function resolveExpenseTypeLabel(value) {
|
||||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
|
||||
}
|
||||
|
||||
function isSystemGeneratedExpenseItemSource(source) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
|
||||
return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
}
|
||||
|
||||
function isLocationRequiredExpenseType(value) {
|
||||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
@@ -135,6 +153,11 @@ function isPlaceholderValue(value) {
|
||||
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
function normalizeDetailNoteDraftValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
return isPlaceholderValue(text) ? '' : text
|
||||
}
|
||||
|
||||
function isValidIsoDate(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
@@ -213,8 +236,65 @@ function extractAttachmentDisplayName(value) {
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
}
|
||||
|
||||
function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
function resolveExpenseItemViewId(source, index, requestModel) {
|
||||
return String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`)
|
||||
}
|
||||
|
||||
function buildTravelTimeLabelMap(items, requestModel) {
|
||||
const travelItems = items
|
||||
.map((item, index) => {
|
||||
const itemType = normalizeExpenseType(item?.itemType || item?.item_type || requestModel?.typeCode || 'other')
|
||||
return {
|
||||
id: resolveExpenseItemViewId(item, index, requestModel),
|
||||
index,
|
||||
itemType,
|
||||
itemDate: normalizeIsoDateValue(item?.itemDate ?? item?.item_date),
|
||||
isSystemGenerated: isSystemGeneratedExpenseItemSource({ ...item, 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()
|
||||
if (!travelItems.length) {
|
||||
return labels
|
||||
}
|
||||
|
||||
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, requestModel, travelTimeLabelMap }) {
|
||||
if (isSystemGenerated) {
|
||||
return '系统自动计算'
|
||||
}
|
||||
if (travelTimeLabelMap?.has(id)) {
|
||||
return travelTimeLabelMap.get(id)
|
||||
}
|
||||
if (itemType === 'ride_ticket') {
|
||||
return '乘车时间'
|
||||
}
|
||||
if (itemType === 'hotel_ticket') {
|
||||
return '住宿时间'
|
||||
}
|
||||
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
|
||||
}
|
||||
|
||||
function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
|
||||
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
|
||||
const id = resolveExpenseItemViewId(source, index, requestModel)
|
||||
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
|
||||
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
|
||||
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
|
||||
@@ -232,26 +312,33 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
)
|
||||
|
||||
return {
|
||||
id: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`),
|
||||
id,
|
||||
itemDate,
|
||||
itemType,
|
||||
itemReason,
|
||||
itemLocation,
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
isSystemGenerated,
|
||||
time: itemDate || '待补充',
|
||||
filledAt: filledAt || '待同步',
|
||||
dayLabel: requestModel?.detailVariant === 'travel' ? `第 ${index + 1} 项` : '业务发生项',
|
||||
dayLabel: resolveExpenseTimeLabel({
|
||||
id,
|
||||
itemType,
|
||||
isSystemGenerated,
|
||||
requestModel,
|
||||
travelTimeLabelMap
|
||||
}),
|
||||
name: resolveExpenseTypeLabel(itemType),
|
||||
category: resolveExpenseTypeLabel(itemType),
|
||||
desc: itemReason || '待补充',
|
||||
detail: resolveLocationDisplay(itemLocation, itemType),
|
||||
amount: amountDisplay,
|
||||
status: attachments.length ? '已识别' : '待补充',
|
||||
tone: attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||||
attachmentTone: attachments.length ? 'ok' : 'missing',
|
||||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||||
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
riskLabel: String(source?.riskLabel || '').trim() || '无',
|
||||
riskText,
|
||||
@@ -260,11 +347,17 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
}
|
||||
|
||||
function rebuildExpenseItems(items, requestModel) {
|
||||
return items.map((item, index) => buildExpenseItemViewModel(item, index, requestModel))
|
||||
const sortedItems = [...items]
|
||||
.sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right)))
|
||||
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel)
|
||||
return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap))
|
||||
}
|
||||
|
||||
function buildExpenseDraftIssues(item) {
|
||||
const issues = []
|
||||
if (item.isSystemGenerated) {
|
||||
return issues
|
||||
}
|
||||
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
||||
|
||||
if (!isValidIsoDate(item.itemDate)) {
|
||||
@@ -441,6 +534,8 @@ export default {
|
||||
itemAmount: '',
|
||||
invoiceId: ''
|
||||
})
|
||||
const detailNoteEditor = ref('')
|
||||
const savingDetailNote = ref(false)
|
||||
|
||||
const request = computed(() => {
|
||||
const normalized = normalizeRequestForUi(props.request)
|
||||
@@ -654,7 +749,7 @@ export default {
|
||||
}
|
||||
|
||||
const expenseTotal = computed(() => {
|
||||
const total = expenseItems.value.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
|
||||
const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0)
|
||||
return formatCurrency(total)
|
||||
})
|
||||
|
||||
@@ -662,10 +757,21 @@ export default {
|
||||
const expenseTableColumnCount = computed(
|
||||
() => 6 + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
const detailNote = computed(
|
||||
() =>
|
||||
request.value.note
|
||||
|| '暂无附加说明。可在这里补充特殊背景、例外原因、补件计划或其他需要财务和审批人重点关注的信息。'
|
||||
const canEditDetailNote = computed(() => isDraftRequest.value)
|
||||
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
|
||||
const detailNote = computed(() => {
|
||||
if (detailNoteSource.value) {
|
||||
return detailNoteSource.value
|
||||
}
|
||||
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
|
||||
})
|
||||
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
|
||||
watch(
|
||||
() => [request.value.claimId, detailNoteSource.value],
|
||||
([, nextNote]) => {
|
||||
detailNoteEditor.value = nextNote
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const draftBlockingIssues = computed(() =>
|
||||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||
@@ -873,10 +979,44 @@ export default {
|
||||
})
|
||||
})
|
||||
|
||||
function resetDetailNote() {
|
||||
detailNoteEditor.value = detailNoteSource.value
|
||||
}
|
||||
|
||||
async function saveDetailNote() {
|
||||
if (!canEditDetailNote.value || savingDetailNote.value) {
|
||||
return
|
||||
}
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法保存附加说明。')
|
||||
return
|
||||
}
|
||||
if (!detailNoteDirty.value) {
|
||||
return
|
||||
}
|
||||
|
||||
savingDetailNote.value = true
|
||||
try {
|
||||
await updateExpenseClaim(request.value.claimId, {
|
||||
reason: detailNoteEditor.value.trim()
|
||||
})
|
||||
toast('附加说明已保存。')
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '附加说明保存失败,请稍后重试。')
|
||||
} finally {
|
||||
savingDetailNote.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startExpenseEdit(item) {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
if (item?.isSystemGenerated) {
|
||||
toast('系统自动计算的补贴行不能手动编辑。')
|
||||
return
|
||||
}
|
||||
|
||||
editingExpenseId.value = item.id
|
||||
expenseEditor.itemDate = item.itemDate || ''
|
||||
@@ -954,6 +1094,11 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (item?.isSystemGenerated) {
|
||||
toast('系统自动计算的补贴行无需上传附件。')
|
||||
return
|
||||
}
|
||||
|
||||
if (item?.invoiceId) {
|
||||
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
||||
return
|
||||
@@ -1036,6 +1181,10 @@ export default {
|
||||
if (!item || !file) {
|
||||
return
|
||||
}
|
||||
if (item?.isSystemGenerated) {
|
||||
toast('系统自动计算的补贴行无需上传附件。')
|
||||
return
|
||||
}
|
||||
|
||||
if (item?.invoiceId) {
|
||||
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
||||
@@ -1138,6 +1287,10 @@ export default {
|
||||
if (!request.value.claimId || !item?.id || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
if (item?.isSystemGenerated) {
|
||||
toast('系统自动计算的补贴行不能删除。')
|
||||
return
|
||||
}
|
||||
|
||||
deletingExpenseId.value = item.id
|
||||
try {
|
||||
@@ -1468,18 +1621,21 @@ export default {
|
||||
confirmReturnRequest,
|
||||
currentAttachmentPreviewInsight,
|
||||
currentAttachmentPreviewRiskCards,
|
||||
currentProgressRingMotion,
|
||||
deleteActionLabel,
|
||||
deleteBusy,
|
||||
deleteDialogDescription,
|
||||
deleteDialogOpen,
|
||||
deleteDialogTitle,
|
||||
deletingAttachmentId,
|
||||
deletingExpenseId,
|
||||
detailNote,
|
||||
draftBlockingIssues,
|
||||
editingExpenseId,
|
||||
creatingExpense,
|
||||
currentProgressRingMotion,
|
||||
canEditDetailNote,
|
||||
deleteActionLabel,
|
||||
deleteBusy,
|
||||
deleteDialogDescription,
|
||||
deleteDialogOpen,
|
||||
deleteDialogTitle,
|
||||
deletingAttachmentId,
|
||||
deletingExpenseId,
|
||||
detailNote,
|
||||
detailNoteDirty,
|
||||
detailNoteEditor,
|
||||
draftBlockingIssues,
|
||||
editingExpenseId,
|
||||
creatingExpense,
|
||||
expenseEditor,
|
||||
expenseItems,
|
||||
expenseTableColumnCount,
|
||||
@@ -1502,18 +1658,21 @@ export default {
|
||||
goToPreviousAttachmentPreview,
|
||||
profile,
|
||||
progressSteps,
|
||||
request,
|
||||
leaderOpinion,
|
||||
removeExpenseAttachment,
|
||||
removeExpenseItem,
|
||||
resolveAttachmentDisplayName,
|
||||
resolveAttachmentPreviewTitle,
|
||||
resolveAttachmentRecognition,
|
||||
resolveExpenseRiskState,
|
||||
resolveExpenseIssues,
|
||||
request,
|
||||
leaderOpinion,
|
||||
removeExpenseAttachment,
|
||||
removeExpenseItem,
|
||||
resetDetailNote,
|
||||
resolveAttachmentDisplayName,
|
||||
resolveAttachmentPreviewTitle,
|
||||
resolveAttachmentRecognition,
|
||||
resolveExpenseRiskState,
|
||||
resolveExpenseIssues,
|
||||
returnBusy,
|
||||
returnDialogOpen,
|
||||
savingExpenseId,
|
||||
returnDialogOpen,
|
||||
saveDetailNote,
|
||||
savingDetailNote,
|
||||
savingExpenseId,
|
||||
showLeaderApprovalPanel,
|
||||
showExpenseRisk,
|
||||
startExpenseEdit,
|
||||
|
||||
@@ -265,19 +265,44 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
|
||||
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
|
||||
|
||||
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
|
||||
const items = [
|
||||
'点击右下角“提交审批”进入流程。',
|
||||
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
|
||||
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
|
||||
]
|
||||
|
||||
return {
|
||||
tone: 'ready',
|
||||
badge: '可直接提交',
|
||||
summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。',
|
||||
items: [
|
||||
'点击右下角“提交审批”进入流程。',
|
||||
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
|
||||
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
|
||||
],
|
||||
riskCards: []
|
||||
items,
|
||||
riskCards: [],
|
||||
sections: [
|
||||
{
|
||||
kind: 'completion',
|
||||
title: '建议补充字段',
|
||||
items
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const sections = []
|
||||
if (normalizedCompletionItems.length) {
|
||||
sections.push({
|
||||
kind: 'completion',
|
||||
title: '建议补充字段',
|
||||
items: normalizedCompletionItems
|
||||
})
|
||||
}
|
||||
if (normalizedRiskCards.length) {
|
||||
sections.push({
|
||||
kind: 'risk',
|
||||
title: '已知存在风险',
|
||||
items: normalizedRiskCards
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
tone: hasHighRisk ? 'warning' : 'pending',
|
||||
badge: hasHighRisk ? '优先整改' : '待核对',
|
||||
@@ -285,6 +310,7 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
|
||||
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议。`
|
||||
: '建议先补齐必填信息,完成后即可提交审批。',
|
||||
items: normalizedCompletionItems,
|
||||
riskCards: normalizedRiskCards
|
||||
riskCards: normalizedRiskCards,
|
||||
sections
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,71 @@ test('progress steps do not expose approver email when manager name is available
|
||||
}
|
||||
})
|
||||
|
||||
test('travel expense items describe departure return and lodging time below the date', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-travel-time-labels',
|
||||
claim_no: 'EXP-202605-TRAVEL',
|
||||
employee_name: '张三',
|
||||
department_name: '市场部',
|
||||
expense_type: 'travel',
|
||||
reason: '北京客户现场出差',
|
||||
location: '北京',
|
||||
amount: 1108,
|
||||
invoice_count: 3,
|
||||
occurred_at: '2026-05-13T01:00:00.000Z',
|
||||
created_at: '2026-05-13T01:30:00.000Z',
|
||||
updated_at: '2026-05-13T03:30:00.000Z',
|
||||
status: 'draft',
|
||||
approval_stage: '待提交',
|
||||
risk_flags_json: [],
|
||||
items: [
|
||||
{
|
||||
id: 'outbound-train',
|
||||
item_type: 'train_ticket',
|
||||
item_reason: '从广州南到北京南',
|
||||
item_location: '北京',
|
||||
item_date: '2026-05-13',
|
||||
item_amount: 354,
|
||||
invoice_id: 'outbound.png'
|
||||
},
|
||||
{
|
||||
id: 'hotel',
|
||||
item_type: 'hotel_ticket',
|
||||
item_reason: '北京全季酒店',
|
||||
item_location: '北京',
|
||||
item_date: '2026-05-14',
|
||||
item_amount: 100,
|
||||
invoice_id: 'hotel.png'
|
||||
},
|
||||
{
|
||||
id: 'return-train',
|
||||
item_type: 'train_ticket',
|
||||
item_reason: '从北京南到广州南',
|
||||
item_location: '广州',
|
||||
item_date: '2026-05-15',
|
||||
item_amount: 354,
|
||||
invoice_id: 'return.png'
|
||||
},
|
||||
{
|
||||
id: 'allowance',
|
||||
item_type: 'travel_allowance',
|
||||
item_reason: '系统自动计算出差补贴',
|
||||
item_location: '北京',
|
||||
item_date: '2026-05-15',
|
||||
item_amount: 300,
|
||||
invoice_id: '',
|
||||
is_system_generated: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(request.expenseItems.find((item) => item.id === 'outbound-train')?.dayLabel, '出发时间')
|
||||
assert.equal(request.expenseItems.find((item) => item.id === 'return-train')?.dayLabel, '返回时间')
|
||||
assert.equal(request.expenseItems.find((item) => item.id === 'hotel')?.dayLabel, '住宿时间')
|
||||
assert.equal(request.expenseItems.at(-1)?.id, 'allowance')
|
||||
assert.equal(request.expenseItems.at(-1)?.dayLabel, '系统自动计算')
|
||||
})
|
||||
|
||||
test('completed finance approval marks finance and archive progress steps', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-finance-completed',
|
||||
|
||||
@@ -17,6 +17,10 @@ const detailViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailViewStyle = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const requestsComposableScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -97,6 +101,77 @@ test('AI advice card splits every attachment risk point with basis and suggestio
|
||||
assert.ok(advice.riskCards.every((card) => card.suggestion.includes('费用项目调整为交通费')))
|
||||
})
|
||||
|
||||
test('AI advice view model exposes grouped completion and risk sections', () => {
|
||||
const advice = buildAiAdviceViewModel({
|
||||
completionItems: ['补充业务地点', '补充报销金额'],
|
||||
riskCards: [
|
||||
{
|
||||
id: 'risk-1',
|
||||
tone: 'high',
|
||||
label: '高风险',
|
||||
title: '票据类型不匹配',
|
||||
risk: '交通票据挂在办公费明细下。',
|
||||
ruleBasis: ['附件类型与当前费用项目不匹配。'],
|
||||
suggestion: '把费用项目调整为交通费。'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(advice.sections.length, 2)
|
||||
assert.deepEqual(
|
||||
advice.sections.map((section) => ({ title: section.title, kind: section.kind })),
|
||||
[
|
||||
{ title: '建议补充字段', kind: 'completion' },
|
||||
{ title: '已知存在风险', kind: 'risk' }
|
||||
]
|
||||
)
|
||||
assert.deepEqual(advice.sections[0].items, ['补充业务地点', '补充报销金额'])
|
||||
assert.equal(advice.sections[1].items.length, 1)
|
||||
})
|
||||
|
||||
test('AI advice view model omits empty sections', () => {
|
||||
const completionOnlyAdvice = buildAiAdviceViewModel({
|
||||
completionItems: ['补充业务地点'],
|
||||
riskCards: []
|
||||
})
|
||||
const riskOnlyAdvice = buildAiAdviceViewModel({
|
||||
completionItems: [],
|
||||
riskCards: [
|
||||
{
|
||||
id: 'risk-1',
|
||||
tone: 'medium',
|
||||
label: '中风险',
|
||||
title: '说明不完整',
|
||||
risk: '缺少业务背景。',
|
||||
ruleBasis: ['系统预审规则命中该风险提示。'],
|
||||
suggestion: '补充说明。'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(completionOnlyAdvice.sections.map((section) => section.title), ['建议补充字段'])
|
||||
assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险'])
|
||||
})
|
||||
|
||||
test('AI advice template renders grouped section titles with completion before risk', () => {
|
||||
assert.match(detailViewTemplate, /v-if="aiAdvice\.sections\.length" class="validation-sections"/)
|
||||
assert.match(detailViewTemplate, /v-for="section in aiAdvice\.sections"/)
|
||||
assert.match(detailViewTemplate, /validation-section--\$\{section\.kind\}/)
|
||||
assert.match(detailViewTemplate, /<h4 class="validation-section-title">\{\{ section\.title \}\}<\/h4>/)
|
||||
assert.match(detailViewTemplate, /v-if="section\.kind === 'completion'" class="validation-list"/)
|
||||
assert.match(detailViewTemplate, /v-else class="risk-advice-list"/)
|
||||
assert.ok(
|
||||
detailViewTemplate.indexOf("section.kind === 'completion'") < detailViewTemplate.indexOf('risk-advice-card')
|
||||
)
|
||||
})
|
||||
|
||||
test('AI advice risk section uses compact card styling hooks', () => {
|
||||
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/)
|
||||
assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*display: grid;\s*gap: 8px;\s*padding: 12px 12px 11px;/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-meta ul,\s*\.validation-section--risk \.risk-advice-meta p \{\s*margin: 0;/)
|
||||
})
|
||||
|
||||
test('AI advice shows only the latest manual return while preserving return count context', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
claimRiskFlags: [
|
||||
@@ -138,16 +213,60 @@ test('expense attachment actions keep preview as the only recognition entry poin
|
||||
assert.doesNotMatch(detailViewScript, /recognizingExpenseId/)
|
||||
})
|
||||
|
||||
test('expense detail table omits compact-breaking summary labels', () => {
|
||||
test('expense detail table shows the amount total below detail rows', () => {
|
||||
assert.match(detailViewTemplate, /<div class="detail-expense-table">/)
|
||||
assert.match(detailViewTemplate, /当前还没有费用明细/)
|
||||
assert.doesNotMatch(detailViewTemplate, /class="total-row"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /expense-total-bar/)
|
||||
assert.doesNotMatch(detailViewTemplate, /合计 \{\{ expenseTotal \}\}/)
|
||||
assert.match(detailViewTemplate, /class="expense-total-under-table"[\s\S]*金额合计[\s\S]*\{\{ expenseTotal \}\}/)
|
||||
assert.doesNotMatch(detailViewTemplate, /\{\{ uploadedExpenseCount \}\} 项已关联票据/)
|
||||
assert.doesNotMatch(detailViewTemplate, /\{\{ expenseSummaryText \}\}/)
|
||||
})
|
||||
|
||||
test('additional note is shown above expense details as travel purpose text', () => {
|
||||
assert.ok(detailViewTemplate.indexOf('<h3>附加说明</h3>') < detailViewTemplate.indexOf('<h3>费用明细</h3>'))
|
||||
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
|
||||
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
|
||||
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
|
||||
assert.match(detailViewTemplate, /v-model="detailNoteEditor"/)
|
||||
assert.match(detailViewTemplate, /提交后将作为明确说明展示/)
|
||||
assert.match(detailViewScript, /const canEditDetailNote = computed\(\(\) => isDraftRequest\.value\)/)
|
||||
assert.match(detailViewScript, /function normalizeDetailNoteDraftValue\(value\)/)
|
||||
assert.match(detailViewScript, /const detailNoteSource = computed\(\(\) => normalizeDetailNoteDraftValue\(request\.value\.note\)\)/)
|
||||
assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId/)
|
||||
assert.match(detailViewScript, /emit\('request-updated', \{ claimId: request\.value\.claimId \}\)/)
|
||||
assert.match(detailViewScript, /暂无附加说明。请补充本次出差或办事事由/)
|
||||
assert.match(detailViewScript, /去北京客户现场出差,拜访 XX 客户并处理项目验收事项/)
|
||||
assert.match(detailViewStyle, /\.detail-note-editor textarea/)
|
||||
assert.match(detailViewStyle, /\.detail-note\.readonly/)
|
||||
})
|
||||
|
||||
test('ticket item types and system allowance row are visible but read only', () => {
|
||||
assert.match(detailViewScript, /value: 'train_ticket', label: '火车票'/)
|
||||
assert.match(detailViewScript, /value: 'flight_ticket', label: '机票'/)
|
||||
assert.match(detailViewScript, /value: 'hotel_ticket', label: '住宿票'/)
|
||||
assert.match(detailViewScript, /value: 'ride_ticket', label: '乘车'/)
|
||||
assert.match(detailViewScript, /value: 'travel_allowance', label: '出差补贴'/)
|
||||
assert.match(detailViewScript, /const SYSTEM_GENERATED_EXPENSE_TYPES = new Set\(\['travel_allowance'\]\)/)
|
||||
assert.match(detailViewTemplate, /'system-generated-row': item\.isSystemGenerated/)
|
||||
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-row-lock"/)
|
||||
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-attachment-note"/)
|
||||
assert.match(detailViewScript, /系统自动计算的补贴行不能手动编辑/)
|
||||
assert.match(detailViewScript, /系统自动计算的补贴行不能删除/)
|
||||
})
|
||||
|
||||
test('travel item date caption distinguishes departure return and trip events', () => {
|
||||
assert.match(detailViewTemplate, /<span>\{\{ item\.dayLabel \}\}<\/span>/)
|
||||
assert.match(detailViewScript, /const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket'\]\)/)
|
||||
assert.match(detailViewScript, /function buildTravelTimeLabelMap\(items, requestModel\)/)
|
||||
assert.match(detailViewScript, /labels\.set\(item\.id, '出发时间'\)/)
|
||||
assert.match(detailViewScript, /labels\.set\(item\.id, '返回时间'\)/)
|
||||
assert.match(detailViewScript, /return '乘车时间'/)
|
||||
assert.match(detailViewScript, /return '住宿时间'/)
|
||||
assert.match(requestsComposableScript, /function buildTravelTimeLabelMap\(items, claim\)/)
|
||||
assert.match(requestsComposableScript, /return claim\?\.expense_type === 'travel' \? '出行时间' : '业务发生时间'/)
|
||||
assert.doesNotMatch(detailViewScript, /第 \$\{index \+ 1\} 项/)
|
||||
})
|
||||
|
||||
test('expense detail table shows each item filled time from item creation time', () => {
|
||||
assert.match(detailViewTemplate, /<th class="col-filled-at">填写时间<\/th>/)
|
||||
assert.match(detailViewTemplate, /<td class="expense-filled-at col-filled-at">[\s\S]*\{\{ item\.filledAt \}\}/)
|
||||
@@ -162,11 +281,11 @@ test('expense item upload remains limited to one receipt per detail row', () =>
|
||||
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /\bmultiple\b/)
|
||||
assert.equal(
|
||||
(detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId"/g) || []).length,
|
||||
(detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId && !item\.isSystemGenerated"/g) || []).length,
|
||||
2
|
||||
)
|
||||
assert.match(detailViewScript, /const attachments = invoiceId \? \[attachmentName \|\| invoiceId\] : \[\]/)
|
||||
assert.match(detailViewScript, /attachmentStatus: attachments\.length \? '已关联票据' : '未上传'/)
|
||||
assert.match(detailViewScript, /attachmentStatus: isSystemGenerated \? '无需附件' : attachments\.length \? '已关联票据' : '未上传'/)
|
||||
assert.match(detailViewScript, /if \(item\?\.invoiceId\) \{[\s\S]*每条费用明细只能关联一张单据/)
|
||||
assert.match(detailViewScript, /const fileCount = fileList\?\.length \|\| 0/)
|
||||
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)
|
||||
|
||||
Reference in New Issue
Block a user