3 Commits

Author SHA1 Message Date
caoxiaozhu
0cda750ff0 feat(web): AI 工作台会话与文档卡片渲染增强
- aiConversationHtmlRenderer 识别单据记录类表格并渲染为卡片列表,新增删除申请单详情的禁用占位链接
- aiWorkbenchConversationStore 增加草稿删除后会话链接失效处理,避免点击已删除单据跳转
- aiApplicationPreviewActions 调整提交/草稿调用路径,PersonalWorkbenchAiMode 接入新的会话存储与渲染
- ConfirmDialog/TravelRequestDeleteDialog/useAppShell/AppShellRouteView 配套适配,同步更新相关前端测试
2026-06-20 21:44:16 +08:00
caoxiaozhu
81e990ab72 feat(server): 申请单支持草稿保存并统一删除权限口径
- user_agent_application 新增草稿分支:识别'保存草稿/存草稿/先保存'等意图,复用可编辑记录更新或建草稿,提交前单据重叠仍拦截
- 草稿态返回单号与待提交提示,submit 仅在确认提交分支触发,避免草稿进入审批流
- reimbursements 删除接口文案与判定统一为系统管理员可删、申请人删自有草稿/退回单,申请单判定改用 is_application_claim_no
- 更新财务规则表与 reimbursement 端点测试
2026-06-20 21:44:12 +08:00
caoxiaozhu
47c6a4bb73 refactor(server): 单号规则收紧为 A/R/D+8 位紧凑格式
- DOCUMENT_NUMBER_PREFIXES 改为 A/R/D,新增短格式与旧格式正则并存识别,提取正则加边界锚定避免误匹配
- build_document_number 去掉时间戳段,统一生成 A+token 等紧凑单号,is_application_claim_no 兼容旧 AP-/APP- 前缀
- access_policy/status_registry/reimbursements/expense_claims/budget_support 统一复用 is_application_claim_no 判定申请单
- 同步 document_numbering 单元测试覆盖新旧两种格式
2026-06-20 21:44:06 +08:00
34 changed files with 1032 additions and 155 deletions

View File

