feat: 报销预审会话状态管理与工作台交互增强
- 新增差旅报销会话状态管理与对话模型重构 - 增强风险观测服务与运行时聊天上下文作用域 - 优化工作台图标资源、助理意图识别与摘要工具 - 完善报销创建视图样式与差旅详情页标准调整交互 - 补充风险观测、运行时聊天与报销端点测试覆盖
78
server/src/app/api/v1/endpoints/steward.py
Normal 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)),
|
||||||
|
)
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
90
server/src/app/schemas/steward.py
Normal 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="模型工具调用轨迹。")
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 [])
|
||||||
|
)
|
||||||
|
|||||||
@@ -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(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
18
server/src/app/services/steward_constants.py
Normal 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)
|
||||||
220
server/src/app/services/steward_intent_agent.py
Normal 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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
365
server/src/app/services/steward_model_plan_builder.py
Normal 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)
|
||||||
645
server/src/app/services/steward_planner.py
Normal 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()
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
214
server/tests/test_steward_planner.py
Normal 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"
|
||||||
BIN
web/src/assets/images/hero-3d-banner-v2.png
Normal file
|
After Width: | Height: | Size: 466 KiB |
BIN
web/src/assets/images/hero-3d-banner.png
Normal file
|
After Width: | Height: | Size: 466 KiB |
BIN
web/src/assets/images/hero-financial-decor.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
107
web/src/assets/images/hero-financial-decor.svg
Normal 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 |
BIN
web/src/assets/images/hero-white-glass-bg-v2.png
Normal file
|
After Width: | Height: | Size: 468 KiB |
BIN
web/src/assets/images/hero-white-glass-bg.png
Normal file
|
After Width: | Height: | Size: 468 KiB |
BIN
web/src/assets/images/raw_documents.png
Normal file
|
After Width: | Height: | Size: 591 KiB |
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
6
web/src/assets/workbench-icons/outline-approval.svg
Normal 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 |
5
web/src/assets/workbench-icons/outline-budget.svg
Normal 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 |
@@ -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 |
@@ -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 |
6
web/src/assets/workbench-icons/outline-policy.svg
Normal 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 |
6
web/src/assets/workbench-icons/outline-reimbursement.svg
Normal 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 |
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
173
web/src/views/scripts/stewardPlanModel.js
Normal 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')
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
158
web/src/views/scripts/useStewardPlanFlow.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||