feat: 报销预审会话状态管理与工作台交互增强

- 新增差旅报销会话状态管理与对话模型重构
- 增强风险观测服务与运行时聊天上下文作用域
- 优化工作台图标资源、助理意图识别与摘要工具
- 完善报销创建视图样式与差旅详情页标准调整交互
- 补充风险观测、运行时聊天与报销端点测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-04 11:03:29 +08:00
parent 87da5df91b
commit 1cbf3fee44
60 changed files with 4156 additions and 393 deletions

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.common import ErrorResponse
from app.schemas.steward import StewardPlanRequest, StewardPlanResponse
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_intent_agent import StewardIntentAgent
from app.services.steward_planner import StewardPlannerService
router = APIRouter(prefix="/steward")
DbSession = Annotated[Session, Depends(get_db)]
@router.post(
"/plans",
response_model=StewardPlanResponse,
summary="生成小财管家任务计划",
description="把首页自然语言和附件元信息拆解为可确认、可追踪、可分派的财务任务计划。",
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "请求缺少任务描述,无法生成小财管家计划。",
}
},
)
def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPlanResponse:
try:
return _build_steward_planner(db).build_plan(payload)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.post(
"/plans/stream",
summary="流式生成小财管家任务计划",
description="以 NDJSON 逐条返回小财管家的过程摘要事件,最后返回完整任务计划。",
)
async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StreamingResponse:
return StreamingResponse(
_iter_steward_plan_events(payload, _build_steward_planner(db)),
media_type="application/x-ndjson",
)
async def _iter_steward_plan_events(
payload: StewardPlanRequest,
planner: StewardPlannerService,
) -> AsyncIterator[str]:
try:
plan = planner.build_plan(payload)
except ValueError as exc:
yield _encode_stream_event("error", {"message": str(exc)})
return
for event in plan.thinking_events:
yield _encode_stream_event("thinking", event.model_dump(mode="json"))
await asyncio.sleep(0.18)
yield _encode_stream_event("plan", plan.model_dump(mode="json"))
def _encode_stream_event(event: str, data: dict[str, Any]) -> str:
return json.dumps({"event": event, "data": data}, ensure_ascii=False) + "\n"
def _build_steward_planner(db: Session) -> StewardPlannerService:
return StewardPlannerService(
intent_agent=StewardIntentAgent(RuntimeChatService(db)),
)

View File

@@ -22,6 +22,7 @@ from app.api.v1.endpoints.receipt_folder import router as receipt_folder_router
from app.api.v1.endpoints.reimbursements import router as reimbursements_router from app.api.v1.endpoints.reimbursements import router as reimbursements_router
from app.api.v1.endpoints.risk_observations import router as risk_observations_router from app.api.v1.endpoints.risk_observations import router as risk_observations_router
from app.api.v1.endpoints.settings import router as settings_router from app.api.v1.endpoints.settings import router as settings_router
from app.api.v1.endpoints.steward import router as steward_router
from app.api.v1.endpoints.system_logs import router as system_logs_router from app.api.v1.endpoints.system_logs import router as system_logs_router
router = APIRouter() router = APIRouter()
@@ -47,4 +48,5 @@ router.include_router(employee_profiles_router, tags=["employee-profiles"])
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"]) router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
router.include_router(risk_observations_router, tags=["risk-observations"]) router.include_router(risk_observations_router, tags=["risk-observations"])
router.include_router(settings_router, tags=["settings"]) router.include_router(settings_router, tags=["settings"])
router.include_router(steward_router, tags=["steward"])
router.include_router(system_logs_router, tags=["system-logs"]) router.include_router(system_logs_router, tags=["system-logs"])

View File

@@ -126,6 +126,7 @@ class ExpenseClaimStandardAdjustmentRisk(BaseModel):
item_id: str | None = Field(default=None, max_length=120) item_id: str | None = Field(default=None, max_length=120)
title: str | None = Field(default=None, max_length=120) title: str | None = Field(default=None, max_length=120)
risk: str | None = Field(default=None, max_length=500) risk: str | None = Field(default=None, max_length=500)
application_days: int | None = Field(default=None, ge=1, le=365)
original_amount: Decimal | None = None original_amount: Decimal | None = None
reimbursable_amount: Decimal | None = None reimbursable_amount: Decimal | None = None

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
StewardTaskType = Literal["expense_application", "reimbursement"]
StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"]
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
StewardTaskStatus = Literal[
"planned",
"needs_confirmation",
"ready_to_delegate",
"delegated",
"completed",
"blocked",
]
StewardConfirmationStatus = Literal["pending", "confirmed", "rejected"]
class StewardAttachmentInput(BaseModel):
name: str = Field(description="附件原始文件名。")
media_type: str = Field(default="", description="附件 MIME 类型。")
ocr_summary: str = Field(default="", description="可选 OCR 摘要。")
ocr_fields: dict[str, Any] = Field(default_factory=dict, description="可选 OCR 结构化字段。")
class StewardPlanRequest(BaseModel):
message: str = Field(description="用户在首页输入的自然语言任务。")
user_id: str | None = Field(default=None, description="当前用户 ID。")
client_now_iso: str | None = Field(default=None, description="客户端当前时间 ISO 字符串。")
attachments: list[StewardAttachmentInput] = Field(default_factory=list, description="随本次输入上传的附件。")
context_json: dict[str, Any] = Field(default_factory=dict, description="调用方上下文。")
class StewardThinkingEvent(BaseModel):
event_id: str = Field(description="过程摘要事件 ID。")
stage: str = Field(description="阶段编码。")
title: str = Field(description="面向用户展示的阶段标题。")
content: str = Field(description="面向用户展示的过程摘要。")
status: str = Field(default="completed", description="事件状态。")
class StewardTask(BaseModel):
task_id: str = Field(description="小财管家任务 ID。")
task_type: StewardTaskType = Field(description="任务类型。")
assigned_agent: StewardAssignedAgent = Field(description="建议分派的下游助手。")
title: str = Field(description="任务标题。")
summary: str = Field(description="任务摘要。")
status: StewardTaskStatus = Field(default="needs_confirmation", description="任务状态。")
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="识别置信度。")
ontology_fields: dict[str, str] = Field(default_factory=dict, description="归一化后的业务本体字段。")
missing_fields: list[str] = Field(default_factory=list, description="仍缺失的本体字段。")
confirmation_required: bool = Field(default=True, description="执行前是否需要用户确认。")
class StewardAttachmentGroup(BaseModel):
group_id: str = Field(description="附件归集组 ID。")
target_task_id: str | None = Field(default=None, description="建议归属的任务 ID。")
scene: str = Field(description="归集场景编码。")
scene_label: str = Field(description="归集场景展示名。")
attachment_names: list[str] = Field(default_factory=list, description="建议纳入的附件名称。")
excluded_attachment_names: list[str] = Field(default_factory=list, description="建议排除或单独处理的附件名称。")
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="归集置信度。")
rationale: str = Field(default="", description="归集依据。")
confirmation_required: bool = Field(default=True, description="归集前是否需要用户确认。")
class StewardConfirmationAction(BaseModel):
confirmation_id: str = Field(description="确认动作 ID。")
action_type: str = Field(description="确认动作类型。")
label: str = Field(description="确认按钮文案。")
description: str = Field(default="", description="确认动作说明。")
target_task_id: str | None = Field(default=None, description="关联任务 ID。")
attachment_group_id: str | None = Field(default=None, description="关联附件归集组 ID。")
status: StewardConfirmationStatus = Field(default="pending", description="确认状态。")
payload: dict[str, Any] = Field(default_factory=dict, description="确认后继续执行所需载荷。")
class StewardPlanResponse(BaseModel):
plan_id: str = Field(description="小财管家计划 ID。")
plan_status: str = Field(default="needs_confirmation", description="计划状态。")
planning_source: StewardPlanningSource = Field(default="rule_fallback", description="计划生成来源。")
summary: str = Field(description="计划摘要。")
thinking_events: list[StewardThinkingEvent] = Field(default_factory=list, description="过程摘要事件。")
tasks: list[StewardTask] = Field(default_factory=list, description="拆解后的任务。")
attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。")
confirmation_groups: list[StewardConfirmationAction] = Field(default_factory=list, description="等待用户确认的动作。")
model_call_traces: list[dict[str, Any]] = Field(default_factory=list, description="模型工具调用轨迹。")

View File

@@ -254,6 +254,7 @@ class ExpenseClaimAttachmentOperationsMixin:
) )
self._sync_claim_from_items(claim) self._sync_claim_from_items(claim)
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
self.db.commit() self.db.commit()
self.db.refresh(claim) self.db.refresh(claim)
@@ -356,6 +357,7 @@ class ExpenseClaimAttachmentOperationsMixin:
item.invoice_id = None item.invoice_id = None
self._sync_claim_from_items(claim) self._sync_claim_from_items(claim)
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
self.db.commit() self.db.commit()
self.db.refresh(claim) self.db.refresh(claim)

View File

@@ -139,3 +139,61 @@ class ExpenseClaimPreReviewMixin:
) )
] ]
return [*preserved_flags, next_flag] return [*preserved_flags, next_flag]
def _refresh_claim_pre_review_flags(
self,
claim: ExpenseClaim,
*,
is_application_claim: bool | None = None,
reviewed_at: datetime | None = None,
) -> bool:
if claim is None:
return False
if is_application_claim is None:
is_application_claim = self._is_expense_application_claim(claim)
reviewed_at = reviewed_at or datetime.now(UTC)
if is_application_claim:
preserved_flags = [
flag
for flag in list(claim.risk_flags_json or [])
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "submission_review"
and str(flag.get("hit_source") or "").strip() == "rule_center"
)
]
application_review = self.evaluate_platform_risk_rules(
claim,
business_stage="expense_application",
)
review_flags = [*preserved_flags, *list(application_review.get("flags") or [])]
else:
review_result = self._run_ai_submission_review(claim)
review_flags = list(review_result.get("risk_flags") or [])
blocking_count = self._count_ai_pre_review_blocking_risks(review_flags)
claim.risk_flags_json = self._replace_ai_pre_review_flag(
review_flags,
self._build_ai_pre_review_flag(
passed=blocking_count <= 0,
blocking_count=blocking_count,
reviewed_at=reviewed_at,
business_stage=risk_business_stage_for_claim(
is_application_claim=is_application_claim,
),
),
)
if not is_application_claim:
claim.approval_stage = "\u5f85\u63d0\u4ea4"
claim.submitted_at = None
return True
@staticmethod
def _has_ai_pre_review_flag(claim: ExpenseClaim) -> bool:
return any(
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == "ai_pre_review"
for flag in list(claim.risk_flags_json or [])
)

View File