@@ -10,16 +10,17 @@ from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
from app.schemas.budget import BudgetClaimAnalysisRead from app.schemas.budget import BudgetClaimAnalysisRead
from app.schemas.common import ErrorResponse, PaginatedResponse from app.schemas.common import ErrorResponse, PaginatedResponse
from app.schemas.ontology import OntologyParseResult, OntologyPermission
from app.schemas.reimbursement import ( from app.schemas.reimbursement import (
ExpenseApplicationPreviewActionPayload, ExpenseApplicationPreviewActionPayload,
ExpenseApplicationPreviewActionResponse, ExpenseApplicationPreviewActionResponse,
ExpenseApplicationPreviewActionResult, ExpenseApplicationPreviewActionResult,
ExpenseClaimAttachmentActionResponse,
ExpenseClaimActionResponse, ExpenseClaimActionResponse,
ExpenseClaimAttachmentRead,
ExpenseClaimApprovalPayload, ExpenseClaimApprovalPayload,
ExpenseClaimItemCreate, ExpenseClaimAttachmentActionResponse,
ExpenseClaimAttachmentRead,
ExpenseClaimItemActionResponse, ExpenseClaimItemActionResponse,
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate, ExpenseClaimItemUpdate,
ExpenseClaimRead, ExpenseClaimRead,
ExpenseClaimReturnPayload, ExpenseClaimReturnPayload,
@@ -30,9 +31,9 @@ from app.schemas.reimbursement import (
TravelReimbursementCalculatorRequest, TravelReimbursementCalculatorRequest,
TravelReimbursementCalculatorResponse, TravelReimbursementCalculatorResponse,
) )
from app.schemas.ontology import OntologyParseResult, OntologyPermission
from app.schemas.user_agent import UserAgentRequest from app.schemas.user_agent import UserAgentRequest
from app.services.budget import BudgetService from app.services.budget import BudgetService
from app.services.document_numbering import is_application_claim_no
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.reimbursement import ReimbursementService from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
@@ -119,7 +120,10 @@ def _build_application_preview_action_context(
"/application-preview-action", "/application-preview-action",
response_model=ExpenseApplicationPreviewActionResponse, response_model=ExpenseApplicationPreviewActionResponse,
summary="按申请核对预览快速保存或提交申请单", summary="按申请核对预览快速保存或提交申请单",
description="用于 AI 工作台已完成表格核对后的轻量建单/提交流程,避免重复进入通用 Orchestrator 编排。", description=(
"用于 AI 工作台已完成表格核对后的轻量建单/提交流程,"
"避免重复进入通用 Orchestrator 编排。"
),
) )
def run_application_preview_action( def run_application_preview_action(
payload: ExpenseApplicationPreviewActionPayload, payload: ExpenseApplicationPreviewActionPayload,
@@ -831,7 +835,7 @@ def pay_expense_claim(
"/claims/{claim_id}", "/claims/{claim_id}",
response_model=ExpenseClaimActionResponse, response_model=ExpenseClaimActionResponse,
summary="删除报销单", summary="删除报销单",
description="申请人可删除自己的草稿、待补充或退回单据(含申请单和报销单);高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限", description="申请人可删除自己的草稿、待补充或退回单据(含申请单和报销单);系统管理员可删除单据;已归档单据仅系统管理员可删除。",
responses={ responses={
status.HTTP_404_NOT_FOUND: { status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse, "model": ErrorResponse,
@@ -855,7 +859,11 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
claim_no = str(claim.claim_no or "").strip() claim_no = str(claim.claim_no or "").strip()
expense_type = str(claim.expense_type or "").strip().lower() expense_type = str(claim.expense_type or "").strip().lower()
document_label = "申请单" if claim_no.upper().startswith(("AP-", "APP-")) or expense_type.endswith("_application") else "报销单" document_label = (
"申请单"
if is_application_claim_no(claim_no) or expense_type.endswith("_application")
else "报销单"
)
return ExpenseClaimActionResponse( return ExpenseClaimActionResponse(
message=f"{claim.claim_no} {document_label}已删除。", message=f"{claim.claim_no} {document_label}已删除。",
claim_id=claim.id, claim_id=claim.id,

View File

@@ -23,6 +23,7 @@ from app.services.budget_types import (
SUBJECT_CODE_ALIASES, SUBJECT_CODE_ALIASES,
SUPPORTED_BUDGET_SUBJECT_CODES, SUPPORTED_BUDGET_SUBJECT_CODES,
) )
from app.services.document_numbering import is_application_claim_no
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
from app.services.expense_type_keywords import resolve_expense_type_code_from_text from app.services.expense_type_keywords import resolve_expense_type_code_from_text
@@ -349,7 +350,11 @@ class BudgetSupportMixin:
def _reservation_source_type_from_claim(claim: ExpenseClaim) -> str: def _reservation_source_type_from_claim(claim: ExpenseClaim) -> str:
claim_no = str(claim.claim_no or "").strip().upper() claim_no = str(claim.claim_no or "").strip().upper()
expense_type = str(claim.expense_type or "").strip().lower() expense_type = str(claim.expense_type or "").strip().lower()
if claim_no.startswith(("AP-", "APP-")) or expense_type == "application" or expense_type.endswith("_application"): if (
is_application_claim_no(claim_no)
or expense_type == "application"
or expense_type.endswith("_application")
):
return "application" return "application"
return "claim" return "claim"

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
import secrets import secrets
from datetime import UTC, datetime from datetime import datetime
from typing import Callable, Literal from typing import Callable, Literal
from sqlalchemy import select from sqlalchemy import select
@@ -13,20 +13,29 @@ from app.models.financial_record import ExpenseClaim
DocumentNumberKind = Literal["application", "reimbursement", "audit"] DocumentNumberKind = Literal["application", "reimbursement", "audit"]
DOCUMENT_NUMBER_PREFIXES: dict[DocumentNumberKind, str] = { DOCUMENT_NUMBER_PREFIXES: dict[DocumentNumberKind, str] = {
"application": "AP", "application": "A",
"reimbursement": "RE", "reimbursement": "R",
"audit": "AD", "audit": "D",
} }
DOCUMENT_NUMBER_TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" DOCUMENT_NUMBER_TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
DOCUMENT_NUMBER_TOKEN_LENGTH = 8 DOCUMENT_NUMBER_TOKEN_LENGTH = 8
DOCUMENT_NUMBER_SHORT_BODY = (
rf"(?:A|R|D)[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
)
DOCUMENT_NUMBER_LEGACY_BODY = (
rf"(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
)
DOCUMENT_NUMBER_PATTERN = re.compile( DOCUMENT_NUMBER_PATTERN = re.compile(
rf"^(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}$", rf"^(?:{DOCUMENT_NUMBER_SHORT_BODY}|{DOCUMENT_NUMBER_LEGACY_BODY})$",
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
DOCUMENT_NUMBER_EXTRACT_PATTERN = re.compile( DOCUMENT_NUMBER_EXTRACT_PATTERN = re.compile(
rf"(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}" rf"(?<![A-Z0-9])(?:"
rf"{DOCUMENT_NUMBER_SHORT_BODY}"
rf"|{DOCUMENT_NUMBER_LEGACY_BODY}"
r"|APP-\d{8}-[A-Z0-9]{6}" r"|APP-\d{8}-[A-Z0-9]{6}"
r"|EXP-\d{6}-\d{3}", r"|EXP-\d{6}-\d{3}"
r")(?![A-Z0-9])",
flags=re.IGNORECASE, flags=re.IGNORECASE,
) )
@@ -45,16 +54,13 @@ def build_document_number(
token: str | None = None, token: str | None = None,
) -> str: ) -> str:
prefix = DOCUMENT_NUMBER_PREFIXES[kind] prefix = DOCUMENT_NUMBER_PREFIXES[kind]
generated_at = timestamp or datetime.now(UTC)
if generated_at.tzinfo is None:
generated_at = generated_at.replace(tzinfo=UTC)
normalized_token = (token or generate_document_token()).strip().upper() normalized_token = (token or generate_document_token()).strip().upper()
if not re.fullmatch( if not re.fullmatch(
rf"[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}", rf"[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
normalized_token, normalized_token,
): ):
raise ValueError("document number token must be 8 chars from the configured alphabet") raise ValueError("document number token must be 8 chars from the configured alphabet")
return f"{prefix}-{generated_at.astimezone(UTC):%Y%m%d%H%M%S}-{normalized_token}" return f"{prefix}{normalized_token}"
def generate_unique_expense_claim_no( def generate_unique_expense_claim_no(
@@ -83,4 +89,9 @@ def generate_unique_expense_claim_no(
def is_application_claim_no(value: object) -> bool: def is_application_claim_no(value: object) -> bool:
normalized = str(value or "").strip().upper() normalized = str(value or "").strip().upper()
return normalized.startswith(("AP-", "APP-")) return bool(
re.fullmatch(
rf"A[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
normalized,
)
) or normalized.startswith(("AP-", "APP-"))

View File

@@ -11,6 +11,7 @@ from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.role import Role from app.models.role import Role
from app.services.document_numbering import is_application_claim_no
from app.services.expense_claim_workflow_constants import ( from app.services.expense_claim_workflow_constants import (
APPLICATION_ARCHIVE_STAGE, APPLICATION_ARCHIVE_STAGE,
ARCHIVE_ACCOUNTING_STAGE, ARCHIVE_ACCOUNTING_STAGE,
@@ -42,6 +43,14 @@ class ExpenseClaimAccessPolicy:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self.db = db self.db = db
@staticmethod
def _build_application_claim_no_condition(claim_no: Any) -> Any:
return or_(
claim_no.like("AP-%"),
claim_no.like("APP-%"),
claim_no.like("A________"),
)
@staticmethod @staticmethod
def has_privileged_claim_access(current_user: CurrentUserContext) -> bool: def has_privileged_claim_access(current_user: CurrentUserContext) -> bool:
if current_user.is_admin: if current_user.is_admin:
@@ -61,8 +70,7 @@ class ExpenseClaimAccessPolicy:
normalized_type = func.lower(func.coalesce(ExpenseClaim.expense_type, "")) normalized_type = func.lower(func.coalesce(ExpenseClaim.expense_type, ""))
claim_no = func.upper(func.coalesce(ExpenseClaim.claim_no, "")) claim_no = func.upper(func.coalesce(ExpenseClaim.claim_no, ""))
application_condition = or_( application_condition = or_(
claim_no.like("AP-%"), ExpenseClaimAccessPolicy._build_application_claim_no_condition(claim_no),
claim_no.like("APP-%"),
normalized_type == "application", normalized_type == "application",
normalized_type.like("%\\_application", escape="\\"), normalized_type.like("%\\_application", escape="\\"),
) )
@@ -101,9 +109,9 @@ class ExpenseClaimAccessPolicy:
normalized_status = str(claim.status or "").strip().lower() normalized_status = str(claim.status or "").strip().lower()
stage = str(claim.approval_stage or "").strip() stage = str(claim.approval_stage or "").strip()
normalized_type = str(claim.expense_type or "").strip().lower() normalized_type = str(claim.expense_type or "").strip().lower()
claim_no = str(claim.claim_no or "").strip().upper() claim_no = str(claim.claim_no or "").strip()
is_application_claim = ( is_application_claim = (
claim_no.startswith(("AP-", "APP-")) is_application_claim_no(claim_no)
or normalized_type == "application" or normalized_type == "application"
or normalized_type.endswith("_application") or normalized_type.endswith("_application")
) )
@@ -715,8 +723,9 @@ class ExpenseClaimAccessPolicy:
"%\\_application", "%\\_application",
escape="\\", escape="\\",
), ),
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("AP-%"), ~self._build_application_claim_no_condition(
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("APP-%"), func.upper(func.coalesce(ExpenseClaim.claim_no, ""))
),
~self.build_archived_claim_condition(), ~self.build_archived_claim_condition(),
) )
conditions.append(company_reimbursement_condition) conditions.append(company_reimbursement_condition)

View File

@@ -14,6 +14,7 @@ from app.services.expense_claim_workflow_constants import (
PAYMENT_PAID_STAGE, PAYMENT_PAID_STAGE,
PAYMENT_PENDING_STAGE, PAYMENT_PENDING_STAGE,
) )
from app.services.document_numbering import is_application_claim_no
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -158,7 +159,7 @@ def is_application_claim_reference(
normalized_no = str(claim_no or "").strip().upper() normalized_no = str(claim_no or "").strip().upper()
normalized_type = str(expense_type or "").strip().lower() normalized_type = str(expense_type or "").strip().lower()
return ( return (
normalized_no.startswith(("AP-", "APP-")) is_application_claim_no(normalized_no)
or normalized_type == "application" or normalized_type == "application"
or normalized_type.endswith("_application") or normalized_type.endswith("_application")
) )

View File

@@ -862,16 +862,13 @@ class ExpenseClaimService(
if claim is None: if claim is None:
return None return None
if not self._access_policy.has_claim_delete_access(current_user):
raise ValueError("只有 admin 管理员可以删除单据。")
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin: if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。") raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
if not self._access_policy.has_claim_delete_access(current_user): if not self._access_policy.has_claim_delete_access(current_user):
self._ensure_draft_claim(claim) self._ensure_draft_claim(claim)
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user): if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
raise ValueError("只有高级财务人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充退回单据。") raise ValueError("只有系统管理员或草稿、待补充退回待提交阶段的申请人本人可以删除单据。")
before_json = self._serialize_claim(claim) before_json = self._serialize_claim(claim)
resource_id = claim.id resource_id = claim.id
@@ -1039,4 +1036,3 @@ class ExpenseClaimService(

View File

@@ -130,6 +130,12 @@ APPLICATION_SUBMIT_KEYWORDS = (
"确认无误提交", "确认无误提交",
"直接提交", "直接提交",
) )
APPLICATION_SAVE_DRAFT_KEYWORDS = (
"保存草稿",
"保存申请草稿",
"存草稿",
"先保存",
)
APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "", "好的", "可以", "没问题"} APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "", "好的", "可以", "没问题"}
APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "", "null", "none"} APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "", "null", "none"}
APPLICATION_DUPLICATE_IGNORED_STATUSES = { APPLICATION_DUPLICATE_IGNORED_STATUSES = {
@@ -197,29 +203,45 @@ class UserAgentApplicationMixin:
facts = self._resolve_expense_application_facts(payload) facts = self._resolve_expense_application_facts(payload)
step = self._resolve_expense_application_step(payload, facts) step = self._resolve_expense_application_step(payload, facts)
application_claim = None application_claim = None
if step == "submitted": if step in {"draft", "submitted"}:
editable_claim = self._find_editable_expense_application_record(payload) editable_claim = self._find_editable_expense_application_record(payload)
if editable_claim is not None: if editable_claim is not None:
application_claim = self._update_expense_application_record(payload, facts, editable_claim) application_claim = self._update_expense_application_record(
payload,
facts,
editable_claim,
submit=step == "submitted",
)
facts["application_edit_mode"] = "true" facts["application_edit_mode"] = "true"
else: elif step == "submitted":
application_claim = self._find_duplicate_expense_application_record(payload, facts) application_claim = self._find_duplicate_expense_application_record(payload, facts)
if application_claim is not None: if application_claim is not None:
step = "duplicate" step = "duplicate"
facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip() facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
else: else:
application_claim = self._create_expense_application_record(payload, facts) application_claim = self._create_expense_application_record(
facts["application_no"] = application_claim.claim_no payload,
facts["application_claim_id"] = application_claim.id facts,
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim) submit=True,
)
else:
application_claim = self._create_expense_application_record(
payload,
facts,
submit=False,
)
if application_claim is not None:
facts["application_no"] = application_claim.claim_no
facts["application_claim_id"] = application_claim.id
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
return UserAgentResponse( return UserAgentResponse(
answer=self._build_expense_application_answer(payload, facts=facts, step=step), answer=self._build_expense_application_answer(payload, facts=facts, step=step),
citations=[], citations=[],
suggested_actions=self._build_expense_application_actions(step, facts), suggested_actions=self._build_expense_application_actions(step, facts),
query_payload=None, query_payload=None,
draft_payload=( draft_payload=(
self._build_submitted_application_payload(application_claim, facts) self._build_persisted_application_payload(application_claim, facts)
if step == "submitted" if step in {"draft", "submitted"}
else None else None
), ),
review_payload=None, review_payload=None,
@@ -251,6 +273,17 @@ class UserAgentApplicationMixin:
] ]
) )
if step == "draft":
application_no = str(facts.get("application_no") or "").strip()
return "\n\n".join(
[
"申请草稿已保存。",
f"草稿单号:{application_no}" if application_no else "草稿单号:待生成",
"当前节点:待提交。",
"后续可进入单据详情继续核对、补充或提交审批。",
]
)
if step == "submitted": if step == "submitted":
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts) application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导" manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
@@ -534,6 +567,8 @@ class UserAgentApplicationMixin:
payload: UserAgentRequest, payload: UserAgentRequest,
facts: dict[str, str], facts: dict[str, str],
) -> str: ) -> str:
if self._is_application_save_draft_action(payload):
return "draft"
if self._resolve_application_missing_base_fields(facts): if self._resolve_application_missing_base_fields(facts):
return "ask_missing" return "ask_missing"
if self._resolve_application_missing_followup_fields(facts): if self._resolve_application_missing_followup_fields(facts):
@@ -1058,6 +1093,8 @@ class UserAgentApplicationMixin:
payload: UserAgentRequest, payload: UserAgentRequest,
facts: dict[str, str], facts: dict[str, str],
claim: ExpenseClaim, claim: ExpenseClaim,
*,
submit: bool,
) -> ExpenseClaim: ) -> ExpenseClaim:
current_user = self._build_application_current_user(payload) current_user = self._build_application_current_user(payload)
flags = claim.risk_flags_json flags = claim.risk_flags_json
@@ -1080,6 +1117,14 @@ class UserAgentApplicationMixin:
claim.occurred_at = self._parse_application_occurred_at(facts.get("time", "")) claim.occurred_at = self._parse_application_occurred_at(facts.get("time", ""))
claim.risk_flags_json = [*preserved_flags, self._build_application_detail_flag(facts)] claim.risk_flags_json = [*preserved_flags, self._build_application_detail_flag(facts)]
if not submit:
claim.status = "draft"
claim.approval_stage = "待提交"
claim.submitted_at = None
self.db.commit()
self.db.refresh(claim)
return claim
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user) submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
@@ -1091,6 +1136,8 @@ class UserAgentApplicationMixin:
self, self,
payload: UserAgentRequest, payload: UserAgentRequest,
facts: dict[str, str], facts: dict[str, str],
*,
submit: bool,
) -> ExpenseClaim: ) -> ExpenseClaim:
claim_no = self._build_application_claim_no(payload, facts) claim_no = self._build_application_claim_no(payload, facts)
existing = self.db.scalar( existing = self.db.scalar(
@@ -1130,22 +1177,23 @@ class UserAgentApplicationMixin:
currency="CNY", currency="CNY",
invoice_count=0, invoice_count=0,
occurred_at=self._parse_application_occurred_at(facts.get("time", "")), occurred_at=self._parse_application_occurred_at(facts.get("time", "")),
submitted_at=datetime.now(UTC), submitted_at=datetime.now(UTC) if submit else None,
status="submitted", status="submitted" if submit else "draft",
approval_stage="直属领导审批", approval_stage="直属领导审批" if submit else "待提交",
risk_flags_json=[self._build_application_detail_flag(facts)], risk_flags_json=[self._build_application_detail_flag(facts)],
) )
self.db.add(claim) self.db.add(claim)
self.db.flush() self.db.flush()
from app.services.expense_claims import ExpenseClaimService if submit:
from app.services.expense_claims import ExpenseClaimService
platform_review = ExpenseClaimService(self.db).evaluate_platform_risk_rules( platform_review = ExpenseClaimService(self.db).evaluate_platform_risk_rules(
claim, claim,
business_stage="expense_application", business_stage="expense_application",
) )
platform_flags = list(platform_review.get("flags") or []) platform_flags = list(platform_review.get("flags") or [])
if platform_flags: if platform_flags:
claim.risk_flags_json = [*list(claim.risk_flags_json or []), *platform_flags] claim.risk_flags_json = [*list(claim.risk_flags_json or []), *platform_flags]
self.db.commit() self.db.commit()
self.db.refresh(claim) self.db.refresh(claim)
return claim return claim
@@ -1382,7 +1430,7 @@ class UserAgentApplicationMixin:
return datetime(year, month, day, tzinfo=UTC) return datetime(year, month, day, tzinfo=UTC)
return datetime.now(UTC) return datetime.now(UTC)
def _build_submitted_application_payload( def _build_persisted_application_payload(
self, self,
claim: ExpenseClaim | None, claim: ExpenseClaim | None,
facts: dict[str, str], facts: dict[str, str],
@@ -1400,6 +1448,21 @@ class UserAgentApplicationMixin:
approval_stage=claim.approval_stage, approval_stage=claim.approval_stage,
) )
@staticmethod
def _is_application_save_draft_action(payload: UserAgentRequest) -> bool:
context_json = payload.context_json or {}
action = str(
context_json.get("application_action")
or context_json.get("applicationAction")
or ""
).strip().lower()
if action in {"save_draft", "application_save_draft", "draft"}:
return True
if bool(context_json.get("application_save_mode") or context_json.get("applicationSaveMode")):
return True
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
return any(keyword in compact_message for keyword in APPLICATION_SAVE_DRAFT_KEYWORDS)
def _is_application_submit_confirmation(self, payload: UserAgentRequest) -> bool: def _is_application_submit_confirmation(self, payload: UserAgentRequest) -> bool:
compact_message = re.sub(r"\s+", "", str(payload.message or "")) compact_message = re.sub(r"\s+", "", str(payload.message or ""))
if any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS): if any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS):

