feat: 细化差旅票据费用明细分类并自动计算出差补贴

将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类
型,根据票据字段自动生成行程/事由描述,结合规则引擎自
动计算出差补贴金额,前端适配费用明细编辑和差旅票据审
核交互,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-21 10:57:06 +08:00
parent 8f65661809
commit b183b0bd5e
26 changed files with 2588 additions and 362 deletions

View File

@@ -18,6 +18,7 @@ from app.schemas.reimbursement import (
ExpenseClaimItemUpdate, ExpenseClaimItemUpdate,
ExpenseClaimRead, ExpenseClaimRead,
ExpenseClaimReturnPayload, ExpenseClaimReturnPayload,
ExpenseClaimUpdate,
ReimbursementCreate, ReimbursementCreate,
ReimbursementRead, ReimbursementRead,
TravelReimbursementCalculatorRequest, TravelReimbursementCalculatorRequest,
@@ -115,6 +116,43 @@ def get_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser) -
return claim 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( @router.patch(
"/claims/{claim_id}/items/{item_id}", "/claims/{claim_id}/items/{item_id}",
response_model=ExpenseClaimRead, response_model=ExpenseClaimRead,

View File

@@ -113,6 +113,10 @@ class ExpenseClaimItemCreate(BaseModel):
invoice_id: str | None = None invoice_id: str | None = None
class ExpenseClaimUpdate(BaseModel):
reason: str | None = Field(default=None, max_length=500)
class ExpenseClaimRead(BaseModel): class ExpenseClaimRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -27,7 +27,12 @@ from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.schemas.ontology import OntologyEntity, OntologyParseResult 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_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
from app.services.agent_foundation import AgentFoundationService from app.services.agent_foundation import AgentFoundationService
@@ -42,10 +47,15 @@ from app.services.expense_rule_runtime import (
) )
from app.services.ocr import OcrService from app.services.ocr import OcrService
EXPENSE_TYPE_LABELS = { EXPENSE_TYPE_LABELS = {
"travel": "差旅", "travel": "差旅",
"hotel": "住宿", "train_ticket": "火车票",
"transport": "交通", "flight_ticket": "机票",
"hotel_ticket": "住宿票",
"ride_ticket": "乘车",
"travel_allowance": "出差补贴",
"hotel": "住宿",
"transport": "交通",
"meal": "餐费", "meal": "餐费",
"meeting": "会务", "meeting": "会务",
"entertainment": "招待", "entertainment": "招待",
@@ -60,8 +70,45 @@ APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
CLAIM_DELETE_ROLE_CODES = {"executive"} CLAIM_DELETE_ROLE_CODES = {"executive"}
MAX_DRAFT_CLAIMS_PER_USER = 3 MAX_DRAFT_CLAIMS_PER_USER = 3
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned") 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 = { LOCATION_REQUIRED_EXPENSE_TYPES = {
"travel", "travel",
"train_ticket",
"flight_ticket",
"hotel_ticket",
"ride_ticket",
"meeting", "meeting",
"entertainment", "entertainment",
} }
@@ -109,9 +156,14 @@ EXPENSE_SCENE_KEYWORDS = {
"training": ("培训", "课程", "讲师", "教材", "学费", "认证"), "training": ("培训", "课程", "讲师", "教材", "学费", "认证"),
} }
EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = { EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = {
"travel": {"travel", "hotel", "transport", "meal"}, "travel": {"travel", "hotel", "transport", "meal"},
"hotel": {"hotel"}, "train_ticket": {"travel"},
"flight_ticket": {"travel"},
"hotel_ticket": {"hotel"},
"ride_ticket": {"transport"},
"travel_allowance": set(),
"hotel": {"hotel"},
"transport": {"transport", "travel"}, "transport": {"transport", "travel"},
"meal": {"meal", "entertainment"}, "meal": {"meal", "entertainment"},
"entertainment": {"entertainment", "meal"}, "entertainment": {"entertainment", "meal"},
@@ -343,23 +395,55 @@ class ExpenseClaimService:
) )
stmt = self._apply_claim_scope(stmt, current_user, include_approval_scope=True) stmt = self._apply_claim_scope(stmt, current_user, include_approval_scope=True)
return self.db.scalar(stmt) return self.db.scalar(stmt)
def update_claim_item( def update_claim(
self, 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, claim_id: str,
item_id: str, item_id: str,
payload: ExpenseClaimItemUpdate, payload: ExpenseClaimItemUpdate,
current_user: CurrentUserContext, current_user: CurrentUserContext,
) -> ExpenseClaim | None: ) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user) claim = self.get_claim(claim_id, current_user)
if claim is None: if claim is None:
return None return None
self._ensure_draft_claim(claim) self._ensure_draft_claim(claim)
item = next((entry for entry in claim.items if entry.id == item_id), None) item = next((entry for entry in claim.items if entry.id == item_id), None)
if item is None: if item is None:
raise LookupError("Item not found") raise LookupError("Item not found")
self._ensure_mutable_claim_item(item)
before_json = self._serialize_claim(claim) before_json = self._serialize_claim(claim)
@@ -407,12 +491,12 @@ class ExpenseClaimService:
current_user: CurrentUserContext, current_user: CurrentUserContext,
) -> ExpenseClaim | None: ) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user) claim = self.get_claim(claim_id, current_user)
if claim is None: if claim is None:
return None return None
self._ensure_draft_claim(claim) self._ensure_draft_claim(claim)
before_json = self._serialize_claim(claim) before_json = self._serialize_claim(claim)
payload = payload or ExpenseClaimItemCreate() payload = payload or ExpenseClaimItemCreate()
occurred_at = claim.occurred_at if claim.occurred_at is not None else datetime.now(UTC) occurred_at = claim.occurred_at if claim.occurred_at is not None else datetime.now(UTC)
item_amount = Decimal("0.00") item_amount = Decimal("0.00")
@@ -509,11 +593,12 @@ class ExpenseClaimService:
item_id=item_id, item_id=item_id,
current_user=current_user, current_user=current_user,
) )
if claim is None: if claim is None:
return None return None
self._ensure_draft_claim(claim) self._ensure_draft_claim(claim)
normalized_name = self._normalize_attachment_filename(filename) self._ensure_mutable_claim_item(item)
normalized_name = self._normalize_attachment_filename(filename)
if not content: if not content:
raise ValueError("上传文件不能为空。") raise ValueError("上传文件不能为空。")
@@ -547,11 +632,20 @@ class ExpenseClaimService:
ocr_document = documents[0] ocr_document = documents[0]
ocr_status = "recognized" ocr_status = "recognized"
document_info = self._build_attachment_document_info(ocr_document) 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( self._backfill_item_amount_from_attachment(
item=item, item=item,
document=ocr_document, document=ocr_document,
document_info=document_info, 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( requirement_check = self._build_attachment_requirement_check(
item=item, item=item,
document_info=document_info, document_info=document_info,
@@ -694,11 +788,12 @@ class ExpenseClaimService:
item_id=item_id, item_id=item_id,
current_user=current_user, current_user=current_user,
) )
if claim is None: if claim is None:
return None return None
self._ensure_draft_claim(claim) self._ensure_draft_claim(claim)
before_json = self._serialize_claim(claim) self._ensure_mutable_claim_item(item)
before_json = self._serialize_claim(claim)
previous_name = self._resolve_attachment_display_name(item.invoice_id) previous_name = self._resolve_attachment_display_name(item.invoice_id)
self._delete_item_attachment_files(item) self._delete_item_attachment_files(item)
item.invoice_id = None item.invoice_id = None
@@ -1234,15 +1329,18 @@ class ExpenseClaimService:
self.db.flush() self.db.flush()
if context_documents or attachment_names: if context_documents or attachment_names:
document_specs = self._build_context_item_specs( document_specs = self._build_context_item_specs(
context_documents=context_documents, context_documents=context_documents,
attachment_names=attachment_names, attachment_names=attachment_names,
occurred_at=final_occurred_at, occurred_at=final_occurred_at,
expense_type=final_expense_type, expense_type=final_expense_type,
amount=final_amount, amount=final_amount,
reason=final_reason, reason=final_reason,
location=final_location, 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: else:
document_specs = [] document_specs = []
@@ -1486,28 +1584,31 @@ class ExpenseClaimService:
) )
return normalized return normalized
def _build_context_item_specs( def _build_context_item_specs(
self, self,
*, *,
context_documents: list[dict[str, Any]], context_documents: list[dict[str, Any]],
attachment_names: list[str], attachment_names: list[str],
occurred_at: datetime, occurred_at: datetime,
expense_type: str, expense_type: str,
amount: Decimal, amount: Decimal,
reason: str, reason: str,
location: str, location: str,
) -> list[dict[str, Any]]: context_json: dict[str, Any],
specs: list[dict[str, Any]] = [] employee_grade: str | None = None,
if context_documents: user_id: str = "",
for document in context_documents: ) -> list[dict[str, Any]]:
specs: list[dict[str, Any]] = []
if context_documents:
for document in context_documents:
specs.append( specs.append(
{ {
"item_date": self._resolve_document_item_date(document, fallback=occurred_at.date()), "item_date": self._resolve_document_item_date(document, fallback=occurred_at.date()),
"item_type": self._resolve_document_item_type(document, fallback=expense_type), "item_type": self._resolve_document_item_type(document, fallback=expense_type),
"item_reason": reason, "item_reason": self._resolve_document_item_reason(document, fallback=reason),
"item_location": location, "item_location": location,
"item_amount": self._resolve_document_item_amount(document), "item_amount": self._resolve_document_item_amount(document),
"invoice_id": str(document.get("filename") or "").strip() or None, "invoice_id": str(document.get("filename") or "").strip() or None,
} }
) )
elif attachment_names: elif attachment_names:
@@ -1535,13 +1636,191 @@ class ExpenseClaimService:
if remaining > Decimal("0.00"): if remaining > Decimal("0.00"):
missing_specs[0]["item_amount"] = remaining missing_specs[0]["item_amount"] = remaining
for spec in specs: for spec in specs:
if spec.get("item_amount") is None: if spec.get("item_amount") is None:
spec["item_amount"] = Decimal("0.00") spec["item_amount"] = Decimal("0.00")
return specs allowance_spec = self._build_travel_allowance_item_spec(
context_documents=context_documents,
def _replace_claim_items( 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, self,
*, *,
claim: ExpenseClaim, claim: ExpenseClaim,
@@ -1565,18 +1844,28 @@ class ExpenseClaimService:
item.item_reason = spec["item_reason"] item.item_reason = spec["item_reason"]
item.item_location = spec["item_location"] item.item_location = spec["item_location"]
item.item_amount = spec["item_amount"] 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) :]: for stale_item in existing_items[len(item_specs) :]:
claim.items.remove(stale_item) claim.items.remove(stale_item)
self.db.delete(stale_item) self.db.delete(stale_item)
def _append_document_items( def _append_document_items(
self, self,
*, *,
claim: ExpenseClaim, claim: ExpenseClaim,
item_specs: list[dict[str, Any]], item_specs: list[dict[str, Any]],
) -> None: ) -> 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 = { existing_invoice_ids = {
str(item.invoice_id or "").strip() str(item.invoice_id or "").strip()
for item in claim.items for item in claim.items
@@ -1587,7 +1876,7 @@ class ExpenseClaimService:
for item in claim.items for item in claim.items
if str(item.invoice_id or "").strip() 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_id = str(spec.get("invoice_id") or "").strip()
invoice_name = self._resolve_attachment_display_name(invoice_id) 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): 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: if invoice_id:
existing_invoice_ids.add(invoice_id) existing_invoice_ids.add(invoice_id)
existing_invoice_names.add(invoice_name) 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: def _resolve_document_item_type(self, document: dict[str, Any], *, fallback: str) -> str:
scene_code = str(document.get("scene_code") or "").strip() document_type = str(document.get("document_type") or "").strip()
if scene_code in {"travel", "hotel", "transport", "meal", "office", "meeting", "training"}: mapped_type = DOCUMENT_TYPE_ITEM_TYPE_MAP.get(document_type)
return scene_code if mapped_type:
return mapped_type
document_type = str(document.get("document_type") or "").strip()
if document_type in {"flight_itinerary", "train_ticket"}: scene_code = str(document.get("scene_code") or "").strip()
return "travel" 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"}: if document_type in {"taxi_receipt", "parking_toll_receipt", "transport_receipt"}:
return "transport" return "transport"
if document_type == "hotel_invoice": if document_type == "hotel_invoice":
@@ -1639,12 +1953,212 @@ class ExpenseClaimService:
if "会务" in scene_label or "会议" in scene_label: if "会务" in scene_label or "会议" in scene_label:
return "meeting" return "meeting"
if "培训" in scene_label: if "培训" in scene_label:
return "training" return "training"
return fallback or "other" return fallback or "other"
def _resolve_document_item_amount(self, document: dict[str, Any]) -> Decimal | None: def _resolve_document_item_reason(self, document: dict[str, Any], *, fallback: str) -> str:
for field in list(document.get("document_fields") or []): document_type = str(document.get("document_type") or "").strip().lower()
if not isinstance(field, dict): 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 continue
key = str(field.get("key") or "").strip().lower().replace("_", "") key = str(field.get("key") or "").strip().lower().replace("_", "")
label = str(field.get("label") or "").replace(" ", "") label = str(field.get("label") or "").replace(" ", "")
@@ -2575,6 +3089,21 @@ class ExpenseClaimService:
"fields": normalized_fields, "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( def _backfill_item_amount_from_attachment(
self, self,
*, *,
@@ -2596,6 +3125,27 @@ class ExpenseClaimService:
if amount is not None and amount > Decimal("0.00"): if amount is not None and amount > Decimal("0.00"):
item.item_amount = amount 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( def _build_attachment_requirement_check(
self, self,
*, *,
@@ -3063,6 +3613,17 @@ class ExpenseClaimService:
if not self._is_editable_claim_status(claim.status): if not self._is_editable_claim_status(claim.status):
raise ValueError("只有草稿、待补充或退回待提交状态的报销单才允许执行该操作。") 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: def _delete_submitted_claim_assistant_sessions(self, claim_id: str | None) -> None:
from app.services.agent_conversations import AgentConversationService from app.services.agent_conversations import AgentConversationService
@@ -4531,10 +5092,16 @@ class ExpenseClaimService:
primary_item.item_date.day, primary_item.item_date.day,
tzinfo=UTC, tzinfo=UTC,
) )
claim.expense_type = str(primary_item.item_type or claim.expense_type or "other").strip() or "other" claim.expense_type = self._resolve_claim_expense_type_from_items(
claim.reason = ( ordered_items,
self._normalize_optional_text(primary_item.item_reason, fallback=claim.reason or "待补充") or "待补充" 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 = ( claim.location = (
self._normalize_optional_text(primary_item.item_location, fallback=claim.location or "待补充") self._normalize_optional_text(primary_item.item_location, fallback=claim.location or "待补充")
or "待补充" or "待补充"
@@ -4543,8 +5110,20 @@ class ExpenseClaimService:
claim, claim,
self._build_claim_attachment_risk_flags(ordered_items), self._build_claim_attachment_risk_flags(ordered_items),
) )
if str(claim.status or "").strip().lower() == "draft": if str(claim.status or "").strip().lower() == "draft":
claim.approval_stage = "待提交" 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: def _refresh_item_attachment_analysis(self, item: ExpenseClaimItem) -> None:
file_path = self._resolve_attachment_path(item.invoice_id) file_path = self._resolve_attachment_path(item.invoice_id)

View File

@@ -9,10 +9,12 @@ from typing import Any
from sqlalchemy import or_, select from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from app.api.deps import CurrentUserContext
from app.core.agent_enums import AgentAssetStatus, AgentAssetType from app.core.agent_enums import AgentAssetStatus, AgentAssetType
from app.models.employee import Employee from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.schemas.agent_asset import AgentAssetListItem from app.schemas.agent_asset import AgentAssetListItem
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
from app.schemas.user_agent import ( from app.schemas.user_agent import (
UserAgentCitation, UserAgentCitation,
UserAgentDraftPayload, 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.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.risk_ontology_bridge import resolve_rule_codes_for_risk_check
from app.services.runtime_chat import RuntimeChatService from app.services.runtime_chat import RuntimeChatService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
SCENARIO_LABELS = { SCENARIO_LABELS = {
"expense": "报销", "expense": "报销",
@@ -187,6 +190,7 @@ DOCUMENT_AMOUNT_PATTERN = re.compile(
) )
DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)") DOCUMENT_CURRENCY_AMOUNT_PATTERN = re.compile(r"[¥¥]\s*([0-9]+(?:[.,][0-9]{1,2})?)")
TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN = re.compile(r"(\d+)\s*(?:晚|间夜)") 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 = { SOURCE_LABELS = {
"user_text": "用户描述", "user_text": "用户描述",
@@ -1900,6 +1904,11 @@ class UserAgentService:
ocr_documents=ocr_documents, ocr_documents=ocr_documents,
claim_groups=claim_groups, 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( missing_slot_keys = self._resolve_review_missing_slot_keys(
payload, payload,
slot_cards=slot_cards, slot_cards=slot_cards,
@@ -1911,10 +1920,11 @@ class UserAgentService:
document_cards=document_cards, document_cards=document_cards,
claim_groups=claim_groups, 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) association_choice_pending = self._is_review_association_choice_pending(payload)
can_proceed = ( can_proceed = (
False 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( else self._can_proceed_review(
payload, payload,
missing_slot_keys=missing_slot_keys, missing_slot_keys=missing_slot_keys,
@@ -1943,7 +1953,15 @@ class UserAgentService:
risk_briefs=risk_briefs, risk_briefs=risk_briefs,
can_proceed=can_proceed, can_proceed=can_proceed,
document_cards=document_cards, 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( return UserAgentReviewPayload(
intent_summary=intent_summary, intent_summary=intent_summary,
@@ -1951,7 +1969,7 @@ class UserAgentService:
scenario=payload.ontology.scenario, scenario=payload.ontology.scenario,
intent=payload.ontology.intent, intent=payload.ontology.intent,
can_proceed=can_proceed, 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, risk_briefs=risk_briefs,
slot_cards=slot_cards, slot_cards=slot_cards,
document_cards=document_cards, document_cards=document_cards,
@@ -2649,6 +2667,230 @@ class UserAgentService:
return True return True
return any(keyword in message_context for keyword in ("差旅", "出差", "机票", "火车", "高铁", "酒店", "住宿")) 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( def _resolve_review_travel_allowance_standard(
self, self,
policy: RuntimeTravelPolicy, policy: RuntimeTravelPolicy,
@@ -3008,7 +3250,7 @@ class UserAgentService:
if draft_payload is not None and draft_payload.claim_no and not can_proceed: if draft_payload is not None and draft_payload.claim_no and not can_proceed:
primary_action.description = f"保存后会生成草稿 {draft_payload.claim_no},后续仍可继续补充。" primary_action.description = f"保存后会生成草稿 {draft_payload.claim_no},后续仍可继续补充。"
return [ actions = [
UserAgentReviewAction( UserAgentReviewAction(
label="取消", label="取消",
action_type="cancel_review", action_type="cancel_review",
@@ -3021,8 +3263,18 @@ class UserAgentService:
description="打开结构化模板,按已识别字段逐项修改。", description="打开结构化模板,按已识别字段逐项修改。",
emphasis="secondary", 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( def _build_review_intent_summary(
self, self,
@@ -3086,20 +3338,22 @@ class UserAgentService:
return "已按您当前确认的信息保存为草稿。后续您可以继续补充缺失项,或修改识别结果后再继续提交。" return "已按您当前确认的信息保存为草稿。后续您可以继续补充缺失项,或修改识别结果后再继续提交。"
if review_action == "link_to_existing_draft": if review_action == "link_to_existing_draft":
document_count = self._resolve_review_document_count(payload) 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: if draft_payload is not None and draft_payload.claim_no:
return ( return (
f"已将本次上传的 {document_count} 张票据关联到草稿 {draft_payload.claim_no}" 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": if review_action == "create_new_claim_from_documents":
document_count = self._resolve_review_document_count(payload) 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: if draft_payload is not None and draft_payload.claim_no:
return ( return (
f"已按当前上传的 {document_count} 张票据新建报销草稿 {draft_payload.claim_no}" f"已按当前上传的 {document_count} 张票据新建报销草稿 {draft_payload.claim_no}"
"您可以继续补充识别字段,确认无误后再提交审批。" f"{followup_copy or '您可以继续补充识别字段,确认无误后再提交审批。'}"
) )
return "已按当前上传票据新建报销草稿。您可以继续补充识别字段,确认无误后再提交审批。" return f"已按当前上传票据新建报销草稿。{followup_copy or '您可以继续补充识别字段,确认无误后再提交审批。'}"
if review_action == "next_step": if review_action == "next_step":
if draft_payload is not None and draft_payload.status == "submitted": if draft_payload is not None and draft_payload.status == "submitted":
stage_text = draft_payload.approval_stage or "审批中" stage_text = draft_payload.approval_stage or "审批中"
@@ -3135,6 +3389,7 @@ class UserAgentService:
risk_briefs: list[UserAgentReviewRiskBrief], risk_briefs: list[UserAgentReviewRiskBrief],
can_proceed: bool, can_proceed: bool,
document_cards: list[UserAgentReviewDocumentCard], document_cards: list[UserAgentReviewDocumentCard],
travel_receipt_state: dict[str, Any] | None = None,
) -> str: ) -> str:
if self._is_review_association_choice_pending(payload): if self._is_review_association_choice_pending(payload):
claim_no = str(payload.tool_payload.get("association_candidate_claim_no") or "").strip() 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( review_payload = UserAgentReviewPayload(
intent_summary="", intent_summary="",
body_message="", body_message="",
scenario=payload.ontology.scenario, scenario=payload.ontology.scenario,
intent=payload.ontology.intent, intent=payload.ontology.intent,
can_proceed=can_proceed, can_proceed=can_proceed,
missing_slots=self._resolve_review_missing_slot_labels(slot_cards), missing_slots=missing_labels,
risk_briefs=risk_briefs, risk_briefs=risk_briefs,
slot_cards=slot_cards, slot_cards=slot_cards,
document_cards=[], document_cards=[],
@@ -3176,6 +3448,155 @@ class UserAgentService:
f"{self._build_review_guidance_copy(review_payload, mention_save_draft=not can_proceed)}" 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 @staticmethod
def _resolve_review_missing_slot_labels( def _resolve_review_missing_slot_labels(
slot_cards: list[UserAgentReviewSlotCard], slot_cards: list[UserAgentReviewSlotCard],
@@ -4076,16 +4497,11 @@ class UserAgentService:
merchant_value = "" merchant_value = ""
for document in ocr_documents: 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 continue
merchant_value = self._extract_document_merchant_name(document) merchant_value = self._extract_document_merchant_name(document)
if merchant_value: if merchant_value:
break 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: if merchant_value:
return self._build_slot_value( return self._build_slot_value(
value=merchant_value, value=merchant_value,
@@ -4407,6 +4823,8 @@ class UserAgentService:
label=display_label, label=display_label,
value=value, value=value,
) )
if display_label == "商户/酒店" and not self._is_hotel_document_item(item):
continue
if display_label and normalized_value: if display_label and normalized_value:
normalized_fields.setdefault(display_label, normalized_value) normalized_fields.setdefault(display_label, normalized_value)
@@ -4418,7 +4836,7 @@ class UserAgentService:
if date_match and "时间" not in normalized_fields: if date_match and "时间" not in normalized_fields:
normalized_fields["时间"] = date_match.group(1) 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: if merchant and "商户/酒店" not in normalized_fields:
normalized_fields["商户/酒店"] = merchant normalized_fields["商户/酒店"] = merchant
return normalized_fields return normalized_fields
@@ -4484,9 +4902,25 @@ class UserAgentService:
merchant = str(fields.get("商户/酒店") or "").strip() merchant = str(fields.get("商户/酒店") or "").strip()
if merchant: if merchant:
return 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() text = " ".join([str(item.get("summary") or ""), str(item.get("text") or "")]).strip()
return self._extract_document_merchant_name_from_text(text) 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 @staticmethod
def _extract_document_merchant_name_from_text(text: str) -> str: def _extract_document_merchant_name_from_text(text: str) -> str:
for keyword in ("酒店", "宾馆", "饭店", "酒楼", "餐厅", "航空", "铁路", "滴滴"): for keyword in ("酒店", "宾馆", "饭店", "酒楼", "餐厅", "航空", "铁路", "滴滴"):

View File

@@ -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": []
}

View File

@@ -1,12 +1,12 @@
{ {
"file_name": "2月20_武汉-上海.pdf", "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", "media_type": "application/pdf",
"size_bytes": 24995, "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, "previewable": true,
"preview_kind": "image", "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_media_type": "image/png",
"preview_file_name": "2月20_武汉-上海.preview.png", "preview_file_name": "2月20_武汉-上海.preview.png",
"analysis": { "analysis": {
@@ -15,7 +15,7 @@
"headline": "AI提示附件存在明显待整改项", "headline": "AI提示附件存在明显待整改项",
"summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。", "summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。",
"points": [ "points": [
"用途字段:用户填写用途“业务发生时间:2026-02-20 至 2026”与票据内容不一致,当前附件更像交通相关材料。" "用途字段:用户填写用途“2026-02-23支撑上海电力项目部署”与票据内容不一致,当前附件更像交通相关材料。"
], ],
"suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。" "suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。"
}, },
@@ -54,15 +54,10 @@
}, },
"requirement_check": { "requirement_check": {
"matches": true, "matches": true,
"current_expense_type": "travel", "current_expense_type": "train_ticket",
"current_expense_type_label": "差旅费", "current_expense_type_label": "火车票",
"allowed_scene_labels": [ "allowed_scene_labels": [],
"差旅" "allowed_document_type_labels": [],
],
"allowed_document_type_labels": [
"机票/航班行程单",
"火车/高铁票"
],
"recognized_scene_code": "travel", "recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据", "recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket", "recognized_document_type": "train_ticket",
@@ -70,7 +65,7 @@
"mismatch_severity": "high", "mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard", "rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准", "rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为差旅费,已识别为火车/高铁票,符合当前差旅费场景的附件要求。" "message": "当前费用项目为火车票,已识别为火车/高铁票。"
}, },
"ocr_status": "recognized", "ocr_status": "recognized",
"ocr_error": "", "ocr_error": "",

View File

@@ -1,12 +1,12 @@
{ {
"file_name": "2月23_上海-武汉.pdf", "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", "media_type": "application/pdf",
"size_bytes": 24940, "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, "previewable": true,
"preview_kind": "image", "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_media_type": "image/png",
"preview_file_name": "2月23_上海-武汉.preview.png", "preview_file_name": "2月23_上海-武汉.preview.png",
"analysis": { "analysis": {
@@ -15,7 +15,7 @@
"headline": "AI提示附件存在明显待整改项", "headline": "AI提示附件存在明显待整改项",
"summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。", "summary": "当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。",
"points": [ "points": [
"用途字段:用户填写用途“业务发生时间:2026-02-20 至 2026”与票据内容不一致,当前附件更像交通相关材料。" "用途字段:用户填写用途“2026-02-23支撑上海电力项目部署”与票据内容不一致,当前附件更像交通相关材料。"
], ],
"suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。" "suggestion": "建议根据风险点补齐清晰票据,或修正金额、日期、费用说明后再提交。"
}, },
@@ -54,15 +54,10 @@
}, },
"requirement_check": { "requirement_check": {
"matches": true, "matches": true,
"current_expense_type": "travel", "current_expense_type": "train_ticket",
"current_expense_type_label": "差旅费", "current_expense_type_label": "火车票",
"allowed_scene_labels": [ "allowed_scene_labels": [],
"差旅" "allowed_document_type_labels": [],
],
"allowed_document_type_labels": [
"机票/航班行程单",
"火车/高铁票"
],
"recognized_scene_code": "travel", "recognized_scene_code": "travel",
"recognized_scene_label": "差旅票据", "recognized_scene_label": "差旅票据",
"recognized_document_type": "train_ticket", "recognized_document_type": "train_ticket",
@@ -70,7 +65,7 @@
"mismatch_severity": "high", "mismatch_severity": "high",
"rule_code": "rule.expense.scene_submission_standard", "rule_code": "rule.expense.scene_submission_standard",
"rule_name": "报销场景提交与附件标准", "rule_name": "报销场景提交与附件标准",
"message": "当前费用项目为差旅费,已识别为火车/高铁票,符合当前差旅费场景的附件要求。" "message": "当前费用项目为火车票,已识别为火车/高铁票。"
}, },
"ocr_status": "recognized", "ocr_status": "recognized",
"ocr_error": "", "ocr_error": "",

View File

@@ -15,7 +15,7 @@ from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.schemas.ontology import OntologyParseRequest from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead 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.agent_conversations import AgentConversationService
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.ontology import SemanticOntologyService 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 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: def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_events() -> None:
user_id = "returned-owner@example.com" user_id = "returned-owner@example.com"
return_flag = { 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 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: def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-1", 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") assert updated["claim_amount"] == Decimal("354.00")
db.refresh(claim) db.refresh(claim)
assert claim.items[0].item_amount == Decimal("354.00") 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") assert claim.amount == Decimal("354.00")
uploaded_meta = service.get_claim_item_attachment_meta( uploaded_meta = service.get_claim_item_attachment_meta(
claim_id=claim.id, 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: def test_delete_claim_item_removes_row_and_attachment_files(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-1", username="emp-1",

View File

@@ -1315,6 +1315,230 @@ def test_user_agent_review_payload_prefers_hotel_invoice_for_hotel_name() -> Non
assert slot_map["merchant_name"].value == "北京中心酒店" 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: def test_user_agent_review_payload_prechecks_taxi_amount_against_rule_standard() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:

View File

@@ -561,6 +561,46 @@
white-space: pre-wrap; 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 { .leader-approval-card {
border-color: rgba(5, 150, 105, .18); border-color: rgba(5, 150, 105, .18);
background: linear-gradient(180deg, #ffffff 0%, #f7fdfb 100%); background: linear-gradient(180deg, #ffffff 0%, #f7fdfb 100%);
@@ -633,6 +673,15 @@
background: #fbfefd; 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-time { width: 11%; }
.detail-expense-table .col-filled-at { width: 15%; } .detail-expense-table .col-filled-at { width: 15%; }
.detail-expense-table .col-type { width: 13%; } .detail-expense-table .col-type { width: 13%; }
@@ -756,6 +805,36 @@
color: #ea580c; 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 { .attachment-action-group {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -932,6 +1011,36 @@
min-width: 128px; 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 { .row-action-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1332,8 +1441,9 @@
} }
.validation-card { .validation-card {
border: 1px solid #e6f0eb; border: 1px solid #e5e7eb;
background: linear-gradient(180deg, #fcfffd 0%, #f7fbf9 100%); background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
} }
.validation-head { .validation-head {
@@ -1341,11 +1451,14 @@
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
margin-bottom: 8px; margin-bottom: 10px;
} }
.validation-head h3 { .validation-head h3 {
margin-bottom: 4px; margin-bottom: 4px;
color: #0f172a;
font-size: 15px;
font-weight: 800;
} }
.validation-head p { .validation-head p {
@@ -1356,28 +1469,32 @@
} }
.validation-pill { .validation-pill {
min-height: 26px; min-height: 24px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 0 10px; padding: 0 10px;
border-radius: 999px; border-radius: 999px;
font-size: 12px; border: 1px solid transparent;
font-size: 11px;
font-weight: 800; font-weight: 800;
} }
.validation-pill.ready { .validation-pill.ready {
background: #dcfce7; background: #f0fdf4;
color: #047857; border-color: #bbf7d0;
color: #166534;
} }
.validation-pill.pending { .validation-pill.pending {
background: #fff7ed; background: #fff7ed;
border-color: #fed7aa;
color: #c2410c; color: #c2410c;
} }
.validation-pill.warning { .validation-pill.warning {
background: #fef2f2; background: #fef2f2;
color: #dc2626; border-color: #fecaca;
color: #b91c1c;
} }
.validation-summary { .validation-summary {
@@ -1387,29 +1504,155 @@
line-height: 1.6; 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 { .validation-list {
display: grid; display: grid;
gap: 6px; gap: 6px;
margin-top: 12px; margin: 0;
padding-left: 18px; padding: 0 0 0 18px;
color: #b45309; color: #0f766e;
font-size: 13px; font-size: 13px;
line-height: 1.55; line-height: 1.55;
} }
.risk-advice-list { .validation-list li::marker {
display: grid; color: #14b8a6;
gap: 12px;
margin-top: 14px;
} }
.risk-advice-card { .validation-section--risk .risk-advice-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
padding: 14px; margin-top: 0;
border: 1px solid #fee2e2; }
.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; 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 { .risk-advice-card.medium {

View File

@@ -4,6 +4,11 @@ import { fetchExpenseClaims } from '../services/reimbursements.js'
const EXPENSE_TYPE_LABELS = { const EXPENSE_TYPE_LABELS = {
travel: '差旅费', travel: '差旅费',
train_ticket: '火车票',
flight_ticket: '机票',
hotel_ticket: '住宿票',
ride_ticket: '乘车',
travel_allowance: '出差补贴',
entertainment: '业务招待费', entertainment: '业务招待费',
office: '办公费', office: '办公费',
meeting: '会务费', meeting: '会务费',
@@ -16,10 +21,17 @@ const EXPENSE_TYPE_LABELS = {
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel', 'travel',
'train_ticket',
'flight_ticket',
'hotel_ticket',
'ride_ticket',
'meeting', 'meeting',
'entertainment' '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 = [ const REIMBURSEMENT_PROGRESS_LABELS = [
'创建单据', '创建单据',
'待提交', '待提交',
@@ -123,6 +135,57 @@ function resolveLocationDisplay(location, typeCode) {
return isLocationRequiredExpenseType(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) { function resolveAttachmentDisplayName(value) {
const normalized = String(value || '').trim() const normalized = String(value || '').trim()
if (!normalized) { if (!normalized) {
@@ -498,11 +561,20 @@ function buildExpenseItems(claim, riskSummary) {
return [] 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 invoiceId = String(item?.invoice_id || '').trim()
const attachmentName = resolveAttachmentDisplayName(invoiceId) const attachmentName = resolveAttachmentDisplayName(invoiceId)
const attachments = invoiceId ? [attachmentName || invoiceId] : [] const attachments = invoiceId ? [attachmentName || invoiceId] : []
const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type) 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 itemTypeLabel = resolveTypeLabel(itemType)
const itemLocation = String(item?.item_location || '').trim() const itemLocation = String(item?.item_location || '').trim()
const itemReason = String(item?.item_reason || '').trim() const itemReason = String(item?.item_reason || '').trim()
@@ -510,7 +582,7 @@ function buildExpenseItems(claim, riskSummary) {
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充' const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
return { return {
id: String(item?.id || `${claim?.id || 'claim'}-item-${index}`), id,
time: formatDate(item?.item_date) || '待补充', time: formatDate(item?.item_date) || '待补充',
itemDate: formatDate(item?.item_date) || '', itemDate: formatDate(item?.item_date) || '',
filledAt: formatDateTime(item?.created_at) || '待同步', filledAt: formatDateTime(item?.created_at) || '待同步',
@@ -519,17 +591,24 @@ function buildExpenseItems(claim, riskSummary) {
itemLocation, itemLocation,
itemAmount, itemAmount,
invoiceId, invoiceId,
dayLabel: claim?.expense_type === 'travel' ? `${index + 1}` : '业务发生项', isSystemGenerated,
dayLabel: resolveExpenseTimeLabel({
id,
itemType,
isSystemGenerated,
claim,
travelTimeLabelMap
}),
name: itemTypeLabel, name: itemTypeLabel,
category: itemTypeLabel, category: itemTypeLabel,
desc: itemReason || '待补充', desc: itemReason || '待补充',
detail: resolveLocationDisplay(itemLocation, itemType), detail: resolveLocationDisplay(itemLocation, itemType),
amount: itemAmountDisplay, amount: itemAmountDisplay,
status: attachments.length ? '已识别' : '待补充', status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
tone: attachments.length ? 'ok' : 'bad', tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
attachmentStatus: attachments.length ? '已关联票据' : '未上传', attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
attachmentHint: attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据', attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据',
attachmentTone: attachments.length ? 'ok' : 'missing', attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
attachments, attachments,
riskLabel: riskSummary === '无' ? '无' : '待关注', riskLabel: riskSummary === '无' ? '无' : '待关注',
riskText: riskSummary === '无' ? '' : riskSummary, riskText: riskSummary === '无' ? '' : riskSummary,

View File

@@ -12,6 +12,13 @@ export function fetchExpenseClaimDetail(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`) 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 = {}) { export function calculateTravelReimbursement(payload = {}) {
return apiRequest('/reimbursements/travel-calculator', { return apiRequest('/reimbursements/travel-calculator', {
method: 'POST', method: 'POST',

View File

@@ -5,6 +5,36 @@ const REQUEST_TYPE_META = {
tone: 'travel', tone: 'travel',
secondaryStatusLabel: '行程状态' 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: { entertainment: {
label: '业务招待费', label: '业务招待费',
detailVariant: 'general', detailVariant: 'general',

View File

@@ -352,6 +352,7 @@
</button> </button>
<button <button
v-if="shouldShowReviewUploadButton(message.reviewPayload)"
type="button" type="button"
class="review-footer-btn" class="review-footer-btn"
:disabled="submitting || reviewActionBusy" :disabled="submitting || reviewActionBusy"

View File

@@ -88,6 +88,46 @@
<div class="detail-grid"> <div class="detail-grid">
<section class="detail-left"> <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"> <article class="detail-card panel">
<div class="detail-card-head"> <div class="detail-card-head">
<div> <div>
@@ -129,7 +169,7 @@
</thead> </thead>
<tbody> <tbody>
<template v-for="item in expenseItems" :key="item.id"> <template v-for="item in expenseItems" :key="item.id">
<tr> <tr :class="{ 'system-generated-row': item.isSystemGenerated }">
<td class="expense-time col-time"> <td class="expense-time col-time">
<template v-if="editingExpenseId === item.id"> <template v-if="editingExpenseId === item.id">
<div class="cell-editor"> <div class="cell-editor">
@@ -200,8 +240,8 @@
<template v-if="editingExpenseId === item.id"> <template v-if="editingExpenseId === item.id">
<div class="cell-editor editor-stack"> <div class="cell-editor editor-stack">
<div class="attachment-action-group"> <div class="attachment-action-group">
<button <button
v-if="isEditableRequest && !item.invoiceId" v-if="isEditableRequest && !item.invoiceId && !item.isSystemGenerated"
class="icon-action upload" class="icon-action upload"
type="button" type="button"
title="上传单据" title="上传单据"
@@ -221,8 +261,8 @@
> >
<i class="mdi mdi-eye-outline"></i> <i class="mdi mdi-eye-outline"></i>
</button> </button>
<button <button
v-if="isEditableRequest && item.invoiceId" v-if="isEditableRequest && item.invoiceId && !item.isSystemGenerated"
class="icon-action danger" class="icon-action danger"
type="button" type="button"
title="删除附件" title="删除附件"
@@ -236,9 +276,13 @@
</div> </div>
</template> </template>
<template v-else> <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 <button
v-if="isEditableRequest && !item.invoiceId" v-if="isEditableRequest && !item.invoiceId && !item.isSystemGenerated"
class="icon-action upload" class="icon-action upload"
type="button" type="button"
title="上传单据" title="上传单据"
@@ -259,7 +303,7 @@
<i class="mdi mdi-eye-outline"></i> <i class="mdi mdi-eye-outline"></i>
</button> </button>
<button <button
v-if="isEditableRequest && item.invoiceId" v-if="isEditableRequest && item.invoiceId && !item.isSystemGenerated"
class="icon-action danger" class="icon-action danger"
type="button" type="button"
title="删除附件" title="删除附件"
@@ -273,7 +317,11 @@
</template> </template>
</td> </td>
<td v-if="isEditableRequest" class="expense-action-cell col-action"> <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 <button
class="inline-action primary" class="inline-action primary"
type="button" type="button"
@@ -328,6 +376,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="expenseItems.length" class="expense-total-under-table">
<span>金额合计</span>
<strong>{{ expenseTotal }}</strong>
</div>
</article> </article>
<article v-if="isEditableRequest" class="detail-card panel validation-card"> <article v-if="isEditableRequest" class="detail-card panel validation-card">
@@ -339,39 +391,43 @@
<span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span> <span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span>
</div> </div>
<p class="validation-summary">{{ aiAdvice.summary }}</p> <p class="validation-summary">{{ aiAdvice.summary }}</p>
<div v-if="aiAdvice.riskCards.length" class="risk-advice-list"> <div v-if="aiAdvice.sections.length" class="validation-sections">
<article <section
v-for="card in aiAdvice.riskCards" v-for="section in aiAdvice.sections"
:key="card.id" :key="section.kind"
:class="['risk-advice-card', card.tone]" :class="['validation-section', `validation-section--${section.kind}`]"
> >
<div class="risk-advice-card-head"> <h4 class="validation-section-title">{{ section.title }}</h4>
<span>{{ card.label }}</span> <ul v-if="section.kind === 'completion'" class="validation-list">
<strong>{{ card.title }}</strong> <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> </div>
<p class="risk-advice-point">{{ card.risk }}</p> </section>
<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> </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>
<article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card"> <article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card">

View File

@@ -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) { function resolveReviewRiskBriefs(reviewPayload) {
if (!Array.isArray(reviewPayload?.risk_briefs)) return [] if (!Array.isArray(reviewPayload?.risk_briefs)) return []
return reviewPayload.risk_briefs.filter((item) => { return reviewPayload.risk_briefs.filter((item) => {
@@ -1762,7 +1776,7 @@ function buildExpenseQueryHint(queryPayload) {
} }
function countReviewPendingItems(reviewPayload) { function countReviewPendingItems(reviewPayload) {
return resolveReviewMissingSlotCards(reviewPayload).length return resolveReviewMissingSlotCards(reviewPayload).length + resolveReviewExtraMissingLabels(reviewPayload).length
} }
function countReviewRiskItems(reviewPayload) { function countReviewRiskItems(reviewPayload) {
@@ -1825,12 +1839,12 @@ function shouldOpenReviewDisclosure(reviewPayload) {
} }
function buildReviewTodoSectionTitle(reviewPayload) { function buildReviewTodoSectionTitle(reviewPayload) {
return resolveReviewMissingSlotCards(reviewPayload).length ? '待补充内容' : '已识别信息' return countReviewPendingItems(reviewPayload) ? '待补充内容' : '已识别信息'
} }
function buildReviewTodoSectionMeta(reviewPayload) { function buildReviewTodoSectionMeta(reviewPayload) {
const count = buildReviewTodoItems(reviewPayload).length const count = buildReviewTodoItems(reviewPayload).length
if (resolveReviewMissingSlotCards(reviewPayload).length) { if (countReviewPendingItems(reviewPayload)) {
return count ? `${count}` : '待确认' return count ? `${count}` : '待确认'
} }
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) { if (chips.length < 3) {
for (const risk of resolveReviewRiskBriefs(reviewPayload)) { for (const risk of resolveReviewRiskBriefs(reviewPayload)) {
if (chips.some((item) => item.label === risk.title)) continue if (chips.some((item) => item.label === risk.title)) continue
@@ -1889,8 +1914,10 @@ function buildReviewAlertChips(reviewPayload) {
function buildReviewTodoItems(reviewPayload) { function buildReviewTodoItems(reviewPayload) {
const missingItems = resolveReviewMissingSlotCards(reviewPayload) const missingItems = resolveReviewMissingSlotCards(reviewPayload)
if (missingItems.length) { const extraMissingLabels = resolveReviewExtraMissingLabels(reviewPayload)
return missingItems.map((item) => { if (missingItems.length || extraMissingLabels.length) {
return [
...missingItems.map((item) => {
const config = REVIEW_SLOT_CONFIG[item.key] || {} const config = REVIEW_SLOT_CONFIG[item.key] || {}
return { return {
key: item.key, key: item.key,
@@ -1900,7 +1927,18 @@ function buildReviewTodoItems(reviewPayload) {
status: config.status || '待补充', status: config.status || '待补充',
tone: 'warning' 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) return resolveReviewRecognizedSlotCards(reviewPayload)
@@ -2571,8 +2609,18 @@ function buildLocallySyncedReviewActions(reviewPayload, canProceed) {
return actions 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 [ return [
...actions.filter((item) => !['save_draft', 'next_step'].includes(String(item?.action_type || '').trim())), ...syncedActions,
{ {
label: '继续下一步', label: '继续下一步',
action_type: 'next_step', action_type: 'next_step',
@@ -2607,12 +2655,17 @@ function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmpt
const missingSlots = nextSlotCards const missingSlots = nextSlotCards
.filter((slot) => slot.required && slot.status === 'missing') .filter((slot) => slot.required && slot.status === 'missing')
.map((slot) => slot.label || slot.key) .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 { return {
...reviewPayload, ...reviewPayload,
can_proceed: canProceed, can_proceed: canProceed,
missing_slots: missingSlots, missing_slots: allMissingSlots,
slot_cards: nextSlotCards, slot_cards: nextSlotCards,
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed) confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
} }
@@ -2821,22 +2874,24 @@ function buildReviewDocumentSummaries(reviewPayload) {
} }
function buildReviewDecisionHint(reviewPayload) { function buildReviewDecisionHint(reviewPayload) {
const missingSlots = resolveReviewMissingSlotCards(reviewPayload) const pendingCount = countReviewPendingItems(reviewPayload)
const riskBriefs = resolveReviewRiskBriefs(reviewPayload) const riskBriefs = resolveReviewRiskBriefs(reviewPayload)
if (reviewPayload?.can_proceed) { if (reviewPayload?.can_proceed) {
if (shouldShowReviewUploadButton(reviewPayload)) {
return '必需信息已整理好;如还有非必需票据可以继续上传,也可以直接进入下一步或保存草稿。'
}
return riskBriefs.length return riskBriefs.length
? `我已经把信息整理好了。你可以直接进入下一步,提交前再看一下下方 ${riskBriefs.length} 条提醒。` ? `我已经把信息整理好了。你可以直接进入下一步,提交前再看一下下方 ${riskBriefs.length} 条提醒。`
: '我已经把信息整理好了。你确认无误后,可以直接进入下一步。' : '我已经把信息整理好了。你确认无误后,可以直接进入下一步。'
} }
if (missingSlots.length) { if (pendingCount) {
return `我先完成了当前这轮识别,还差 ${missingSlots.length} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。` return `我先完成了当前这轮识别,还差 ${pendingCount} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。`
} }
return '如果你觉得识别结果有偏差,点“修改识别信息”直接校正,我会按新内容重新识别。' return '如果你觉得识别结果有偏差,点“修改识别信息”直接校正,我会按新内容重新识别。'
} }
function buildReviewMissingHint(reviewPayload) { function buildReviewMissingHint(reviewPayload) {
const missingSlots = resolveReviewMissingSlotCards(reviewPayload) if (!countReviewPendingItems(reviewPayload)) {
if (!missingSlots.length) {
return '' return ''
} }
if (reviewPayload?.can_proceed) { if (reviewPayload?.can_proceed) {
@@ -2860,8 +2915,19 @@ function buildReviewActionHint(reviewPayload) {
return '如果现在信息还不完整,可以先保存草稿;识别错了就点“修改识别信息”。' 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) { function buildReviewStatusTag(reviewPayload) {
const missingCount = resolveReviewMissingSlotCards(reviewPayload).length const missingCount = countReviewPendingItems(reviewPayload)
if (reviewPayload?.can_proceed) { if (reviewPayload?.can_proceed) {
return '可继续处理' return '可继续处理'
} }
@@ -5607,6 +5673,7 @@ export default {
buildReviewTodoSectionMeta, buildReviewTodoSectionMeta,
buildReviewAlertChips, buildReviewAlertChips,
buildReviewTodoItems, buildReviewTodoItems,
shouldShowReviewUploadButton,
resolveReviewSubmitActions, resolveReviewSubmitActions,
resolveReviewPrimaryAction, resolveReviewPrimaryAction,
resolveReviewEditAction, resolveReviewEditAction,

View File

@@ -15,6 +15,7 @@ import {
returnExpenseClaim, returnExpenseClaim,
submitExpenseClaim, submitExpenseClaim,
uploadExpenseClaimItemAttachment, uploadExpenseClaimItemAttachment,
updateExpenseClaim,
updateExpenseClaimItem updateExpenseClaimItem
} from '../../services/reimbursements.js' } from '../../services/reimbursements.js'
import { import {
@@ -32,6 +33,10 @@ import {
const EXPENSE_TYPE_OPTIONS = [ const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' }, { value: 'travel', label: '差旅费' },
{ value: 'train_ticket', label: '火车票' },
{ value: 'flight_ticket', label: '机票' },
{ value: 'hotel_ticket', label: '住宿票' },
{ value: 'ride_ticket', label: '乘车' },
{ value: 'entertainment', label: '业务招待费' }, { value: 'entertainment', label: '业务招待费' },
{ value: 'office', label: '办公费' }, { value: 'office', label: '办公费' },
{ value: 'meeting', label: '会务费' }, { value: 'meeting', label: '会务费' },
@@ -39,15 +44,23 @@ const EXPENSE_TYPE_OPTIONS = [
{ value: 'hotel', label: '住宿费' }, { value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' }, { value: 'transport', label: '交通费' },
{ value: 'meal', label: '餐费' }, { value: 'meal', label: '餐费' },
{ value: 'travel_allowance', label: '出差补贴' },
{ value: 'other', label: '其他费用' } { value: 'other', label: '其他费用' }
] ]
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel', 'travel',
'train_ticket',
'flight_ticket',
'hotel_ticket',
'ride_ticket',
'meeting', 'meeting',
'entertainment' '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) { function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0 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 || '其他费用' 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) { function isLocationRequiredExpenseType(value) {
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value)) return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
} }
@@ -135,6 +153,11 @@ function isPlaceholderValue(value) {
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, '')) return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
} }
function normalizeDetailNoteDraftValue(value) {
const text = String(value || '').trim()
return isPlaceholderValue(text) ? '' : text
}
function isValidIsoDate(value) { function isValidIsoDate(value) {
const normalized = String(value || '').trim() const normalized = String(value || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
@@ -213,8 +236,65 @@ function extractAttachmentDisplayName(value) {
return normalized.split('/').filter(Boolean).pop() || normalized 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 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 itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim() const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date) const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
@@ -232,26 +312,33 @@ function buildExpenseItemViewModel(source, index, requestModel) {
) )
return { return {
id: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`), id,
itemDate, itemDate,
itemType, itemType,
itemReason, itemReason,
itemLocation, itemLocation,
itemAmount, itemAmount,
invoiceId, invoiceId,
isSystemGenerated,
time: itemDate || '待补充', time: itemDate || '待补充',
filledAt: filledAt || '待同步', filledAt: filledAt || '待同步',
dayLabel: requestModel?.detailVariant === 'travel' ? `${index + 1}` : '业务发生项', dayLabel: resolveExpenseTimeLabel({
id,
itemType,
isSystemGenerated,
requestModel,
travelTimeLabelMap
}),
name: resolveExpenseTypeLabel(itemType), name: resolveExpenseTypeLabel(itemType),
category: resolveExpenseTypeLabel(itemType), category: resolveExpenseTypeLabel(itemType),
desc: itemReason || '待补充', desc: itemReason || '待补充',
detail: resolveLocationDisplay(itemLocation, itemType), detail: resolveLocationDisplay(itemLocation, itemType),
amount: amountDisplay, amount: amountDisplay,
status: attachments.length ? '已识别' : '待补充', status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
tone: attachments.length ? 'ok' : 'bad', tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
attachmentStatus: attachments.length ? '已关联票据' : '未上传', attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(), attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
attachmentTone: attachments.length ? 'ok' : 'missing', attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
attachments, attachments,
riskLabel: String(source?.riskLabel || '').trim() || '无', riskLabel: String(source?.riskLabel || '').trim() || '无',
riskText, riskText,
@@ -260,11 +347,17 @@ function buildExpenseItemViewModel(source, index, requestModel) {
} }
function rebuildExpenseItems(items, 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) { function buildExpenseDraftIssues(item) {
const issues = [] const issues = []
if (item.isSystemGenerated) {
return issues
}
const locationRequired = isLocationRequiredExpenseType(item.itemType) const locationRequired = isLocationRequiredExpenseType(item.itemType)
if (!isValidIsoDate(item.itemDate)) { if (!isValidIsoDate(item.itemDate)) {
@@ -441,6 +534,8 @@ export default {
itemAmount: '', itemAmount: '',
invoiceId: '' invoiceId: ''
}) })
const detailNoteEditor = ref('')
const savingDetailNote = ref(false)
const request = computed(() => { const request = computed(() => {
const normalized = normalizeRequestForUi(props.request) const normalized = normalizeRequestForUi(props.request)
@@ -654,7 +749,7 @@ export default {
} }
const expenseTotal = computed(() => { 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) return formatCurrency(total)
}) })
@@ -662,10 +757,21 @@ export default {
const expenseTableColumnCount = computed( const expenseTableColumnCount = computed(
() => 6 + (isEditableRequest.value ? 1 : 0) () => 6 + (isEditableRequest.value ? 1 : 0)
) )
const detailNote = computed( const canEditDetailNote = computed(() => isDraftRequest.value)
() => const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
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(() => const draftBlockingIssues = computed(() =>
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : [] 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) { function startExpenseEdit(item) {
if (!isEditableRequest.value || actionBusy.value) { if (!isEditableRequest.value || actionBusy.value) {
return return
} }
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行不能手动编辑。')
return
}
editingExpenseId.value = item.id editingExpenseId.value = item.id
expenseEditor.itemDate = item.itemDate || '' expenseEditor.itemDate = item.itemDate || ''
@@ -954,6 +1094,11 @@ export default {
return return
} }
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行无需上传附件。')
return
}
if (item?.invoiceId) { if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。') toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
return return
@@ -1036,6 +1181,10 @@ export default {
if (!item || !file) { if (!item || !file) {
return return
} }
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行无需上传附件。')
return
}
if (item?.invoiceId) { if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。') toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
@@ -1138,6 +1287,10 @@ export default {
if (!request.value.claimId || !item?.id || actionBusy.value) { if (!request.value.claimId || !item?.id || actionBusy.value) {
return return
} }
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行不能删除。')
return
}
deletingExpenseId.value = item.id deletingExpenseId.value = item.id
try { try {
@@ -1468,18 +1621,21 @@ export default {
confirmReturnRequest, confirmReturnRequest,
currentAttachmentPreviewInsight, currentAttachmentPreviewInsight,
currentAttachmentPreviewRiskCards, currentAttachmentPreviewRiskCards,
currentProgressRingMotion, currentProgressRingMotion,
deleteActionLabel, canEditDetailNote,
deleteBusy, deleteActionLabel,
deleteDialogDescription, deleteBusy,
deleteDialogOpen, deleteDialogDescription,
deleteDialogTitle, deleteDialogOpen,
deletingAttachmentId, deleteDialogTitle,
deletingExpenseId, deletingAttachmentId,
detailNote, deletingExpenseId,
draftBlockingIssues, detailNote,
editingExpenseId, detailNoteDirty,
creatingExpense, detailNoteEditor,
draftBlockingIssues,
editingExpenseId,
creatingExpense,
expenseEditor, expenseEditor,
expenseItems, expenseItems,
expenseTableColumnCount, expenseTableColumnCount,
@@ -1502,18 +1658,21 @@ export default {
goToPreviousAttachmentPreview, goToPreviousAttachmentPreview,
profile, profile,
progressSteps, progressSteps,
request, request,
leaderOpinion, leaderOpinion,
removeExpenseAttachment, removeExpenseAttachment,
removeExpenseItem, removeExpenseItem,
resolveAttachmentDisplayName, resetDetailNote,
resolveAttachmentPreviewTitle, resolveAttachmentDisplayName,
resolveAttachmentRecognition, resolveAttachmentPreviewTitle,
resolveExpenseRiskState, resolveAttachmentRecognition,
resolveExpenseIssues, resolveExpenseRiskState,
resolveExpenseIssues,
returnBusy, returnBusy,
returnDialogOpen, returnDialogOpen,
savingExpenseId, saveDetailNote,
savingDetailNote,
savingExpenseId,
showLeaderApprovalPanel, showLeaderApprovalPanel,
showExpenseRisk, showExpenseRisk,
startExpenseEdit, startExpenseEdit,

View File

@@ -265,19 +265,44 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high') const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) { if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
const items = [
'点击右下角“提交审批”进入流程。',
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
]
return { return {
tone: 'ready', tone: 'ready',
badge: '可直接提交', badge: '可直接提交',
summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。', summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。',
items: [ items,
'点击右下角“提交审批”进入流程。', riskCards: [],
'提交前再核对一次合计金额与各条费用明细金额是否一致。', sections: [
'如有特殊业务背景或例外情况,可在下方附加说明中补充。' {
], kind: 'completion',
riskCards: [] 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 { return {
tone: hasHighRisk ? 'warning' : 'pending', tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : '待核对', badge: hasHighRisk ? '优先整改' : '待核对',
@@ -285,6 +310,7 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议。` ? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议。`
: '建议先补齐必填信息,完成后即可提交审批。', : '建议先补齐必填信息,完成后即可提交审批。',
items: normalizedCompletionItems, items: normalizedCompletionItems,
riskCards: normalizedRiskCards riskCards: normalizedRiskCards,
sections
} }
} }

View File

@@ -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', () => { test('completed finance approval marks finance and archive progress steps', () => {
const request = mapExpenseClaimToRequest({ const request = mapExpenseClaimToRequest({
id: 'claim-finance-completed', id: 'claim-finance-completed',

View File

@@ -17,6 +17,10 @@ const detailViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)), fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
'utf8' 'utf8'
) )
const detailViewStyle = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8'
)
const requestsComposableScript = readFileSync( const requestsComposableScript = readFileSync(
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)), fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
'utf8' '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('费用项目调整为交通费'))) 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', () => { test('AI advice shows only the latest manual return while preserving return count context', () => {
const riskCards = buildAttachmentRiskCards({ const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [ claimRiskFlags: [
@@ -138,16 +213,60 @@ test('expense attachment actions keep preview as the only recognition entry poin
assert.doesNotMatch(detailViewScript, /recognizingExpenseId/) 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, /<div class="detail-expense-table">/)
assert.match(detailViewTemplate, /当前还没有费用明细/) assert.match(detailViewTemplate, /当前还没有费用明细/)
assert.doesNotMatch(detailViewTemplate, /class="total-row"/) assert.doesNotMatch(detailViewTemplate, /class="total-row"/)
assert.doesNotMatch(detailViewTemplate, /expense-total-bar/) assert.match(detailViewTemplate, /class="expense-total-under-table"[\s\S]*金额合计[\s\S]*\{\{ expenseTotal \}\}/)
assert.doesNotMatch(detailViewTemplate, /合计 \{\{ expenseTotal \}\}/)
assert.doesNotMatch(detailViewTemplate, /\{\{ uploadedExpenseCount \}\} 项已关联票据/) assert.doesNotMatch(detailViewTemplate, /\{\{ uploadedExpenseCount \}\} 项已关联票据/)
assert.doesNotMatch(detailViewTemplate, /\{\{ expenseSummaryText \}\}/) 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', () => { 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, /<th class="col-filled-at">填写时间<\/th>/)
assert.match(detailViewTemplate, /<td class="expense-filled-at col-filled-at">[\s\S]*\{\{ item\.filledAt \}\}/) 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.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
assert.doesNotMatch(detailViewTemplate, /\bmultiple\b/) assert.doesNotMatch(detailViewTemplate, /\bmultiple\b/)
assert.equal( assert.equal(
(detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId"/g) || []).length, (detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId && !item\.isSystemGenerated"/g) || []).length,
2 2
) )
assert.match(detailViewScript, /const attachments = invoiceId \? \[attachmentName \|\| invoiceId\] : \[\]/) 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, /if \(item\?\.invoiceId\) \{[\s\S]*每条费用明细只能关联一张单据/)
assert.match(detailViewScript, /const fileCount = fileList\?\.length \|\| 0/) assert.match(detailViewScript, /const fileCount = fileList\?\.length \|\| 0/)
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/) assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)