@@ -48,6 +48,7 @@ from app.services.expense_claim_attachment_analysis import ExpenseClaimAttachmen
from app.services.expense_claim_attachment_document import ExpenseClaimAttachmentDocumentMixin from app.services.expense_claim_attachment_document import ExpenseClaimAttachmentDocumentMixin
from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin from app.services.expense_claim_attachment_operations import ExpenseClaimAttachmentOperationsMixin
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
from app.services.expense_claim_workflow_constants import DIRECT_MANAGER_APPROVAL_STAGE
from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin from app.services.expense_claim_document_item_builder import ExpenseClaimDocumentItemBuilderMixin
from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin from app.services.expense_claim_document_parsing import ExpenseClaimDocumentParsingMixin
from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin from app.services.expense_claim_draft_flow import ExpenseClaimDraftFlowMixin
@@ -278,6 +279,9 @@ class ExpenseClaimService(
if payload.reason is not None: if payload.reason is not None:
claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充" claim.reason = self._normalize_optional_text(payload.reason, allow_empty=True) or "待补充"
if not self._is_expense_application_claim(claim):
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
self.db.commit() self.db.commit()
self.db.refresh(claim) self.db.refresh(claim)
@@ -306,6 +310,146 @@ class ExpenseClaimService(
normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01")) normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01"))
return f"{normalized:.2f}" return f"{normalized:.2f}"
@staticmethod
def _normalize_standard_adjustment_days(value: Any) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value if 1 <= value <= 365 else None
text = str(value or "").strip()
if not text:
return None
match = re.search(r"\d{1,3}", text)
if not match:
return None
days = int(match.group(0))
return days if 1 <= days <= 365 else None
@staticmethod
def _normalize_standard_adjustment_text(value: Any) -> str:
text = str(value or "").strip()
if not text or text in {"-", "N/A", "n/a"}:
return ""
if text in {"待补充", "未知", "暂无", "非必填"}:
return ""
return text
def _iter_standard_adjustment_application_details(self, claim: ExpenseClaim) -> list[dict[str, Any]]:
details: list[dict[str, Any]] = []
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
detail = flag.get("application_detail") or flag.get("applicationDetail")
if isinstance(detail, dict):
details.append(detail)
related = flag.get("related_application") or flag.get("relatedApplication")
if isinstance(related, dict):
details.append(related)
return details
def _resolve_standard_adjustment_days(
self,
claim: ExpenseClaim,
item: ExpenseClaimItem,
entry: Any,
) -> int:
direct_days = self._normalize_standard_adjustment_days(getattr(entry, "application_days", None))
if direct_days is not None:
return direct_days
for detail in self._iter_standard_adjustment_application_details(claim):
for key in ("application_days", "applicationDays", "days"):
detail_days = self._normalize_standard_adjustment_days(detail.get(key))
if detail_days is not None:
return detail_days
candidates = [
getattr(entry, "risk", None),
getattr(entry, "title", None),
item.item_reason,
claim.reason,
]
for text in candidates:
match = re.search(r"(\d{1,3})\s*(?:天|晚|夜)", str(text or ""))
if match:
days = self._normalize_standard_adjustment_days(match.group(1))
if days is not None:
return days
return 1
def _resolve_standard_adjustment_location(
self,
claim: ExpenseClaim,
item: ExpenseClaimItem,
) -> str:
for value in (item.item_location, claim.location):
text = self._normalize_standard_adjustment_text(value)
if text:
return text
for detail in self._iter_standard_adjustment_application_details(claim):
for key in ("application_location", "applicationLocation", "location", "city"):
text = self._normalize_standard_adjustment_text(detail.get(key))
if text:
return text
return ""
def _resolve_policy_standard_reimbursable_amount(
self,
*,
claim: ExpenseClaim,
item: ExpenseClaimItem,
entry: Any,
current_user: CurrentUserContext,
) -> Decimal | None:
item_type = str(item.item_type or "").strip().lower()
if item_type not in {"hotel", "hotel_ticket"}:
return None
location = self._resolve_standard_adjustment_location(claim, item)
grade = str(claim.employee_grade or current_user.grade or "").strip()
if not location or not grade:
return None
try:
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
result = TravelReimbursementCalculatorService(self.db).calculate(
TravelReimbursementCalculatorRequest(
days=self._resolve_standard_adjustment_days(claim, item, entry),
location=location,
grade=grade,
),
current_user,
)
except Exception:
return None
return self._normalize_standard_adjustment_amount(result.hotel_amount)
def _resolve_standard_adjustment_reimbursable_amount(
self,
*,
claim: ExpenseClaim,
item: ExpenseClaimItem,
entry: Any,
original_amount: Decimal,
current_user: CurrentUserContext,
) -> Decimal:
policy_amount = self._resolve_policy_standard_reimbursable_amount(
claim=claim,
item=item,
entry=entry,
current_user=current_user,
)
if policy_amount is not None:
return min(max(policy_amount, Decimal("0.00")), original_amount)
entry_amount = self._normalize_standard_adjustment_amount(entry.reimbursable_amount)
if entry_amount is not None:
return min(max(entry_amount, Decimal("0.00")), original_amount)
return original_amount
def accept_standard_adjustment( def accept_standard_adjustment(
self, self,
*, *,
@@ -340,11 +484,13 @@ class ExpenseClaimService(
self._normalize_standard_adjustment_amount(entry.original_amount) self._normalize_standard_adjustment_amount(entry.original_amount)
or Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01")) or Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
) )
reimbursable_amount = ( reimbursable_amount = self._resolve_standard_adjustment_reimbursable_amount(
self._normalize_standard_adjustment_amount(entry.reimbursable_amount) claim=claim,
or original_amount item=item,
entry=entry,
original_amount=original_amount,
current_user=current_user,
) )
reimbursable_amount = min(max(reimbursable_amount, Decimal("0.00")), original_amount)
employee_absorbed_amount = (original_amount - reimbursable_amount).quantize(Decimal("0.01")) employee_absorbed_amount = (original_amount - reimbursable_amount).quantize(Decimal("0.01"))
item_label = ( item_label = (
str(item.item_reason or "").strip() str(item.item_reason or "").strip()
@@ -456,6 +602,7 @@ class ExpenseClaimService(
self._refresh_item_attachment_analysis(item) self._refresh_item_attachment_analysis(item)
self._sync_claim_from_items(claim) self._sync_claim_from_items(claim)
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
self.db.commit() self.db.commit()
self.db.refresh(claim) self.db.refresh(claim)
@@ -510,6 +657,7 @@ class ExpenseClaimService(
self.db.add(item) self.db.add(item)
self._sync_claim_from_items(claim) self._sync_claim_from_items(claim)
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
self.db.commit() self.db.commit()
self.db.refresh(claim) self.db.refresh(claim)
@@ -548,6 +696,7 @@ class ExpenseClaimService(
self.db.delete(item) self.db.delete(item)
self._sync_claim_from_items(claim) self._sync_claim_from_items(claim)
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
self.db.commit() self.db.commit()
self.db.refresh(claim) self.db.refresh(claim)
@@ -645,12 +794,13 @@ class ExpenseClaimService(
budget_flags, budget_flags,
business_stage="reimbursement", business_stage="reimbursement",
) )
review_result = self._run_ai_submission_review(claim) if not self._has_ai_pre_review_flag(claim):
self._refresh_claim_pre_review_flags(claim, is_application_claim=False)
claim.status = "submitted"
claim.approval_stage = DIRECT_MANAGER_APPROVAL_STAGE
claim.submitted_at = datetime.now(UTC)
claim.status = str(review_result.get("status") or "supplement")
claim.approval_stage = str(review_result.get("approval_stage") or "待补充")
claim.risk_flags_json = list(review_result.get("risk_flags") or [])
claim.submitted_at = datetime.now(UTC) if claim.status == "submitted" else None
self.db.commit() self.db.commit()
self.db.refresh(claim) self.db.refresh(claim)
@@ -872,11 +1022,3 @@ class ExpenseClaimService(

View File

@@ -33,17 +33,25 @@ FEEDBACK_STATUS_MAP = {
class RiskObservationService: class RiskObservationService:
_storage_ready_cache: set[str] = set()
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self.db = db self.db = db
def ensure_storage_ready(self) -> None: def ensure_storage_ready(self) -> None:
bind = self.db.get_bind()
cache_key = str(getattr(bind, "url", "") or id(bind))
if cache_key in self._storage_ready_cache:
return
Base.metadata.create_all( Base.metadata.create_all(
bind=self.db.get_bind(), bind=bind,
tables=[ tables=[
RiskObservation.__table__, RiskObservation.__table__,
RiskObservationFeedback.__table__, RiskObservationFeedback.__table__,
], ],
) )
self._storage_ready_cache.add(cache_key)
def upsert_observation( def upsert_observation(
self, self,

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
from dataclasses import dataclass from dataclasses import dataclass
from http import HTTPStatus from http import HTTPStatus
from time import monotonic, sleep from time import monotonic, sleep
@@ -61,6 +62,23 @@ class RuntimeChatResult:
return [item.model_dump() for item in self.calls] return [item.model_dump() for item in self.calls]
@dataclass(slots=True)
class RuntimeChatToolCall:
name: str
arguments: dict[str, Any]
call_id: str | None = None
raw_arguments: str = ""
@dataclass(slots=True)
class RuntimeToolCallResult:
tool_call: RuntimeChatToolCall | None
calls: list[RuntimeChatCallTrace]
def calls_as_dicts(self) -> list[dict[str, Any]]:
return [item.model_dump() for item in self.calls]
class RuntimeChatService: class RuntimeChatService:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self.db = db self.db = db
@@ -208,6 +226,131 @@ class RuntimeChatService:
return RuntimeChatResult(None, calls) return RuntimeChatResult(None, calls)
def complete_with_tool_call(
self,
messages: list[dict[str, Any]],
*,
tools: list[dict[str, Any]],
tool_choice: dict[str, Any] | str | None = None,
slot_priority: tuple[str, ...] = ("main", "backup"),
max_tokens: int = 1200,
temperature: float = 0.1,
timeout_seconds: int | None = None,
slot_timeouts: dict[str, int] | None = None,
max_attempts: int | None = None,
) -> RuntimeToolCallResult:
configs: list[dict[str, str]] = []
calls: list[RuntimeChatCallTrace] = []
for slot in slot_priority:
config = self._load_chat_slot(slot)
if config is None:
calls.append(
RuntimeChatCallTrace(
slot=slot,
provider="",
model="",
attempt=0,
status="skipped",
skipped_reason="not_configured",
)
)
continue
configs.append(config)
if not configs:
return RuntimeToolCallResult(None, calls)
resolved_timeout_seconds = timeout_seconds or DEFAULT_RUNTIME_CHAT_TIMEOUT_SECONDS
resolved_slot_timeouts = dict(slot_timeouts or {})
resolved_max_attempts = max_attempts or DEFAULT_RUNTIME_CHAT_RETRY_ATTEMPTS
for attempt in range(1, resolved_max_attempts + 1):
for config in configs:
cache_key = self._build_slot_cache_key(config)
if _slot_failure_until.get(cache_key, 0.0) > monotonic():
logger.info(
"Skip runtime chat tool slot=%s provider=%s because it is in cooldown",
config["slot"],
config["provider"],
)
calls.append(
RuntimeChatCallTrace(
slot=config["slot"],
provider=config["provider"],
model=config["model"],
attempt=attempt,
status="skipped",
skipped_reason="cooldown",
)
)
continue
started = monotonic()
try:
tool_call = self._request_chat_tool_call(
config,
messages,
tools=tools,
tool_choice=tool_choice,
max_tokens=max_tokens,
temperature=temperature,
timeout_seconds=resolved_slot_timeouts.get(
config["slot"],
resolved_timeout_seconds,
),
)
duration_ms = int((monotonic() - started) * 1000)
if tool_call is not None:
_slot_failure_until.pop(cache_key, None)
calls.append(
RuntimeChatCallTrace(
slot=config["slot"],
provider=config["provider"],
model=config["model"],
attempt=attempt,
status="succeeded",
duration_ms=duration_ms,
)
)
return RuntimeToolCallResult(tool_call, calls)
calls.append(
RuntimeChatCallTrace(
slot=config["slot"],
provider=config["provider"],
model=config["model"],
attempt=attempt,
status="empty",
duration_ms=duration_ms,
error_message="模型未返回工具调用。",
)
)
except Exception as exc:
duration_ms = int((monotonic() - started) * 1000)
_slot_failure_until[cache_key] = (
monotonic() + DEFAULT_RUNTIME_CHAT_FAILURE_COOLDOWN_SECONDS
)
calls.append(
RuntimeChatCallTrace(
slot=config["slot"],
provider=config["provider"],
model=config["model"],
attempt=attempt,
status="failed",
duration_ms=duration_ms,
error_message=str(exc),
)
)
logger.warning(
"Runtime chat tool request failed slot=%s provider=%s attempt=%s/%s: %s",
config["slot"],
config["provider"],
attempt,
resolved_max_attempts,
exc,
)
if attempt < resolved_max_attempts:
sleep(DEFAULT_RUNTIME_CHAT_RETRY_DELAY_SECONDS)
return RuntimeToolCallResult(None, calls)
@staticmethod @staticmethod
def _build_slot_cache_key(config: dict[str, str]) -> str: def _build_slot_cache_key(config: dict[str, str]) -> str:
return "|".join( return "|".join(
@@ -295,6 +438,51 @@ class RuntimeChatService:
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
) )
def _request_chat_tool_call(
self,
config: dict[str, str],
messages: list[dict[str, Any]],
*,
tools: list[dict[str, Any]],
tool_choice: dict[str, Any] | str | None,
max_tokens: int,
temperature: float,
timeout_seconds: int,
) -> RuntimeChatToolCall | None:
provider = config["provider"]
endpoint = config["endpoint"]
model = config["model"]
api_key = config["apiKey"]
if provider == "Azure OpenAI":
return self._request_azure_openai_tool_call(
endpoint=endpoint,
model=model,
api_key=api_key,
messages=messages,
tools=tools,
tool_choice=tool_choice,
max_tokens=max_tokens,
temperature=temperature,
timeout_seconds=timeout_seconds,
)
if provider == "Ollama":
raise ConnectivityCheckError("Ollama 暂不支持小财管家 function calling。")
return self._request_openai_compatible_tool_call(
provider=provider,
endpoint=endpoint,
model=model,
api_key=api_key,
messages=messages,
tools=tools,
tool_choice=tool_choice,
max_tokens=max_tokens,
temperature=temperature,
timeout_seconds=timeout_seconds,
)
def _request_openai_compatible( def _request_openai_compatible(
self, self,
*, *,
@@ -331,6 +519,46 @@ class RuntimeChatService:
) )
return self._extract_openai_text(payload) return self._extract_openai_text(payload)
def _request_openai_compatible_tool_call(
self,
*,
provider: str,
endpoint: str,
model: str,
api_key: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
tool_choice: dict[str, Any] | str | None,
max_tokens: int,
temperature: float,
timeout_seconds: int,
) -> RuntimeChatToolCall | None:
url = _ensure_path(_normalize_endpoint(endpoint), "chat/completions")
request_payload: dict[str, Any] = {
"model": model,
"messages": messages,
"tools": tools,
"tool_choice": tool_choice or "auto",
"max_tokens": max_tokens,
"temperature": temperature,
}
if provider == "GLM":
request_payload["thinking"] = {"type": "disabled"}
status_code, payload = _send_json_request(
"POST",
url,
headers=_build_headers(api_key=api_key, use_bearer=True),
payload=request_payload,
timeout_seconds=timeout_seconds,
)
if status_code >= HTTPStatus.BAD_REQUEST:
raise ConnectivityCheckError(
f"模型接口返回异常状态 {status_code}",
status_code=status_code,
)
return self._extract_openai_tool_call(payload)
def _request_ollama( def _request_ollama(
self, self,
*, *,
@@ -396,6 +624,41 @@ class RuntimeChatService:
) )
return self._extract_openai_text(payload) return self._extract_openai_text(payload)
def _request_azure_openai_tool_call(
self,
*,
endpoint: str,
model: str,
api_key: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
tool_choice: dict[str, Any] | str | None,
max_tokens: int,
temperature: float,
timeout_seconds: int,
) -> RuntimeChatToolCall | None:
deployment_base = _build_azure_deployment_base(endpoint, model)
url = f"{deployment_base}/chat/completions?api-version={AZURE_API_VERSION}"
status_code, payload = _send_json_request(
"POST",
url,
headers=_build_headers(api_key=api_key, use_bearer=False, use_api_key=True),
payload={
"messages": messages,
"tools": tools,
"tool_choice": tool_choice or "auto",
"max_tokens": max_tokens,
"temperature": temperature,
},
timeout_seconds=timeout_seconds,
)
if status_code >= HTTPStatus.BAD_REQUEST:
raise ConnectivityCheckError(
f"Azure OpenAI 返回异常状态 {status_code}",
status_code=status_code,
)
return self._extract_openai_tool_call(payload)
@staticmethod @staticmethod
def _extract_openai_text(payload: Any) -> str: def _extract_openai_text(payload: Any) -> str:
if not isinstance(payload, dict): if not isinstance(payload, dict):
@@ -426,3 +689,74 @@ class RuntimeChatService:
return text.strip() return text.strip()
return "" return ""
@staticmethod
def _extract_openai_tool_call(payload: Any) -> RuntimeChatToolCall | None:
if not isinstance(payload, dict):
return None
choices = payload.get("choices")
if not isinstance(choices, list) or not choices:
return None
first_choice = choices[0]
if not isinstance(first_choice, dict):
return None
message = first_choice.get("message")
if not isinstance(message, dict):
return None
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
first_tool = tool_calls[0]
if isinstance(first_tool, dict):
function_payload = first_tool.get("function")
if isinstance(function_payload, dict):
return RuntimeChatService._build_runtime_tool_call(
name=function_payload.get("name"),
arguments=function_payload.get("arguments"),
call_id=first_tool.get("id"),
)
function_call = message.get("function_call")
if isinstance(function_call, dict):
return RuntimeChatService._build_runtime_tool_call(
name=function_call.get("name"),
arguments=function_call.get("arguments"),
call_id=None,
)
return None
@staticmethod
def _build_runtime_tool_call(
*,
name: Any,
arguments: Any,
call_id: Any,
) -> RuntimeChatToolCall | None:
tool_name = str(name or "").strip()
if not tool_name:
return None
raw_arguments = ""
if isinstance(arguments, dict):
parsed_arguments = arguments
raw_arguments = json.dumps(arguments, ensure_ascii=False)
else:
raw_arguments = str(arguments or "").strip()
if not raw_arguments:
parsed_arguments = {}
else:
parsed = json.loads(raw_arguments)
if not isinstance(parsed, dict):
raise ValueError("工具调用参数必须是 JSON object。")
parsed_arguments = parsed
return RuntimeChatToolCall(
name=tool_name,
arguments=parsed_arguments,
call_id=str(call_id).strip() if call_id else None,
raw_arguments=raw_arguments,
)

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
BUSINESS_CANONICAL_FIELD_ORDER = (
"expense_type",
"time_range",
"location",
"reason",
"amount",
"transport_mode",
"attachments",
"customer_name",
"merchant_name",
"department_name",
"employee_name",
"employee_no",
)
BUSINESS_CANONICAL_FIELDS = frozenset(BUSINESS_CANONICAL_FIELD_ORDER)

View File

@@ -0,0 +1,220 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import date
from typing import Any
from app.schemas.steward import StewardPlanRequest
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.runtime_chat import RuntimeChatService
STEWARD_INTENT_FUNCTION_NAME = "submit_steward_intent_plan"
@dataclass(frozen=True, slots=True)
class StewardIntentAgentResult:
payload: dict[str, Any]
model_call_traces: list[dict[str, Any]]
class StewardIntentAgent:
"""使用大模型 function calling 识别小财管家的复合财务意图。"""
def __init__(self, runtime_chat_service: RuntimeChatService) -> None:
self.runtime_chat_service = runtime_chat_service
self.last_call_traces: list[dict[str, Any]] = []
def detect(
self,
request: StewardPlanRequest,
*,
base_date: date,
canonical_fields: list[str],
) -> StewardIntentAgentResult | None:
result = self.runtime_chat_service.complete_with_tool_call(
self._build_messages(request, base_date=base_date, canonical_fields=canonical_fields),
tools=[self._build_intent_tool_schema(canonical_fields)],
tool_choice={
"type": "function",
"function": {"name": STEWARD_INTENT_FUNCTION_NAME},
},
max_tokens=1800,
temperature=0.1,
timeout_seconds=18,
max_attempts=1,
)
self.last_call_traces = result.calls_as_dicts()
if result.tool_call is None or result.tool_call.name != STEWARD_INTENT_FUNCTION_NAME:
return None
return StewardIntentAgentResult(
payload=result.tool_call.arguments,
model_call_traces=self.last_call_traces,
)
@staticmethod
def _build_messages(
request: StewardPlanRequest,
*,
base_date: date,
canonical_fields: list[str],
) -> list[dict[str, Any]]:
context_payload = {
"message": request.message,
"base_date": base_date.isoformat(),
"client_now_iso": request.client_now_iso,
"user_id": request.user_id,
"canonical_ontology_fields": canonical_fields,
"review_form_values": normalize_ontology_form_values(
request.context_json.get("review_form_values")
),
"context_json": {
key: value
for key, value in request.context_json.items()
if key
in {
"entry_source",
"session_type",
"role_codes",
"username",
"name",
"department_name",
"employee_grade",
"employee_no",
"client_timezone_offset_minutes",
}
},
"attachments": [
{
"index": index + 1,
"name": item.name,
"media_type": item.media_type,
"ocr_summary": item.ocr_summary,
"ocr_fields": item.ocr_fields,
}
for index, item in enumerate(request.attachments)
if item.name
],
}
return [
{
"role": "system",
"content": (
"你是 X-Financial 的小财管家意图识别智能体。"
"你必须通过 function calling 输出结构化计划,不能只返回普通文本。"
"当前版本只支持 expense_application 和 reimbursement 两类任务;"
"你只做识别、拆解、归集和确认点规划,不能执行入库、绑定附件或提交审批。"
"所有 ontology_fields 只能使用调用方给出的 canonical_ontology_fields"
"如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。"
"相对日期必须以 base_date 为准转换为明确日期。"
"thinking_events 只能是面向用户的过程摘要,不能暴露内部推理链。"
),
},
{
"role": "user",
"content": json.dumps(context_payload, ensure_ascii=False),
},
]
@staticmethod
def _build_intent_tool_schema(canonical_fields: list[str]) -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": STEWARD_INTENT_FUNCTION_NAME,
"description": "提交小财管家的复合财务意图识别结果。",
"parameters": {
"type": "object",
"properties": {
"thinking_events": {
"type": "array",
"items": {
"type": "object",
"properties": {
"stage": {"type": "string"},
"title": {"type": "string"},
"content": {"type": "string"},
},
"required": ["stage", "title", "content"],
},
},
"tasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"task_type": {
"type": "string",
"enum": ["expense_application", "reimbursement"],
},
"title": {"type": "string"},
"summary": {"type": "string"},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
},
"ontology_fields": {
"type": "object",
"additionalProperties": {"type": "string"},
},
"missing_fields": {
"type": "array",
"items": {
"type": "string",
"enum": canonical_fields,
},
},
},
"required": [
"task_type",
"title",
"summary",
"confidence",
"ontology_fields",
"missing_fields",
],
},
},
"attachment_groups": {
"type": "array",
"items": {
"type": "object",
"properties": {
"target_task_index": {
"type": "integer",
"minimum": 1,
},
"scene": {"type": "string"},
"scene_label": {"type": "string"},
"attachment_names": {
"type": "array",
"items": {"type": "string"},
},
"excluded_attachment_names": {
"type": "array",
"items": {"type": "string"},
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
},
"rationale": {"type": "string"},
},
"required": [
"scene",
"scene_label",
"attachment_names",
"excluded_attachment_names",
"confidence",
"rationale",
],
},
},
},
"required": ["thinking_events", "tasks", "attachment_groups"],
},
},
}

View File

@@ -0,0 +1,365 @@
from __future__ import annotations
import re
import uuid
from datetime import date
from typing import Any
from app.schemas.steward import (
StewardAttachmentGroup,
StewardAttachmentInput,
StewardPlanRequest,
StewardPlanResponse,
StewardTask,
StewardThinkingEvent,
)
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.steward_constants import BUSINESS_CANONICAL_FIELDS
from app.services.steward_intent_agent import StewardIntentAgentResult
class StewardModelPlanBuilder:
"""把模型 function calling 返回值转换为小财管家的服务端计划。"""
def __init__(self, planner: Any) -> None:
self.planner = planner
def build(
self,
intent_result: StewardIntentAgentResult,
*,
request: StewardPlanRequest,
base_date: date,
) -> StewardPlanResponse | None:
tasks = self._build_tasks_from_model_payload(intent_result.payload, request, base_date)
if not tasks:
return None
attachment_groups = self._build_attachment_groups_from_model_payload(
intent_result.payload,
request.attachments,
tasks,
)
if request.attachments and not attachment_groups:
attachment_groups = self.planner._build_attachment_groups(request.attachments, tasks)
confirmation_groups = self.planner._build_confirmation_actions(tasks, attachment_groups)
thinking_events = self._build_llm_thinking_events(
intent_result.payload,
tasks=tasks,
attachment_groups=attachment_groups,
attachments=request.attachments,
)
return StewardPlanResponse(
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate",
planning_source="llm_function_call",
summary=self.planner._build_summary(tasks, attachment_groups),
thinking_events=thinking_events,
tasks=tasks,
attachment_groups=attachment_groups,
confirmation_groups=confirmation_groups,
model_call_traces=intent_result.model_call_traces,
)
def _build_tasks_from_model_payload(
self,
payload: dict[str, Any],
request: StewardPlanRequest,
base_date: date,
) -> list[StewardTask]:
raw_tasks = payload.get("tasks")
if not isinstance(raw_tasks, list):
return []
tasks: list[StewardTask] = []
for raw_task in raw_tasks:
if not isinstance(raw_task, dict):
continue
task_type = str(raw_task.get("task_type") or "").strip()
if task_type not in {"expense_application", "reimbursement"}:
continue
task_index = len(tasks) + 1
fields = self._sanitize_model_ontology_fields(
raw_task.get("ontology_fields"),
request=request,
base_date=base_date,
)
supplement_segment = " ".join(
[
str(raw_task.get("title") or ""),
str(raw_task.get("summary") or ""),
]
)
supplement_fields = self.planner._extract_ontology_fields(
supplement_segment,
task_type,
base_date,
request,
)
for key, value in supplement_fields.items():
fields.setdefault(key, value)
assigned_agent = (
"application_assistant"
if task_type == "expense_application"
else "reimbursement_assistant"
)
task_id = f"task_{'app' if task_type == 'expense_application' else 'reim'}_{task_index:03d}"
title_prefix = "费用申请" if task_type == "expense_application" else "费用报销"
title = self.planner._clean_text(raw_task.get("title")) or self.planner._build_task_title(
title_prefix,
fields,
task_index,
)
summary = self.planner._clean_text(raw_task.get("summary")) or self.planner._build_task_summary(
supplement_segment,
fields,
)
missing_fields = self._sanitize_model_missing_fields(
raw_task.get("missing_fields"),
task_type=task_type,
fields=fields,
)
tasks.append(
StewardTask(
task_id=task_id,
task_type=task_type, # type: ignore[arg-type]
assigned_agent=assigned_agent, # type: ignore[arg-type]
title=title,
summary=summary,
status="needs_confirmation",
confidence=self._resolve_model_confidence(
raw_task.get("confidence"),
segment=supplement_segment,
fields=fields,
task_type=task_type,
),
ontology_fields=fields,
missing_fields=missing_fields,
confirmation_required=True,
)
)
return tasks
def _sanitize_model_ontology_fields(
self,
raw_fields: Any,
*,
request: StewardPlanRequest,
base_date: date,
) -> dict[str, str]:
normalized_context = normalize_ontology_form_values(request.context_json.get("review_form_values"))
fields: dict[str, str] = {
key: value
for key, value in normalized_context.items()
if key in BUSINESS_CANONICAL_FIELDS and str(value or "").strip()
}
if not isinstance(raw_fields, dict):
return fields
normalized_model_fields = normalize_ontology_form_values(raw_fields)
for key, value in normalized_model_fields.items():
if key not in BUSINESS_CANONICAL_FIELDS:
continue
normalized_value = self._normalize_model_field_value(key, value, base_date)
if normalized_value:
fields[key] = normalized_value
if request.attachments and not fields.get("attachments"):
fields["attachments"] = "".join(item.name for item in request.attachments if item.name)
return {key: value for key, value in fields.items() if key in BUSINESS_CANONICAL_FIELDS and value}
def _build_attachment_groups_from_model_payload(
self,
payload: dict[str, Any],
attachments: list[StewardAttachmentInput],
tasks: list[StewardTask],
) -> list[StewardAttachmentGroup]:
raw_groups = payload.get("attachment_groups")
if not isinstance(raw_groups, list) or not attachments:
return []
uploaded_names = {item.name for item in attachments if item.name}
groups: list[StewardAttachmentGroup] = []
for raw_group in raw_groups:
if not isinstance(raw_group, dict):
continue
attachment_names = self._filter_uploaded_attachment_names(
raw_group.get("attachment_names"),
uploaded_names,
)
excluded_names = self._filter_uploaded_attachment_names(
raw_group.get("excluded_attachment_names"),
uploaded_names,
)
if not attachment_names and not excluded_names:
continue
scene = self.planner._clean_text(raw_group.get("scene")) or "other"
groups.append(
StewardAttachmentGroup(
group_id=f"ag_{self._slug_scene(scene)}_{len(groups) + 1:03d}",
target_task_id=self._resolve_model_group_target_task_id(raw_group, tasks),
scene=scene,
scene_label=self.planner._clean_text(raw_group.get("scene_label")) or "待确认费用",
attachment_names=attachment_names,
excluded_attachment_names=excluded_names,
confidence=self._clamp_confidence(raw_group.get("confidence"), default=0.68),
rationale=(
self.planner._clean_text(raw_group.get("rationale"))
or "模型根据附件线索生成归集建议。"
),
confirmation_required=True,
)
)
return groups
def _build_llm_thinking_events(
self,
payload: dict[str, Any],
*,
tasks: list[StewardTask],
attachment_groups: list[StewardAttachmentGroup],
attachments: list[StewardAttachmentInput],
) -> list[StewardThinkingEvent]:
events = [
StewardThinkingEvent(
event_id="intent_agent_function_call",
stage="llm_function_call",
title="意图识别智能体接管",
content=(
"已调用系统主模型的 submit_steward_intent_plan 工具,"
"把用户话术转换为可校验的结构化财务任务计划。"
),
)
]
raw_events = payload.get("thinking_events")
if isinstance(raw_events, list):
for raw_event in raw_events[:4]:
if not isinstance(raw_event, dict):
continue
title = self.planner._clean_text(raw_event.get("title"))
content = self.planner._clean_text(raw_event.get("content"))
if not title or not content:
continue
events.append(
StewardThinkingEvent(
event_id=f"intent_agent_model_{len(events):03d}",
stage=self.planner._clean_text(raw_event.get("stage")) or "model_summary",
title=title,
content=content,
)
)
if len(events) == 1:
events.extend(self.planner._build_thinking_events(tasks, attachment_groups, attachments)[1:])
return events
def _sanitize_model_missing_fields(
self,
raw_missing_fields: Any,
*,
task_type: str,
fields: dict[str, str],
) -> list[str]:
missing_fields: list[str] = []
if isinstance(raw_missing_fields, list):
for item in raw_missing_fields:
key = str(item or "").strip()
if key in BUSINESS_CANONICAL_FIELDS and key not in missing_fields and not fields.get(key):
missing_fields.append(key)
for key in self.planner._resolve_missing_fields(task_type, fields):
if key not in missing_fields:
missing_fields.append(key)
return missing_fields
def _resolve_model_confidence(
self,
value: Any,
*,
segment: str,
fields: dict[str, str],
task_type: str,
) -> float:
return self._clamp_confidence(
value,
default=self.planner._resolve_task_confidence(segment, fields, task_type),
)
def _normalize_model_field_value(self, key: str, value: Any, base_date: date) -> str:
cleaned = self.planner._clean_text(value)
if not cleaned:
return ""
if key == "time_range":
return self.planner._extract_time_range(cleaned, base_date) or cleaned
if key == "expense_type":
return self._normalize_expense_type_value(cleaned)
if key == "transport_mode":
return self._normalize_transport_mode_value(cleaned)
return cleaned
@staticmethod
def _normalize_expense_type_value(value: str) -> str:
normalized = str(value or "").strip().lower()
if normalized in {"travel", "travel_application", "差旅", "差旅费", "出差"}:
return "travel"
if normalized in {"transport", "traffic", "交通", "交通费", "打车", "出租车"}:
return "transport"
if normalized in {"entertainment", "meal", "招待", "接待", "餐饮", "业务招待"}:
return "entertainment"
if normalized in {"office", "办公", "办公用品"}:
return "office"
return normalized
@staticmethod
def _normalize_transport_mode_value(value: str) -> str:
normalized = str(value or "").strip().lower()
if normalized in {"train", "高铁", "动车", "火车"}:
return "train"
if normalized in {"flight", "air", "飞机", "机票", "航班"}:
return "flight"
if normalized in {"taxi", "出租车", "的士", "网约车", "打车"}:
return "taxi"
if normalized in {"subway", "地铁"}:
return "subway"
return normalized
@staticmethod
def _filter_uploaded_attachment_names(raw_names: Any, uploaded_names: set[str]) -> list[str]:
if not isinstance(raw_names, list):
return []
names: list[str] = []
for raw_name in raw_names:
name = str(raw_name or "").strip()
if name in uploaded_names and name not in names:
names.append(name)
return names
@staticmethod
def _resolve_model_group_target_task_id(raw_group: dict[str, Any], tasks: list[StewardTask]) -> str | None:
try:
target_index = int(raw_group.get("target_task_index") or 0)
except (TypeError, ValueError):
target_index = 0
if target_index > 0 and target_index <= len(tasks):
return tasks[target_index - 1].task_id
target_task_id = str(raw_group.get("target_task_id") or "").strip()
if target_task_id and any(task.task_id == target_task_id for task in tasks):
return target_task_id
return None
@staticmethod
def _slug_scene(value: str) -> str:
normalized = re.sub(r"[^a-zA-Z0-9_]+", "_", str(value or "").strip().lower()).strip("_")
return normalized or "other"
@staticmethod
def _clamp_confidence(value: Any, *, default: float) -> float:
try:
parsed = float(value)
except (TypeError, ValueError):
parsed = default
return round(min(1.0, max(0.0, parsed)), 2)

View File

@@ -0,0 +1,645 @@
from __future__ import annotations
import re
import uuid
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from typing import Any
from app.schemas.steward import (
StewardAttachmentGroup,
StewardAttachmentInput,
StewardConfirmationAction,
StewardPlanRequest,
StewardPlanResponse,
StewardTask,
StewardThinkingEvent,
)
from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER, BUSINESS_CANONICAL_FIELDS
from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.steward_intent_agent import StewardIntentAgent
from app.services.steward_model_plan_builder import StewardModelPlanBuilder
CITY_NAMES = (
"北京",
"上海",
"广州",
"深圳",
"杭州",
"南京",
"苏州",
"成都",
"重庆",
"天津",
"武汉",
"西安",
"长沙",
"郑州",
"青岛",
"厦门",
"福州",
"合肥",
"济南",
"沈阳",
"大连",
"宁波",
"无锡",
)
APPLICATION_SPLIT_PATTERN = re.compile(r"(?:^|[,。;;])[^,。;;]*?(?:申请|出差申请|差旅申请)[^,。;;]*")
REIMBURSEMENT_PATTERN = re.compile(r"(?:我要报销|还需要报销|需要报销|报销)([^,。;;?!\n]+)")
MONTH_DAY_PATTERN = re.compile(r"(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*(?:日|号)?")
ISO_DATE_PATTERN = re.compile(r"(?P<year>\d{4})[-/年](?P<month>\d{1,2})[-/月](?P<day>\d{1,2})(?:日)?")
@dataclass(frozen=True)
class PlannedTaskDraft:
task_type: str
segment: str
index: int
class StewardPlannerService:
"""小财管家第一版规划服务:只生成计划,不执行入库类动作。"""
def __init__(self, intent_agent: StewardIntentAgent | None = None) -> None:
self.intent_agent = intent_agent
def build_plan(self, request: StewardPlanRequest) -> StewardPlanResponse:
message = self._clean_text(request.message)
if not message:
raise ValueError("小财管家需要一段任务描述。")
base_date = self._resolve_base_date(request.client_now_iso, request.context_json)
model_call_traces: list[dict[str, Any]] = []
fallback_reason = ""
if self.intent_agent is not None:
try:
intent_result = self.intent_agent.detect(
request,
base_date=base_date,
canonical_fields=list(BUSINESS_CANONICAL_FIELD_ORDER),
)
if intent_result is not None:
model_call_traces = intent_result.model_call_traces
llm_plan = StewardModelPlanBuilder(self).build(
intent_result,
request=request,
base_date=base_date,
)
if llm_plan is not None:
return llm_plan
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
fallback_reason = "主模型未返回可用的 function calling 计划,已切换到规则兜底。"
except Exception as exc:
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
fallback_reason = f"主模型 function calling 调用失败,已切换到规则兜底:{exc}"
return self._build_rule_fallback_plan(
request,
base_date=base_date,
model_call_traces=model_call_traces,
fallback_reason=fallback_reason,
)
def _build_rule_fallback_plan(
self,
request: StewardPlanRequest,
*,
base_date: date,
model_call_traces: list[dict[str, Any]] | None = None,
fallback_reason: str = "",
) -> StewardPlanResponse:
message = self._clean_text(request.message)
task_drafts = self._extract_task_drafts(message)
tasks = [self._build_task(draft, base_date, request) for draft in task_drafts]
if not tasks:
tasks = [self._build_fallback_task(message, base_date, request)]
attachment_groups = self._build_attachment_groups(request.attachments, tasks)
confirmation_groups = self._build_confirmation_actions(tasks, attachment_groups)
thinking_events = self._build_thinking_events(tasks, attachment_groups, request.attachments)
if fallback_reason:
thinking_events.insert(
0,
StewardThinkingEvent(
event_id="intent_agent_rule_fallback",
stage="rule_fallback",
title="意图识别智能体进入兜底模式",
content=fallback_reason,
),
)
plan_id = f"steward_plan_{uuid.uuid4().hex[:12]}"
return StewardPlanResponse(
plan_id=plan_id,
plan_status="needs_confirmation" if confirmation_groups else "ready_to_delegate",
planning_source="rule_fallback",
summary=self._build_summary(tasks, attachment_groups),
thinking_events=thinking_events,
tasks=tasks,
attachment_groups=attachment_groups,
confirmation_groups=confirmation_groups,
model_call_traces=model_call_traces or [],
)
def _extract_task_drafts(self, message: str) -> list[PlannedTaskDraft]:
drafts: list[PlannedTaskDraft] = []
first_reimbursement = self._find_first_reimbursement_index(message)
application_source = message[:first_reimbursement] if first_reimbursement >= 0 else message
if self._looks_like_application(application_source):
drafts.append(
PlannedTaskDraft(
task_type="expense_application",
segment=application_source.strip(",。;; "),
index=len(drafts) + 1,
)
)
for match in REIMBURSEMENT_PATTERN.finditer(message):
segment = f"报销{match.group(1)}"
drafts.append(
PlannedTaskDraft(
task_type="reimbursement",
segment=segment.strip(",。;; "),
index=len(drafts) + 1,
)
)
return drafts
@staticmethod
def _find_first_reimbursement_index(message: str) -> int:
candidates = [message.find(item) for item in ("我要报销", "还需要报销", "需要报销", "报销")]
positives = [item for item in candidates if item >= 0]
return min(positives) if positives else -1
@staticmethod
def _looks_like_application(text: str) -> bool:
compact = re.sub(r"\s+", "", text)
return bool(compact) and "申请" in compact and bool(re.search(r"出差|差旅|费用|交通|住宿|采购|会务|会议", compact))
def _build_task(
self,
draft: PlannedTaskDraft,
base_date: date,
request: StewardPlanRequest,
) -> StewardTask:
fields = self._extract_ontology_fields(draft.segment, draft.task_type, base_date, request)
missing_fields = self._resolve_missing_fields(draft.task_type, fields)
task_id = f"task_{'app' if draft.task_type == 'expense_application' else 'reim'}_{draft.index:03d}"
assigned_agent = (
"application_assistant"
if draft.task_type == "expense_application"
else "reimbursement_assistant"
)
title_prefix = "费用申请" if draft.task_type == "expense_application" else "费用报销"
title = self._build_task_title(title_prefix, fields, draft.index)
return StewardTask(
task_id=task_id,
task_type=draft.task_type, # type: ignore[arg-type]
assigned_agent=assigned_agent, # type: ignore[arg-type]
title=title,
summary=self._build_task_summary(draft.segment, fields),
status="needs_confirmation",
confidence=self._resolve_task_confidence(draft.segment, fields, draft.task_type),
ontology_fields=fields,
missing_fields=missing_fields,
confirmation_required=True,
)
def _build_fallback_task(
self,
message: str,
base_date: date,
request: StewardPlanRequest,
) -> StewardTask:
task_type = "reimbursement" if "报销" in message or request.attachments else "expense_application"
draft = PlannedTaskDraft(task_type=task_type, segment=message, index=1)
task = self._build_task(draft, base_date, request)
return task.model_copy(update={"confidence": min(task.confidence, 0.58)})
def _extract_ontology_fields(
self,
segment: str,
task_type: str,
base_date: date,
request: StewardPlanRequest,
) -> dict[str, str]:
normalized_context = normalize_ontology_form_values(request.context_json.get("review_form_values"))
fields: dict[str, str] = {
key: value
for key, value in normalized_context.items()
if key in BUSINESS_CANONICAL_FIELDS and str(value or "").strip()
}
expense_type = self._infer_expense_type(segment, task_type)
if expense_type and not fields.get("expense_type"):
fields["expense_type"] = expense_type
time_range = self._extract_time_range(segment, base_date)
if time_range and not fields.get("time_range"):
fields["time_range"] = time_range
location = self._extract_location(segment)
if location and not fields.get("location"):
fields["location"] = location
reason = self._extract_reason(segment, task_type)
if reason and not fields.get("reason"):
fields["reason"] = reason
transport_mode = self._extract_transport_mode(segment)
if transport_mode and not fields.get("transport_mode"):
fields["transport_mode"] = transport_mode
if request.attachments:
fields["attachments"] = "".join(item.name for item in request.attachments if item.name)
return {key: value for key, value in fields.items() if key in BUSINESS_CANONICAL_FIELDS and value}
@staticmethod
def _infer_expense_type(segment: str, task_type: str) -> str:
compact = re.sub(r"\s+", "", segment)
if re.search(r"招待|接待|餐饮|宴请|客户吃饭|业务餐", compact):
return "entertainment"
if re.search(r"出差|差旅|住宿|酒店|机票|航班|高铁|火车", compact):
return "travel"
if re.search(r"交通|出租车|的士|网约车|打车|地铁|公交", compact):
return "transport" if task_type == "reimbursement" else "travel"
return "travel" if task_type == "expense_application" else "other"
def _extract_time_range(self, segment: str, base_date: date) -> str:
compact = re.sub(r"\s+", "", segment)
if "昨天" in compact:
return (base_date - timedelta(days=1)).isoformat()
if "前天" in compact:
return (base_date - timedelta(days=2)).isoformat()
if "明天" in compact:
return (base_date + timedelta(days=1)).isoformat()
if "后天" in compact:
return (base_date + timedelta(days=2)).isoformat()
iso_match = ISO_DATE_PATTERN.search(compact)
if iso_match:
return self._safe_date(
int(iso_match.group("year")),
int(iso_match.group("month")),
int(iso_match.group("day")),
)
month_day = MONTH_DAY_PATTERN.search(compact)
if month_day:
return self._safe_date(
base_date.year,
int(month_day.group("month")),
int(month_day.group("day")),
)
return ""
@staticmethod
def _safe_date(year: int, month: int, day: int) -> str:
try:
return date(year, month, day).isoformat()
except ValueError:
return ""
@staticmethod
def _extract_location(segment: str) -> str:
compact = re.sub(r"\s+", "", segment)
for prefix in ("", "", "", "前往"):
match = re.search(fr"{prefix}({'|'.join(CITY_NAMES)})", compact)
if match:
return match.group(1)
for city in CITY_NAMES:
if city in compact:
return city
return ""
@staticmethod
def _extract_reason(segment: str, task_type: str) -> str:
cleaned = re.sub(r"\s+", "", segment).strip(",。;; ")
if task_type == "expense_application":
match = re.search(r"(辅助|支持|协助|支撑|参加|拜访|调研|实施|部署|审核).+", cleaned)
if match:
return StewardPlannerService._strip_trailing_connectors(match.group(0))
reason = re.sub(r"^.*?(?:出差|差旅)", "", cleaned).strip(",。;;的费用")
return StewardPlannerService._strip_trailing_connectors(reason) or cleaned
cleaned = re.sub(r"^报销", "", cleaned)
cleaned = re.sub(r"^(?:昨天|前天|明天|后天|\d{1,2}月\d{1,2}(?:日|号)?)的?", "", cleaned)
return cleaned.strip(",。;; ") or segment.strip()
@staticmethod
def _strip_trailing_connectors(value: str) -> str:
cleaned = str(value or "").strip(",。;; ")
return re.sub(r"(?:并且|而且|同时|另外|还需要|需要)$", "", cleaned).strip(",。;; ")
@staticmethod
def _extract_transport_mode(segment: str) -> str:
compact = re.sub(r"\s+", "", segment)
if re.search(r"高铁|动车|火车", compact):
return "train"
if re.search(r"飞机|机票|航班", compact):
return "flight"
if re.search(r"出租车|的士|网约车|打车", compact):
return "taxi"
if "交通" in compact:
return "other"
return ""
@staticmethod
def _resolve_missing_fields(task_type: str, fields: dict[str, str]) -> list[str]:
required = ["expense_type", "time_range", "reason"]
if task_type == "expense_application":
required.append("location")
return [key for key in required if not str(fields.get(key) or "").strip()]
@staticmethod
def _resolve_task_confidence(segment: str, fields: dict[str, str], task_type: str) -> float:
compact = re.sub(r"\s+", "", segment)
intent_score = 1.0 if ("申请" in compact if task_type == "expense_application" else "报销" in compact) else 0.45
time_score = 1.0 if fields.get("time_range") else 0.0
location_score = 1.0 if fields.get("location") else 0.2
scene_score = 1.0 if fields.get("expense_type") and fields["expense_type"] != "other" else 0.35
confidence = min(1.0, 0.35 * intent_score + 0.25 * time_score + 0.2 * location_score + 0.2 * scene_score)
return round(max(0.45, confidence), 2)
def _build_attachment_groups(
self,
attachments: list[StewardAttachmentInput],
tasks: list[StewardTask],
) -> list[StewardAttachmentGroup]:
if not attachments:
return []
classified = [(item, self._classify_attachment(item)) for item in attachments if item.name]
travel_related = [item.name for item, scene in classified if scene in {"travel", "transport"}]
excluded = [item.name for item, scene in classified if scene not in {"travel", "transport"}]
target_task = self._resolve_attachment_target_task(tasks)
groups: list[StewardAttachmentGroup] = []
if travel_related:
confidence = 0.72 + min(0.18, len(travel_related) * 0.04)
groups.append(
StewardAttachmentGroup(
group_id="ag_travel_001",
target_task_id=target_task.task_id if target_task else None,
scene="travel",
scene_label="差旅相关费用",
attachment_names=travel_related,
excluded_attachment_names=excluded,
confidence=round(confidence, 2),
rationale="附件名称或 OCR 摘要中包含差旅、交通、住宿、火车、机票等线索。",
confirmation_required=True,
)
)
elif excluded:
groups.append(
StewardAttachmentGroup(
group_id="ag_other_001",
target_task_id=None,
scene="other",
scene_label="待人工确认费用",
attachment_names=excluded,
excluded_attachment_names=[],
confidence=0.5,
rationale="当前附件缺少可稳定归属到申请或报销任务的差旅线索。",
confirmation_required=True,
)
)
return groups
@staticmethod
def _resolve_attachment_target_task(tasks: list[StewardTask]) -> StewardTask | None:
reimbursement_tasks = [item for item in tasks if item.task_type == "reimbursement"]
for task in reimbursement_tasks:
if task.ontology_fields.get("expense_type") == "travel":
return task
return reimbursement_tasks[0] if reimbursement_tasks else None
@staticmethod
def _classify_attachment(attachment: StewardAttachmentInput) -> str:
text = " ".join(
[
attachment.name,
attachment.media_type,
attachment.ocr_summary,
" ".join(f"{key}:{value}" for key, value in attachment.ocr_fields.items()),
]
)
compact = re.sub(r"\s+", "", text).lower()
if re.search(r"招待|接待|餐饮|宴请|客户|meal|entertainment", compact):
return "entertainment"
if re.search(r"酒店|住宿|差旅|出差|高铁|火车|动车|机票|航班|train|flight|hotel|travel", compact):
return "travel"
if re.search(r"出租车|的士|网约车|打车|交通|taxi|transport", compact):
return "transport"
return "other"
def _build_confirmation_actions(
self,
tasks: list[StewardTask],
attachment_groups: list[StewardAttachmentGroup],
) -> list[StewardConfirmationAction]:
actions: list[StewardConfirmationAction] = []
for task in tasks:
if task.task_type == "expense_application":
action_type = "confirm_create_application"
label = "确认创建申请单"
else:
action_type = "confirm_create_reimbursement_draft"
label = "确认创建报销草稿"
actions.append(
StewardConfirmationAction(
confirmation_id=f"confirm_{task.task_id}",
action_type=action_type,
label=label,
description=f"确认后把“{task.title}”交给{self._agent_label(task.assigned_agent)}继续核对。",
target_task_id=task.task_id,
payload={
"task_id": task.task_id,
"task_type": task.task_type,
"assigned_agent": task.assigned_agent,
"ontology_fields": task.ontology_fields,
},
)
)
for group in attachment_groups:
actions.append(
StewardConfirmationAction(
confirmation_id=f"confirm_{group.group_id}",
action_type="confirm_attachment_group",
label="确认附件归集",
description=f"确认后将 {len(group.attachment_names)} 份附件按“{group.scene_label}”归集。",
target_task_id=group.target_task_id,
attachment_group_id=group.group_id,
payload={
"attachment_group_id": group.group_id,
"target_task_id": group.target_task_id,
"attachment_names": group.attachment_names,
"excluded_attachment_names": group.excluded_attachment_names,
},
)
)
return actions
@staticmethod
def _agent_label(assigned_agent: str) -> str:
return "申请助手" if assigned_agent == "application_assistant" else "报销助手"
def _build_thinking_events(
self,
tasks: list[StewardTask],
attachment_groups: list[StewardAttachmentGroup],
attachments: list[StewardAttachmentInput],
) -> list[StewardThinkingEvent]:
application_count = sum(1 for item in tasks if item.task_type == "expense_application")
reimbursement_count = sum(1 for item in tasks if item.task_type == "reimbursement")
task_intent_summary = self._summarize_task_intents(tasks)
ontology_summary = self._summarize_ontology_coverage(tasks)
delegation_summary = self._summarize_delegation_targets(tasks)
events = [
StewardThinkingEvent(
event_id="intent_agent_entry",
stage="intent_agent",
title="意图识别智能体接管",
content=(
f"检测到复合财务话术,当前不是单一助手会话;"
f"已进入小财管家编排模式,候选任务共 {len(tasks)} 个。"
),
),
StewardThinkingEvent(
event_id="intent_task_split",
stage="task_split",
title=f"拆分申请 {application_count} 个、报销 {reimbursement_count}",
content=task_intent_summary,
),
StewardThinkingEvent(
event_id="intent_ontology_mapping",
stage="ontology_mapping",
title="映射业务本体字段",
content=ontology_summary,
),
]
if attachments:
events.append(
StewardThinkingEvent(
event_id="intent_attachment_correlation",
stage="attachment_correlation",
title="关联附件与任务线索",
content=self._summarize_attachment_correlation(attachment_groups, len(attachments)),
)
)
events.append(
StewardThinkingEvent(
event_id="intent_delegation_gate",
stage="delegation_gate",
title="生成确认点并准备分派",
content=f"{delegation_summary} 创建单据、生成草稿、绑定附件和提交审批都会等待用户确认。",
)
)
return events
@staticmethod
def _summarize_task_intents(tasks: list[StewardTask]) -> str:
if not tasks:
return "当前输入尚未形成稳定任务,先保留为待确认财务事项。"
parts = []
for task in tasks:
task_label = "申请" if task.task_type == "expense_application" else "报销"
fields = task.ontology_fields
anchors = []
if fields.get("time_range"):
anchors.append(fields["time_range"])
if fields.get("location"):
anchors.append(fields["location"])
if fields.get("expense_type"):
anchors.append(fields["expense_type"])
anchor_text = "".join(anchors) if anchors else "待补充关键字段"
parts.append(f"{task_label}{task.title}{anchor_text}")
return "".join(parts)
@staticmethod
def _summarize_ontology_coverage(tasks: list[StewardTask]) -> str:
canonical_keys = []
missing_keys = []
for task in tasks:
canonical_keys.extend(task.ontology_fields.keys())
missing_keys.extend(task.missing_fields)
unique_keys = sorted({item for item in canonical_keys if item})
unique_missing = sorted({item for item in missing_keys if item})
mapped = "".join(unique_keys) if unique_keys else "暂无稳定字段"
missing = ";缺失字段:" + "".join(unique_missing) if unique_missing else ""
return f"已使用 canonical ontology fields{mapped}{missing}。兼容字段只作为输入别名,不直接进入业务逻辑。"
@staticmethod
def _summarize_attachment_correlation(
attachment_groups: list[StewardAttachmentGroup],
total_attachment_count: int,
) -> str:
grouped_names = []
excluded_names = []
for group in attachment_groups:
grouped_names.extend(group.attachment_names)
excluded_names.extend(group.excluded_attachment_names)
grouped_text = "".join(grouped_names) if grouped_names else "暂无可稳定归集附件"
excluded_text = ";排除或单独确认:" + "".join(excluded_names) if excluded_names else ""
return f"已核对 {total_attachment_count} 份附件,建议归集:{grouped_text}{excluded_text}"
@staticmethod
def _summarize_delegation_targets(tasks: list[StewardTask]) -> str:
application_count = sum(1 for item in tasks if item.assigned_agent == "application_assistant")
reimbursement_count = sum(1 for item in tasks if item.assigned_agent == "reimbursement_assistant")
parts = []
if application_count:
parts.append(f"{application_count} 个申请任务交给申请助手")
if reimbursement_count:
parts.append(f"{reimbursement_count} 个报销任务交给报销助手")
return "".join(parts) + "" if parts else "尚无可分派任务。"
@staticmethod
def _build_summary(tasks: list[StewardTask], attachment_groups: list[StewardAttachmentGroup]) -> str:
parts = [f"我识别到 {len(tasks)} 个待处理任务"]
if attachment_groups:
grouped = sum(len(item.attachment_names) for item in attachment_groups)
parts.append(f"并形成 {grouped} 份附件的归集建议")
parts.append(",请确认后我再分派给对应助手执行。")
return "".join(parts)
@staticmethod
def _build_task_title(prefix: str, fields: dict[str, str], index: int) -> str:
location = fields.get("location", "")
time_range = fields.get("time_range", "")
expense_type = fields.get("expense_type", "")
subject = location or {"travel": "差旅", "transport": "交通", "entertainment": "招待"}.get(expense_type, "")
if subject and time_range:
return f"{prefix} {time_range} {subject}"
if subject:
return f"{prefix} {subject}"
return f"{prefix} {index}"
@staticmethod
def _build_task_summary(segment: str, fields: dict[str, str]) -> str:
field_parts = []
for key, label in (
("time_range", "时间"),
("location", "地点"),
("expense_type", "费用类型"),
("reason", "事由"),
("transport_mode", "交通方式"),
):
value = fields.get(key)
if value:
field_parts.append(f"{label}{value}")
return "".join(field_parts) or segment
@staticmethod
def _resolve_base_date(client_now_iso: str | None, context_json: dict[str, Any]) -> date:
raw_value = client_now_iso or str(context_json.get("client_now_iso") or "").strip()
if raw_value:
try:
parsed = datetime.fromisoformat(raw_value.replace("Z", "+00:00"))
return parsed.date()
except ValueError:
pass
return datetime.now(UTC).date()
@staticmethod
def _clean_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()

View File

@@ -1918,6 +1918,77 @@ def test_update_claim_item_reanalyzes_existing_attachment(monkeypatch, tmp_path)
assert refreshed_meta["requirement_check"]["matches"] is False assert refreshed_meta["requirement_check"]["matches"] is False
assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"]) assert any("附件类型要求" in point for point in refreshed_meta["analysis"]["points"])
def test_upload_attachment_refreshes_claim_pre_review(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext(
username="emp-1",
name="submitter",
role_codes=[],
is_admin=False,
)
review_calls: list[str] = []
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
) -> OcrRecognizeBatchRead:
return OcrRecognizeBatchRead(
total_file_count=1,
success_count=1,
documents=[
OcrRecognizeDocumentRead(
filename="receipt.png",
media_type="image/png",
text="office receipt amount 88 2026-05-13",
summary="recognized office receipt",
avg_score=0.98,
line_count=1,
page_count=1,
warnings=[],
)
],
)
def fake_review(self, reviewed_claim):
review_calls.append(reviewed_claim.id)
return {
"risk_flags": [
*list(reviewed_claim.risk_flags_json or []),
{
"source": "submission_review",
"severity": "high",
"label": "upload-time-risk",
"message": "risk generated after attachment upload",
},
]
}
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", fake_review)
with build_session() as db:
claim = build_claim(expense_type="office", location="Shanghai")
claim.invoice_count = 0
claim.items[0].invoice_id = None
db.add(claim)
db.commit()
payload = ExpenseClaimService(db).upload_claim_item_attachment(
claim_id=claim.id,
item_id=claim.items[0].id,
filename="receipt.png",
content=b"fake-image-bytes",
media_type="image/png",
current_user=current_user,
)
flags = payload["claim_risk_flags"]
assert review_calls == [claim.id]
assert any(flag.get("label") == "upload-time-risk" for flag in flags)
pre_review = next(flag for flag in flags if flag.get("source") == "ai_pre_review")
assert pre_review["status"] == "failed"
assert pre_review["blocking_risk_count"] >= 1
def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_path) -> None: def test_upload_train_ticket_attachment_backfills_item_amount(monkeypatch, tmp_path) -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
@@ -2619,6 +2690,60 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.approval_stage == "直属领导审批" assert submitted.approval_stage == "直属领导审批"
assert submitted.submitted_at is not None assert submitted.submitted_at is not None
def test_submit_claim_reuses_upload_pre_review_without_rerunning_review(monkeypatch) -> None:
current_user = CurrentUserContext(
username="emp-submit@example.com",
name="submitter",
role_codes=[],
is_admin=False,
)
def fail_review(self, reviewed_claim):
raise AssertionError("submit should reuse upload-time pre-review")
monkeypatch.setattr(ExpenseClaimService, "_run_ai_submission_review", fail_review)
with build_session() as db:
manager = Employee(
employee_no="E7010",
name="Manager",
email="manager-reuse@example.com",
)
employee = Employee(
employee_no="E7011",
name="submitter",
email="emp-submit@example.com",
manager=manager,
)
claim = build_claim(expense_type="transport", location="Shanghai")
claim.employee = employee
claim.employee_id = employee.id
claim.items[0].invoice_id = "taxi-ticket.png"
claim.risk_flags_json = [
{
"source": "submission_review",
"severity": "medium",
"label": "upload-time-warning",
"message": "generated before submit",
},
{
"source": "ai_pre_review",
"status": "passed",
"passed": True,
"severity": "info",
"blocking_risk_count": 0,
},
]
db.add_all([manager, employee, claim])
db.commit()
submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert any(flag.get("label") == "upload-time-warning" for flag in submitted.risk_flags_json)
assert any(flag.get("source") == "ai_pre_review" for flag in submitted.risk_flags_json)
def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None: def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
@@ -2669,28 +2794,92 @@ def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_s
) )
assert adjusted is not None assert adjusted is not None
assert adjusted.amount == Decimal("600.00") assert adjusted.amount == Decimal("450.00")
standard_flag = next( standard_flag = next(
flag flag
for flag in adjusted.risk_flags_json for flag in adjusted.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment" if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
) )
assert standard_flag["original_amount"] == "880.00" assert standard_flag["original_amount"] == "880.00"
assert standard_flag["reimbursable_amount"] == "600.00" assert standard_flag["reimbursable_amount"] == "450.00"
assert standard_flag["employee_absorbed_amount"] == "280.00" assert standard_flag["employee_absorbed_amount"] == "430.00"
assert standard_flag["visibility_scope"] == "leader" assert standard_flag["visibility_scope"] == "leader"
submitted = service.submit_claim(claim.id, current_user) submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None assert submitted is not None
assert submitted.status == "submitted" assert submitted.status == "submitted"
assert submitted.amount == Decimal("600.00") assert submitted.amount == Decimal("450.00")
assert any( assert any(
isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment" isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
for flag in submitted.risk_flags_json for flag in submitted.risk_flags_json
) )
def test_accept_standard_adjustment_uses_policy_amount_when_payload_has_no_downgrade() -> None:
current_user = CurrentUserContext(
username="emp-policy-standard@example.com",
name="张三",
role_codes=[],
is_admin=False,
grade="P4",
)
with build_session() as db:
manager = Employee(
employee_no="E7032",
name="李经理",
email="manager-policy-standard@example.com",
)
employee = Employee(
employee_no="E7033",
name="张三",
email="emp-policy-standard@example.com",
grade="P4",
manager=manager,
)
claim = build_claim(expense_type="hotel", location="北京")
claim.employee = employee
claim.employee_id = employee.id
claim.amount = Decimal("1000.00")
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "北京住宿"
claim.items[0].item_location = "北京"
claim.items[0].item_amount = Decimal("1000.00")
db.add_all([manager, employee, claim])
db.commit()
adjusted = ExpenseClaimService(db).accept_standard_adjustment(
claim_id=claim.id,
payload=ExpenseClaimStandardAdjustmentPayload(
risks=[
{
"risk_id": "risk-hotel-policy-1",
"item_id": claim.items[0].id,
"title": "住宿超标待说明",
"risk": "住宿票据金额超过职级标准。",
"application_days": 2,
"original_amount": Decimal("1000.00"),
"reimbursable_amount": Decimal("1000.00"),
}
]
),
current_user=current_user,
)
assert adjusted is not None
assert adjusted.amount == Decimal("900.00")
standard_flag = next(
flag
for flag in adjusted.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
)
assert standard_flag["original_amount"] == "1000.00"
assert standard_flag["reimbursable_amount"] == "900.00"
assert standard_flag["employee_absorbed_amount"] == "100.00"
assert standard_flag["visibility_scope"] == "leader"
def test_pre_review_claim_records_ai_result_without_submitting() -> None: def test_pre_review_claim_records_ai_result_without_submitting() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-pre-review@example.com", username="emp-pre-review@example.com",