View File

@@ -11,6 +11,8 @@ from sqlalchemy.pool import StaticPool
from app.db.base import Base from app.db.base import Base
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.services.document_numbering import ( from app.services.document_numbering import (
DOCUMENT_NUMBER_EXTRACT_PATTERN,
DOCUMENT_NUMBER_PATTERN,
build_document_number, build_document_number,
generate_unique_expense_claim_no, generate_unique_expense_claim_no,
is_application_claim_no, is_application_claim_no,
@@ -32,19 +34,37 @@ def test_build_document_number_uses_kind_prefix_timestamp_and_token() -> None:
timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC) timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC)
assert ( assert (
build_document_number("application", timestamp=timestamp, token="ABCDEFGH") build_document_number("application", timestamp=timestamp, token="7K3M9Q2P")
== "AP-20260525103045-ABCDEFGH" == "A7K3M9Q2P"
) )
assert ( assert (
build_document_number("reimbursement", timestamp=timestamp, token="ABCDEFGH") build_document_number("reimbursement", timestamp=timestamp, token="7K3M9Q2P")
== "RE-20260525103045-ABCDEFGH" == "R7K3M9Q2P"
) )
assert ( assert (
build_document_number("audit", timestamp=timestamp, token="ABCDEFGH") build_document_number("audit", timestamp=timestamp, token="7K3M9Q2P")
== "AD-20260525103045-ABCDEFGH" == "D7K3M9Q2P"
) )
def test_document_number_pattern_accepts_short_and_legacy_numbers() -> None:
assert DOCUMENT_NUMBER_PATTERN.fullmatch("A7K3M9Q2P")
assert DOCUMENT_NUMBER_PATTERN.fullmatch("R7K3M9Q2P")
assert DOCUMENT_NUMBER_PATTERN.fullmatch("D7K3M9Q2P")
assert DOCUMENT_NUMBER_PATTERN.fullmatch("AP-20260525103045-ABCDEFGH")
assert DOCUMENT_NUMBER_PATTERN.fullmatch("RE-20260525103045-HGFEDCBA")
def test_document_number_extract_pattern_finds_short_and_legacy_numbers() -> None:
query = "查看 A7K3M9Q2P、R7K3M9Q2P 和 AP-20260525103045-ABCDEFGH 的状态"
assert [match.group(0) for match in DOCUMENT_NUMBER_EXTRACT_PATTERN.finditer(query)] == [
"A7K3M9Q2P",
"R7K3M9Q2P",
"AP-20260525103045-ABCDEFGH",
]
def test_build_document_number_rejects_ambiguous_token_chars() -> None: def test_build_document_number_rejects_ambiguous_token_chars() -> None:
timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC) timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC)
@@ -57,7 +77,7 @@ def test_generate_unique_expense_claim_no_retries_existing_candidate() -> None:
with build_session() as db: with build_session() as db:
db.add( db.add(
ExpenseClaim( ExpenseClaim(
claim_no="RE-20260525103045-ABCDEFGH", claim_no="RABCDEFGH",
employee_name="张三", employee_name="张三",
department_name="市场部", department_name="市场部",
project_code=None, project_code=None,
@@ -84,11 +104,13 @@ def test_generate_unique_expense_claim_no_retries_existing_candidate() -> None:
timestamp=timestamp, timestamp=timestamp,
token_factory=lambda: next(tokens), token_factory=lambda: next(tokens),
) )
== "RE-20260525103045-HGFEDCBA" == "RHGFEDCBA"
) )
def test_is_application_claim_no_supports_new_and_legacy_prefixes() -> None: def test_is_application_claim_no_supports_new_and_legacy_prefixes() -> None:
assert is_application_claim_no("A7K3M9Q2P")
assert is_application_claim_no("AP-20260525103045-ABCDEFGH") assert is_application_claim_no("AP-20260525103045-ABCDEFGH")
assert is_application_claim_no("APP-20260525-ABC123") assert is_application_claim_no("APP-20260525-ABC123")
assert not is_application_claim_no("R7K3M9Q2P")
assert not is_application_claim_no("RE-20260525103045-ABCDEFGH") assert not is_application_claim_no("RE-20260525103045-ABCDEFGH")

View File

@@ -20,7 +20,6 @@ from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.models.role import Role from app.models.role import Role
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
from app.services.expense_claims import ExpenseClaimService
from app.services.ocr import OcrService from app.services.ocr import OcrService
@@ -814,6 +813,37 @@ def test_claim_delete_allows_admin_and_cleans_risk_observations(monkeypatch, tmp
assert db.get(RiskObservationFeedback, "risk-observation-feedback-delete-1") is None assert db.get(RiskObservationFeedback, "risk-observation-feedback-delete-1") is None
def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
claim, _ = seed_claim(db)
claim.claim_no = "AP-20260620-DRAFT"
claim.expense_type = "travel_application"
claim_id = claim.id
db.commit()
response = client.delete(
f"/api/v1/reimbursements/claims/{claim_id}",
headers={
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "张三",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["claim_id"] == claim_id
assert payload["status"] == "deleted"
assert "申请单已删除" in payload["message"]
with session_factory() as db:
assert db.get(ExpenseClaim, claim_id) is None
def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(monkeypatch, tmp_path) -> None: def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path) monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
@@ -859,7 +889,19 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
"source": "user_message", "source": "user_message",
"user_id": "zhangsan@example.com", "user_id": "zhangsan@example.com",
"conversation_id": "conversation-fast-submit", "conversation_id": "conversation-fast-submit",
"message": "差旅费用申请提交审批\n申请类型:差旅费用申请\n申请时间2026-07-01 至 2026-07-03\n地点:北京\n事由:项目实施\n天数3天\n出行方式:火车\n申请金额1000元\n直接提交", "message": "\n".join(
[
"差旅费用申请提交审批",
"申请类型:差旅费用申请",
"申请时间2026-07-01 至 2026-07-03",
"地点:北京",
"事由:项目实施",
"天数3天",
"出行方式:火车",
"申请金额1000元",
"直接提交",
]
),
"context_json": { "context_json": {
"session_type": "application", "session_type": "application",
"entry_source": "workbench_ai_inline", "entry_source": "workbench_ai_inline",
@@ -899,3 +941,81 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
assert claim is not None assert claim is not None
assert claim.status == "submitted" assert claim.status == "submitted"
assert claim.employee_name == "张三" assert claim.employee_name == "张三"
def test_application_preview_action_saves_draft_with_detail_reference(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
client, session_factory = build_client()
with session_factory() as db:
seed_claim(db)
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers={
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E10001",
"x-auth-role-codes": "user",
},
json={
"source": "user_message",
"user_id": "zhangsan@example.com",
"conversation_id": "conversation-fast-save",
"message": "\n".join(
[
"费用申请保存草稿",
"申请类型:差旅费用申请",
"申请时间2026-07-04 至 2026-07-05",
"地点:上海",
"事由:项目验收",
"天数2天",
"出行方式:火车",
"申请金额800元",
"保存草稿",
]
),
"context_json": {
"session_type": "application",
"entry_source": "workbench_ai_inline",
"document_type": "expense_application",
"application_stage": "expense_application",
"application_action": "save_draft",
"application_save_mode": True,
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
"time": "2026-07-04 至 2026-07-05",
"location": "上海",
"reason": "项目验收",
"days": "2天",
"transportMode": "火车",
"amount": "800元",
"applicant": "张三",
"department": "市场部",
"position": "招商主管",
"grade": "P4",
"managerName": "李总",
}
},
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
draft_payload = payload["result"]["draft_payload"]
assert draft_payload["draft_type"] == "expense_application"
assert draft_payload["status"] == "draft"
assert draft_payload["approval_stage"] == "待提交"
assert draft_payload["claim_id"]
assert draft_payload["claim_no"].startswith("AP-")
with session_factory() as db:
claim = db.get(ExpenseClaim, draft_payload["claim_id"])
assert claim is not None
assert claim.status == "draft"
assert claim.approval_stage == "待提交"
assert claim.submitted_at is None
assert claim.employee_name == "张三"

View File

@@ -1321,6 +1321,8 @@
margin-top: 18px; margin-top: 18px;
border: 1px solid rgba(226, 232, 240, 0.9); border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 14px; border-radius: 14px;
background: #ffffff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
} }
.workbench-ai-answer-markdown :deep(table) { .workbench-ai-answer-markdown :deep(table) {
@@ -1342,6 +1344,123 @@
font-weight: 850; font-weight: 850;
} }
.workbench-ai-answer-markdown :deep(.ai-html-record-list) {
display: grid;
gap: 10px;
margin-top: 18px;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-item) {
display: grid;
grid-template-columns: minmax(220px, 1.15fr) minmax(260px, 0.85fr) auto;
gap: 16px;
align-items: center;
padding: 15px 16px;
border: 1px solid rgba(203, 213, 225, 0.86);
border-left: 3px solid #60a5fa;
border-radius: 14px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.9));
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.045);
}
.workbench-ai-answer-markdown :deep(.ai-html-record-main) {
min-width: 0;
display: grid;
gap: 5px;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-kicker) {
width: fit-content;
max-width: 100%;
padding: 2px 8px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.08);
color: #1d4ed8;
font-size: 12px;
font-weight: 850;
line-height: 1.35;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-id) {
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 15px;
font-weight: 860;
line-height: 1.45;
overflow-wrap: anywhere;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-reason) {
color: #475569;
font-size: 14px;
font-weight: 660;
line-height: 1.55;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-meta) {
min-width: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
gap: 10px;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-meta-item) {
min-width: 0;
display: grid;
gap: 2px;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-meta-item small) {
color: #94a3b8;
font-size: 12px;
font-weight: 760;
line-height: 1.3;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-meta-item b) {
color: #334155;
font-size: 14px;
font-weight: 780;
line-height: 1.45;
overflow-wrap: anywhere;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-action) {
justify-self: end;
display: inline-flex;
align-items: center;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-action .ai-html-action-link) {
min-height: 32px;
padding: 0 15px;
border-radius: 10px;
background: #2563eb;
color: #ffffff;
box-shadow: none;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-action .ai-html-action-link:hover) {
background: #1d4ed8;
color: #ffffff;
transform: translateY(-1px);
}
.workbench-ai-answer-markdown :deep(.ai-html-action-link.is-disabled) {
cursor: not-allowed;
pointer-events: none;
background: rgba(100, 116, 139, 0.14);
color: #64748b;
box-shadow: none;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-action .ai-html-action-link.is-disabled:hover) {
background: rgba(100, 116, 139, 0.14);
color: #64748b;
transform: none;
}
.workbench-ai-answer-markdown :deep(.ai-html-image-frame) { .workbench-ai-answer-markdown :deep(.ai-html-image-frame) {
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
@@ -1418,6 +1537,15 @@
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.workbench-ai-answer-markdown :deep(.ai-html-record-item) {
grid-template-columns: 1fr;
align-items: stretch;
}
.workbench-ai-answer-markdown :deep(.ai-html-record-action) {
justify-self: start;
}
.workbench-ai-answer-markdown :deep(.ai-document-card) { .workbench-ai-answer-markdown :deep(.ai-document-card) {
padding: 14px; padding: 14px;
} }

View File

@@ -1,7 +1,7 @@
.employee-risk-profile-card { .employee-risk-profile-card {
display: grid; display: grid;
gap: 12px; gap: 14px;
padding: 14px 16px; padding: 16px 18px;
} }
.employee-risk-head { .employee-risk-head {
@@ -74,17 +74,17 @@
.employee-risk-body { .employee-risk-body {
display: grid; display: grid;
gap: 12px; gap: 14px;
} }
.employee-risk-decision-panel { .employee-risk-decision-panel {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(220px, .85fr); grid-template-columns: minmax(0, 1fr) minmax(320px, .72fr);
align-items: stretch; align-items: stretch;
gap: 12px; gap: 18px;
padding: 12px; padding: 16px 18px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 2px; border-radius: 4px;
background: #ffffff; background: #ffffff;
} }
@@ -101,7 +101,9 @@
.employee-risk-decision-main { .employee-risk-decision-main {
min-width: 0; min-width: 0;
display: grid; display: grid;
gap: 4px; align-content: center;
gap: 8px;
padding: 4px 0;
} }
.employee-risk-decision-main > span, .employee-risk-decision-main > span,
@@ -117,7 +119,7 @@
.employee-risk-decision-main strong { .employee-risk-decision-main strong {
min-width: 0; min-width: 0;
color: #0f172a; color: #0f172a;
font-size: 15px; font-size: 16px;
font-weight: 900; font-weight: 900;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -143,10 +145,10 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
gap: 5px; gap: 7px;
padding: 10px 12px; padding: 13px 15px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 2px; border-radius: 4px;
background: #fff; background: #fff;
} }
@@ -175,20 +177,21 @@
} }
.employee-risk-review-summary { .employee-risk-review-summary {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px; gap: 10px;
margin: 0; margin: 0;
} }
.employee-risk-review-item { .employee-risk-review-item {
min-width: 0; min-width: 0;
flex: 1 1 180px;
display: grid; display: grid;
gap: 4px; align-content: start;
padding: 9px 10px; gap: 7px;
min-height: 66px;
padding: 12px 14px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 2px; border-radius: 4px;
background: #fff; background: #fff;
} }
@@ -232,10 +235,10 @@
.employee-risk-profile-section { .employee-risk-profile-section {
display: grid; display: grid;
gap: 8px; gap: 12px;
padding: 10px 12px; padding: 14px 16px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 2px; border-radius: 4px;
background: #fff; background: #fff;
} }
@@ -264,7 +267,7 @@
.employee-risk-profile-list { .employee-risk-profile-list {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 10px;
} }
.employee-risk-evidence-row { .employee-risk-evidence-row {
@@ -272,7 +275,7 @@
display: grid; display: grid;
gap: 0; gap: 0;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 2px; border-radius: 4px;
background: #f8fafc; background: #f8fafc;
overflow: hidden; overflow: hidden;
} }
@@ -301,14 +304,14 @@
} }
.employee-risk-evidence-title { .employee-risk-evidence-title {
min-height: 40px; min-height: 48px;
display: flex; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(72px, auto) 48px;
align-items: center; align-items: center;
justify-content: space-between; column-gap: 14px;
gap: 8px; padding: 10px 14px;
padding: 8px 10px;
color: #0f172a; color: #0f172a;
font-size: 11px; font-size: 12px;
font-weight: 850; font-weight: 850;
} }
@@ -319,10 +322,11 @@
.employee-risk-evidence-title strong { .employee-risk-evidence-title strong {
height: 20px; height: 20px;
flex: 0 0 auto; min-width: 48px;
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
padding: 0 6px; justify-self: center;
padding: 0 7px;
border-radius: 4px; border-radius: 4px;
background: #eef2f7; background: #eef2f7;
color: #475569; color: #475569;
@@ -343,10 +347,11 @@
.employee-risk-evidence-title::after { .employee-risk-evidence-title::after {
content: '展开'; content: '展开';
flex: 0 0 auto; justify-self: end;
color: #94a3b8; color: #94a3b8;
font-size: 10px; font-size: 10px;
font-weight: 800; font-weight: 800;
text-align: right;
} }
.employee-risk-evidence-row[open] .employee-risk-evidence-title::after { .employee-risk-evidence-row[open] .employee-risk-evidence-title::after {
@@ -355,9 +360,9 @@
.employee-risk-evidence-row ul { .employee-risk-evidence-row ul {
display: grid; display: grid;
gap: 3px; gap: 6px;
margin: 0; margin: 0;
padding: 0 10px 10px 10px; padding: 0 14px 14px 14px;
list-style: none; list-style: none;
align-content: start; align-content: start;
border-top: 1px solid #e2e8f0; border-top: 1px solid #e2e8f0;
@@ -366,8 +371,8 @@
.employee-risk-evidence-row li { .employee-risk-evidence-row li {
min-width: 0; min-width: 0;
color: #475569; color: #475569;
font-size: 11px; font-size: 12px;
line-height: 1.45; line-height: 1.58;
overflow-wrap: anywhere; overflow-wrap: anywhere;
white-space: normal; white-space: normal;
} }
@@ -383,8 +388,8 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.employee-risk-review-item { .employee-risk-review-summary {
flex-basis: 100%; grid-template-columns: 1fr;
} }
.employee-risk-title-wrap, .employee-risk-title-wrap,

View File

@@ -1140,8 +1140,11 @@ function canShowInlineSuggestedActions(message = {}) {
function isInlineSuggestedActionDisabled(action = {}, message = {}) { function isInlineSuggestedActionDisabled(action = {}, message = {}) {
const actionType = String(action?.action_type || '').trim() const actionType = String(action?.action_type || '').trim()
return ( return (
[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) && Boolean(action?.disabled) ||
isApplicationPreviewEstimatePending(message) (
[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) &&
isApplicationPreviewEstimatePending(message)
)
) )
} }
@@ -1267,8 +1270,21 @@ function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
} }
function buildInlineApplicationActionDetailHref(reference = '') { function buildInlineApplicationActionDetailHref(reference = '') {
const value = String(reference || '').trim() const source = reference && typeof reference === 'object' ? reference : { reference }
return value ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(value)}` : '' const claimId = String(source.claimId || source.claim_id || source.id || '').trim()
const claimNo = String(source.claimNo || source.claim_no || source.documentNo || source.document_no || '').trim()
const fallback = String(source.reference || '').trim()
if (claimId || claimNo) {
const params = new URLSearchParams()
if (claimId) {
params.set('claim_id', claimId)
}
if (claimNo) {
params.set('claim_no', claimNo)
}
return `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(params.toString())}`
}
return fallback ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(fallback)}` : ''
} }
function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) { function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
@@ -1293,7 +1309,7 @@ function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
function buildInlineApplicationResultTable(draftPayload = {}, options = {}) { function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
const info = resolveInlineApplicationActionDocumentInfo(draftPayload) const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
const reference = info.claimNo || info.claimId const reference = info.claimNo || info.claimId
const href = buildInlineApplicationActionDetailHref(reference) const href = buildInlineApplicationActionDetailHref(info)
const actionText = href ? `[查看](${href})` : '-' const actionText = href ? `[查看](${href})` : '-'
return [ return [
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |', '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
@@ -1945,22 +1961,40 @@ function parseAiApplicationDetailHref(href = '') {
if (!encodedReference) { if (!encodedReference) {
return null return null
} }
let reference = ''
try { try {
const reference = decodeURIComponent(encodedReference).trim() reference = decodeURIComponent(encodedReference).trim()
return reference ? { reference } : null
} catch { } catch {
return { reference: encodedReference } reference = encodedReference.trim()
} }
if (!reference) {
return null
}
const params = new URLSearchParams(reference)
const claimId = String(params.get('claim_id') || '').trim()
const claimNo = String(params.get('claim_no') || '').trim()
if (claimId || claimNo) {
return {
reference: claimNo || claimId,
claimId,
claimNo
}
}
return { reference }
} }
function buildAiDocumentDetailRequest(detailReference = {}) { function buildAiDocumentDetailRequest(detailReference = {}) {
const reference = String(detailReference.reference || '').trim() const reference = String(detailReference.reference || '').trim()
const isApplication = /^APP?-/i.test(reference) const claimId = String(detailReference.claimId || detailReference.claim_id || '').trim()
const claimNo = String(detailReference.claimNo || detailReference.claim_no || '').trim()
const lookupReference = claimId || reference
const displayReference = claimNo || reference
const isApplication = /^APP?-/i.test(displayReference) || Boolean(claimId || claimNo)
return { return {
id: reference, id: lookupReference,
claimId: reference, claimId: claimId || reference,
claimNo: reference, claimNo: claimNo || reference,
documentNo: reference, documentNo: displayReference,
documentType: isApplication ? 'application' : 'reimbursement', documentType: isApplication ? 'application' : 'reimbursement',
documentTypeCode: isApplication ? 'application' : 'reimbursement', documentTypeCode: isApplication ? 'application' : 'reimbursement',
detailLookupOnly: true, detailLookupOnly: true,
@@ -2371,7 +2405,11 @@ function handleInlineSuggestedAction(action = {}, sourceMessage = null) {
if (actionType === 'open_application_detail') { if (actionType === 'open_application_detail') {
const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim() const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim()
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim() const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
emit('open-document', buildAiDocumentDetailRequest({ reference: claimNo || claimId })) emit('open-document', buildAiDocumentDetailRequest({
reference: claimNo || claimId,
claimId,
claimNo
}))
return return
} }
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') { if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {

View File

@@ -321,6 +321,55 @@ function handleCancel() {
max-height: min(420px, calc(100dvh - 292px)); max-height: min(420px, calc(100dvh - 292px));
} }
.shared-confirm-card--destructive {
width: min(420px, calc(100vw - 40px));
gap: 12px;
padding: 20px 22px;
border-color: rgba(var(--danger-rgb), 0.16);
border-radius: 6px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(248, 250, 252, 0.97));
box-shadow:
0 18px 42px rgba(15, 23, 42, 0.16),
0 1px 0 rgba(255, 255, 255, 0.92) inset;
}
.shared-confirm-card--destructive .shared-confirm-badge {
min-height: 24px;
padding: 0 9px;
font-size: 11px;
font-weight: 850;
}
.shared-confirm-card--destructive h4 {
font-size: 19px;
line-height: 1.42;
font-weight: 850;
}
.shared-confirm-card--destructive p {
max-width: 34em;
color: #64748b;
font-size: 13px;
line-height: 1.65;
}
.shared-confirm-card--destructive .shared-confirm-actions {
gap: 8px;
padding-top: 2px;
}
.shared-confirm-card--destructive .shared-confirm-btn {
min-width: 112px;
min-height: 38px;
border-radius: 6px;
font-size: 13px;
}
.shared-confirm-card--destructive .shared-confirm-btn.confirm.danger {
box-shadow: 0 10px 20px rgba(var(--danger-rgb), 0.18);
}
.shared-confirm-card--compact h4 { .shared-confirm-card--compact h4 {
font-size: 15px; font-size: 15px;
line-height: 1.35; line-height: 1.35;

View File

@@ -5,6 +5,8 @@
badge-tone="danger" badge-tone="danger"
:title="title" :title="title"
:description="description" :description="description"
size="destructive"
actions-align="end"
cancel-text="取消" cancel-text="取消"
confirm-text="确认删除" confirm-text="确认删除"
busy-text="删除中..." busy-text="删除中..."

View File

@@ -8,6 +8,7 @@ import { useToast } from './useToast.js'
import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../services/reimbursements.js' import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../services/reimbursements.js'
import { fetchOntologyParse } from '../services/ontology.js' import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js' import { fetchLatestConversation } from '../services/orchestrator.js'
import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js' import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { import {
ASSISTANT_SCOPE_SESSION_STEWARD, ASSISTANT_SCOPE_SESSION_STEWARD,
@@ -607,6 +608,7 @@ export function useAppShell() {
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim() const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
if (deletedClaimId) { if (deletedClaimId) {
clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE) clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE)
markAiWorkbenchConversationDraftDeleted(currentUser.value || {}, payload)
smartEntryInvalidatedDraftClaimId.value = deletedClaimId smartEntryInvalidatedDraftClaimId.value = deletedClaimId
} }

View File

@@ -1,5 +1,4 @@
import { apiRequest } from './api.js' import { apiRequest } from './api.js'
import { runOrchestrator } from './orchestrator.js'
import { import {
buildApplicationPreviewRows, buildApplicationPreviewRows,
buildApplicationPreviewSubmitText, buildApplicationPreviewSubmitText,
@@ -128,19 +127,12 @@ export function buildAiApplicationPreviewActionPayload({
export function runAiApplicationPreviewAction(params = {}, options = {}) { export function runAiApplicationPreviewAction(params = {}, options = {}) {
const payload = buildAiApplicationPreviewActionPayload(params) const payload = buildAiApplicationPreviewActionPayload(params)
if (params.actionType === AI_APPLICATION_ACTION_SUBMIT) { const isSubmit = params.actionType === AI_APPLICATION_ACTION_SUBMIT
return apiRequest('/reimbursements/application-preview-action', { return apiRequest('/reimbursements/application-preview-action', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
timeoutMs: 45000, timeoutMs: isSubmit ? 45000 : 30000,
timeoutMessage: '申请提交处理超时,请稍后重试。', timeoutMessage: isSubmit ? '申请提交处理超时,请稍后重试。' : '申请草稿保存超时,请稍后重试。',
...options
})
}
return runOrchestrator(payload, {
timeoutMs: 75000,
timeoutMessage: '申请草稿保存超时,请稍后重试。',
...options ...options
}) })
} }

View File

@@ -21,6 +21,7 @@ const BUSINESS_FIELD_LABELS = new Set([
]) ])
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:' const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:' const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_' const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
@@ -62,6 +63,10 @@ function isApplicationDetailHref(href = '') {
return String(href || '').trim().startsWith(APPLICATION_DETAIL_HREF_PREFIX) return String(href || '').trim().startsWith(APPLICATION_DETAIL_HREF_PREFIX)
} }
function isDeletedApplicationDetailHref(href = '') {
return String(href || '').trim().startsWith(DELETED_APPLICATION_DETAIL_HREF_PREFIX)
}
function isDocumentDetailHref(href = '') { function isDocumentDetailHref(href = '') {
return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX) return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX)
} }
@@ -79,6 +84,17 @@ function sanitizeImageSrc(src = '') {
function renderLinkHtml(label = '', href = '') { function renderLinkHtml(label = '', href = '') {
const sanitizedHref = sanitizeHref(href) const sanitizedHref = sanitizeHref(href)
if (isDeletedApplicationDetailHref(href)) {
return [
'<span',
' class="ai-html-action-link ai-html-action-link-application is-disabled"',
' data-ai-action="deleted-application-detail"',
' aria-disabled="true"',
'>',
label,
'</span>'
].join('')
}
if (isApplicationDetailHref(href)) { if (isApplicationDetailHref(href)) {
return [ return [
`<a href="${sanitizedHref}"`, `<a href="${sanitizedHref}"`,
@@ -482,6 +498,80 @@ function renderOrderedList(items = []) {
].join('') ].join('')
} }
function normalizeTableHeaderCell(value = '') {
return String(value || '').replace(/\s+/g, '').trim()
}
function findTableColumnIndex(normalizedHeader = [], labels = []) {
return labels
.map((label) => normalizedHeader.indexOf(label))
.find((index) => index >= 0) ?? -1
}
function resolveTableCell(row = [], normalizedHeader = [], labels = []) {
const columnIndex = findTableColumnIndex(normalizedHeader, labels)
return columnIndex >= 0 ? String(row[columnIndex] || '').trim() : ''
}
function hasMeaningfulTableValue(value = '') {
const text = String(value || '').trim()
return Boolean(text && text !== '-')
}
function isDocumentRecordTable(normalizedHeader = []) {
return (
normalizedHeader.includes('单据编号') &&
normalizedHeader.includes('操作') &&
normalizedHeader.some((label) => ['单据类型', '申请时间', '单据状态', '状态', '当前节点', '事由'].includes(label))
)
}
function renderRecordMeta(label = '', value = '') {
if (!hasMeaningfulTableValue(value)) {
return ''
}
return [
'<span class="ai-html-record-meta-item">',
`<small>${escapeHtml(label)}</small>`,
`<b>${renderInlineHtml(value)}</b>`,
'</span>'
].join('')
}
function renderDocumentRecordList(header = [], bodyRows = []) {
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
const items = bodyRows.map((row) => {
const documentType = resolveTableCell(row, normalizedHeader, ['单据类型'])
const documentNo = resolveTableCell(row, normalizedHeader, ['单据编号'])
const applyTime = resolveTableCell(row, normalizedHeader, ['申请时间'])
const status = resolveTableCell(row, normalizedHeader, ['单据状态', '状态'])
const stage = resolveTableCell(row, normalizedHeader, ['当前节点'])
const reason = resolveTableCell(row, normalizedHeader, ['事由'])
const action = resolveTableCell(row, normalizedHeader, ['操作'])
return [
'<article class="ai-html-record-item" role="listitem">',
'<div class="ai-html-record-main">',
hasMeaningfulTableValue(documentType) ? `<span class="ai-html-record-kicker">${renderInlineHtml(documentType)}</span>` : '',
hasMeaningfulTableValue(documentNo) ? `<strong class="ai-html-record-id">${renderInlineHtml(documentNo)}</strong>` : '',
hasMeaningfulTableValue(reason) ? `<p class="ai-html-record-reason">${renderInlineHtml(reason)}</p>` : '',
'</div>',
'<div class="ai-html-record-meta">',
renderRecordMeta('申请时间', applyTime),
renderRecordMeta('状态', status),
renderRecordMeta('当前节点', stage),
'</div>',
hasMeaningfulTableValue(action) ? `<div class="ai-html-record-action">${renderInlineHtml(action)}</div>` : '',
'</article>'
].join('')
}).filter(Boolean)
return [
'<div class="ai-html-record-list" role="list">',
...items,
'</div>'
].join('')
}
function renderTable(lines = []) { function renderTable(lines = []) {
const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length) const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length)
if (rows.length < 2) { if (rows.length < 2) {
@@ -489,6 +579,10 @@ function renderTable(lines = []) {
} }
const header = rows[0] const header = rows[0]
const bodyRows = rows.slice(2) const bodyRows = rows.slice(2)
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
if (isDocumentRecordTable(normalizedHeader)) {
return renderDocumentRecordList(header, bodyRows)
}
return [ return [
'<div class="ai-html-table-wrap">', '<div class="ai-html-table-wrap">',

View File

@@ -1,11 +1,144 @@
const STORAGE_KEY_PREFIX = 'x-financial:workbench-ai-conversations' const STORAGE_KEY_PREFIX = 'x-financial:workbench-ai-conversations'
const MAX_CONVERSATION_HISTORY = 30 const MAX_CONVERSATION_HISTORY = 30
const MAX_STORED_MESSAGES = 80 const MAX_STORED_MESSAGES = 80
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
const APPLICATION_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-application-detail:[^)]+)\)/g
function safeString(value) { function safeString(value) {
return String(value || '').trim() return String(value || '').trim()
} }
function normalizeIdentifier(value) {
return safeString(value)
}
function collectDeletedDraftIdentifiers(payload = {}) {
return new Set([
payload.claimId,
payload.claim_id,
payload.id,
payload.claimNo,
payload.claim_no,
payload.documentNo,
payload.document_no
].map((item) => normalizeIdentifier(item)).filter(Boolean))
}
function decodeApplicationDetailHref(href = '') {
const value = safeString(href)
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
return []
}
const encodedReference = value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)
if (!encodedReference) {
return []
}
let reference = ''
try {
reference = decodeURIComponent(encodedReference).trim()
} catch {
reference = encodedReference.trim()
}
const identifiers = new Set([reference, encodedReference].map((item) => normalizeIdentifier(item)).filter(Boolean))
const params = new URLSearchParams(reference)
const detailParamKeys = ['claim_id', 'claim_no', 'document_no']
detailParamKeys.forEach((key) => {
const paramValue = normalizeIdentifier(params.get(key))
if (paramValue) {
identifiers.add(paramValue)
}
})
return [...identifiers]
}
function applicationDetailHrefMatchesDeletedDraft(href = '', identifiers = new Set()) {
return decodeApplicationDetailHref(href).some((item) => identifiers.has(item))
}
function buildDeletedApplicationDetailHref(href = '') {
const value = safeString(href)
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
return ''
}
return `${DELETED_APPLICATION_DETAIL_HREF_PREFIX}${value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)}`
}
function markApplicationDetailLinksDeleted(content = '', identifiers = new Set()) {
let changed = false
const nextContent = String(content || '').replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
if (!applicationDetailHrefMatchesDeletedDraft(href, identifiers)) {
return match
}
const deletedHref = buildDeletedApplicationDetailHref(href)
if (!deletedHref) {
return '草稿已删除'
}
changed = true
return `[草稿已删除](${deletedHref})`
})
return { content: nextContent, changed }
}
function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) {
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
return [
payload.claim_id,
payload.claimId,
payload.id,
payload.claim_no,
payload.claimNo,
payload.document_no,
payload.documentNo
].map((item) => normalizeIdentifier(item)).some((item) => item && identifiers.has(item))
}
function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) {
let changed = false
const nextActions = (Array.isArray(actions) ? actions : []).map((action) => {
if (String(action?.action_type || '').trim() !== 'open_application_detail') {
return action
}
if (!actionMatchesDeletedDraft(action, identifiers)) {
return action
}
changed = true
return {
...action,
label: '草稿已删除',
description: '草稿单据已经删除,请重新再次申请。',
icon: 'mdi mdi-trash-can-outline',
disabled: true,
action_type: 'deleted_application_detail'
}
})
return { actions: nextActions, changed }
}
function buildDraftDeletedMessage(payload = {}) {
const claimNo = safeString(payload.claimNo || payload.claim_no || payload.documentNo || payload.document_no)
return {
id: `draft-deleted-${safeString(payload.claimId || payload.claim_id || payload.id || claimNo) || Date.now()}`,
role: 'assistant',
content: [
`用户已经删除了草稿单据${claimNo ? ` ${claimNo}` : ''}`,
'草稿单据已经删除,请重新再次申请。'
].join('\n\n'),
feedback: '',
stewardPlan: null,
suggestedActions: []
}
}
function conversationHasDeletionNotice(messages = [], identifiers = new Set()) {
return messages.some((message) => {
const content = safeString(message?.content)
return content.includes('用户已经删除了草稿单据') && [...identifiers].some((item) => content.includes(item))
})
}
function resolveUserStorageKey(user = {}) { function resolveUserStorageKey(user = {}) {
const identity = safeString(user.username || user.email || user.name || 'anonymous') const identity = safeString(user.username || user.email || user.name || 'anonymous')
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}` return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
@@ -153,3 +286,46 @@ export function deleteAiWorkbenchConversation(user = {}, conversationId = '') {
writeStoredList(user, nextList) writeStoredList(user, nextList)
return loadAiWorkbenchConversationHistory(user) return loadAiWorkbenchConversationHistory(user)
} }
export function markAiWorkbenchConversationDraftDeleted(user = {}, payload = {}) {
const identifiers = collectDeletedDraftIdentifiers(payload)
if (!identifiers.size) {
return loadAiWorkbenchConversationHistory(user)
}
const nextList = readStoredList(user).map((conversation) => {
const normalized = normalizeConversation(conversation)
let conversationChanged = false
const messages = normalized.messages.map((message) => {
const contentResult = markApplicationDetailLinksDeleted(message.content, identifiers)
const actionsResult = markSuggestedActionsDeleted(message.suggestedActions, identifiers)
if (!contentResult.changed && !actionsResult.changed) {
return message
}
conversationChanged = true
return {
...message,
content: contentResult.content,
suggestedActions: actionsResult.actions
}
})
if (!conversationChanged) {
return normalized
}
if (!conversationHasDeletionNotice(messages, identifiers)) {
messages.push(buildDraftDeletedMessage(payload))
}
return {
...normalized,
desc: '草稿单据已经删除,请重新再次申请。',
messages,
updatedAt: Date.now()
}
})
writeStoredList(user, nextList)
return loadAiWorkbenchConversationHistory(user)
}

View File

@@ -154,7 +154,7 @@
@back-to-requests="closeRequestDetail" @back-to-requests="closeRequestDetail"
@open-assistant="openSmartEntry" @open-assistant="openSmartEntry"
@request-updated="handleRequestUpdated" @request-updated="handleRequestUpdated"
@request-deleted="handleRequestDeleted" @request-deleted="handleDetailRequestDeleted"
/> />
<section <section
@@ -460,6 +460,11 @@ function handleAiConversationHistoryChange(payload = []) {
aiConversationHistory.value = Array.isArray(payload) ? payload : [] aiConversationHistory.value = Array.isArray(payload) ? payload : []
} }
async function handleDetailRequestDeleted(payload = {}) {
await handleRequestDeleted(payload)
aiConversationHistory.value = loadAiWorkbenchConversationHistory(currentUser.value || {})
}
function handleAiConversationRename(payload = {}) { function handleAiConversationRename(payload = {}) {
const conversationId = String(payload.id || '').trim() const conversationId = String(payload.id || '').trim()
const title = String(payload.title || '').trim() const title = String(payload.title || '').trim()

View File

@@ -713,7 +713,19 @@ export default {
)) ))
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value)) const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value)) const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => isPlatformAdminUser(currentUser.value)) const isApplicantDeletableRequest = computed(() => {
if (!isCurrentApplicant.value) {
return false
}
const status = String(request.value.status || request.value.approvalKey || '').trim().toLowerCase()
return ['draft', 'supplement', 'returned'].includes(status)
})
const canDeleteRequest = computed(() => {
if (isPlatformAdminUser(currentUser.value)) {
return true
}
return isApplicantDeletableRequest.value
})
const isDirectManagerApprovalStage = computed(() => { const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim() const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批' return node === '直属领导审批'
@@ -926,11 +938,12 @@ export default {
} }
return isDraftRequest.value ? '删除草稿' : '删除单据' return isDraftRequest.value ? '删除草稿' : '删除单据'
}) })
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`) const deleteDialogTarget = computed(() => request.value.documentNo || request.value.id || '当前单据')
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value}吗?`)
const deleteDialogDescription = computed(() => const deleteDialogDescription = computed(() =>
isDraftRequest.value isDraftRequest.value
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。' ? `${deleteDialogTarget.value} 删除后该草稿及其当前费用明细将不可恢复`
: `删除后该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复,请确认本次操作` : `${deleteDialogTarget.value} 删除后${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复。`
) )
const actionBusy = computed(() => const actionBusy = computed(() =>
Boolean(savingExpenseId.value) Boolean(savingExpenseId.value)
@@ -2514,8 +2527,8 @@ export default {
isArchivedRequest.value isArchivedRequest.value
? '已归档单据不能删除,只有高级管理员可以执行删除。' ? '已归档单据不能删除,只有高级管理员可以执行删除。'
: isApplicationDocument.value : isApplicationDocument.value
? '当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。' ? '当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。'
: '当前单据已进入流程,只有高级财务人员可以删除。' : '当前单据已进入流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。'
) )
return return
} }
@@ -2542,7 +2555,11 @@ export default {
const payload = await deleteExpenseClaim(request.value.claimId) const payload = await deleteExpenseClaim(request.value.claimId)
deleteDialogOpen.value = false deleteDialogOpen.value = false
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`) toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`)
emit('request-deleted', { claimId: request.value.claimId }) emit('request-deleted', {
claimId: request.value.claimId,
claimNo: request.value.claimNo || request.value.documentNo || request.value.id,
documentNo: request.value.documentNo || request.value.id
})
} catch (error) { } catch (error) {
toast(error?.message || '删除单据失败,请稍后重试。') toast(error?.message || '删除单据失败,请稍后重试。')
} finally { } finally {

View File

@@ -56,15 +56,27 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
assert.equal(body.context_json.application_preview.fields.transportMode, '火车') assert.equal(body.context_json.application_preview.fields.transportMode, '火车')
} }
async function testSaveDraftActionKeepsOrchestratorPath() { async function testSaveDraftActionUsesFastPreviewEndpoint() {
let capturedUrl = '' let capturedUrl = ''
let capturedOptions = null
global.fetch = async (url) => { global.fetch = async (url, options) => {
capturedUrl = String(url) capturedUrl = String(url)
capturedOptions = options
return { return {
ok: true, ok: true,
async json() { async json() {
return { status: 'succeeded', result: {} } return {
status: 'succeeded',
result: {
draft_payload: {
claim_id: 'claim-fast-draft',
claim_no: 'AP-20260620-DRAFT',
status: 'draft',
approval_stage: '待提交'
}
}
}
} }
} }
} }
@@ -75,12 +87,17 @@ async function testSaveDraftActionKeepsOrchestratorPath() {
currentUser: { username: 'zhangsan@example.com', name: '张三' } currentUser: { username: 'zhangsan@example.com', name: '张三' }
}) })
assert.equal(capturedUrl, '/api/v1/orchestrator/run') assert.equal(capturedUrl, '/api/v1/reimbursements/application-preview-action')
assert.equal(capturedOptions.method, 'POST')
const body = JSON.parse(capturedOptions.body)
assert.equal(body.context_json.application_action, 'save_draft')
assert.equal(body.context_json.application_save_mode, true)
assert.equal(body.context_json.application_stage, 'expense_application')
} }
async function run() { async function run() {
await testSubmitActionUsesFastPreviewEndpoint() await testSubmitActionUsesFastPreviewEndpoint()
await testSaveDraftActionKeepsOrchestratorPath() await testSaveDraftActionUsesFastPreviewEndpoint()
console.log('ai-application-preview-actions tests passed') console.log('ai-application-preview-actions tests passed')
} }

View File

@@ -47,17 +47,49 @@ test('AI conversation renderer supports tables and escapes unsafe HTML', () => {
test('AI conversation renderer renders application detail action links as buttons', () => { test('AI conversation renderer renders application detail action links as buttons', () => {
const rendered = renderAiConversationHtml([ const rendered = renderAiConversationHtml([
'| 单据编号 | 操作 |', '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
'| --- | --- |', '| --- | --- | --- | --- | --- |',
'| AP-OVERLAP | [查看](#ai-open-application-detail:AP-OVERLAP) |' '| 出差申请 | AP-OVERLAP | 草稿 | 待提交 | [查看](#ai-open-application-detail:AP-OVERLAP) |'
].join('\n')) ].join('\n'))
assert.match(rendered, /<div class="ai-html-record-list" role="list">/)
assert.match(rendered, /<article class="ai-html-record-item" role="listitem">/)
assert.match(rendered, /<strong class="ai-html-record-id">AP-OVERLAP<\/strong>/)
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-application"/) assert.match(rendered, /class="ai-html-action-link ai-html-action-link-application"/)
assert.match(rendered, /data-ai-action="open-application-detail"/) assert.match(rendered, /data-ai-action="open-application-detail"/)
assert.match(rendered, /href="#ai-open-application-detail:AP-OVERLAP"/) assert.match(rendered, /href="#ai-open-application-detail:AP-OVERLAP"/)
assert.doesNotMatch(rendered, /<table>/)
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-application-detail/) assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-application-detail/)
}) })
test('AI conversation renderer renders deleted application detail actions as disabled buttons', () => {
const rendered = renderAiConversationHtml([
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
'| --- | --- | --- | --- | --- |',
'| 出差申请 | AP-20260620-DRAFT | 已删除 | 已删除 | [草稿已删除](#ai-deleted-application-detail:claim-draft-1) |'
].join('\n'))
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-application is-disabled"/)
assert.match(rendered, /aria-disabled="true"/)
assert.match(rendered, /data-ai-action="deleted-application-detail"/)
assert.doesNotMatch(rendered, /href="#ai-deleted-application-detail/)
})
test('AI conversation renderer turns application conflict tables into record lists', () => {
const rendered = renderAiConversationHtml([
'| 单据编号 | 申请时间 | 状态 | 事由 | 操作 |',
'| --- | --- | --- | --- | --- |',
'| AP-20260620063557-4JU2MWEF | 2026-02-20 至 2026-02-23 | 审批中 | 辅助国网仿生产服务器部署 | [查看](#ai-open-application-detail:AP-20260620063557-4JU2MWEF) |'
].join('\n'))
assert.match(rendered, /<div class="ai-html-record-list" role="list">/)
assert.match(rendered, /申请时间/)
assert.match(rendered, /2026-02-20 至 2026-02-23/)
assert.match(rendered, /辅助国网仿生产服务器部署/)
assert.match(rendered, /<div class="ai-html-record-action">/)
assert.doesNotMatch(rendered, /<table>/)
})
test('AI conversation renderer renders document detail action links as buttons', () => { test('AI conversation renderer renders document detail action links as buttons', () => {
const rendered = renderAiConversationHtml('[查看单据](#ai-open-document-detail:CL-20260221001)') const rendered = renderAiConversationHtml('[查看单据](#ai-open-document-detail:CL-20260221001)')

View File

@@ -3,6 +3,11 @@ import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import {
markAiWorkbenchConversationDraftDeleted,
loadAiWorkbenchConversationHistory,
saveAiWorkbenchConversation
} from '../src/utils/aiWorkbenchConversationStore.js'
import { import {
clearAssistantSessionSnapshotForDraftClaim, clearAssistantSessionSnapshotForDraftClaim,
readAssistantSessionSnapshot, readAssistantSessionSnapshot,
@@ -79,6 +84,42 @@ test('claim delete flow invalidates the matching financial assistant session', (
assert.match(createViewScript, /toast\('该草稿单据已删除,相关财务助手会话已清空。'\)/) assert.match(createViewScript, /toast\('该草稿单据已删除,相关财务助手会话已清空。'\)/)
}) })
test('deleting an application draft marks AI workbench detail links as unavailable', () => {
installWindowStub()
const user = { username: 'zhangsan@example.com' }
saveAiWorkbenchConversation(user, {
id: 'conversation-application-draft',
title: '申请草稿',
messages: [
{
id: 'assistant-draft-saved',
role: 'assistant',
content: [
'### 申请草稿已保存',
'',
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
'| --- | --- | --- | --- | --- |',
'| 出差申请 | AP-20260620-DRAFT | 草稿 | 待提交 | [查看](#ai-open-application-detail:claim_id%3Dclaim-draft-1%26claim_no%3DAP-20260620-DRAFT) |'
].join('\n')
}
]
})
const nextHistory = markAiWorkbenchConversationDraftDeleted(user, {
claimId: 'claim-draft-1',
claimNo: 'AP-20260620-DRAFT'
})
const conversation = nextHistory.find((item) => item.id === 'conversation-application-draft')
assert.ok(conversation)
assert.match(conversation.messages[0].content, /#ai-deleted-application-detail:/)
assert.doesNotMatch(conversation.messages[0].content, /#ai-open-application-detail:/)
assert.match(conversation.messages.at(-1).content, /用户已经删除了草稿单据 AP-20260620-DRAFT/)
assert.match(conversation.messages.at(-1).content, /草稿单据已经删除,请重新再次申请。/)
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
})
test('saving a draft keeps the financial assistant open for continued work', () => { test('saving a draft keeps the financial assistant open for continued work', () => {
const appShellScript = readFileSync( const appShellScript = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)), fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),

View File

@@ -322,11 +322,12 @@ test('stage risk advice card focuses on document risks without profile or budget
assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/) assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/)
assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/) assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/)
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/) assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1\.15fr\) minmax\(220px, \.85fr\);/) assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(320px, \.72fr\);/)
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-summary \{[\s\S]*display: flex;[\s\S]*flex-wrap: wrap;/) assert.match(stageRiskAdviceStyles, /\.employee-risk-review-summary \{[\s\S]*display: grid;[\s\S]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\);/)
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-item \{[\s\S]*flex: 1 1 180px;/) assert.match(stageRiskAdviceStyles, /\.employee-risk-review-item \{[\s\S]*min-height: 66px;/)
assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/) assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/)
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row summary \{[\s\S]*cursor: pointer;/) assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row summary \{[\s\S]*cursor: pointer;/)
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(72px, auto\) 48px;/)
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title::after \{[\s\S]*content: '展开';/) assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title::after \{[\s\S]*content: '展开';/)
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/) assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/)
assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/) assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)

View File

@@ -19,6 +19,10 @@ const confirmDialogComponent = readFileSync(
fileURLToPath(new URL('../src/components/shared/ConfirmDialog.vue', import.meta.url)), fileURLToPath(new URL('../src/components/shared/ConfirmDialog.vue', import.meta.url)),
'utf8' 'utf8'
) )
const deleteDialogComponent = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestDeleteDialog.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}(`)
@@ -138,6 +142,17 @@ test('submit confirm dialog is constrained for laptop viewport height', () => {
assert.match(confirmDialogComponent, /@media \(max-width: 720px\) \{[\s\S]*max-height: calc\(100dvh - 28px\)/) assert.match(confirmDialogComponent, /@media \(max-width: 720px\) \{[\s\S]*max-height: calc\(100dvh - 28px\)/)
}) })
test('delete request dialog uses a compact destructive confirmation layout', () => {
assert.match(deleteDialogComponent, /size="destructive"/)
assert.match(deleteDialogComponent, /actions-align="end"/)
assert.match(detailViewScript, /const deleteDialogTarget = computed\(\(\) => request\.value\.documentNo \|\| request\.value\.id \|\| '当前单据'\)/)
assert.match(detailViewScript, /const deleteDialogTitle = computed\(\(\) => `确认\$\{deleteActionLabel\.value\}吗?`\)/)
assert.doesNotMatch(detailViewScript, /const deleteDialogTitle = computed\(\(\) => `确认\$\{deleteActionLabel\.value\} \$\{request\.value\.id\} 吗?`\)/)
assert.match(confirmDialogComponent, /\.shared-confirm-card--destructive \{[\s\S]*width: min\(420px, calc\(100vw - 40px\)\);/)
assert.match(confirmDialogComponent, /\.shared-confirm-card--destructive h4 \{[\s\S]*font-size: 19px;/)
assert.match(confirmDialogComponent, /\.shared-confirm-card--destructive \.shared-confirm-btn \{[\s\S]*min-width: 112px;[\s\S]*min-height: 38px;/)
})
test('detail header and fallback progress use reimbursement wording', () => { test('detail header and fallback progress use reimbursement wording', () => {
assert.match(detailViewScript, /label:\s*'单据申请日期'/) assert.match(detailViewScript, /label:\s*'单据申请日期'/)
assert.match(detailExpenseModelScript, /label:\s*'关联单据'/) assert.match(detailExpenseModelScript, /label:\s*'关联单据'/)
@@ -145,15 +160,24 @@ test('detail header and fallback progress use reimbursement wording', () => {
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/) assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
}) })
test('detail delete action is gated by admin-only permission', () => { test('detail delete action allows admins or the applicant while the request is editable', () => {
assert.match(detailViewScript, /const canDeleteRequest = computed\(\(\) => isPlatformAdminUser\(currentUser\.value\)\)/) assert.match(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{/)
assert.match(detailViewScript, /if \(isPlatformAdminUser\(currentUser\.value\)\) \{[\s\S]*return true/)
assert.match(detailViewScript, /return isApplicantDeletableRequest\.value/)
assert.match(detailViewScript, /const isApplicantDeletableRequest = computed\(\(\) => \{/)
assert.match(detailViewScript, /isCurrentApplicant\.value/)
assert.match(detailViewScript, /\['draft', 'supplement', 'returned'\]\.includes\(status\)/)
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/) assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/)
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/) assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
}) })
test('detail delete action does not allow applicant or claim manager fallback', () => { test('detail delete action does not allow in-progress applicant or claim manager fallback', () => {
assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*isCurrentApplicant[\s\S]*\}\)/) const canDeleteStart = detailViewScript.indexOf('const canDeleteRequest = computed')
assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*canManageCurrentClaim[\s\S]*\}\)/) const canDeleteEnd = detailViewScript.indexOf('\n const isDirectManagerApprovalStage', canDeleteStart)
assert.ok(canDeleteStart >= 0)
assert.ok(canDeleteEnd > canDeleteStart)
const canDeleteBlock = detailViewScript.slice(canDeleteStart, canDeleteEnd)
assert.doesNotMatch(canDeleteBlock, /canManageCurrentClaim/)
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/) assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/)
assert.match(detailViewScript, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/) assert.match(detailViewScript, /当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。/)
}) })