View File

@@ -113,6 +113,53 @@ def seed_claim(db: Session) -> tuple[ExpenseClaim, ExpenseClaimItem]:
return claim, item return claim, item
def test_claim_standard_adjustment_endpoint_recalculates_and_marks_reviewer_notice() -> None:
client, session_factory = build_client()
with session_factory() as db:
claim, item = seed_claim(db)
claim.expense_type = "hotel"
claim.location = "北京"
claim.amount = Decimal("1000.00")
item.item_type = "hotel_ticket"
item.item_reason = "北京住宿"
item.item_location = "北京"
item.item_amount = Decimal("1000.00")
db.commit()
claim_id = claim.id
item_id = item.id
response = client.post(
f"/api/v1/reimbursements/claims/{claim_id}/standard-adjustment",
json={
"risks": [
{
"risk_id": "risk-hotel-endpoint-1",
"item_id": item_id,
"title": "住宿超标待说明",
"risk": "住宿票据金额超过职级标准。",
"application_days": 2,
"original_amount": "1000.00",
"reimbursable_amount": "1000.00",
}
]
},
headers={"x-auth-username": "emp-1", "x-auth-name": "Zhang San", "x-auth-grade": "P4"},
)
assert response.status_code == 200
payload = response.json()
assert payload["amount"] == "900.00"
standard_flag = next(
flag
for flag in payload["risk_flags_json"]
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
)
assert standard_flag["original_amount"] == "1000.00"
assert standard_flag["reimbursable_amount"] == "900.00"
assert standard_flag["employee_absorbed_amount"] == "100.00"
assert standard_flag["visibility_scope"] == "leader"
def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path) -> None: def test_claim_item_attachment_upload_preview_and_delete(monkeypatch, tmp_path) -> None:
def fake_recognize( def fake_recognize(
self, self,

View File

@@ -4,6 +4,7 @@ from collections.abc import Generator
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -124,6 +125,27 @@ def test_platform_rule_flags_are_persisted_as_risk_observations() -> None:
assert persisted.contribution_scores_json == {"S_rule": 100} assert persisted.contribution_scores_json == {"S_rule": 100}
def test_risk_observation_storage_ready_is_cached_per_bind(monkeypatch: pytest.MonkeyPatch) -> None:
with _build_session() as db:
RiskObservationService._storage_ready_cache.clear()
create_all_calls = []
original_create_all = Base.metadata.create_all
def spy_create_all(*args, **kwargs):
create_all_calls.append(kwargs.get("bind"))
return original_create_all(*args, **kwargs)
monkeypatch.setattr(Base.metadata, "create_all", spy_create_all)
service = RiskObservationService(db)
service.ensure_storage_ready()
service.ensure_storage_ready()
RiskObservationService(db).ensure_storage_ready()
assert len(create_all_calls) == 1
RiskObservationService._storage_ready_cache.clear()
def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback() -> None: def test_risk_observation_endpoints_return_list_detail_dashboard_and_feedback() -> None:
client, session_factory = _build_client() client, session_factory = _build_client()
with session_factory() as db: with session_factory() as db:

View File

@@ -150,6 +150,62 @@ def test_runtime_chat_disables_glm_thinking_for_direct_user_answers(monkeypatch)
assert captured["timeout_seconds"] == 17 assert captured["timeout_seconds"] == 17
def test_runtime_chat_openai_compatible_tool_call_payload(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db)
captured: dict[str, object] = {}
def fake_send_json_request(method, url, *, headers, payload, timeout_seconds):
captured["method"] = method
captured["url"] = url
captured["headers"] = headers
captured["payload"] = payload
captured["timeout_seconds"] = timeout_seconds
return 200, {
"choices": [
{
"message": {
"tool_calls": [
{
"id": "call_001",
"type": "function",
"function": {
"name": "submit_steward_intent_plan",
"arguments": "{\"tasks\": []}",
},
}
]
}
}
]
}
monkeypatch.setattr("app.services.runtime_chat._send_json_request", fake_send_json_request)
tool_call = service._request_openai_compatible_tool_call(
provider="OpenAI Compatible",
endpoint="https://api.example.com/v1",
model="gpt-test",
api_key="secret",
messages=[{"role": "user", "content": "hello"}],
tools=[{"type": "function", "function": {"name": "submit_steward_intent_plan"}}],
tool_choice={"type": "function", "function": {"name": "submit_steward_intent_plan"}},
max_tokens=128,
temperature=0.1,
timeout_seconds=19,
)
assert tool_call is not None
assert tool_call.name == "submit_steward_intent_plan"
assert tool_call.arguments == {"tasks": []}
assert captured["url"] == "https://api.example.com/v1/chat/completions"
assert captured["payload"]["tools"][0]["function"]["name"] == "submit_steward_intent_plan"
assert captured["payload"]["tool_choice"]["function"]["name"] == "submit_steward_intent_plan"
assert captured["headers"]["Authorization"] == "Bearer secret"
def test_runtime_chat_supports_single_pass_fast_failover(monkeypatch) -> None: def test_runtime_chat_supports_single_pass_fast_failover(monkeypatch) -> None:
_clear_runtime_chat_cooldown() _clear_runtime_chat_cooldown()
session_factory = build_session_factory() session_factory = build_session_factory()

View File

@@ -0,0 +1,214 @@
from __future__ import annotations
import json
from fastapi.testclient import TestClient
from app.main import create_app
from app.schemas.steward import StewardAttachmentInput, StewardPlanRequest
from app.services.steward_intent_agent import StewardIntentAgentResult
from app.services.steward_planner import StewardPlannerService
class FakeFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields):
assert "expense_type" in canonical_fields
assert base_date.isoformat() == "2026-06-04"
return StewardIntentAgentResult(
payload={
"thinking_events": [
{
"stage": "task_split",
"title": "识别复合报销意图",
"content": "模型工具调用识别出 1 个报销任务,并关联本次上传的交通附件。",
}
],
"tasks": [
{
"task_type": "reimbursement",
"title": "费用报销 2026-06-03 交通",
"summary": "报销昨天客户现场沟通产生的交通费。",
"confidence": 0.91,
"ontology_fields": {
"occurred_date": "昨天",
"transport_type": "出租车",
"reason_value": "客户现场沟通",
"expense_type": "交通费",
"unregistered_field": "不能进入业务字段",
},
"missing_fields": ["amount", "transport_type"],
}
],
"attachment_groups": [
{
"target_task_index": 1,
"scene": "transport",
"scene_label": "交通费用",
"attachment_names": ["出租车票.png"],
"excluded_attachment_names": ["客户招待发票.jpg"],
"confidence": 0.86,
"rationale": "出租车票与交通报销任务匹配,招待发票不归入该任务。",
}
],
},
model_call_traces=[
{
"slot": "main",
"provider": "OpenAI Compatible",
"model": "gpt-test",
"attempt": 1,
"status": "succeeded",
}
],
)
class EmptyFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields):
return None
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
payload = StewardPlanRequest(
message="我要报销昨天客户现场沟通的交通费",
client_now_iso="2026-06-04T09:30:00+08:00",
attachments=[
StewardAttachmentInput(name="出租车票.png"),
StewardAttachmentInput(name="客户招待发票.jpg"),
],
)
result = StewardPlannerService(intent_agent=FakeFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "llm_function_call"
assert result.model_call_traces[0]["status"] == "succeeded"
assert len(result.tasks) == 1
fields = result.tasks[0].ontology_fields
assert fields["time_range"] == "2026-06-03"
assert fields["transport_mode"] == "taxi"
assert fields["reason"] == "客户现场沟通"
assert fields["expense_type"] == "transport"
assert "occurred_date" not in fields
assert "transport_type" not in fields
assert "reason_value" not in fields
assert "unregistered_field" not in fields
assert result.tasks[0].missing_fields == ["amount"]
assert result.attachment_groups[0].attachment_names == ["出租车票.png"]
assert result.attachment_groups[0].excluded_attachment_names == ["客户招待发票.jpg"]
assert result.thinking_events[0].stage == "llm_function_call"
def test_steward_planner_falls_back_to_rules_when_function_calling_is_unavailable() -> None:
payload = StewardPlanRequest(
message="我要报销昨天的交通费",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.tasks[0].ontology_fields["time_range"] == "2026-06-03"
assert result.thinking_events[0].stage == "rule_fallback"
def test_steward_planner_splits_application_and_reimbursement_tasks() -> None:
payload = StewardPlanRequest(
message=(
"我想要申请7月2日去北京出差辅助北京供电局的税务审核任务"
"并且我要报销昨天的交通费还需要报销6月3日出差去上海的费用"
),
user_id="u001",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService().build_plan(payload)
assert len(result.tasks) == 3
assert [task.task_type for task in result.tasks] == [
"expense_application",
"reimbursement",
"reimbursement",
]
assert result.tasks[0].assigned_agent == "application_assistant"
assert result.tasks[0].ontology_fields["time_range"] == "2026-07-02"
assert result.tasks[0].ontology_fields["location"] == "北京"
assert result.tasks[0].ontology_fields["reason"] == "辅助北京供电局的税务审核任务"
assert result.tasks[1].ontology_fields["time_range"] == "2026-06-03"
assert result.tasks[1].ontology_fields["expense_type"] == "transport"
assert result.tasks[2].ontology_fields["time_range"] == "2026-06-03"
assert result.tasks[2].ontology_fields["location"] == "上海"
assert result.tasks[2].ontology_fields["expense_type"] == "travel"
assert all(action.status == "pending" for action in result.confirmation_groups)
def test_steward_planner_outputs_only_canonical_ontology_fields() -> None:
payload = StewardPlanRequest(
message="我要报销昨天的交通费",
client_now_iso="2026-06-04T09:30:00+08:00",
context_json={
"review_form_values": {
"occurred_date": "2026-06-03",
"transport_type": "taxi",
"reason_value": "客户现场沟通",
}
},
)
result = StewardPlannerService().build_plan(payload)
fields = result.tasks[0].ontology_fields
assert fields["time_range"] == "2026-06-03"
assert fields["transport_mode"] == "taxi"
assert fields["reason"] == "客户现场沟通"
assert "occurred_date" not in fields
assert "transport_type" not in fields
assert "reason_value" not in fields
def test_steward_planner_builds_travel_attachment_group_with_exclusions() -> None:
payload = StewardPlanRequest(
message="还需要报销6月3日出差去上海的费用",
client_now_iso="2026-06-04T09:30:00+08:00",
attachments=[
StewardAttachmentInput(name="上海高铁票.jpg"),
StewardAttachmentInput(name="上海酒店发票.pdf"),
StewardAttachmentInput(name="出租车票.png"),
StewardAttachmentInput(name="客户招待发票.jpg"),
],
)
result = StewardPlannerService().build_plan(payload)
assert len(result.attachment_groups) == 1
group = result.attachment_groups[0]
assert group.scene == "travel"
assert group.attachment_names == ["上海高铁票.jpg", "上海酒店发票.pdf", "出租车票.png"]
assert group.excluded_attachment_names == ["客户招待发票.jpg"]
assert group.confirmation_required is True
attachment_actions = [
action for action in result.confirmation_groups if action.action_type == "confirm_attachment_group"
]
assert len(attachment_actions) == 1
def test_steward_stream_endpoint_emits_thinking_before_plan() -> None:
client = TestClient(create_app())
with client.stream(
"POST",
"/api/v1/steward/plans/stream",
json={
"message": "我要报销昨天的交通费",
"client_now_iso": "2026-06-04T09:30:00+08:00",
},
) as response:
assert response.status_code == 200
events = [
json.loads(line.decode("utf-8") if isinstance(line, bytes) else line)
for line in response.iter_lines()
if line
]
assert [event["event"] for event in events][:2] == ["thinking", "thinking"]
assert events[-1]["event"] == "plan"
assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03"

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1,107 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" width="100%" height="100%">
<defs>
<!-- Soft, large shadow for the main cards -->
<filter id="shadow-lg" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="24" stdDeviation="32" flood-color="#0f172a" flood-opacity="0.08"/>
</filter>
<!-- Tighter shadow for floating chips -->
<filter id="shadow-sm" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="16" flood-color="#3a7ca5" flood-opacity="0.12"/>
</filter>
<!-- Glowing effect for spheres -->
<filter id="glow">
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<linearGradient id="glass-base" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#f8fafc" stop-opacity="0.65"/>
</linearGradient>
<linearGradient id="blue-primary" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3a7ca5" stop-opacity="1"/>
<stop offset="100%" stop-color="#255b7d" stop-opacity="1"/>
</linearGradient>
<linearGradient id="amber-accent" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#b58b4c" stop-opacity="1"/>
<stop offset="100%" stop-color="#d4a359" stop-opacity="1"/>
</linearGradient>
</defs>
<g transform="translate(40, 20)">
<!-- Background Document (Left) -->
<g transform="translate(20, 60) rotate(-12)" filter="url(#shadow-lg)">
<rect width="260" height="340" rx="16" fill="url(#glass-base)" stroke="#ffffff" stroke-width="2"/>
<!-- UI Skeleton Lines -->
<text x="30" y="50" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="20" font-weight="bold" fill="#64748b">支出分析</text>
<rect x="30" y="70" width="200" height="8" rx="4" fill="#e2e8f0" opacity="0.7"/>
<rect x="30" y="90" width="160" height="8" rx="4" fill="#e2e8f0" opacity="0.7"/>
<!-- Bar Chart Component -->
<rect x="30" y="140" width="24" height="80" rx="6" fill="#e2e8f0" opacity="0.8"/>
<rect x="70" y="100" width="24" height="120" rx="6" fill="#e2e8f0" opacity="0.8"/>
<rect x="110" y="160" width="24" height="60" rx="6" fill="#e2e8f0" opacity="0.8"/>
<rect x="150" y="80" width="24" height="140" rx="6" fill="url(#blue-primary)"/>
<rect x="190" y="120" width="24" height="100" rx="6" fill="#e2e8f0" opacity="0.8"/>
</g>
<!-- Center Floating Sphere -->
<circle cx="280" cy="90" r="30" fill="url(#amber-accent)" opacity="0.85" filter="url(#glow)"/>
<!-- Main Foreground Document (Right Focus) -->
<g transform="translate(320, 10) rotate(6)" filter="url(#shadow-lg)">
<!-- Main Card Body -->
<rect width="320" height="400" rx="20" fill="url(#glass-base)" stroke="#ffffff" stroke-width="3"/>
<!-- Header Section -->
<circle cx="44" cy="50" r="18" fill="url(#blue-primary)"/>
<text x="76" y="58" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="22" font-weight="800" fill="#3a7ca5">费用趋势</text>
<!-- Area Chart Widget -->
<rect x="30" y="100" width="260" height="130" rx="12" fill="#f1f5f9" opacity="0.5"/>
<path d="M 30 200 L 40 180 Q 90 120 140 150 T 260 90 L 290 90 L 290 230 L 30 230 Z" fill="#3a7ca5" opacity="0.1"/>
<path d="M 40 180 Q 90 120 140 150 T 260 90" fill="none" stroke="#3a7ca5" stroke-width="4" stroke-linecap="round"/>
<!-- Chart Nodes -->
<circle cx="140" cy="150" r="6" fill="#ffffff" stroke="#3a7ca5" stroke-width="3"/>
<circle cx="260" cy="90" r="6" fill="#ffffff" stroke="#3a7ca5" stroke-width="3"/>
<!-- Data List Rows -->
<g transform="translate(30, 250)" filter="url(#shadow-sm)">
<rect width="260" height="40" rx="10" fill="#ffffff" opacity="0.95"/>
<rect x="16" y="16" width="60" height="8" rx="4" fill="#94a3b8"/>
<rect x="200" y="16" width="44" height="8" rx="4" fill="url(#blue-primary)"/>
</g>
<g transform="translate(30, 306)" filter="url(#shadow-sm)">
<rect width="260" height="40" rx="10" fill="#ffffff" opacity="0.95"/>
<rect x="16" y="16" width="80" height="8" rx="4" fill="#cbd5e1"/>
<rect x="210" y="16" width="34" height="8" rx="4" fill="url(#blue-primary)"/>
</g>
</g>
<!-- Floating UI Chip 1 (Top Left) -->
<g transform="translate(140, 0) rotate(-6)" filter="url(#shadow-sm)">
<rect width="150" height="48" rx="24" fill="#ffffff" stroke="#f1f5f9" stroke-width="2"/>
<circle cx="24" cy="24" r="10" fill="#10b981"/>
<rect x="44" y="20" width="80" height="8" rx="4" fill="#334155"/>
</g>
<!-- Floating UI Chip 2 (Bottom Right) -->
<g transform="translate(580, 280) rotate(14)" filter="url(#shadow-sm)">
<rect width="130" height="52" rx="14" fill="url(#blue-primary)"/>
<rect x="20" y="22" width="90" height="8" rx="4" fill="#ffffff" opacity="0.9"/>
</g>
<!-- Decorative small spheres -->
<circle cx="120" cy="350" r="8" fill="#3a7ca5" opacity="0.4" filter="url(#glow)"/>
<circle cx="680" cy="120" r="12" fill="#b58b4c" opacity="0.3" filter="url(#glow)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

View File

@@ -70,13 +70,8 @@
} }
.capability-icon { .capability-icon {
border: 1px solid color-mix(in srgb, var(--capability-color) 18%, rgba(255, 255, 255, 0.68)); --workbench-list-icon-size: 40px;
background: --workbench-list-icon-art-size: 23px;
linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.24)),
color-mix(in srgb, var(--capability-soft) 72%, transparent);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
} }
.workbench-card { .workbench-card {

View File

@@ -61,6 +61,8 @@
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
} }
/* .expense-stats-panel 的特殊背景已转移至全局的 .workbench-card */
.insight-metric-list, .insight-metric-list,
.insight-profile-list { .insight-profile-list {
min-height: 0; min-height: 0;
@@ -102,6 +104,23 @@
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04); rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04);
} }
/* 局部改造让费用统计内层的小卡片也变为低透明度透镜形成双层液态玻璃Double Glassmorphism的极品手感 */
.expense-stats-panel .insight-metric-row {
background: rgba(255, 255, 255, 0.05) !important;
border-color: rgba(255, 255, 255, 0.2) !important;
border-left-color: rgba(255, 255, 255, 0.6) !important;
backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.03),
inset 0 1px 0 rgba(255, 255, 255, 0.3) !important;
}
.expense-stats-panel .insight-metric-row:hover {
background: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.4) !important;
}
.insight-metric-icon, .insight-metric-icon,
.insight-profile-icon { .insight-profile-icon {
display: inline-flex; display: inline-flex;

View File

@@ -304,9 +304,10 @@
} }
.capability-icon { .capability-icon {
--workbench-list-icon-size: 34px;
--workbench-list-icon-art-size: 20px;
width: 34px; width: 34px;
height: 34px; height: 34px;
font-size: 20px;
} }
.capability-copy { .capability-copy {

View File

@@ -33,6 +33,10 @@
gap: 10px; gap: 10px;
overflow: visible; overflow: visible;
color: var(--workbench-ink); color: var(--workbench-ink);
/* 恢复极简纯净的页面底层 */
background: transparent;
background-color: var(--workbench-surface-soft);
} }
.workbench :where(button, textarea) { .workbench :where(button, textarea) {
@@ -53,33 +57,33 @@
.workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; } .workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; }
.assistant-hero { .assistant-hero {
--assistant-bg-position: center right;
--assistant-bg-size: cover;
--assistant-readability-mask:
linear-gradient(90deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.86) 42%, rgba(255, 255, 255, 0.44) 68%, rgba(255, 255, 255, 0.18) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07) 52%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16) 100%);
position: relative; position: relative;
z-index: 2; z-index: 2;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px; padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px;
border: 1px solid color-mix(in srgb, var(--workbench-primary) 14%, var(--workbench-line)); border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 4px; border-radius: 16px;
background: background:
var(--assistant-readability-mask), linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%);
var(--assistant-theme-tint), background-color: transparent;
var(--assistant-bg-image) var(--assistant-bg-position) / var(--assistant-bg-size) no-repeat; backdrop-filter: blur(40px) saturate(200%);
background-color: color-mix(in srgb, var(--workbench-primary-soft) 42%, #ffffff); -webkit-backdrop-filter: blur(40px) saturate(200%);
background-blend-mode: normal, color, luminosity; box-shadow: 0 16px 32px rgba(0, 0, 0, 0.04), inset 0 2px 4px rgba(255, 255, 255, 1);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.6);
isolation: isolate; isolation: isolate;
} }
.assistant-hero::after { .assistant-hero::after {
content: none; content: "";
position: absolute;
top: 0;
right: 100px;
bottom: 0;
width: 50%;
min-width: 400px;
background: url("../../images/hero-financial-decor.svg") right center / auto 100% no-repeat;
pointer-events: none;
z-index: 0;
} }
.assistant-hero::before { .assistant-hero::before {
@@ -140,6 +144,14 @@
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
.assistant-composer:focus-within {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.85);
box-shadow:
0 0 0 4px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14),
0 16px 36px rgba(15, 23, 42, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.96);
}
.assistant-composer textarea { .assistant-composer textarea {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -317,19 +329,26 @@
.capability-card { .capability-card {
position: relative; position: relative;
isolation: isolate;
display: grid; display: grid;
grid-template-columns: 40px minmax(0, 1fr) 10px; grid-template-columns: 40px minmax(0, 1fr) 10px;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
min-height: 0; min-height: 0;
padding: 17px 12px 17px 26px; padding: 17px 12px 17px 26px;
overflow: hidden; overflow: visible;
border: 1px solid var(--workbench-line); border: 1px solid rgba(255, 255, 255, 0.9);
border-left: 3px solid color-mix(in srgb, var(--capability-color) 54%, var(--workbench-line)); border-left: 3px solid color-mix(in srgb, var(--capability-color) 80%, rgba(255, 255, 255, 0.9));
border-radius: 4px; border-radius: 12px;
background: var(--workbench-surface); background:
linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%);
background-color: transparent;
backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
text-align: left; text-align: left;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035); box-shadow:
0 16px 32px rgba(0, 0, 0, 0.04),
inset 0 2px 4px rgba(255, 255, 255, 1);
transition: transition:
border-color 180ms var(--ease), border-color 180ms var(--ease),
box-shadow 180ms var(--ease), box-shadow 180ms var(--ease),
@@ -337,21 +356,33 @@
transform 180ms var(--ease); transform 180ms var(--ease);
} }
.capability-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
pointer-events: none;
z-index: 0;
}
.capability-card > * {
position: relative;
z-index: 1;
}
.capability-card::after { .capability-card::after {
display: none; display: none;
} }
.capability-icon { .capability-icon {
--workbench-list-icon-size: 40px;
--workbench-list-icon-art-size: 23px;
width: 40px; width: 40px;
height: 40px; height: 40px;
display: grid;
place-items: center;
border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--capability-color) 20%, #ffffff);
background: var(--capability-soft);
color: var(--capability-color); color: var(--capability-color);
font-size: 24px;
box-shadow: none;
} }
.capability-copy { .capability-copy {
@@ -417,7 +448,7 @@
.workbench-content-grid { .workbench-content-grid {
display: grid; display: grid;
grid-template-columns: minmax(560px, 1.45fr) minmax(320px, 0.82fr); grid-template-columns: minmax(640px, 1.8fr) minmax(260px, 0.55fr);
gap: 14px; gap: 14px;
align-items: stretch; align-items: stretch;
min-height: 0; min-height: 0;
@@ -425,14 +456,38 @@
} }
.workbench-card { .workbench-card {
position: relative;
isolation: isolate;
min-height: 0; min-height: 0;
height: 100%; height: 100%;
overflow: hidden;
padding: 12px 14px; padding: 12px 14px;
border: 1px solid var(--workbench-line); background:
border-radius: 4px; linear-gradient(125deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.3) 40%, rgba(255, 255, 255, 0.1) 60%, rgba(255, 255, 255, 0.5) 100%);
background: var(--workbench-surface); background-color: transparent;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035); backdrop-filter: blur(40px) saturate(200%);
-webkit-backdrop-filter: blur(40px) saturate(200%);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 16px;
box-shadow:
0 16px 32px rgba(0, 0, 0, 0.04),
inset 0 2px 4px rgba(255, 255, 255, 1);
}
.workbench-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
pointer-events: none;
z-index: 1;
}
.workbench-card > * {
position: relative;
z-index: 2;
} }
.progress-panel, .progress-panel,