View File

@@ -181,6 +181,9 @@ test('AI mode formats saved application draft as a detail table without continui
assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/) assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/)
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 操作 \|/) assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 操作 \|/)
assert.match(aiMode, /\[查看\]\(\$\{href\}\)/) assert.match(aiMode, /\[查看\]\(\$\{href\}\)/)
assert.match(aiMode, /buildInlineApplicationActionDetailHref\(info\)/)
assert.match(aiMode, /params\.set\('claim_id', claimId\)/)
assert.match(aiMode, /params\.set\('claim_no', claimNo\)/)
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText') const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart) const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)

View File

@@ -267,6 +267,18 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__meta\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__meta\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-action-link\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-action-link\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-list\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-item\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-meta\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-action \.ai-html-action-link\)/)
assert.match(
aiModeStyles,
/\.workbench-ai-answer-markdown :deep\(\.ai-html-record-item\)\s*\{[\s\S]*grid-template-columns:\s*minmax\(220px,\s*1\.15fr\)\s*minmax\(260px,\s*0\.85fr\)\s*auto;/
)
assert.match(
aiModeStyles,
/\.workbench-ai-answer-markdown :deep\(\.ai-html-record-action \.ai-html-action-link\)[\s\S]*background:\s*#2563eb;/
)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/)
assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/) assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/) assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/)

View File

@@ -37,6 +37,10 @@ test('workbench document detail keeps workbench as the return target', () => {
test('AI detail links wait for full document detail instead of rendering a half snapshot', () => { test('AI detail links wait for full document detail instead of rendering a half snapshot', () => {
assert.match(aiMode, /detailLookupOnly:\s*true/) assert.match(aiMode, /detailLookupOnly:\s*true/)
assert.match(aiMode, /params\.get\('claim_id'\)/)
assert.match(aiMode, /params\.get\('claim_no'\)/)
assert.match(aiMode, /claimId:\s*claimId \|\| reference/)
assert.match(aiMode, /claimNo:\s*claimNo \|\| reference/)
assert.match( assert.match(
appShell, appShell,
/v-else-if="activeView === 'documents' && detailMode && !selectedRequest"[\s\S]*正在加载完整单据详情/ /v-else-if="activeView === 'documents' && detailMode && !selectedRequest"[\s\S]*正在加载完整单据详情/