View File

@@ -472,20 +472,24 @@
.notification-popover { .notification-popover {
position: absolute; position: absolute;
top: calc(100% + 8px); top: calc(100% + 12px);
right: 0; right: 0;
z-index: 60; z-index: 60;
width: clamp(340px, 34vw, 420px); width: 380px;
max-width: calc(100vw - 24px); max-width: calc(100vw - 24px);
max-height: min(520px, calc(100vh - 96px)); max-height: min(520px, calc(100vh - 96px));
display: grid; display: flex;
grid-template-rows: auto auto minmax(0, 1fr); flex-direction: column;
gap: 0; gap: 0;
overflow: hidden; overflow: hidden;
border: 1px solid #d7e0ea; border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 4px; border-radius: 12px;
background: #fff; background: #ffffff;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12); box-shadow:
0 16px 36px rgba(0, 0, 0, 0.08),
0 4px 12px rgba(0, 0, 0, 0.03),
0 0 1px rgba(0, 0, 0, 0.1);
overscroll-behavior-y: contain;
} }
.notification-popover::before { .notification-popover::before {
@@ -665,9 +669,11 @@
max-height: min(336px, calc(100vh - 226px)); max-height: min(336px, calc(100vh - 226px));
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
padding: 6px 0; padding: 4px 0 12px;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f8fafc; scrollbar-color: #cbd5e1 #f8fafc;
overscroll-behavior-y: contain;
scrollbar-gutter: stable;
} }
.notification-list::-webkit-scrollbar { .notification-list::-webkit-scrollbar {
@@ -687,13 +693,13 @@
display: grid; display: grid;
grid-template-columns: 34px minmax(0, 1fr) 16px; grid-template-columns: 34px minmax(0, 1fr) 16px;
align-items: center; align-items: center;
gap: 9px; gap: 12px;
min-height: 0; min-height: 68px;
padding: 10px 14px; padding: 12px 16px;
border: 0; border: 0;
border-left: 3px solid transparent;
border-radius: 0; border-radius: 0;
background: transparent; background: #ffffff;
flex-shrink: 0;
text-align: left; text-align: left;
transition: transition:
background 180ms var(--ease), background 180ms var(--ease),
@@ -705,16 +711,27 @@
} }
.notification-row.unread { .notification-row.unread {
border-left-color: var(--theme-primary-active); background: #f8fafc;
background: linear-gradient(90deg, var(--theme-primary-light-9) 0%, #fff 42%); position: relative;
}
.notification-row.unread::before {
content: '';
position: absolute;
left: 0;
top: 16px;
bottom: 16px;
width: 3px;
border-radius: 0 4px 4px 0;
background: var(--theme-primary-active);
} }
.notification-row:hover { .notification-row:hover {
background: #f8fafc; background: #f1f5f9;
} }
.notification-row.unread:hover { .notification-row.unread:hover {
background: linear-gradient(90deg, var(--theme-primary-light-8) 0%, #f8fafc 48%); background: #f1f5f9;
} }
.notification-type-icon { .notification-type-icon {
@@ -722,11 +739,12 @@
height: 34px; height: 34px;
display: grid; display: grid;
place-items: center; place-items: center;
border: 1px solid var(--theme-primary-light-6); border: 1px solid rgba(0,0,0,0.04);
border-radius: 4px; border-radius: 8px;
background: #fff; background: #ffffff;
color: var(--theme-primary-active); color: var(--theme-primary-active);
font-size: 18px; font-size: 16px;
box-shadow: 0 1.5px 4px rgba(0,0,0,0.03);
} }
.notification-type-icon.danger { .notification-type-icon.danger {
@@ -755,28 +773,29 @@
.notification-copy { .notification-copy {
min-width: 0; min-width: 0;
display: grid; display: flex;
flex-direction: column;
gap: 4px; gap: 4px;
} padding-top: 1px;
padding-bottom: 1px;
.notification-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.notification-title-line { .notification-title-line {
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
} }
.notification-copy strong { .notification-copy strong {
min-width: 0; min-width: 0;
color: #0f172a; color: #0f172a;
font-size: 13px; font-size: 13.5px;
font-weight: 800; font-weight: 750;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.notification-title-line b { .notification-title-line b {
@@ -796,22 +815,24 @@
} }
.notification-copy small { .notification-copy small {
display: -webkit-box; color: #64748b;
overflow: hidden;
color: #475569;
font-size: 12px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
white-space: normal; display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
} }
.notification-meta { .notification-meta {
min-width: 0; min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-end;
gap: 8px; gap: 8px;
margin-top: 2px;
} }
.notification-meta em, .notification-meta em,
@@ -827,7 +848,7 @@
} }
.notification-meta em { .notification-meta em {
flex: 1 1 auto; display: none;
} }
.notification-meta time { .notification-meta time {

View File

@@ -22,6 +22,18 @@
border-color: rgba(96, 165, 250, 0.3); border-color: rgba(96, 165, 250, 0.3);
} }
.message-stack {
min-width: 0;
display: grid;
justify-items: start;
gap: 8px;
}
.message-row.user .message-stack {
order: 1;
justify-items: end;
}
.message-avatar { .message-avatar {
width: 38px; width: 38px;
height: 38px; height: 38px;
@@ -52,6 +64,94 @@
box-shadow: 0 10px 22px rgba(148, 163, 184, 0.14); box-shadow: 0 10px 22px rgba(148, 163, 184, 0.14);
} }
.steward-intent-bubble {
width: min(100%, 680px);
border: 1px solid #c9ddea;
border-radius: 4px;
background: #eef6fb;
color: #1f3f5b;
box-shadow: 0 8px 18px rgba(148, 163, 184, 0.12);
}
.steward-intent-bubble[open] {
background: #f3f9fd;
}
.steward-intent-bubble summary {
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
cursor: pointer;
list-style: none;
}
.steward-intent-bubble summary::-webkit-details-marker {
display: none;
}
.steward-intent-bubble summary > span {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
color: #234a68;
font-size: 12px;
font-weight: 820;
}
.steward-intent-bubble summary > span i {
color: #3a7ca5;
font-size: 14px;
}
.steward-intent-bubble summary small {
margin-left: auto;
color: #6f8295;
font-size: 12px;
font-weight: 720;
}
.steward-intent-bubble summary > i {
color: #64748b;
transition: transform 180ms ease;
}
.steward-intent-bubble[open] summary > i {
transform: rotate(180deg);
}
.steward-intent-event-list {
margin: 0;
padding: 0 12px 12px 30px;
display: grid;
gap: 7px;
}
.steward-intent-event-list li strong {
display: block;
color: #274b68;
font-size: 12px;
font-weight: 820;
}
.steward-intent-event-list li span {
display: block;
margin-top: 2px;
color: #5c7185;
font-size: 12px;
line-height: 1.55;
}
.steward-intent-empty {
margin: 0;
padding: 0 12px 12px;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.message-bubble-application-preview { .message-bubble-application-preview {
max-width: min(100%, 980px); max-width: min(100%, 980px);
} }

View File

@@ -396,6 +396,32 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
.steward-composer-row {
align-items: flex-end;
gap: 8px;
}
.steward-composer-leading-actions {
display: flex;
align-items: center;
gap: 8px;
}
.steward-composer-shell {
min-height: 48px;
}
.steward-composer-shell .composer-shell-body {
min-height: 48px;
padding: 5px 12px;
}
.steward-composer-shell textarea {
min-height: 38px;
max-height: 150px;
padding: 9px 4px;
}
.composer-side-btn, .composer-side-btn,
.composer-row .tool-btn, .composer-row .tool-btn,
.composer-row .send-btn { .composer-row .send-btn {

View File

@@ -62,6 +62,97 @@
background: #fff; background: #fff;
} }
.steward-plan-block {
margin-top: 12px;
display: grid;
gap: 10px;
}
.steward-task-card,
.steward-attachment-card {
border: 1px solid #dbe7f2;
border-radius: 4px;
background: #f8fbfe;
}
.steward-task-list,
.steward-attachment-list {
display: grid;
gap: 8px;
}
.steward-task-card,
.steward-attachment-card {
padding: 10px 12px;
}
.steward-task-card header,
.steward-attachment-card header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.steward-task-card header span,
.steward-attachment-card header span {
color: #24618a;
font-size: 12px;
font-weight: 700;
}
.steward-task-card header small,
.steward-attachment-card header small {
margin-left: auto;
color: #71879b;
font-size: 12px;
}
.steward-task-card > strong {
display: block;
color: #1f3448;
font-size: 14px;
}
.steward-task-card p,
.steward-attachment-card p {
margin: 5px 0 0;
color: #5c7185;
font-size: 12px;
line-height: 1.55;
}
.steward-task-meta,
.steward-attachment-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.steward-task-meta span,
.steward-attachment-chip {
border: 1px solid #d5e2ee;
border-radius: 4px;
background: #fff;
color: #49677f;
font-size: 12px;
line-height: 1.4;
padding: 3px 7px;
}
.steward-attachment-chip.include {
border-color: #c7deef;
background: #eef7fc;
color: #24618a;
}
.steward-attachment-chip.exclude {
border-color: #ecd6c4;
background: #fff8f2;
color: #8a5a24;
}
.welcome-quick-actions { .welcome-quick-actions {
margin-top: 14px; margin-top: 14px;
padding-top: 12px; padding-top: 12px;

View File

@@ -884,6 +884,18 @@
font-size: 16px; font-size: 16px;
} }
.standard-adjustment-banner {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.submit-progress-banner {
border-color: #c7d2fe;
background: #eef2ff;
color: #3730a3;
}
.detail-expense-table table { .detail-expense-table table {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -1094,12 +1106,28 @@
} }
.expense-reimbursable-amount { .expense-reimbursable-amount {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
color: #0f172a; color: #0f172a;
font-size: 13px; font-size: 13px;
font-weight: 880; font-weight: 880;
white-space: nowrap; white-space: nowrap;
} }
.expense-reimbursable-label {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 5px;
border-radius: 3px;
background: #eef2ff;
color: #3730a3;
font-size: 10px;
font-weight: 850;
}
.expense-adjusted-amount em { .expense-adjusted-amount em {
color: #991b1b; color: #991b1b;
font-size: 11px; font-size: 11px;
@@ -1989,51 +2017,26 @@
line-height: 1.6; line-height: 1.6;
} }
.risk-override-card textarea { .risk-override-guidance {
min-height: 88px;
border-color: #fecaca;
background: #fff;
color: #0f172a;
}
.risk-override-card textarea.risk-note-editor-textarea {
min-height: 34px;
max-height: 78px;
resize: none;
}
.risk-override-card textarea:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, .12);
outline: none;
}
.risk-override-submit-row {
display: grid; display: grid;
gap: 6px; gap: 4px;
} padding: 10px 12px;
border: 1px solid #dbeafe;
.risk-override-save-btn {
min-height: 34px;
border: 1px solid #bfdbfe;
border-radius: 4px; border-radius: 4px;
background: #eff6ff; background: #eff6ff;
color: #1d4ed8; }
.risk-override-guidance strong {
color: #1e40af;
font-size: 12px; font-size: 12px;
font-weight: 850; font-weight: 850;
cursor: pointer; line-height: 1.45;
} }
.risk-override-save-btn:disabled { .risk-override-guidance span {
cursor: not-allowed; color: #475569;
opacity: .58;
}
.risk-override-submit-row span {
color: #64748b;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
text-align: center;
} }
.validation-card { .validation-card {

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M8.5 4.5h7A1.5 1.5 0 0 1 17 6v13a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 5 19V6a1.5 1.5 0 0 1 1.5-1.5h2"/>
<path d="M8.5 4.5A2 2 0 0 1 10.5 3h2A2 2 0 0 1 14.5 4.5v1h-6Z"/>
<path d="M8.5 12.8 11 15.2l5-5.2"/>
<path d="M8 18h7"/>
</svg>

After

Width:  |  Height:  |  Size: 431 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 4.5v7h7A7 7 0 1 1 12 4.5Z"/>
<path d="M14.5 3.8A7.2 7.2 0 0 1 20.2 9h-5.7Z"/>
<path d="M7.5 16.5h4M7.5 13.5H10"/>
</svg>

After

Width:  |  Height:  |  Size: 320 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M7 3.5h7l3 3v13a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5v-15A1.5 1.5 0 0 1 7.5 3.5Z"/>
<path d="M14 3.5V7h3.5"/>
<path d="M9 11h6M9 14h3"/>
<path d="M15.5 13.5v4M13.5 15.5h4"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4.5 19.5h15"/>
<path d="M6 16.5V8.8M10 16.5V5.5M14 16.5v-6M18 16.5V7"/>
<path d="M6 12.5c2.2-3.2 4-1.2 6.2-4.1 1.5-2 3-1.3 5.3-3.4"/>
<path d="m17.6 5 .4 3.2-3.1-.5"/>
</svg>

After

Width:  |  Height:  |  Size: 373 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5.5 5.5A2.5 2.5 0 0 1 8 3h12v16.5H8A2.5 2.5 0 0 0 5.5 22Z"/>
<path d="M5.5 5.5v16"/>
<path d="M9 7.5h7M9 10.5h6M9 14.5h4"/>
<path d="M17 3v16.5"/>
</svg>

After

Width:  |  Height:  |  Size: 352 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6.5 4.5h9.8A1.7 1.7 0 0 1 18 6.2v13.3l-2.2-1.2-2.2 1.2-2.2-1.2-2.2 1.2-2.2-1.2-2.2 1.2V6.2a1.7 1.7 0 0 1 1.7-1.7Z"/>
<path d="M8 9h8M8 12h6"/>
<path d="M9 15.5h2.7c1.8 0 3.3-1.4 3.3-3.2v-.8"/>
<path d="m13.3 10 1.7 1.7 1.7-1.7"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

View File

@@ -9,7 +9,7 @@
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }"> <article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }">
<div class="assistant-copy"> <div class="assistant-copy">
<h1>{{ displayUserName }}我是您的 <span>AI 费用助手</span></h1> <h1>{{ displayUserName }}我是您的 <span>小财管家</span></h1>
<input <input
ref="fileInputRef" ref="fileInputRef"
@@ -26,7 +26,7 @@
v-model="assistantDraft" v-model="assistantDraft"
maxlength="1000" maxlength="1000"
rows="2" rows="2"
placeholder="请输入费用申请、报销问题、预算查询或制度问答..." placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行..."
:readonly="isComposerPending" :readonly="isComposerPending"
@keydown.enter.prevent="handleWorkbenchEnter" @keydown.enter.prevent="handleWorkbenchEnter"
/> />
@@ -180,7 +180,12 @@
:class="`capability-card--${item.tone}`" :class="`capability-card--${item.tone}`"
@click="openCapabilityAssistant(item)" @click="openCapabilityAssistant(item)"
> >
<span class="capability-icon"><i :class="item.icon"></i></span> <WorkbenchListIcon
class="capability-icon"
:icon-key="item.key"
color="var(--capability-color)"
accent="var(--capability-soft)"
/>
<span class="capability-copy"> <span class="capability-copy">
<strong>{{ item.title }}</strong> <strong>{{ item.title }}</strong>
<small>{{ item.primary }}</small> <small>{{ item.primary }}</small>
@@ -221,8 +226,8 @@
<small>{{ item.id }}</small> <small>{{ item.id }}</small>
</span> </span>
<span class="progress-type" :title="item.expenseTypeLabel || '其他费用'"> <span class="progress-type" :title="`${item.documentTypeLabel} · ${item.expenseTypeLabel || '其他费用'}`">
<strong>{{ item.expenseTypeLabel || '其他费用' }}</strong> <strong>{{ item.documentTypeLabel }} · {{ item.expenseTypeLabel || '其他费用' }}</strong>
</span> </span>
<span class="progress-steps" aria-hidden="true"> <span class="progress-steps" aria-hidden="true">
@@ -349,9 +354,10 @@
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue' import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue' import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue' import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp' import workbenchHeroBackground from '../../assets/images/hero-3d-banner.png'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js' import { useToast } from '../../composables/useToast.js'
import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js' import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js'
@@ -425,6 +431,7 @@ let employeeProfileLoadSeq = 0
const MAX_ATTACHMENTS = 10 const MAX_ATTACHMENTS = 10
const SESSION_TYPE_EXPENSE = 'expense' const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge' const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const SESSION_TYPE_STEWARD = 'steward'
const hasExpenseConversation = computed(() => const hasExpenseConversation = computed(() =>
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId) Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
@@ -607,6 +614,7 @@ function buildAssistantPayload() {
return { return {
prompt: buildWorkbenchPromptText(), prompt: buildWorkbenchPromptText(),
source: 'workbench', source: 'workbench',
sessionType: SESSION_TYPE_STEWARD,
files: Array.from(selectedFiles.value) files: Array.from(selectedFiles.value)
} }
} }
@@ -674,7 +682,7 @@ function applyQuickPrompt(prompt) {
focusAssistantInput() focusAssistantInput()
} }
function openPromptAssistant(prompt) { function openPromptAssistant(prompt, sessionType = SESSION_TYPE_STEWARD) {
if (pendingAction.value) { if (pendingAction.value) {
return return
} }
@@ -682,6 +690,7 @@ function openPromptAssistant(prompt) {
const payload = { const payload = {
prompt: buildWorkbenchPromptText(prompt), prompt: buildWorkbenchPromptText(prompt),
source: 'workbench', source: 'workbench',
sessionType,
files: Array.from(selectedFiles.value), files: Array.from(selectedFiles.value),
conversation: null conversation: null
} }
@@ -704,7 +713,7 @@ function openWorkbenchTarget(item) {
return return
} }
openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的费用进度`) openPromptAssistant(item?.prompt || `查询 ${item?.id || ''} 的费用进度`, SESSION_TYPE_EXPENSE)
} }
function openCapabilityAssistant(item) { function openCapabilityAssistant(item) {

View File

@@ -99,29 +99,33 @@ function handleCancel() {
</script> </script>
<style scoped> <style scoped>
.shared-confirm-mask { .shared-confirm-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 10020; z-index: 10020;
display: grid;
place-items: center;
padding: 24px;
background: rgba(15, 23, 42, 0.32);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.shared-confirm-card {
width: min(480px, 100%);
display: grid; display: grid;
place-items: center;
padding: 20px;
background: rgba(15, 23, 42, 0.32);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.shared-confirm-card {
width: min(480px, calc(100vw - 40px));
max-height: calc(100vh - 40px);
max-height: calc(100dvh - 40px);
display: flex;
flex-direction: column;
gap: 14px; gap: 14px;
padding: 24px; padding: 24px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14); border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14);
border-radius: 8px; border-radius: 4px;
background: background:
radial-gradient(circle at top left, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.10), transparent 36%), radial-gradient(circle at top left, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.10), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98)); linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 250, 252, 0.98));
box-shadow: 0 28px 56px rgba(15, 23, 42, 0.18); box-shadow: 0 28px 56px rgba(15, 23, 42, 0.18);
overflow: hidden;
} }
.shared-confirm-badge { .shared-confirm-badge {
@@ -165,12 +169,18 @@ function handleCancel() {
line-height: 1.7; line-height: 1.7;
} }
.shared-confirm-body { .shared-confirm-body {
display: grid; display: grid;
gap: 10px; gap: 10px;
} min-height: 0;
max-height: min(380px, calc(100dvh - 300px));
overflow-y: auto;
padding-right: 2px;
scrollbar-width: thin;
}
.shared-confirm-actions { .shared-confirm-actions {
flex: 0 0 auto;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 10px; gap: 10px;
@@ -257,12 +267,22 @@ function handleCancel() {
.shared-confirm-card--compact { .shared-confirm-card--compact {
width: min(360px, 100%); width: min(360px, 100%);
max-height: calc(100vh - 36px);
max-height: calc(100dvh - 36px);
gap: 8px; gap: 8px;
padding: 16px; padding: 16px;
border-radius: 6px; border-radius: 4px;
background: #fff; background: #fff;
} }
.shared-confirm-card--review {
width: min(560px, calc(100vw - 40px));
}
.shared-confirm-card--review .shared-confirm-body {
max-height: min(420px, calc(100dvh - 292px));
}
.shared-confirm-card--compact h4 { .shared-confirm-card--compact h4 {
font-size: 15px; font-size: 15px;
line-height: 1.35; line-height: 1.35;
@@ -287,16 +307,23 @@ function handleCancel() {
@media (max-width: 720px) { @media (max-width: 720px) {
.shared-confirm-mask { .shared-confirm-mask {
padding: 18px; padding: 14px;
}
.shared-confirm-card {
padding: 20px;
border-radius: 8px;
} }
.shared-confirm-card h4 { .shared-confirm-card {
font-size: 19px; width: min(100%, calc(100vw - 28px));
max-height: calc(100vh - 28px);
max-height: calc(100dvh - 28px);
padding: 20px;
border-radius: 4px;
}
.shared-confirm-body {
max-height: min(360px, calc(100dvh - 260px));
}
.shared-confirm-card h4 {
font-size: 19px;
} }
.shared-confirm-actions { .shared-confirm-actions {

View File

@@ -31,22 +31,21 @@ const iconStyle = computed(() => iconMeta.value.style)
<style scoped> <style scoped>
.workbench-list-icon { .workbench-list-icon {
position: relative; position: relative;
width: 56px; width: var(--workbench-list-icon-size, 48px);
height: 56px; height: var(--workbench-list-icon-size, 48px);
flex-shrink: 0; flex-shrink: 0;
color: var(--icon-color, var(--theme-primary)); color: var(--icon-color, var(--theme-primary));
} }
.workbench-list-icon__halo { .workbench-list-icon__halo {
position: absolute; position: absolute;
inset: -3px; top: 8px;
border-radius: 20px; bottom: 8px;
background: radial-gradient( left: 0;
circle at 50% 40%, width: 3px;
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 24%, transparent) 0%, border-radius: 2px;
transparent 72% background: color-mix(in srgb, var(--icon-color, var(--theme-primary)) 78%, #ffffff);
); opacity: 0.72;
opacity: 0.7;
} }
.workbench-list-icon__panel { .workbench-list-icon__panel {
@@ -57,26 +56,25 @@ const iconStyle = computed(() => iconMeta.value.style)
display: grid; display: grid;
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
border-radius: 18px; border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--icon-color, var(--theme-primary)) 18%, var(--line, #e2e8f0)); border: 1px solid color-mix(in srgb, var(--icon-color, var(--theme-primary)) 22%, var(--line, #e2e8f0));
background: background:
radial-gradient(circle at 24% 16%, rgba(255, 255, 255, 0.98), transparent 46%), linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.44)),
linear-gradient( linear-gradient(
160deg, 135deg,
color-mix(in srgb, var(--icon-accent, var(--theme-primary-soft)) 72%, #fff) 0%, color-mix(in srgb, var(--icon-accent, var(--theme-primary-soft)) 64%, #fff) 0%,
#fff 44%, #fff 52%,
color-mix(in srgb, var(--icon-color, var(--theme-primary)) 7%, var(--surface-soft, #f8fafc)) 100% color-mix(in srgb, var(--icon-color, var(--theme-primary)) 8%, var(--surface-soft, #f8fafc)) 100%
); );
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.98), inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 1px 2px rgba(15, 23, 42, 0.04), 0 1px 2px rgba(15, 23, 42, 0.045);
0 10px 20px color-mix(in srgb, var(--icon-color, var(--theme-primary)) 12%, transparent);
} }
.workbench-list-icon__shine { .workbench-list-icon__shine {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.55), transparent 38%); background: linear-gradient(110deg, rgba(255, 255, 255, 0.42), transparent 44%);
pointer-events: none; pointer-events: none;
} }
@@ -85,16 +83,15 @@ const iconStyle = computed(() => iconMeta.value.style)
z-index: 1; z-index: 1;
display: grid; display: grid;
place-items: center; place-items: center;
width: 30px; width: var(--workbench-list-icon-art-size, 28px);
height: 30px; height: var(--workbench-list-icon-art-size, 28px);
} }
.workbench-list-icon__art :deep(.workbench-heroicon) { .workbench-list-icon__art :deep(.workbench-heroicon) {
width: 30px; width: var(--workbench-list-icon-art-size, 28px);
height: 30px; height: var(--workbench-list-icon-art-size, 28px);
display: block; display: block;
color: color-mix(in srgb, var(--icon-color, var(--theme-primary)) 86%, var(--theme-primary-active)); color: color-mix(in srgb, var(--icon-color, var(--theme-primary)) 86%, var(--theme-primary-active));
filter: drop-shadow(0 2px 5px color-mix(in srgb, var(--icon-color, var(--theme-primary)) 18%, transparent));
} }
.workbench-list-icon--outline .workbench-list-icon__art :deep(.workbench-heroicon) { .workbench-list-icon--outline .workbench-list-icon__art :deep(.workbench-heroicon) {

View File

@@ -10,11 +10,43 @@
/> />
</span> </span>
<div class="message-bubble" :class="ui.buildMessageBubbleClass(message)"> <div class="message-stack">
<details
v-if="message.role === 'assistant' && message.stewardPlan && (message.stewardPlan.streamStatus === 'streaming' || message.stewardPlan.thinkingEvents?.length)"
class="steward-intent-bubble"
:open="message.stewardPlan.streamStatus === 'streaming'"
aria-label="小财管家意图识别智能体"
>
<summary>
<span>
<i class="mdi mdi-brain"></i>
意图识别智能体
</span>
<small>{{ message.stewardPlan.streamStatus === 'streaming' ? '识别中' : `${message.stewardPlan.thinkingEvents?.length || 0}` }}</small>
<i class="mdi mdi-chevron-down"></i>
</summary>
<ol v-if="message.stewardPlan.thinkingEvents?.length" class="steward-intent-event-list">
<li
v-for="event in (message.stewardPlan.thinkingEvents || []).slice(0, message.stewardPlan.visibleThinkingEventCount || message.stewardPlan.thinkingEvents?.length || 0)"
:key="`${message.id}-${event.eventId}`"
>
<strong>{{ event.title }}</strong>
<span>{{ event.content }}</span>
</li>
</ol>
<p v-else class="steward-intent-empty">正在建立任务上下文...</p>
</details>
<div
v-if="!message.stewardPlan || message.stewardPlan.streamStatus !== 'streaming' || message.text"
class="message-bubble"
:class="ui.buildMessageBubbleClass(message)"
>
<header class="message-meta"> <header class="message-meta">
<strong>{{ message.role === 'assistant' ? (message.assistantName || ui.ASSISTANT_DISPLAY_NAME) : '我' }}</strong> <strong>{{ message.role === 'assistant' ? (message.assistantName || ui.ASSISTANT_DISPLAY_NAME) : '我' }}</strong>
<time>{{ message.time }}</time> <time>{{ message.time }}</time>
</header> </header>
<div <div
v-if="message.text && message.role === 'assistant' && message.reviewPayload && ui.buildReviewMainMessageText(message)" v-if="message.text && message.role === 'assistant' && message.reviewPayload && ui.buildReviewMainMessageText(message)"
class="review-summary message-answer-content message-answer-markdown" class="review-summary message-answer-content message-answer-markdown"
@@ -40,6 +72,59 @@
:report="message.budgetReport" :report="message.budgetReport"
/> />
<div
v-if="message.role === 'assistant' && message.stewardPlan && message.stewardPlan.streamStatus !== 'streaming'"
class="steward-plan-block"
role="group"
aria-label="小财管家任务计划"
>
<div v-if="message.stewardPlan.tasks?.length" class="steward-task-list">
<article
v-for="task in message.stewardPlan.tasks"
:key="`${message.id}-${task.taskId}`"
class="steward-task-card"
>
<header>
<span>{{ task.taskTypeLabel }}</span>
<small>{{ task.assignedAgentLabel }}</small>
</header>
<strong>{{ task.title }}</strong>
<p>{{ task.summary }}</p>
<div class="steward-task-meta">
<span>置信度 {{ Math.round((task.confidence || 0) * 100) }}%</span>
<span v-if="task.missingFields?.length">待补充 {{ task.missingFields.join('') }}</span>
<span v-else>字段已齐备</span>
</div>
</article>
</div>
<div v-if="message.stewardPlan.attachmentGroups?.length" class="steward-attachment-list">
<article
v-for="group in message.stewardPlan.attachmentGroups"
:key="`${message.id}-${group.groupId}`"
class="steward-attachment-card"
>
<header>
<span>{{ group.sceneLabel }}</span>
<small>{{ Math.round((group.confidence || 0) * 100) }}%</small>
</header>
<p>{{ group.rationale }}</p>
<div class="steward-attachment-chip-row">
<span
v-for="name in group.attachmentNames"
:key="`${group.groupId}-in-${name}`"
class="steward-attachment-chip include"
>{{ name }}</span>
<span
v-for="name in group.excludedAttachmentNames"
:key="`${group.groupId}-out-${name}`"
class="steward-attachment-chip exclude"
>排除{{ name }}</span>
</div>
</article>
</div>
</div>
<div <div
v-if="message.role === 'assistant' && message.applicationPreview" v-if="message.role === 'assistant' && message.applicationPreview"
class="application-preview-table" class="application-preview-table"
@@ -472,6 +557,7 @@
</span> </span>
</div> </div>
</div> </div>
</div>
<div <div
v-if="ui.isOperationFeedbackVisible(message)" v-if="ui.isOperationFeedbackVisible(message)"

138
web/src/services/steward.js Normal file
View File

@@ -0,0 +1,138 @@
import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
export function fetchStewardPlan(payload, options = {}) {
return apiRequest('/steward/plans', {
method: 'POST',
body: JSON.stringify(payload),
...options
})
}
export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) {
const {
timeoutMs = 0,
timeoutMessage = '小财管家任务规划超时,请稍后重试。'
} = options
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
const timeoutId = controller && Number(timeoutMs) > 0
? globalThis.setTimeout(() => controller.abort(), Number(timeoutMs))
: 0
let response
try {
response = await fetch(`${getRuntimeApiBaseUrl()}/steward/plans/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload),
signal: controller?.signal
})
} catch (error) {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
if (error?.name === 'AbortError') {
throw new Error(timeoutMessage)
}
throw new Error('无法连接小财管家流式服务,请确认后端已启动。')
}
if (!response.ok) {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
throw new Error(await resolveStreamError(response))
}
if (!response.body?.getReader) {
const text = await response.text()
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
return consumeNdjsonText(text, handlers)
}
const decoder = new TextDecoder('utf-8')
const reader = response.body.getReader()
let buffer = ''
let finalPlan = null
try {
while (true) {
const { value, done } = await reader.read()
if (done) {
break
}
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const event = parseStreamLine(line)
if (!event) continue
finalPlan = handleStreamEvent(event, handlers) || finalPlan
}
}
buffer += decoder.decode()
if (buffer.trim()) {
const event = parseStreamLine(buffer)
if (event) {
finalPlan = handleStreamEvent(event, handlers) || finalPlan
}
}
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(timeoutMessage)
}
throw error
} finally {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
}
if (!finalPlan) {
throw new Error('小财管家流式结果缺少最终任务计划。')
}
return finalPlan
}
async function resolveStreamError(response) {
try {
const payload = await response.json()
return String(payload?.detail || payload?.message || '').trim() || '小财管家流式接口请求失败。'
} catch {
return '小财管家流式接口请求失败。'
}
}
function consumeNdjsonText(text, handlers) {
let finalPlan = null
String(text || '').split('\n').forEach((line) => {
const event = parseStreamLine(line)
if (!event) return
finalPlan = handleStreamEvent(event, handlers) || finalPlan
})
return finalPlan
}
function parseStreamLine(line) {
const normalized = String(line || '').trim()
if (!normalized) {
return null
}
return JSON.parse(normalized)
}
function handleStreamEvent(event, handlers) {
if (event.event === 'error') {
throw new Error(String(event.data?.message || '').trim() || '小财管家规划失败,请稍后重试。')
}
handlers.onEvent?.(event)
if (event.event === 'plan') {
return event.data
}
return null
}

View File

@@ -4,8 +4,14 @@ export const ASSISTANT_SCOPE_SESSION_APPLICATION = 'application'
export const ASSISTANT_SCOPE_SESSION_EXPENSE = 'expense' export const ASSISTANT_SCOPE_SESSION_EXPENSE = 'expense'
export const ASSISTANT_SCOPE_SESSION_APPROVAL = 'approval' export const ASSISTANT_SCOPE_SESSION_APPROVAL = 'approval'
export const ASSISTANT_SCOPE_SESSION_KNOWLEDGE = 'knowledge' export const ASSISTANT_SCOPE_SESSION_KNOWLEDGE = 'knowledge'
export const ASSISTANT_SCOPE_SESSION_STEWARD = 'steward'
const SESSION_SCOPE_CONFIG = { const SESSION_SCOPE_CONFIG = {
[ASSISTANT_SCOPE_SESSION_STEWARD]: {
label: '小财管家',
icon: 'mdi mdi-account-tie-outline',
scope: '多任务拆解、附件归集、申请助手和报销助手统一调度'
},
[ASSISTANT_SCOPE_SESSION_APPLICATION]: { [ASSISTANT_SCOPE_SESSION_APPLICATION]: {
label: '申请助手', label: '申请助手',
icon: 'mdi mdi-file-plus-outline', icon: 'mdi mdi-file-plus-outline',
@@ -105,6 +111,10 @@ export function inferAssistantScopeTarget(rawText, options = {}) {
const approvalMatched = APPROVAL_PATTERN.test(text) const approvalMatched = APPROVAL_PATTERN.test(text)
const knowledgeMatched = KNOWLEDGE_PATTERN.test(text) const knowledgeMatched = KNOWLEDGE_PATTERN.test(text)
if (applicationMatched && expenseMatched) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}
if (approvalMatched && /(待我审核|待审|审核意见|审批意见|审批通过|审批驳回|驳回|退回|审核中心|审批中心|处理意见)/.test(text)) { if (approvalMatched && /(待我审核|待审|审核意见|审批意见|审批通过|审批驳回|驳回|退回|审核中心|审批中心|处理意见)/.test(text)) {
return ASSISTANT_SCOPE_SESSION_APPROVAL return ASSISTANT_SCOPE_SESSION_APPROVAL
} }

View File

@@ -2,6 +2,7 @@ import {
ASSISTANT_SCOPE_SESSION_APPLICATION, ASSISTANT_SCOPE_SESSION_APPLICATION,
ASSISTANT_SCOPE_SESSION_EXPENSE, ASSISTANT_SCOPE_SESSION_EXPENSE,
ASSISTANT_SCOPE_SESSION_KNOWLEDGE, ASSISTANT_SCOPE_SESSION_KNOWLEDGE,
ASSISTANT_SCOPE_SESSION_STEWARD,
hasExpenseApplicationIntentSignal, hasExpenseApplicationIntentSignal,
hasReimbursementIntentSignal, hasReimbursementIntentSignal,
inferAssistantScopeTarget inferAssistantScopeTarget
@@ -63,6 +64,10 @@ export function resolveWorkbenchSessionTypeFromOntology(ontology, rawText, fallb
return fallback return fallback
} }
if (applicationSignal && reimbursementSignal) {
return ASSISTANT_SCOPE_SESSION_STEWARD
}
if (hasApplicationDocumentEntity(ontology)) { if (hasApplicationDocumentEntity(ontology)) {
return ASSISTANT_SCOPE_SESSION_APPLICATION return ASSISTANT_SCOPE_SESSION_APPLICATION
} }

View File

@@ -1,6 +1,12 @@
import briefcaseIcon from '../assets/workbench-icons/outline-briefcase.svg?raw' import briefcaseIcon from '../assets/workbench-icons/outline-briefcase.svg?raw'
import approvalIcon from '../assets/workbench-icons/outline-approval.svg?raw'
import budgetIcon from '../assets/workbench-icons/outline-budget.svg?raw'
import documentTextIcon from '../assets/workbench-icons/outline-document-text.svg?raw' import documentTextIcon from '../assets/workbench-icons/outline-document-text.svg?raw'
import expenseApplicationIcon from '../assets/workbench-icons/outline-expense-application.svg?raw'
import financeAnalysisIcon from '../assets/workbench-icons/outline-finance-analysis.svg?raw'
import paperAirplaneIcon from '../assets/workbench-icons/outline-paper-airplane.svg?raw' import paperAirplaneIcon from '../assets/workbench-icons/outline-paper-airplane.svg?raw'
import policyIcon from '../assets/workbench-icons/outline-policy.svg?raw'
import reimbursementIcon from '../assets/workbench-icons/outline-reimbursement.svg?raw'
import shoppingBagIcon from '../assets/workbench-icons/outline-shopping-bag.svg?raw' import shoppingBagIcon from '../assets/workbench-icons/outline-shopping-bag.svg?raw'
import truckIcon from '../assets/workbench-icons/outline-truck.svg?raw' import truckIcon from '../assets/workbench-icons/outline-truck.svg?raw'
import usersIcon from '../assets/workbench-icons/outline-users.svg?raw' import usersIcon from '../assets/workbench-icons/outline-users.svg?raw'
@@ -13,6 +19,12 @@ function prepareHeroiconMarkup(svgRaw) {
} }
export const workbenchIconMap = { export const workbenchIconMap = {
'expense-application': { markup: prepareHeroiconMarkup(expenseApplicationIcon), style: 'outline' },
'quick-reimbursement': { markup: prepareHeroiconMarkup(reimbursementIcon), style: 'outline' },
'budget-planning': { markup: prepareHeroiconMarkup(budgetIcon), style: 'outline' },
'quick-approval': { markup: prepareHeroiconMarkup(approvalIcon), style: 'outline' },
'finance-analysis': { markup: prepareHeroiconMarkup(financeAnalysisIcon), style: 'outline' },
'company-policy': { markup: prepareHeroiconMarkup(policyIcon), style: 'outline' },
hospitality: { markup: prepareHeroiconMarkup(usersIcon), style: 'outline' }, hospitality: { markup: prepareHeroiconMarkup(usersIcon), style: 'outline' },
travelDraft: { markup: prepareHeroiconMarkup(briefcaseIcon), style: 'outline' }, travelDraft: { markup: prepareHeroiconMarkup(briefcaseIcon), style: 'outline' },
receipts: { markup: prepareHeroiconMarkup(documentTextIcon), style: 'outline' }, receipts: { markup: prepareHeroiconMarkup(documentTextIcon), style: 'outline' },

View File

@@ -245,10 +245,14 @@ function buildProgressItems(ownedRequests) {
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据' const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中' const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中'
const isApplication = title.includes('申请') || (requestId || '').toUpperCase().startsWith('SQ') || (requestId || '').toUpperCase().startsWith('CL')
const documentTypeLabel = isApplication ? '申请单' : '报销单'
return { return {
id: requestId, id: requestId,
requestId, requestId,
title, title,
documentTypeLabel,
expenseTypeLabel: resolveExpenseCategory(request), expenseTypeLabel: resolveExpenseCategory(request),
amount: formatCurrency(request?.amount), amount: formatCurrency(request?.amount),
status, status,

View File

@@ -184,7 +184,41 @@
</div> </div>
</div> </div>
<div class="composer-row" :class="{ 'knowledge-mode': isKnowledgeSession }"> <div v-if="isStewardSession" class="composer-row steward-composer-row">
<div class="composer-leading-actions steward-composer-leading-actions">
<button
type="button"
class="tool-btn composer-side-btn"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="上传附件"
title="上传附件"
@click="triggerFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
</div>
<div class="composer-shell steward-composer-shell">
<div class="composer-shell-body">
<textarea
ref="composerTextareaRef"
v-model="composerDraft"
rows="1"
:placeholder="composerPlaceholder"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@input="handleComposerInput"
@keydown.enter.exact.prevent="handleComposerEnter"
@keydown.ctrl.enter.prevent="submitComposer"
/>
</div>
</div>
<button class="send-btn composer-side-btn" type="submit" :disabled="!canSubmit || reviewActionBusy || sessionSwitchBusy" aria-label="发送">
<i :class="submitting ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
</button>
</div>
<div v-else class="composer-row" :class="{ 'knowledge-mode': isKnowledgeSession }">
<div v-if="!isKnowledgeSession" class="composer-leading-actions"> <div v-if="!isKnowledgeSession" class="composer-leading-actions">
<button <button
type="button" type="button"

View File

@@ -184,6 +184,14 @@
<i class="mdi mdi-loading mdi-spin"></i> <i class="mdi mdi-loading mdi-spin"></i>
<span>{{ smartEntryRecognitionText }}</span> <span>{{ smartEntryRecognitionText }}</span>
</div> </div>
<div v-if="standardAdjustmentBusy" class="expense-recognition-banner standard-adjustment-banner">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在重新测算费用请稍候明细和合计会在后台完成后自动更新</span>
</div>
<div v-if="submitBusy" class="expense-recognition-banner submit-progress-banner">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在提交审批请稍候系统正在完成自动检测预算占用和审批流转</span>
</div>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -280,7 +288,10 @@
<template v-else> <template v-else>
<div v-if="item.hasStandardAdjustment" class="expense-adjusted-amount"> <div v-if="item.hasStandardAdjustment" class="expense-adjusted-amount">
<span class="expense-original-amount">{{ item.originalAmountDisplay || item.amount }}</span> <span class="expense-original-amount">{{ item.originalAmountDisplay || item.amount }}</span>
<strong class="expense-reimbursable-amount">{{ item.reimbursableAmountDisplay }}</strong> <strong class="expense-reimbursable-amount">
<span class="expense-reimbursable-label">职级测算</span>
{{ item.reimbursableAmountDisplay }}
</strong>
<em v-if="item.employeeAbsorbedAmountDisplay">自担 {{ item.employeeAbsorbedAmountDisplay }}</em> <em v-if="item.employeeAbsorbedAmountDisplay">自担 {{ item.employeeAbsorbedAmountDisplay }}</em>
</div> </div>
<strong v-else>{{ item.amount }}</strong> <strong v-else>{{ item.amount }}</strong>
@@ -754,6 +765,7 @@
:open="submitConfirmDialogOpen" :open="submitConfirmDialogOpen"
badge="提交确认" badge="提交确认"
badge-tone="warning" badge-tone="warning"
size="review"
:title="`确认提交 ${request.id} 吗?`" :title="`确认提交 ${request.id} 吗?`"
:description="submitConfirmDescription" :description="submitConfirmDescription"
cancel-text="返回核对" cancel-text="返回核对"
@@ -788,8 +800,9 @@
:open="riskOverrideDialogOpen" :open="riskOverrideDialogOpen"
badge="异常说明" badge="异常说明"
badge-tone="danger" badge-tone="danger"
size="review"
:title="`当前存在 ${submitRiskWarnings.length} 条需说明的风险`" :title="`当前存在 ${submitRiskWarnings.length} 条需说明的风险`"
description="请先补充异常说明后提交领导审批;也可以不填写说明,选择按职级最高可报销金额重新计算。" description="请回到费用明细的异常说明列补充原因后再提交;如果不补充说明,选择按职级最高可报销金额重新计算。"
cancel-text="返回整改" cancel-text="返回整改"
confirm-text="按职级标准重算" confirm-text="按职级标准重算"
busy-text="处理中..." busy-text="处理中..."
@@ -827,27 +840,10 @@
<strong>{{ currentSubmitRiskWarning.title }}</strong> <strong>{{ currentSubmitRiskWarning.title }}</strong>
</div> </div>
<p>{{ currentSubmitRiskWarning.risk }}</p> <p>{{ currentSubmitRiskWarning.risk }}</p>
<textarea
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
class="risk-note-editor-textarea"
rows="1"
maxlength="160"
placeholder="请说明原因,例如客户指定酒店、会议高峰、协议酒店满房等"
aria-label="异常说明"
@input="resizeExpenseNoteInput"
@keydown.enter="resizeExpenseNoteInput"
></textarea>
</article> </article>
<div class="risk-override-submit-row"> <div class="risk-override-guidance">
<button <strong>请在费用明细的异常说明列补充原因后再提交</strong>
class="risk-override-save-btn" <span>如果不补充说明可直接选择按职级标准重算超出标准的部分由员工自担</span>
type="button"
:disabled="riskOverrideBusy"
@click="confirmRiskOverrideReasons"
>
保存说明并继续提交
</button>
<span>不填写说明时系统会按职级最高报销标准重算金额</span>
</div> </div>
</div> </div>
</ConfirmDialog> </ConfirmDialog>

View File

@@ -15,6 +15,7 @@ import { useTravelReimbursementReviewDrawer } from './useTravelReimbursementRevi
import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSubmitComposer.js' import { useTravelReimbursementSubmitComposer } from './useTravelReimbursementSubmitComposer.js'
import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js' import { useTravelReimbursementReviewActions } from './useTravelReimbursementReviewActions.js'
import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js' import { useTravelReimbursementGuidedFlow } from './useTravelReimbursementGuidedFlow.js'
import { useStewardPlanFlow } from './useStewardPlanFlow.js'
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js' import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
import { import {
buildOperationFeedbackPayload, buildOperationFeedbackPayload,
@@ -24,6 +25,7 @@ import { recognizeOcrFiles } from '../../services/ocr.js'
import { fetchAgentRunDetail } from '../../services/agentAssets.js' import { fetchAgentRunDetail } from '../../services/agentAssets.js'
import { createOperationFeedback } from '../../services/operationFeedback.js' import { createOperationFeedback } from '../../services/operationFeedback.js'
import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js' import { deleteConversation, runOrchestrator } from '../../services/orchestrator.js'
import { fetchStewardPlan, fetchStewardPlanStream } from '../../services/steward.js'
import { renderMarkdown } from '../../utils/markdown.js' import { renderMarkdown } from '../../utils/markdown.js'
import { clearAssistantSessionSnapshot } from '../../utils/assistantSessionSnapshot.js' import { clearAssistantSessionSnapshot } from '../../utils/assistantSessionSnapshot.js'
import { import {
@@ -182,6 +184,7 @@ import {
SESSION_TYPE_APPROVAL, SESSION_TYPE_APPROVAL,
SESSION_TYPE_EXPENSE, SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE, SESSION_TYPE_KNOWLEDGE,
SESSION_TYPE_STEWARD,
canUseBudgetAssistantSession, canUseBudgetAssistantSession,
aiAvatar, aiAvatar,
buildExpenseIntentConfirmationMessage, buildExpenseIntentConfirmationMessage,
@@ -674,9 +677,12 @@ export default {
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE) const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION) const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION)
const isStewardSession = computed(() => activeSessionType.value === SESSION_TYPE_STEWARD)
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value)) const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
const assistantHeaderTitle = computed(() => '个人工作台') const assistantHeaderTitle = computed(() => (isStewardSession.value ? '小财管家' : '个人工作台'))
const assistantHeaderDescription = computed(() => '个人工作窗,一站式费控解决枢纽') const assistantHeaderDescription = computed(() =>
isStewardSession.value ? '统一财务任务编排入口' : '个人工作窗,一站式费控解决枢纽'
)
const { const {
flowRunId, flowRunId,
flowSteps, flowSteps,
@@ -747,6 +753,9 @@ export default {
showInsightPanel.value ? '隐藏详细信息' : '展开详细信息' showInsightPanel.value ? '隐藏详细信息' : '展开详细信息'
) )
const composerPlaceholder = computed(() => { const composerPlaceholder = computed(() => {
if (isStewardSession.value) {
return '例如申请7月2日去北京出差同时报销昨天交通费和6月3日上海出差费用。'
}
if (isKnowledgeSession.value) { if (isKnowledgeSession.value) {
return '例如:差旅住宿标准是什么?发票抬头不一致还能报销吗?' return '例如:差旅住宿标准是什么?发票抬头不一致还能报销吗?'
} }
@@ -1213,6 +1222,9 @@ export default {
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW) const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
const shortcuts = computed(() => { const shortcuts = computed(() => {
if (isStewardSession.value) {
return []
}
const accessibleModes = filterAssistantSessionModes(ASSISTANT_SESSION_MODE_OPTIONS, currentUser.value) const accessibleModes = filterAssistantSessionModes(ASSISTANT_SESSION_MODE_OPTIONS, currentUser.value)
const visibleModes = props.entrySource === 'budget' const visibleModes = props.entrySource === 'budget'
? accessibleModes.filter((mode) => mode.key === SESSION_TYPE_BUDGET) ? accessibleModes.filter((mode) => mode.key === SESSION_TYPE_BUDGET)
@@ -1416,6 +1428,7 @@ export default {
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('click', handleComposerDatePickerOutside) document.removeEventListener('click', handleComposerDatePickerOutside)
clearStewardThinkingTimers()
stopFlowRuntime() stopFlowRuntime()
stopAttachmentRuntime() stopAttachmentRuntime()
}) })
@@ -1518,6 +1531,27 @@ export default {
messages.value.splice(index, 1, nextMessage) messages.value.splice(index, 1, nextMessage)
} }
const { submitStewardPlan, clearStewardThinkingTimers } = useStewardPlanFlow({
activeSessionType,
attachedFiles,
composerDraft,
currentUser,
fileInputRef,
messages,
createMessage,
fetchStewardPlan,
fetchStewardPlanStream,
nextTick,
persistSessionState,
replaceMessage,
scrollToBottom,
adjustComposerTextareaHeight,
submitting,
reviewActionBusy,
sessionSwitchBusy,
toast
})
async function runShortcut(shortcut) { async function runShortcut(shortcut) {
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) { if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
if (shortcut.targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) { if (shortcut.targetSessionType === SESSION_TYPE_BUDGET && !canUseBudgetAssistantSession(currentUser.value)) {
@@ -1675,6 +1709,15 @@ export default {
scrollToBottom() scrollToBottom()
}) })
persistSessionState() persistSessionState()
if (actionPayload.auto_submit && carryText) {
await submitComposer({
rawText: carryText,
userText: action.label || '确认继续处理',
pendingText: '正在按确认内容继续处理...',
files: carryFiles,
skipScopeGuard: true
})
}
return return
} }
@@ -2406,6 +2449,9 @@ export default {
// submitting.value = true // submitting.value = true
// recognizeOcrFiles(files) // recognizeOcrFiles(files)
// submitting.value = false // submitting.value = false
if (isStewardSession.value && await submitStewardPlan(options)) {
return null
}
if (await handleGuidedComposerSubmit(options)) { if (await handleGuidedComposerSubmit(options)) {
return null return null
} }
@@ -2651,7 +2697,7 @@ export default {
return { return {
emit, messageItemUi, insightPanelUi, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection, emit, messageItemUi, insightPanelUi, ASSISTANT_DISPLAY_NAME, aiAvatar, userAvatar, fileInputRef, composerTextareaRef, messageListRef, composerDraft, composerDatePickerOpen, composerDateMode, composerSingleDate, composerRangeStartDate, composerRangeEndDate, composerBusinessTimeTags, composerCanApplyDateSelection,
toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText, toggleComposerDatePicker, closeComposerDatePicker, setComposerDateMode, handleComposerDateInputChange, removeComposerBusinessTimeTag, flowSteps, flowRunId, flowRefreshBusy, completedFlowStepCount, flowOverallStatusTone, flowOverallStatusText, flowTotalDurationText,
attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, hotKnowledgeQuestions, attachedFiles, composerFilesExpanded, visibleAttachedFiles, hiddenAttachedFileCount, submitting, sessionSwitchBusy, messages, currentInsight, linkedRequest, canSubmit, activeSessionType, isKnowledgeSession, isStewardSession, hotKnowledgeQuestions,
hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, assistantHeaderTitle, assistantHeaderDescription, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer, hasInsightPanelContent, showInsightPanel, insightPanelToggleLabel, assistantHeaderTitle, assistantHeaderDescription, composerPlaceholder, currentIntentLabel, canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewPanelScope, activeReviewFilePreviews, reviewDrawerMode, isReviewOverviewDrawer, isReviewDocumentDrawer, isReviewRiskDrawer, isReviewFlowDrawer,
reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument, reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS, reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,

View File

@@ -612,10 +612,10 @@ export default {
const submitConfirmDialogOpen = ref(false) const submitConfirmDialogOpen = ref(false)
const riskOverrideDialogOpen = ref(false) const riskOverrideDialogOpen = ref(false)
const riskOverrideBusy = ref(false) const riskOverrideBusy = ref(false)
const standardAdjustmentBusy = ref(false)
const riskOverrideIndex = ref(0) const riskOverrideIndex = ref(0)
const highlightedRiskCardId = ref('') const highlightedRiskCardId = ref('')
let highlightedRiskCardTimer = 0 let highlightedRiskCardTimer = 0
const riskOverrideReasons = reactive({})
const deleteBusy = ref(false) const deleteBusy = ref(false)
const deleteDialogOpen = ref(false) const deleteDialogOpen = ref(false)
const returnBusy = ref(false) const returnBusy = ref(false)
@@ -653,6 +653,8 @@ export default {
}) })
const detailNoteEditor = ref('') const detailNoteEditor = ref('')
const savingDetailNote = ref(false) const savingDetailNote = ref(false)
let standardAdjustmentTaskSeq = 0
let submitTaskSeq = 0
const request = computed(() => { const request = computed(() => {
const normalized = normalizeRequestForUi(props.request) const normalized = normalizeRequestForUi(props.request)
@@ -901,7 +903,6 @@ export default {
const actionBusy = computed(() => const actionBusy = computed(() =>
Boolean(savingExpenseId.value) Boolean(savingExpenseId.value)
|| submitBusy.value || submitBusy.value
|| riskOverrideBusy.value
|| deleteBusy.value || deleteBusy.value
|| returnBusy.value || returnBusy.value
|| approveBusy.value || approveBusy.value
@@ -935,6 +936,10 @@ export default {
Object.keys(expenseAttachmentMeta).forEach((key) => { Object.keys(expenseAttachmentMeta).forEach((key) => {
delete expenseAttachmentMeta[key] delete expenseAttachmentMeta[key]
}) })
standardAdjustmentTaskSeq += 1
standardAdjustmentBusy.value = false
submitTaskSeq += 1
submitBusy.value = false
closeAttachmentPreview() closeAttachmentPreview()
} }
pendingUploadExpenseId.value = '' pendingUploadExpenseId.value = ''
@@ -1776,17 +1781,6 @@ export default {
return return
} }
riskOverrideIndex.value = 0 riskOverrideIndex.value = 0
const activeIds = new Set(warnings.map((risk) => risk.id))
Object.keys(riskOverrideReasons).forEach((riskId) => {
if (!activeIds.has(riskId)) {
delete riskOverrideReasons[riskId]
}
})
warnings.forEach((risk) => {
if (typeof riskOverrideReasons[risk.id] !== 'string') {
riskOverrideReasons[risk.id] = ''
}
})
riskOverrideDialogOpen.value = true riskOverrideDialogOpen.value = true
} }
@@ -1824,105 +1818,43 @@ export default {
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length
} }
function mergeDetailNoteWithRiskOverride(appendix) { function confirmStandardAdjustment() {
const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value if (riskOverrideBusy.value || standardAdjustmentBusy.value) {
return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n') return
}
const claimId = String(request.value?.claimId || '').trim()
if (!claimId) {
toast('\u5f53\u524d\u8349\u7a3f\u7f3a\u5c11 claimId\uff0c\u6682\u65f6\u65e0\u6cd5\u6309\u804c\u7ea7\u6807\u51c6\u91cd\u7b97\u3002')
return
}
riskOverrideDialogOpen.value = false
standardAdjustmentBusy.value = true
const taskSeq = ++standardAdjustmentTaskSeq
toast('\u6b63\u5728\u540e\u53f0\u6309\u804c\u7ea7\u6807\u51c6\u91cd\u65b0\u6d4b\u7b97\u8d39\u7528\u3002')
void runStandardAdjustmentRecalculation(claimId, taskSeq)
} }
async function confirmRiskOverrideReasons() { async function runStandardAdjustmentRecalculation(claimId, taskSeq) {
if (riskOverrideBusy.value) {
return
}
const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim())
if (missingIndex >= 0) {
riskOverrideIndex.value = missingIndex
toast('请为每一条风险填写异常说明。')
return
}
const itemNoteGroups = new Map()
const claimLevelRisks = []
submitRiskWarnings.value.forEach((risk, index) => {
const reason = String(riskOverrideReasons[risk.id] || '').trim()
const item = resolveExpenseItemForRiskCard(risk)
if (item?.id) {
const currentGroup = itemNoteGroups.get(item.id) || { item, reasons: [] }
currentGroup.reasons.push(reason)
itemNoteGroups.set(item.id, currentGroup)
} else {
const title = String(risk.title || risk.label || '风险').trim()
claimLevelRisks.push(`异常说明:第${index + 1}${title}${reason}`)
}
})
riskOverrideBusy.value = true
try {
await Promise.all(
[...itemNoteGroups.entries()].map(([itemId, group]) => {
const existingNote = String(group.item?.itemNote || '').trim()
const nextNote = [
existingNote,
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
].filter(Boolean).join('\n')
return updateExpenseClaimItem(request.value.claimId, itemId, {
item_note: nextNote
})
})
)
itemNoteGroups.forEach((group, itemId) => {
const existingNote = String(group.item?.itemNote || '').trim()
const nextNote = [
existingNote,
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
].filter(Boolean).join('\n')
applyLocalExpenseItemPatch(itemId, {
itemNote: nextNote
})
})
if (claimLevelRisks.length) {
const appendix = claimLevelRisks.join('\n')
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
if (nextNote.length > 500) {
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
return
}
await updateExpenseClaim(request.value.claimId, {
reason: nextNote
})
detailNoteEditor.value = nextNote
}
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true
toast('异常说明已保存,可继续提交审批。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '异常说明保存失败,请稍后重试。')
} finally {
riskOverrideBusy.value = false
}
}
async function confirmStandardAdjustment() {
if (riskOverrideBusy.value) {
return
}
riskOverrideBusy.value = true
try { try {
const payload = await buildStandardAdjustmentPayload() const payload = await buildStandardAdjustmentPayload()
if (!payload.risks.length) { if (!payload.risks.length) {
toast('当前风险暂未匹配到可重算的费用明细,请先补充异常说明。') toast('\u5f53\u524d\u98ce\u9669\u6682\u672a\u5339\u914d\u5230\u53ef\u91cd\u7b97\u7684\u8d39\u7528\u660e\u7ec6\uff0c\u8bf7\u5148\u8865\u5145\u5f02\u5e38\u8bf4\u660e\u3002')
return
}
const response = await acceptExpenseClaimStandardAdjustment(claimId, payload)
if (taskSeq !== standardAdjustmentTaskSeq || String(request.value?.claimId || '').trim() !== claimId) {
return return
} }
const response = await acceptExpenseClaimStandardAdjustment(request.value.claimId, payload)
applyStandardAdjustmentResponse(response) applyStandardAdjustmentResponse(response)
riskOverrideDialogOpen.value = false toast('\u5df2\u6309\u804c\u7ea7\u6700\u9ad8\u62a5\u9500\u6807\u51c6\u91cd\u7b97\u5b9e\u9645\u62a5\u9500\u91d1\u989d\uff0c\u53ef\u7ee7\u7eed\u63d0\u4ea4\u5ba1\u6279\u3002')
submitConfirmDialogOpen.value = true
toast('已按职级最高报销标准重算实际报销金额。')
emit('request-updated', { claimId: request.value.claimId }) emit('request-updated', { claimId: request.value.claimId })
} catch (error) { } catch (error) {
toast(error?.message || '按职级标准重算失败,请稍后重试。') toast(error?.message || '\u6309\u804c\u7ea7\u6807\u51c6\u91cd\u7b97\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002')
} finally { } finally {
riskOverrideBusy.value = false if (taskSeq === standardAdjustmentTaskSeq) {
standardAdjustmentBusy.value = false
}
} }
} }
@@ -2375,6 +2307,11 @@ export default {
return return
} }
if (standardAdjustmentBusy.value) {
toast('费用正在按职级标准重新测算,完成后再提交审批。')
return
}
if (draftBlockingIssues.value.length) { if (draftBlockingIssues.value.length) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。') toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
return return
@@ -2396,7 +2333,7 @@ export default {
submitConfirmDialogOpen.value = false submitConfirmDialogOpen.value = false
} }
async function confirmSubmitRequest() { function confirmSubmitRequest() {
if (!request.value.claimId) { if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。') toast('当前草稿缺少 claimId暂时无法提交。')
submitConfirmDialogOpen.value = false submitConfirmDialogOpen.value = false
@@ -2409,34 +2346,57 @@ export default {
return return
} }
if (standardAdjustmentBusy.value) {
toast('费用正在按职级标准重新测算,完成后再提交审批。')
submitConfirmDialogOpen.value = false
return
}
if (draftBlockingIssues.value.length) { if (draftBlockingIssues.value.length) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。') toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
submitConfirmDialogOpen.value = false submitConfirmDialogOpen.value = false
return return
} }
const claimId = String(request.value.claimId || '').trim()
const documentNo = request.value.id
const isApplication = isApplicationDocument.value
submitBusy.value = true submitBusy.value = true
submitConfirmDialogOpen.value = false
const taskSeq = ++submitTaskSeq
toast('\u6b63\u5728\u540e\u53f0\u63d0\u4ea4\u5ba1\u6279\uff0c\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u66f4\u65b0\u5355\u636e\u72b6\u6001\u3002')
void runSubmitRequest(claimId, documentNo, isApplication, taskSeq)
}
async function runSubmitRequest(claimId, documentNo, isApplication, taskSeq) {
try { try {
const payload = await submitExpenseClaim(request.value.claimId) const payload = await submitExpenseClaim(claimId)
if (taskSeq !== submitTaskSeq || String(request.value?.claimId || '').trim() !== claimId) {
return
}
const claimStatus = String(payload?.status || '').trim().toLowerCase() const claimStatus = String(payload?.status || '').trim().toLowerCase()
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim() const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
if (claimStatus === 'submitted') { if (claimStatus === 'submitted') {
toast( toast(
isApplicationDocument.value isApplication
? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}` ? `${documentNo} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${request.value.id} 已提交审批${approvalStage ? `,当前节点:${approvalStage}` : '。'}` : `${documentNo} 已提交审批${approvalStage ? `,当前节点:${approvalStage}` : '。'}`
) )
} else if (claimStatus === 'supplement') { } else if (claimStatus === 'supplement') {
toast(`${request.value.id} 自动检测未通过,已转待补充。`) toast(`${documentNo} 自动检测未通过,已转待补充。`)
} else { } else {
toast(`${request.value.id} 提交结果已更新。`) toast(`${documentNo} 提交结果已更新。`)
} }
submitConfirmDialogOpen.value = false submitConfirmDialogOpen.value = false
emit('request-updated', { claimId: request.value.claimId }) emit('request-updated', { claimId })
} catch (error) { } catch (error) {
toast(error?.message || '提交审批失败,请稍后重试。') if (taskSeq === submitTaskSeq) {
toast(error?.message || '提交审批失败,请稍后重试。')
}
} finally { } finally {
submitBusy.value = false if (taskSeq === submitTaskSeq) {
submitBusy.value = false
}
} }
} }
@@ -2664,6 +2624,10 @@ export default {
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
standardAdjustmentTaskSeq += 1
standardAdjustmentBusy.value = false
submitTaskSeq += 1
submitBusy.value = false
if (highlightedRiskCardTimer) { if (highlightedRiskCardTimer) {
window.clearTimeout(highlightedRiskCardTimer) window.clearTimeout(highlightedRiskCardTimer)
highlightedRiskCardTimer = 0 highlightedRiskCardTimer = 0
@@ -2688,7 +2652,7 @@ export default {
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog, closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog, closeSmartEntryUploadDialog, closeRiskOverrideDialog, closeSmartEntryUploadDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest, closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmPayRequest, confirmRiskOverrideReasons, confirmStandardAdjustment, confirmSmartEntryUpload, confirmPayRequest, confirmStandardAdjustment, confirmSmartEntryUpload,
chooseSmartEntryFile, clearSmartEntryFile, chooseSmartEntryFile, clearSmartEntryFile,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion, currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning, currentSubmitRiskWarning,
@@ -2715,10 +2679,11 @@ export default {
resolveRiskCardDomId, isHighlightedRiskCard, resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel, returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
requiresApprovalOpinion, requiresApprovalOpinion,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId, saveDetailNote, savingDetailNote, savingExpenseId,
smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary, smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary,
smartEntryRecognitionBusy, smartEntryRecognitionText, smartEntryRecognitionBusy, smartEntryRecognitionText,
smartEntryUploadBusy, smartEntryUploadDialogOpen, smartEntryUploadInput, smartEntryUploadBusy, smartEntryUploadDialogOpen, smartEntryUploadInput,
standardAdjustmentBusy,
showAiAdvicePanel, showApplicationLeaderOpinion, showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showStageRiskAdvice, showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy, showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,

View File

@@ -0,0 +1,173 @@
import {
ASSISTANT_SCOPE_ACTION_SWITCH,
} from '../../utils/assistantSessionScope.js'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE
} from './travelReimbursementConversationModel.js'
const TASK_TYPE_LABELS = {
expense_application: '费用申请',
reimbursement: '费用报销'
}
const AGENT_LABELS = {
application_assistant: '申请助手',
reimbursement_assistant: '报销助手'
}
export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) {
const safeFiles = Array.isArray(files) ? files : []
return {
message: String(rawText || '').trim(),
user_id: String(currentUser.username || currentUser.name || 'anonymous').trim() || 'anonymous',
client_now_iso: new Date().toISOString(),
attachments: safeFiles.map((file) => ({
name: String(file?.name || '').trim(),
media_type: String(file?.type || '').trim()
})).filter((item) => item.name),
context_json: {
entry_source: 'workbench',
session_type: 'steward',
role_codes: Array.isArray(currentUser.roleCodes) ? currentUser.roleCodes : [],
username: currentUser.username || '',
name: currentUser.name || currentUser.username || '',
department_name: currentUser.departmentName || currentUser.department || '',
employee_grade: currentUser.grade || ''
}
}
}
export function normalizeStewardPlan(rawPlan = {}, options = {}) {
const visibleThinkingEventCount = Number.isFinite(options.visibleThinkingEventCount)
? Number(options.visibleThinkingEventCount)
: Number(rawPlan.visibleThinkingEventCount || rawPlan.visible_thinking_event_count || 0)
return {
planId: String(rawPlan.plan_id || rawPlan.planId || ''),
planStatus: String(rawPlan.plan_status || rawPlan.planStatus || ''),
summary: String(rawPlan.summary || ''),
visibleThinkingEventCount,
thinkingEvents: Array.isArray(rawPlan.thinking_events)
? rawPlan.thinking_events.map((item) => ({
eventId: String(item.event_id || item.eventId || ''),
stage: String(item.stage || ''),
title: String(item.title || ''),
content: String(item.content || ''),
status: String(item.status || 'completed')
}))
: [],
tasks: Array.isArray(rawPlan.tasks)
? rawPlan.tasks.map((item) => ({
taskId: String(item.task_id || item.taskId || ''),
taskType: String(item.task_type || item.taskType || ''),
taskTypeLabel: TASK_TYPE_LABELS[String(item.task_type || item.taskType || '')] || '财务任务',
assignedAgent: String(item.assigned_agent || item.assignedAgent || ''),
assignedAgentLabel: AGENT_LABELS[String(item.assigned_agent || item.assignedAgent || '')] || '财务助手',
title: String(item.title || ''),
summary: String(item.summary || ''),
status: String(item.status || ''),
confidence: Number(item.confidence || 0),
ontologyFields: item.ontology_fields || item.ontologyFields || {},
missingFields: Array.isArray(item.missing_fields || item.missingFields)
? item.missing_fields || item.missingFields
: [],
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true
}))
: [],
attachmentGroups: Array.isArray(rawPlan.attachment_groups)
? rawPlan.attachment_groups.map((item) => ({
groupId: String(item.group_id || item.groupId || ''),
targetTaskId: String(item.target_task_id || item.targetTaskId || ''),
scene: String(item.scene || ''),
sceneLabel: String(item.scene_label || item.sceneLabel || ''),
attachmentNames: Array.isArray(item.attachment_names || item.attachmentNames)
? item.attachment_names || item.attachmentNames
: [],
excludedAttachmentNames: Array.isArray(item.excluded_attachment_names || item.excludedAttachmentNames)
? item.excluded_attachment_names || item.excludedAttachmentNames
: [],
confidence: Number(item.confidence || 0),
rationale: String(item.rationale || ''),
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true
}))
: [],
confirmationGroups: Array.isArray(rawPlan.confirmation_groups)
? rawPlan.confirmation_groups
: []
}
}
export function buildStewardPlanMessageText(plan) {
const normalized = normalizeStewardPlan(plan)
const taskLines = normalized.tasks.map((task, index) =>
`${index + 1}. ${task.title || task.taskTypeLabel},交给${task.assignedAgentLabel}`
)
return [
'**小财管家已完成任务拆解。**',
'',
normalized.summary || `我识别到 ${normalized.tasks.length} 个待处理任务,请确认后继续执行。`,
'',
...taskLines
].join('\n')
}
export function buildStewardSuggestedActions(plan) {
const normalized = normalizeStewardPlan(plan)
const taskById = new Map(normalized.tasks.map((task) => [task.taskId, task]))
const groupById = new Map(normalized.attachmentGroups.map((group) => [group.groupId, group]))
return normalized.confirmationGroups.map((action) => {
const actionType = String(action.action_type || action.actionType || '').trim()
const taskId = String(action.target_task_id || action.targetTaskId || '').trim()
const groupId = String(action.attachment_group_id || action.attachmentGroupId || '').trim()
const task = taskById.get(taskId)
const group = groupById.get(groupId)
const targetSessionType = actionType === 'confirm_create_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE
return {
label: String(action.label || '确认继续处理'),
description: String(action.description || ''),
icon: actionType === 'confirm_create_application'
? 'mdi mdi-file-plus-outline'
: actionType === 'confirm_attachment_group'
? 'mdi mdi-folder-check-outline'
: 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: targetSessionType,
carry_text: buildStewardCarryText(actionType, task, group),
carry_files: actionType !== 'confirm_create_application',
auto_submit: true,
steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''),
steward_plan_id: normalized.planId
}
}
})
}
function buildStewardCarryText(actionType, task, group) {
if (actionType === 'confirm_attachment_group' && group) {
return [
`我确认将以下附件归集为${group.sceneLabel || '当前报销任务'},请继续整理报销核对信息。`,
`附件:${group.attachmentNames.join('、') || '待确认'}`,
group.excludedAttachmentNames.length
? `暂不归集:${group.excludedAttachmentNames.join('、')}`
: ''
].filter(Boolean).join('\n')
}
if (!task) {
return '我确认继续处理这项财务任务,请按现有流程核对信息。'
}
const fields = Object.entries(task.ontologyFields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => `${key}: ${value}`)
return [
`我确认处理“小财管家”识别的任务:${task.title || task.taskTypeLabel}`,
task.summary ? `任务摘要:${task.summary}` : '',
fields.length ? `本体字段:${fields.join('')}` : '',
task.missingFields.length ? `待补充字段:${task.missingFields.join('、')}` : '',
'请按现有流程生成核对结果,并在需要入库、绑定附件或提交审批前让我再次确认。'
].filter(Boolean).join('\n')
}

View File

@@ -16,8 +16,10 @@ export const SESSION_TYPE_APPLICATION = 'application'
export const SESSION_TYPE_APPROVAL = 'approval' export const SESSION_TYPE_APPROVAL = 'approval'
export const SESSION_TYPE_KNOWLEDGE = 'knowledge' export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
export const SESSION_TYPE_BUDGET = 'budget' export const SESSION_TYPE_BUDGET = 'budget'
export const SESSION_TYPE_STEWARD = 'steward'
export const ASSISTANT_SESSION_TYPES = [ export const ASSISTANT_SESSION_TYPES = [
SESSION_TYPE_STEWARD,
SESSION_TYPE_APPLICATION, SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE, SESSION_TYPE_EXPENSE,
SESSION_TYPE_APPROVAL, SESSION_TYPE_APPROVAL,
@@ -26,6 +28,12 @@ export const ASSISTANT_SESSION_TYPES = [
] ]
export const ASSISTANT_SESSION_MODE_OPTIONS = [ export const ASSISTANT_SESSION_MODE_OPTIONS = [
{
key: SESSION_TYPE_STEWARD,
label: '小财管家',
icon: 'mdi mdi-account-tie-outline',
description: '统一拆解多任务、归集附件,并调度申请助手和报销助手'
},
{ {
key: SESSION_TYPE_APPLICATION, key: SESSION_TYPE_APPLICATION,
label: '申请助手', label: '申请助手',
@@ -323,6 +331,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
pendingAttachmentAssociation: null, pendingAttachmentAssociation: null,
applicationPreview: null, applicationPreview: null,
budgetReport: null, budgetReport: null,
stewardPlan: null,
operationFeedback: null, operationFeedback: null,
...extras ...extras
} }
@@ -574,6 +583,21 @@ export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedR
})) }))
} }
if (normalizedSessionType === SESSION_TYPE_STEWARD) {
return [
{
label: '申请出差并报销票据',
prompt: '我想申请下周去北京出差,并报销昨天的交通费。',
icon: 'mdi mdi-account-tie-outline'
},
{
label: '归集多张附件',
prompt: '我上传了多张票据,请先帮我判断哪些属于差旅报销。',
icon: 'mdi mdi-folder-multiple-outline'
}
]
}
if (normalizedSessionType === SESSION_TYPE_APPLICATION) { if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
return APPLICATION_WELCOME_QUICK_ACTIONS return APPLICATION_WELCOME_QUICK_ACTIONS
} }
@@ -606,6 +630,18 @@ export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SE
].join('\n') ].join('\n')
} }
if (normalizedSessionType === SESSION_TYPE_STEWARD) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'**欢迎来到个人财务中心 · 小财管家。** 我会先拆解您的一句话多任务,归集附件,再把确认后的任务分派给申请助手或报销助手。',
'',
'业务范围:多任务识别、附件归集、确认点管理、申请助手和报销助手调度。创建单据、绑定附件和提交审批都会先让您确认。',
'',
'您可以一次性描述多个申请或报销事项,也可以先上传附件让我归集。'
].join('\n')
}
if (normalizedSessionType === SESSION_TYPE_APPLICATION) { if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
return [ return [
`${greeting}!今日是 **${ctx.dateLine}**。`, `${greeting}!今日是 **${ctx.dateLine}**。`,
@@ -678,6 +714,17 @@ export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SE
} }
} }
if (normalizedSessionType === SESSION_TYPE_STEWARD) {
return {
intent: 'welcome',
metricLabel: '当前入口',
metricValue: '小财管家',
title: '小财管家',
summary: `${ctx.honorific},这里会先拆解多任务和归集附件,再把确认后的事项交给申请助手或报销助手处理。`,
agent: null
}
}
if (normalizedSessionType === SESSION_TYPE_APPLICATION) { if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
return { return {
intent: 'welcome', intent: 'welcome',
@@ -890,6 +937,7 @@ export function serializeSessionMessages(messages) {
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null, pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
applicationPreview: message.applicationPreview || null, applicationPreview: message.applicationPreview || null,
budgetReport: message.budgetReport || null, budgetReport: message.budgetReport || null,
stewardPlan: message.stewardPlan || null,
operationFeedback: message.operationFeedback || null, operationFeedback: message.operationFeedback || null,
assistantName: message.assistantName || '', assistantName: message.assistantName || '',
isWelcome: Boolean(message.isWelcome), isWelcome: Boolean(message.isWelcome),
@@ -913,6 +961,7 @@ export function hasMeaningfulSessionMessages(messages) {
|| message.draftPayload || message.draftPayload
|| message.applicationPreview || message.applicationPreview
|| message.budgetReport || message.budgetReport
|| message.stewardPlan
|| message.operationFeedback || message.operationFeedback
|| message.pendingAttachmentAssociation || message.pendingAttachmentAssociation
|| (Array.isArray(message.riskFlags) && message.riskFlags.length) || (Array.isArray(message.riskFlags) && message.riskFlags.length)

View File

@@ -12,6 +12,15 @@ function normalizeAmount(value) {
return Number.isFinite(amount) && amount > 0 ? amount : 0 return Number.isFinite(amount) && amount > 0 ? amount : 0
} }
function parseDayCount(value) {
const match = String(value || '').match(/\d{1,3}/)
if (!match) {
return 0
}
const days = Number(match[0])
return Number.isFinite(days) && days > 0 ? Math.max(1, Math.min(365, Math.floor(days))) : 0
}
export function buildCurrentStandardAdjustmentMap(request = {}, riskFlags = []) { export function buildCurrentStandardAdjustmentMap(request = {}, riskFlags = []) {
return buildStandardAdjustmentMap({ return buildStandardAdjustmentMap({
...request, ...request,
@@ -94,7 +103,20 @@ function resolveParsedStandardAmount(card, item) {
return candidates.length ? Math.max(...candidates) : null return candidates.length ? Math.max(...candidates) : null
} }
function extractRiskCardNightCount(card) { function extractRequestApplicationDays(request = {}) {
return parseDayCount(
request?.relatedApplication?.days
|| request?.application_days
|| request?.applicationDays
|| request?.days
)
}
function extractRiskCardNightCount(card, request = {}) {
const applicationDays = extractRequestApplicationDays(request)
if (applicationDays) {
return applicationDays
}
const corpus = [card?.risk, card?.summary, card?.suggestion, card?.title] const corpus = [card?.risk, card?.summary, card?.suggestion, card?.title]
.map(normalizeText) .map(normalizeText)
.join(' ') .join(' ')
@@ -112,7 +134,7 @@ async function resolveTravelStandardAmount({ card, item, request, calculateTrave
return null return null
} }
const grade = normalizeText(request?.employeeGrade || request?.profileGrade) const grade = normalizeText(request?.employeeGrade || request?.profileGrade)
const days = extractRiskCardNightCount(card) const days = extractRiskCardNightCount(card, request)
try { try {
const result = await calculateTravelReimbursement({ days, location, grade }) const result = await calculateTravelReimbursement({ days, location, grade })
const hotelAmount = Number(result?.hotel_amount ?? result?.hotelAmount) const hotelAmount = Number(result?.hotel_amount ?? result?.hotelAmount)
@@ -167,6 +189,7 @@ export async function buildStandardAdjustmentPayload({
item_id: item.id, item_id: item.id,
title: warning.title, title: warning.title,
risk: warning.risk || warning.summary, risk: warning.risk || warning.summary,
application_days: extractRiskCardNightCount(warning, request),
original_amount: originalAmount, original_amount: originalAmount,
reimbursable_amount: reimbursableAmount reimbursable_amount: reimbursableAmount
}) })

View File

@@ -0,0 +1,158 @@
import {
buildStewardPlanMessageText,
buildStewardPlanRequest,
buildStewardSuggestedActions,
normalizeStewardPlan
} from './stewardPlanModel.js'
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
export function useStewardPlanFlow({
activeSessionType,
attachedFiles,
composerDraft,
currentUser,
fileInputRef,
messages,
createMessage,
fetchStewardPlan,
fetchStewardPlanStream,
nextTick,
persistSessionState,
replaceMessage,
scrollToBottom,
adjustComposerTextareaHeight,
submitting,
reviewActionBusy,
sessionSwitchBusy,
toast
}) {
function isStewardSession() {
return String(activeSessionType.value || '').trim() === SESSION_TYPE_STEWARD
}
function clearStewardThinkingTimers() {
// 保留给页面卸载调用;流式版不再使用前端延时器。
}
async function submitStewardPlan(options = {}) {
if (!isStewardSession()) return false
if (submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return true
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
const files = Array.from(options.files ?? attachedFiles.value ?? [])
if (!rawText && !files.length) return true
const fileNames = files.map((file) => file.name).filter(Boolean)
const userText = String(options.userText || rawText || `我上传了 ${fileNames.length} 份附件,请小财管家先归集任务。`).trim()
submitting.value = true
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
const pendingPlan = normalizeStewardPlan({
plan_status: 'streaming',
summary: '',
thinking_events: []
})
const pendingMessage = createMessage('assistant', '', [], {
assistantName: '小财管家',
meta: ['小财管家', '流式分析中'],
stewardPlan: {
...pendingPlan,
streamStatus: 'streaming'
}
})
messages.value.push(pendingMessage)
composerDraft.value = ''
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
try {
const requestPayload = buildStewardPlanRequest({
rawText,
files,
currentUser: currentUser.value || {}
})
const plan = await fetchPlanWithStreaming(pendingMessage.id, requestPayload)
const normalizedPlan = normalizeStewardPlan(plan, {
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER
})
replaceMessage(pendingMessage.id, createMessage('assistant', buildStewardPlanMessageText(plan), [], {
assistantName: '小财管家',
meta: ['小财管家', '等待确认'],
stewardPlan: {
...normalizedPlan,
streamStatus: 'completed'
},
suggestedActions: buildStewardSuggestedActions(plan)
}))
persistSessionState()
nextTick(scrollToBottom)
} catch (error) {
replaceMessage(pendingMessage.id, createMessage('assistant', error?.message || '小财管家规划失败,请稍后重试。', [], {
assistantName: '小财管家',
meta: ['小财管家', '规划失败']
}))
toast(error?.message || '小财管家规划失败,请稍后重试。')
persistSessionState()
} finally {
submitting.value = false
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
return true
}
function fetchPlanWithStreaming(messageId, requestPayload) {
if (typeof fetchStewardPlanStream === 'function') {
return fetchStewardPlanStream(requestPayload, {
onEvent: (event) => handleStreamEvent(messageId, event)
}, {
timeoutMs: 20000,
timeoutMessage: '小财管家任务规划超时,请稍后重试。'
})
}
return fetchStewardPlan(requestPayload, {
timeoutMs: 16000,
timeoutMessage: '小财管家任务规划超时,请稍后重试。'
})
}
function handleStreamEvent(messageId, event) {
if (event.event !== 'thinking') {
return
}
const message = messages.value.find((item) => item.id === messageId)
if (!message?.stewardPlan) return
const existingEvents = Array.isArray(message.stewardPlan.thinkingEvents)
? message.stewardPlan.thinkingEvents
: []
const normalizedPlan = normalizeStewardPlan({
...message.stewardPlan,
thinking_events: [...existingEvents, event.data]
}, {
visibleThinkingEventCount: existingEvents.length + 1
})
message.stewardPlan = {
...message.stewardPlan,
...normalizedPlan,
streamStatus: 'streaming'
}
persistSessionState()
nextTick(scrollToBottom)
}
return {
isStewardSession,
submitStewardPlan,
clearStewardThinkingTimers
}
}

View File

@@ -16,6 +16,7 @@ import {
SESSION_TYPE_APPLICATION, SESSION_TYPE_APPLICATION,
SESSION_TYPE_BUDGET, SESSION_TYPE_BUDGET,
SESSION_TYPE_EXPENSE, SESSION_TYPE_EXPENSE,
SESSION_TYPE_STEWARD,
buildInitialInsightFromConversation, buildInitialInsightFromConversation,
buildWelcomeInsight, buildWelcomeInsight,
buildWelcomeQuickActions, buildWelcomeQuickActions,
@@ -35,6 +36,15 @@ import {
normalizeGuidedFlowState normalizeGuidedFlowState
} from './travelReimbursementGuidedFlowModel.js' } from './travelReimbursementGuidedFlowModel.js'
const STEWARD_IDLE_INSIGHT = {
intent: 'idle',
metricLabel: '',
metricValue: '',
title: '',
summary: '',
agent: null
}
export function useTravelReimbursementSessionState({ export function useTravelReimbursementSessionState({
props, props,
currentUser, currentUser,
@@ -79,6 +89,9 @@ export function useTravelReimbursementSessionState({
if (!Array.isArray(messages) || !messages.length) { if (!Array.isArray(messages) || !messages.length) {
return [] return []
} }
if (isStewardSessionType(sessionType)) {
return messages.filter((message) => !message?.isWelcome)
}
const currentActions = buildWelcomeQuickActions( const currentActions = buildWelcomeQuickActions(
sessionType, sessionType,
currentUser.value, currentUser.value,
@@ -92,6 +105,27 @@ export function useTravelReimbursementSessionState({
)) ))
} }
function isStewardSessionType(sessionType) {
return normalizeAssistantSessionType(sessionType) === SESSION_TYPE_STEWARD
}
function buildSessionMessages(restoredMessages, sessionType) {
if (Array.isArray(restoredMessages) && restoredMessages.length) {
return restoredMessages
}
if (isStewardSessionType(sessionType)) {
return []
}
return [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)]
}
function buildSessionInsight(sessionType) {
if (isStewardSessionType(sessionType)) {
return { ...STEWARD_IDLE_INSIGHT }
}
return buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value)
}
function buildConversationSessionState(conversation, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) { function buildConversationSessionState(conversation, fallbackSessionType = resolveDefaultSessionTypeFromEntry()) {
const sessionType = resolveAccessibleSessionType( const sessionType = resolveAccessibleSessionType(
resolveInitialSessionType(conversation, fallbackSessionType), resolveInitialSessionType(conversation, fallbackSessionType),
@@ -103,13 +137,12 @@ export function useTravelReimbursementSessionState({
return { return {
sessionType, sessionType,
messages: restoredMessages.length messages: buildSessionMessages(restoredMessages, sessionType),
? restoredMessages
: [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)],
conversationId: resolveInitialConversationId(conversation), conversationId: resolveInitialConversationId(conversation),
draftClaimId: resolveInitialDraftClaimId(conversation), draftClaimId: resolveInitialDraftClaimId(conversation),
currentInsight: currentInsight: isStewardSessionType(sessionType)
initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value), ? buildSessionInsight(sessionType)
: initialInsight || buildSessionInsight(sessionType),
reviewFilePreviews: restoredReviewFilePreviews, reviewFilePreviews: restoredReviewFilePreviews,
composerDraft: '', composerDraft: '',
attachedFiles: [], attachedFiles: [],
@@ -127,17 +160,10 @@ export function useTravelReimbursementSessionState({
) )
return { return {
sessionType: normalizedSessionType, sessionType: normalizedSessionType,
messages: [ messages: buildSessionMessages([], normalizedSessionType),
createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, normalizedSessionType, currentUser.value)
],
conversationId: '', conversationId: '',
draftClaimId: '', draftClaimId: '',
currentInsight: buildWelcomeInsight( currentInsight: buildSessionInsight(normalizedSessionType),
props.entrySource,
linkedRequest.value,
normalizedSessionType,
currentUser.value
),
reviewFilePreviews: [], reviewFilePreviews: [],
composerDraft: '', composerDraft: '',
attachedFiles: [], attachedFiles: [],
@@ -169,14 +195,12 @@ export function useTravelReimbursementSessionState({
return { return {
sessionType, sessionType,
messages: restoredMessages.length messages: buildSessionMessages(restoredMessages, sessionType),
? restoredMessages
: [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)],
conversationId: String(state.conversationId || '').trim(), conversationId: String(state.conversationId || '').trim(),
draftClaimId: String(state.draftClaimId || '').trim(), draftClaimId: String(state.draftClaimId || '').trim(),
currentInsight: currentInsight: isStewardSessionType(sessionType)
state.currentInsight ? buildSessionInsight(sessionType)
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value), : state.currentInsight || buildSessionInsight(sessionType),
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews), reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
composerDraft: String(state.composerDraft || ''), composerDraft: String(state.composerDraft || ''),
attachedFiles: [], attachedFiles: [],
@@ -301,26 +325,15 @@ export function useTravelReimbursementSessionState({
nextState.sessionType, nextState.sessionType,
resolveDefaultSessionTypeFromEntry() resolveDefaultSessionTypeFromEntry()
) )
messages.value = Array.isArray(nextState.messages) && nextState.messages.length messages.value = buildSessionMessages(
? nextState.messages refreshWelcomeQuickActions(nextState.messages, activeSessionType.value),
: [ activeSessionType.value
createWelcomeAssistantMessage( )
props.entrySource,
linkedRequest.value,
activeSessionType.value,
currentUser.value
)
]
conversationId.value = String(nextState.conversationId || '').trim() conversationId.value = String(nextState.conversationId || '').trim()
draftClaimId.value = String(nextState.draftClaimId || '').trim() draftClaimId.value = String(nextState.draftClaimId || '').trim()
currentInsight.value = currentInsight.value = isStewardSessionType(activeSessionType.value)
nextState.currentInsight ? buildSessionInsight(activeSessionType.value)
|| buildWelcomeInsight( : nextState.currentInsight || buildSessionInsight(activeSessionType.value)
props.entrySource,
linkedRequest.value,
activeSessionType.value,
currentUser.value
)
reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : [] reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : []
composerDraft.value = String(nextState.composerDraft || '') composerDraft.value = String(nextState.composerDraft || '')
if (runtimeRefs.attachedFiles) { if (runtimeRefs.attachedFiles) {

View File

@@ -26,6 +26,7 @@ import {
buildTravelReceiptMaterialPrompts buildTravelReceiptMaterialPrompts
} from '../src/views/scripts/travelRequestDetailAdviceModel.js' } from '../src/views/scripts/travelRequestDetailAdviceModel.js'
import { import {
buildStandardAdjustmentPayload,
filterSubmitterResolvedRiskCards filterSubmitterResolvedRiskCards
} from '../src/views/scripts/travelRequestDetailStandardAdjustment.js' } from '../src/views/scripts/travelRequestDetailStandardAdjustment.js'
@@ -712,6 +713,7 @@ test('expense detail table has per-item risk explanation column', () => {
test('expense detail shows standard-adjusted reimbursable amount separately from receipt amount', () => { test('expense detail shows standard-adjusted reimbursable amount separately from receipt amount', () => {
assert.match(detailViewTemplate, /v-if="item\.hasStandardAdjustment" class="expense-adjusted-amount"/) assert.match(detailViewTemplate, /v-if="item\.hasStandardAdjustment" class="expense-adjusted-amount"/)
assert.match(detailViewTemplate, /class="expense-original-amount"[\s\S]*item\.originalAmountDisplay/) assert.match(detailViewTemplate, /class="expense-original-amount"[\s\S]*item\.originalAmountDisplay/)
assert.match(detailViewTemplate, /class="expense-reimbursable-label"[\s\S]*职级测算/)
assert.match(detailViewTemplate, /class="expense-reimbursable-amount"[\s\S]*item\.reimbursableAmountDisplay/) assert.match(detailViewTemplate, /class="expense-reimbursable-amount"[\s\S]*item\.reimbursableAmountDisplay/)
assert.match(detailViewTemplate, /submitConfirmAmountDisplay/) assert.match(detailViewTemplate, /submitConfirmAmountDisplay/)
assert.match(detailViewStyle, /\.expense-original-amount[\s\S]*text-decoration-line: line-through/) assert.match(detailViewStyle, /\.expense-original-amount[\s\S]*text-decoration-line: line-through/)
@@ -753,6 +755,43 @@ test('expense detail shows standard-adjusted reimbursable amount separately from
assert.equal(item.hasStandardAdjustment, true) assert.equal(item.hasStandardAdjustment, true)
}) })
test('standard adjustment payload carries application days for policy recalculation', async () => {
const payload = await buildStandardAdjustmentPayload({
warnings: [
{
id: 'risk-hotel-days-1',
itemId: 'expense-item-days-1',
title: '住宿超标待说明',
risk: '住宿票据金额超过职级标准。'
}
],
expenseItems: [
{
id: 'expense-item-days-1',
itemType: 'hotel_ticket',
itemLocation: '北京',
itemAmount: 2000
}
],
request: {
relatedApplication: { days: '4天' },
employeeGrade: 'P4',
location: '北京'
},
calculateTravelReimbursement: async (query) => {
assert.equal(query.days, 4)
assert.equal(query.location, '北京')
assert.equal(query.grade, 'P4')
return { hotel_amount: 1800 }
}
})
assert.equal(payload.risks.length, 1)
assert.equal(payload.risks[0].application_days, 4)
assert.equal(payload.risks[0].original_amount, 2000)
assert.equal(payload.risks[0].reimbursable_amount, 1800)
})
test('plain reimbursable amount does not mark an item as standard-adjusted during detail rebuild', () => { test('plain reimbursable amount does not mark an item as standard-adjusted during detail rebuild', () => {
const item = buildExpenseItemViewModel( const item = buildExpenseItemViewModel(
{ {

View File

@@ -15,6 +15,10 @@ const detailExpenseModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailExpenseModel.js', import.meta.url)), fileURLToPath(new URL('../src/views/scripts/travelRequestDetailExpenseModel.js', import.meta.url)),
'utf8' 'utf8'
) )
const confirmDialogComponent = readFileSync(
fileURLToPath(new URL('../src/components/shared/ConfirmDialog.vue', import.meta.url)),
'utf8'
)
function extractFunction(source, name) { function extractFunction(source, name) {
let signatureIndex = source.indexOf(`function ${name}(`) let signatureIndex = source.indexOf(`function ${name}(`)
@@ -44,10 +48,12 @@ function extractFunction(source, name) {
test('detail submit opens a confirmation dialog before calling submit API', () => { test('detail submit opens a confirmation dialog before calling submit API', () => {
assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*:confirm-text="submitConfirmText"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/) assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*:confirm-text="submitConfirmText"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/)
assert.match(detailViewTemplate, /:open="submitConfirmDialogOpen"[\s\S]*size="review"/)
assert.match(detailViewTemplate, /cancel-text="返回核对"/) assert.match(detailViewTemplate, /cancel-text="返回核对"/)
assert.match(detailViewTemplate, /@click="handleSubmit"/) assert.match(detailViewTemplate, /@click="handleSubmit"/)
assert.match(detailViewScript, /const submitConfirmDialogOpen = ref\(false\)/) assert.match(detailViewScript, /const submitConfirmDialogOpen = ref\(false\)/)
assert.match(detailViewTemplate, /v-if="submitBusy" class="expense-recognition-banner submit-progress-banner"/)
assert.doesNotMatch(detailViewScript, /preReviewExpenseClaim\(request\.value\.claimId\)/) assert.doesNotMatch(detailViewScript, /preReviewExpenseClaim\(request\.value\.claimId\)/)
assert.match(detailViewScript, /const submitActionLabel = computed/) assert.match(detailViewScript, /const submitActionLabel = computed/)
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/) assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/)
@@ -60,28 +66,57 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest') const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
assert.doesNotMatch(handleSubmit, /submitExpenseClaim/) assert.doesNotMatch(handleSubmit, /submitExpenseClaim/)
assert.doesNotMatch(handleSubmit, /runAiPreReview\(\)/) assert.doesNotMatch(handleSubmit, /runAiPreReview\(\)/)
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/) assert.match(confirmSubmitRequest, /submitConfirmDialogOpen\.value = false[\s\S]*void runSubmitRequest\(claimId, documentNo, isApplication, taskSeq\)/)
assert.doesNotMatch(confirmSubmitRequest, /await /)
assert.doesNotMatch(confirmSubmitRequest, /submitExpenseClaim/)
const runSubmitRequest = extractFunction(detailViewScript, 'runSubmitRequest')
assert.match(runSubmitRequest, /submitExpenseClaim\(claimId\)/)
assert.match(runSubmitRequest, /taskSeq !== submitTaskSeq/)
}) })
test('detail submit warns on missing risk explanation and supports standard adjustment', () => { test('detail submit warns on missing risk explanation and supports standard adjustment', () => {
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/) assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/)
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"[\s\S]*size="review"/)
assert.match(detailViewTemplate, /异常说明/) assert.match(detailViewTemplate, /异常说明/)
assert.match(detailViewTemplate, /按职级标准重算/) assert.match(detailViewTemplate, /按职级标准重算/)
assert.match(detailViewTemplate, /保存说明并继续提交/) assert.match(detailViewTemplate, /class="risk-override-guidance"/)
assert.match(detailViewTemplate, /goToPreviousSubmitRisk/) assert.match(detailViewTemplate, /goToPreviousSubmitRisk/)
assert.match(detailViewTemplate, /goToNextSubmitRisk/) assert.match(detailViewTemplate, /goToNextSubmitRisk/)
assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/) assert.doesNotMatch(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/)
assert.doesNotMatch(detailViewTemplate, /risk-override-save-btn/)
assert.doesNotMatch(detailViewTemplate, /confirmRiskOverrideReasons/)
assert.match(detailViewScript, /const submitRiskWarnings = computed/) assert.match(detailViewScript, /const submitRiskWarnings = computed/)
const handleSubmit = extractFunction(detailViewScript, 'handleSubmit') const handleSubmit = extractFunction(detailViewScript, 'handleSubmit')
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest') const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
assert.match(handleSubmit, /submitRiskWarnings\.value\.length[\s\S]*openRiskOverrideDialog\(\)/) assert.match(handleSubmit, /submitRiskWarnings\.value\.length[\s\S]*openRiskOverrideDialog\(\)/)
assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/) assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/)
assert.match(detailViewScript, /function confirmRiskOverrideReasons\(\)/) assert.doesNotMatch(detailViewScript, /riskOverrideReasons/)
assert.match(detailViewScript, /updateExpenseClaimItem\(request\.value\.claimId, itemId,[\s\S]*item_note: nextNote/s) assert.doesNotMatch(detailViewScript, /function confirmRiskOverrideReasons\(\)/)
assert.match(detailViewScript, /function confirmStandardAdjustment\(\)/) assert.match(detailViewScript, /function confirmStandardAdjustment\(\)/)
assert.match(detailViewScript, /acceptExpenseClaimStandardAdjustment\(request\.value\.claimId, payload\)/) assert.match(detailViewTemplate, /v-if="standardAdjustmentBusy" class="expense-recognition-banner standard-adjustment-banner"/)
assert.match(detailViewScript, /const standardAdjustmentBusy = ref\(false\)/)
const confirmStandardAdjustment = extractFunction(detailViewScript, 'confirmStandardAdjustment')
assert.match(confirmStandardAdjustment, /const claimId = String\(request\.value\?\.claimId/)
assert.match(confirmStandardAdjustment, /riskOverrideDialogOpen\.value = false[\s\S]*standardAdjustmentBusy\.value = true[\s\S]*void runStandardAdjustmentRecalculation\(claimId, taskSeq\)/)
assert.doesNotMatch(confirmStandardAdjustment, /await /)
assert.doesNotMatch(confirmStandardAdjustment, /acceptExpenseClaimStandardAdjustment/)
const runStandardAdjustmentRecalculation = extractFunction(detailViewScript, 'runStandardAdjustmentRecalculation')
assert.match(runStandardAdjustmentRecalculation, /acceptExpenseClaimStandardAdjustment\(claimId, payload\)/)
assert.doesNotMatch(runStandardAdjustmentRecalculation, /submitConfirmDialogOpen\.value = true/)
const actionBusyStart = detailViewScript.indexOf('const actionBusy = computed')
const actionBusyEnd = detailViewScript.indexOf('const profile = computed', actionBusyStart)
assert.ok(actionBusyStart > -1 && actionBusyEnd > actionBusyStart)
assert.doesNotMatch(detailViewScript.slice(actionBusyStart, actionBusyEnd), /standardAdjustmentBusy/)
assert.match(detailExpenseModelScript, /STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'/) assert.match(detailExpenseModelScript, /STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'/)
assert.match(detailViewTemplate, /异常说明/) })
test('submit confirm dialog is constrained for laptop viewport height', () => {
assert.match(confirmDialogComponent, /max-height:\s*calc\(100vh - 40px\)/)
assert.match(confirmDialogComponent, /max-height:\s*calc\(100dvh - 40px\)/)
assert.match(confirmDialogComponent, /\.shared-confirm-body \{[\s\S]*min-height: 0;[\s\S]*overflow-y: auto;/)
assert.match(confirmDialogComponent, /\.shared-confirm-actions \{[\s\S]*flex: 0 0 auto;/)
assert.match(confirmDialogComponent, /\.shared-confirm-card--review \{[\s\S]*width: min\(560px, calc\(100vw - 40px\)\);/)
assert.match(confirmDialogComponent, /@media \(max-width: 720px\) \{[\s\S]*max-height: calc\(100dvh - 28px\)/)
}) })
test('detail header and fallback progress use reimbursement wording', () => { test('detail header and fallback progress use reimbursement wording', () => {