2 Commits

Author SHA1 Message Date
caoxiaozhu
3b74a330a3 feat(web): AI 文档查询卡片重构与单号判定统一
- documentClassification 抽出 isApplicationDocumentNo,统一兼容 AP-/APP- 旧格式与 A+8 新格式,aiDocumentQueryModel 复用
- aiDocumentQueryModel 文档卡片改为结构化字段布局(单据类型/金额/申请人/编号/操作),新增查询范围摘要区,渲染走 HTML 信任块
- AppShellRouteView/useAppShell/useRequests/detailAlerts/riskVisibility 等差旅详情模型适配单号判定
- 同步更新 ai-document-query-model/workbench-ai-mode-switch 测试,新增 document-classification 测试
2026-06-20 22:04:37 +08:00
caoxiaozhu
8158716e23 test(server): 适配 A/R/D 紧凑单号格式
- approval_routing/service/user_agent 测试中报销单查询统一兼容 RE- 旧格式与 R+8 新格式,申请单单号断言改为短格式
- generate_claim_no 用例重命名为短前缀校验,正则改为 R[A-HJ-NP-Z2-9]{8}
- 同步更新差旅/交通/通信等财务规则表
2026-06-20 22:04:31 +08:00
26 changed files with 407 additions and 242 deletions

View File

@@ -5,7 +5,7 @@ from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine, or_
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -36,6 +36,15 @@ def build_session() -> Session:
return session_factory() return session_factory()
def reimbursement_claim_query(db: Session):
return db.query(ExpenseClaim).filter(
or_(
ExpenseClaim.claim_no.like("RE-%"),
ExpenseClaim.claim_no.like("R________"),
)
)
def _seed_budget_monitor_role(db: Session) -> Role: def _seed_budget_monitor_role(db: Session) -> Role:
role = Role(role_code="budget_monitor", name="预算管理员") role = Role(role_code="budget_monitor", name="预算管理员")
db.add(role) db.add(role)
@@ -149,7 +158,7 @@ def test_low_risk_application_skips_budget_manager_and_generates_draft() -> None
assert approved is not None assert approved is not None
assert approved.status == "approved" assert approved.status == "approved"
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1 assert reimbursement_claim_query(db).count() == 1
assert any( assert any(
isinstance(flag, dict) isinstance(flag, dict)
and flag.get("source") == "approval_routing" and flag.get("source") == "approval_routing"

View File

@@ -6,7 +6,7 @@ from datetime import UTC, date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine, or_
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
@@ -18,8 +18,8 @@ from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.role import Role from app.models.role import Role
from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.ontology import OntologyParseRequest
from app.schemas.reimbursement import ( from app.schemas.reimbursement import (
ExpenseClaimItemCreate, ExpenseClaimItemCreate,
ExpenseClaimItemUpdate, ExpenseClaimItemUpdate,
@@ -28,19 +28,19 @@ from app.schemas.reimbursement import (
) )
from app.services.agent_conversations import AgentConversationService from app.services.agent_conversations import AgentConversationService
from app.services.budget import BudgetService from app.services.budget import BudgetService
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
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.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
from app.services.expense_claim_workflow_constants import ( from app.services.expense_claim_workflow_constants import (
APPROVAL_DONE_STAGE,
APPLICATION_ARCHIVE_STAGE, APPLICATION_ARCHIVE_STAGE,
APPLICATION_LINK_STATUS_STAGE, APPLICATION_LINK_STATUS_STAGE,
APPROVAL_DONE_STAGE,
BUDGET_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE,
DIRECT_MANAGER_APPROVAL_STAGE, DIRECT_MANAGER_APPROVAL_STAGE,
FINANCE_APPROVAL_STAGE, FINANCE_APPROVAL_STAGE,
) )
from app.services.ontology import SemanticOntologyService from app.services.expense_claims import ExpenseClaimService
from app.services.ocr import OcrService from app.services.ocr import OcrService
from app.services.ontology import SemanticOntologyService
from app.services.receipt_folder import ReceiptFolderService from app.services.receipt_folder import ReceiptFolderService
@@ -121,6 +121,15 @@ def build_session() -> Session:
return session_factory() return session_factory()
def reimbursement_claim_query(db: Session):
return db.query(ExpenseClaim).filter(
or_(
ExpenseClaim.claim_no.like("RE-%"),
ExpenseClaim.claim_no.like("R________"),
)
)
def test_append_budget_flags_replaces_duplicate_budget_warning() -> None: def test_append_budget_flags_replaces_duplicate_budget_warning() -> None:
base_warning = { base_warning = {
"source": "budget_control", "source": "budget_control",
@@ -1770,7 +1779,7 @@ def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_
assert manual_returns == [return_flag] assert manual_returns == [return_flag]
def test_generate_claim_no_uses_re_prefix_timestamp_and_random_suffix() -> None: def test_generate_claim_no_uses_short_reimbursement_prefix_and_random_suffix() -> None:
with build_session() as db: with build_session() as db:
db.add_all( db.add_all(
[ [
@@ -1813,7 +1822,7 @@ def test_generate_claim_no_uses_re_prefix_timestamp_and_random_suffix() -> None:
service = ExpenseClaimService(db) service = ExpenseClaimService(db)
assert re.fullmatch( assert re.fullmatch(
r"RE-\d{14}-[A-HJ-NP-Z2-9]{8}", r"R[A-HJ-NP-Z2-9]{8}",
service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)), service._generate_claim_no(datetime(2026, 5, 14, tzinfo=UTC)),
) )
@@ -1831,7 +1840,7 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
db.flush() db.flush()
db.add( db.add(
ExpenseClaim( ExpenseClaim(
claim_no="RE-20260525101010-ABCDEFGH", claim_no="RABCDEFGH",
employee_name="历史单据", employee_name="历史单据",
department_name="财务部", department_name="财务部",
project_code=None, project_code=None,
@@ -1856,9 +1865,7 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
) )
) )
service = ExpenseClaimService(db) service = ExpenseClaimService(db)
generated_claim_nos = iter( generated_claim_nos = iter(["RABCDEFGH", "RHGFEDCBA"])
["RE-20260525101010-ABCDEFGH", "RE-20260525101010-HGFEDCBA"]
)
service._generate_claim_no = lambda occurred_at: next(generated_claim_nos) service._generate_claim_no = lambda occurred_at: next(generated_claim_nos)
result = service.upsert_draft_from_ontology( result = service.upsert_draft_from_ontology(
@@ -1874,8 +1881,8 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
created_claim = db.get(ExpenseClaim, result["claim_id"]) created_claim = db.get(ExpenseClaim, result["claim_id"])
assert created_claim is not None assert created_claim is not None
assert created_claim.claim_no == "RE-20260525101010-HGFEDCBA" assert created_claim.claim_no == "RHGFEDCBA"
assert result["claim_no"] == "RE-20260525101010-HGFEDCBA" assert result["claim_no"] == "RHGFEDCBA"
def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() -> None: def test_create_claim_item_adds_blank_draft_row_without_forcing_attachment() -> None:
@@ -5695,7 +5702,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
assert leader_approved is not None assert leader_approved is not None
assert leader_approved.status == "submitted" assert leader_approved.status == "submitted"
assert leader_approved.approval_stage == "预算管理者审批" assert leader_approved.approval_stage == "预算管理者审批"
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0 assert reimbursement_claim_query(db).count() == 0
assert any( assert any(
isinstance(flag, dict) isinstance(flag, dict)
and flag.get("source") == "manual_approval" and flag.get("source") == "manual_approval"
@@ -5727,7 +5734,7 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
) )
) )
assert all(claim.claim_no != "APP-20260525-APPROVE" for claim in archived_claims) assert all(claim.claim_no != "APP-20260525-APPROVE" for claim in archived_claims)
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one() generated_draft = reimbursement_claim_query(db).one()
assert generated_draft.status == "draft" assert generated_draft.status == "draft"
assert generated_draft.approval_stage == "待提交" assert generated_draft.approval_stage == "待提交"
assert generated_draft.expense_type == "travel" assert generated_draft.expense_type == "travel"
@@ -5949,7 +5956,7 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() ->
db.refresh(claim) db.refresh(claim)
assert claim.status == "submitted" assert claim.status == "submitted"
assert claim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE assert claim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0 assert reimbursement_claim_query(db).count() == 0
def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> None: def test_direct_manager_p8_executive_completes_application_without_duplicate_budget_approval() -> None:
@@ -6023,7 +6030,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
assert approved is not None assert approved is not None
assert approved.status == "approved" assert approved.status == "approved"
assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE assert approved.approval_stage == APPLICATION_LINK_STATUS_STAGE
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1 assert reimbursement_claim_query(db).count() == 1
assert not any( assert not any(
isinstance(flag, dict) isinstance(flag, dict)
and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE and flag.get("next_approval_stage") == BUDGET_MANAGER_APPROVAL_STAGE
@@ -6111,7 +6118,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
assert approved is not None assert approved is not None
assert approved.status == "approved" assert approved.status == "approved"
assert approved.approval_stage == "关联单据状态" assert approved.approval_stage == "关联单据状态"
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 1 assert reimbursement_claim_query(db).count() == 1
assert not any( assert not any(
isinstance(flag, dict) isinstance(flag, dict)
and flag.get("next_approval_stage") == "预算管理者审批" and flag.get("next_approval_stage") == "预算管理者审批"
@@ -6130,7 +6137,7 @@ def test_direct_manager_budget_monitor_completes_application_claim_without_dupli
and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver" and flag.get("budget_approval_merged_reason") == "direct_manager_is_department_budget_approver"
for flag in approved.risk_flags_json for flag in approved.risk_flags_json
) )
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one() generated_draft = reimbursement_claim_query(db).one()
assert generated_draft.status == "draft" assert generated_draft.status == "draft"
assert generated_draft.expense_type == "travel" assert generated_draft.expense_type == "travel"
reviewer_claims = ExpenseClaimService(db).list_claims(manager_user) reviewer_claims = ExpenseClaimService(db).list_claims(manager_user)
@@ -6333,10 +6340,10 @@ def test_application_approval_transfers_budget_reservation_to_reimbursement_draf
assert leader_approved.approval_stage == "预算管理者审批" assert leader_approved.approval_stage == "预算管理者审批"
assert reservation.source_type == "application" assert reservation.source_type == "application"
assert reservation.source_id == claim.id assert reservation.source_id == claim.id
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0 assert reimbursement_claim_query(db).count() == 0
approved = service.approve_claim(claim.id, budget_user, opinion="预算通过") approved = service.approve_claim(claim.id, budget_user, opinion="预算通过")
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one() generated_draft = reimbursement_claim_query(db).one()
db.refresh(reservation) db.refresh(reservation)
assert approved is not None assert approved is not None
@@ -6428,7 +6435,7 @@ def test_direct_manager_approval_defaults_blank_opinion_to_agree() -> None:
and flag.get("next_approval_stage") == "预算管理者审批" and flag.get("next_approval_stage") == "预算管理者审批"
for flag in approved.risk_flags_json for flag in approved.risk_flags_json
) )
assert db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).count() == 0 assert reimbursement_claim_query(db).count() == 0
def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None: def test_budget_analysis_uses_current_application_reservation_without_double_counting() -> None:

View File

@@ -4,14 +4,14 @@ import re
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from sqlalchemy import create_engine from sqlalchemy import create_engine, or_
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool from sqlalchemy.pool import StaticPool
from app.core.agent_enums import AgentAssetType
from app.db.base import Base from app.db.base import Base
from app.models.employee import Employee from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.core.agent_enums import AgentAssetType
from app.schemas.ontology import OntologyParseRequest from app.schemas.ontology import OntologyParseRequest
from app.schemas.user_agent import UserAgentCitation, UserAgentRequest, UserAgentReviewRiskBrief from app.schemas.user_agent import UserAgentCitation, UserAgentRequest, UserAgentReviewRiskBrief
from app.services.agent_assets import AgentAssetService from app.services.agent_assets import AgentAssetService
@@ -30,6 +30,16 @@ def build_session_factory() -> sessionmaker[Session]:
return sessionmaker(bind=engine, autoflush=False, autocommit=False) return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def application_claim_query(db: Session):
return db.query(ExpenseClaim).filter(
or_(
ExpenseClaim.claim_no.like("AP-%"),
ExpenseClaim.claim_no.like("APP-%"),
ExpenseClaim.claim_no.like("A________"),
)
)
def build_application_user_agent_response( def build_application_user_agent_response(
db: Session, db: Session,
message: str, message: str,
@@ -629,9 +639,9 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
assert "系统已推送给 陈硕 审核,当前节点:陈硕审核中" in response.answer assert "系统已推送给 陈硕 审核,当前节点:陈硕审核中" in response.answer
assert "下方是简要单据信息" in response.answer assert "下方是简要单据信息" in response.answer
assert "申请信息:" not in response.answer assert "申请信息:" not in response.answer
assert re.search(r"AP-\d{14}-[A-HJ-NP-Z2-9]{8}", response.answer) assert re.search(r"A[A-HJ-NP-Z2-9]{8}", response.answer)
assert response.suggested_actions == [] assert response.suggested_actions == []
claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).one() claim = application_claim_query(db).one()
assert claim.status == "submitted" assert claim.status == "submitted"
assert claim.approval_stage == "直属领导审批" assert claim.approval_stage == "直属领导审批"
assert claim.expense_type == "travel_application" assert claim.expense_type == "travel_application"
@@ -675,7 +685,7 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
context_overrides={"manager_name": "陈硕"}, context_overrides={"manager_name": "陈硕"},
history=history, history=history,
) )
first_claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).one() first_claim = application_claim_query(db).one()
second_response = build_application_user_agent_response( second_response = build_application_user_agent_response(
db, db,
@@ -684,7 +694,7 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
history=history, history=history,
) )
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all() claims = application_claim_query(db).all()
assert len(claims) == 1 assert len(claims) == 1
assert "申请单据已生成" in first_response.answer assert "申请单据已生成" in first_response.answer
assert "已存在申请单" in second_response.answer assert "已存在申请单" in second_response.answer
@@ -745,7 +755,7 @@ def test_user_agent_application_submit_blocks_overlapping_travel_dates() -> None
}, },
) )
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all() claims = application_claim_query(db).all()
assert len(claims) == 1 assert len(claims) == 1
assert "已存在申请单" in response.answer assert "已存在申请单" in response.answer
assert "系统没有重复创建" in response.answer assert "系统没有重复创建" in response.answer
@@ -841,7 +851,7 @@ def test_user_agent_application_edit_resubmits_returned_application_claim() -> N
) )
db.refresh(claim) db.refresh(claim)
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all() claims = application_claim_query(db).all()
assert len(claims) == 1 assert len(claims) == 1
assert "申请单据已修改并重新提交" in response.answer assert "申请单据已修改并重新提交" in response.answer
assert response.draft_payload is not None assert response.draft_payload is not None

View File

@@ -1094,30 +1094,84 @@
list-style: decimal; list-style: decimal;
} }
.workbench-ai-answer-markdown :deep(.ai-document-query-summary) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 0;
padding: 2px 0 0;
border: 0;
background: transparent;
color: #334155;
}
.workbench-ai-answer-markdown :deep(.ai-document-query-summary__label) {
display: inline-flex;
align-items: center;
min-height: auto;
padding: 0;
border-radius: 0;
background: transparent;
color: #64748b;
font-size: 13px;
font-weight: 760;
line-height: 1.2;
}
.workbench-ai-answer-markdown :deep(.ai-document-query-summary__scope) {
min-width: 0;
color: #0f172a;
font-size: 15px;
font-weight: 860;
line-height: 1.5;
overflow-wrap: anywhere;
}
.workbench-ai-answer-markdown :deep(.ai-document-query-summary__count) {
display: inline-flex;
align-items: baseline;
gap: 4px;
color: #64748b;
font-size: 14px;
font-weight: 700;
line-height: 1.4;
}
.workbench-ai-answer-markdown :deep(.ai-document-query-summary__count strong) {
color: #1d4ed8;
font-size: 14px;
font-weight: 900;
}
.workbench-ai-answer-markdown :deep(.ai-document-card-list) { .workbench-ai-answer-markdown :deep(.ai-document-card-list) {
display: grid; display: grid;
gap: 12px; gap: 16px;
margin-top: 18px; margin-top: 14px;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card) { .workbench-ai-answer-markdown :deep(.ai-document-card) {
position: relative; position: relative;
display: grid; display: grid;
gap: 12px; gap: 0;
padding: 16px 18px; overflow: hidden;
border: 1px solid rgba(226, 232, 240, 0.9); padding: 0;
border-left: 3px solid #cbd5e1; border: 1px solid rgba(203, 213, 225, 0.76);
border-left: 0;
border-radius: 12px; border-radius: 12px;
background: #ffffff; background: rgba(255, 255, 255, 0.96);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.04); box-shadow:
0 1px 2px rgba(15, 23, 42, 0.035),
0 10px 26px rgba(15, 23, 42, 0.045);
color: #334155; color: #334155;
animation: workbenchDocumentCardReveal 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both; animation: workbenchDocumentCardReveal 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card:hover) { .workbench-ai-answer-markdown :deep(.ai-document-card:hover) {
border-color: rgba(148, 163, 184, 0.7); border-color: rgba(148, 163, 184, 0.72);
box-shadow: 0 2px 4px rgba(15, 23, 42, 0.05), 0 8px 20px rgba(15, 23, 42, 0.07); background: #ffffff;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.065);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -1133,186 +1187,161 @@
animation-delay: 120ms; animation-delay: 120ms;
} }
/* 状态语义色:左侧边条颜色随状态变化,一眼判断当前阶段 */ /* 状态语义色:头部浅底色和状态文字随单据状态变化 */
.workbench-ai-answer-markdown :deep(.ai-document-card.is-pending) {
border-left-color: #2563eb;
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success) {
border-left-color: #16a34a;
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning) {
border-left-color: #d97706;
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger) {
border-left-color: #dc2626;
}
/* 卡片头部:状态 + 类型(左) · 单据编号(右) */
.workbench-ai-answer-markdown :deep(.ai-document-card__head) { .workbench-ai-answer-markdown :deep(.ai-document-card__head) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 16px;
min-width: 0; min-width: 0;
} padding: 13px 18px;
background: rgba(37, 99, 235, 0.11);
.workbench-ai-answer-markdown :deep(.ai-document-card__head-left) {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__status) { .workbench-ai-answer-markdown :deep(.ai-document-card__status) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 22px; min-height: 24px;
padding: 0 9px; padding: 0;
border-radius: 6px; border-radius: 0;
background: rgba(148, 163, 184, 0.16); background: transparent;
color: #475569; color: #1d4ed8;
font-size: 12px; font-size: 15px;
font-weight: 700; font-weight: 860;
line-height: 1.2; line-height: 1.3;
white-space: nowrap; white-space: nowrap;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__head) {
background: rgba(22, 163, 74, 0.1);
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__head) {
background: rgba(217, 119, 6, 0.12);
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__head) {
background: rgba(220, 38, 38, 0.1);
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-pending .ai-document-card__status) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-pending .ai-document-card__status) {
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8; color: #1d4ed8;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__status) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__status) {
background: rgba(22, 163, 74, 0.1);
color: #15803d; color: #15803d;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__status) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__status) {
background: rgba(217, 119, 6, 0.1);
color: #b45309; color: #b45309;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__status) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__status) {
background: rgba(220, 38, 38, 0.1);
color: #b91c1c; color: #b91c1c;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__type) {
color: #64748b;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__number) {
flex: 0 0 auto;
color: #94a3b8;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
overflow-wrap: anywhere;
}
/* 卡片主体:事由(主焦点) + 申请人/部门(次焦点) */
.workbench-ai-answer-markdown :deep(.ai-document-card__body) { .workbench-ai-answer-markdown :deep(.ai-document-card__body) {
display: grid; display: grid;
gap: 6px; gap: 14px;
min-width: 0; min-width: 0;
padding: 16px 18px 18px;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__reason) { .workbench-ai-answer-markdown :deep(.ai-document-card__reason) {
display: -webkit-box; display: -webkit-box;
color: #0f172a; min-width: 0;
font-size: 16px; color: #1e40af;
font-weight: 700; font-size: 15px;
font-weight: 760;
line-height: 1.45; line-height: 1.45;
overflow: hidden; overflow: hidden;
-webkit-line-clamp: 2; -webkit-line-clamp: 1;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__owner-line) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__reason) {
display: flex; color: #166534;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__owner) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__reason) {
color: #1e293b; color: #92400e;
font-size: 13px;
font-weight: 600;
line-height: 1.3;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__dept) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__reason) {
color: #64748b; color: #991b1b;
font-size: 13px;
font-weight: 500;
line-height: 1.3;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__dot) { .workbench-ai-answer-markdown :deep(.ai-document-card__details) {
color: #cbd5e1;
font-size: 12px;
font-weight: 700;
}
/* 卡片底部:辅助元信息(左) · 金额(右) · 操作 */
.workbench-ai-answer-markdown :deep(.ai-document-card__foot) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 12px;
border-top: 1px solid rgba(226, 232, 240, 0.9);
}
.workbench-ai-answer-markdown :deep(.ai-document-card__meta) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__meta-item) {
color: #64748b;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__amount-block) {
display: grid; display: grid;
justify-items: end; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px; gap: 12px 28px;
flex: 0 0 auto; padding-top: 2px;
border-top: 1px solid rgba(203, 213, 225, 0.76);
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__amount-label) { .workbench-ai-answer-markdown :deep(.ai-document-card__field) {
color: #94a3b8; display: grid;
font-size: 11px; grid-template-columns: 86px minmax(0, 1fr);
font-weight: 500; align-items: center;
line-height: 1.2; gap: 14px;
min-width: 0;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__field--action) {
grid-column: 1 / -1;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__label) {
color: #8a94a6;
font-size: 13px;
font-weight: 640;
line-height: 1.4;
white-space: nowrap;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__value) {
min-width: 0;
color: #334155;
font-size: 14px;
font-weight: 720;
line-height: 1.45;
overflow-wrap: anywhere;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__amount) { .workbench-ai-answer-markdown :deep(.ai-document-card__amount) {
color: #0f172a; color: #0f172a;
font-size: 17px; font-size: 18px;
font-weight: 700; font-weight: 900;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__number) {
color: #64748b;
font-size: 13px;
font-weight: 740;
letter-spacing: 0;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__action) { .workbench-ai-answer-markdown :deep(.ai-document-card__action) {
flex: 0 0 auto; display: inline-flex;
align-items: center;
width: fit-content;
min-height: 26px;
padding: 0;
border-radius: 0;
background: transparent;
color: #1d4ed8;
font-size: 14px;
font-weight: 820;
box-shadow: none;
white-space: nowrap;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__action:hover) {
background: transparent;
color: #1e40af;
text-decoration: underline;
} }
.workbench-ai-answer-markdown :deep(.markdown-table-wrap), .workbench-ai-answer-markdown :deep(.markdown-table-wrap),
@@ -1547,31 +1576,37 @@
} }
.workbench-ai-answer-markdown :deep(.ai-document-card) { .workbench-ai-answer-markdown :deep(.ai-document-card) {
padding: 14px; padding: 0;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__head) { .workbench-ai-answer-markdown :deep(.ai-document-card__head) {
align-items: flex-start; align-items: flex-start;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__body) {
padding: 14px;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__details) {
grid-template-columns: 1fr;
gap: 10px;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__field) {
grid-template-columns: 76px minmax(0, 1fr);
gap: 10px;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__field--action) {
grid-column: auto;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__number) { .workbench-ai-answer-markdown :deep(.ai-document-card__number) {
flex-basis: 100%;
text-align: left; text-align: left;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__foot) {
flex-wrap: wrap;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__amount-block) {
justify-items: start;
order: 2;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__action) {
order: 3;
margin-left: auto;
}
} }
.workbench-ai-application-preview { .workbench-ai-application-preview {

View File

@@ -10,6 +10,7 @@ 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 { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js' import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { isApplicationDocumentNo } from '../utils/documentClassification.js'
import { import {
ASSISTANT_SCOPE_SESSION_STEWARD, ASSISTANT_SCOPE_SESSION_STEWARD,
buildUnsupportedBusinessScopeConversation, buildUnsupportedBusinessScopeConversation,
@@ -428,8 +429,7 @@ export function useAppShell() {
return ( return (
documentType === 'application' documentType === 'application'
|| documentType === 'expense_application' || documentType === 'expense_application'
|| normalizedClaimNo.startsWith('AP-') || isApplicationDocumentNo(normalizedClaimNo)
|| normalizedClaimNo.startsWith('APP-')
) )
} }

View File

@@ -1,6 +1,7 @@
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { fetchAllExpenseClaims } from '../services/reimbursements.js' import { fetchAllExpenseClaims } from '../services/reimbursements.js'
import { isApplicationDocumentNo } from '../utils/documentClassification.js'
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js' import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js'
const EXPENSE_TYPE_LABELS = { const EXPENSE_TYPE_LABELS = {
@@ -212,8 +213,7 @@ function resolveDocumentTypeMeta(claim, typeCode) {
const isApplication = const isApplication =
explicitType === DOCUMENT_TYPE_APPLICATION explicitType === DOCUMENT_TYPE_APPLICATION
|| explicitType === 'expense_application' || explicitType === 'expense_application'
|| claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo)
|| claimNo.startsWith('APP-')
|| normalizedType === 'application' || normalizedType === 'application'
|| normalizedType.endsWith('_application') || normalizedType.endsWith('_application')

View File

@@ -1,4 +1,5 @@
import { extractExpenseClaimItems } from '../services/reimbursements.js' import { extractExpenseClaimItems } from '../services/reimbursements.js'
import { isApplicationDocumentNo } from './documentClassification.js'
const DOCUMENT_QUERY_LIMIT = 8 const DOCUMENT_QUERY_LIMIT = 8
@@ -336,8 +337,7 @@ function resolveDocumentTypeCode(claim = {}) {
|| explicitType === 'expense_application' || explicitType === 'expense_application'
|| expenseType === 'application' || expenseType === 'application'
|| expenseType.endsWith('_application') || expenseType.endsWith('_application')
|| documentNo.startsWith('AP-') || isApplicationDocumentNo(documentNo)
|| documentNo.startsWith('APP-')
) { ) {
return 'application' return 'application'
} }
@@ -679,49 +679,66 @@ function buildDocumentCardHtml(record = {}) {
const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement' const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement'
const statusTone = record.statusTone || 'is-pending' const statusTone = record.statusTone || 'is-pending'
const amountLabel = record.documentType === 'application' ? '预计金额' : '报销金额' const amountLabel = record.documentType === 'application' ? '预计金额' : '报销金额'
const ownerText = [record.ownerLabel, record.departmentLabel]
// footer 左侧辅助元信息:业务地点(可选)+ 时间 .filter((item) => item && item !== '未显示')
const metaParts = [] .join(' · ') || '未显示'
if (record.locationLabel) {
metaParts.push(`<span class="ai-document-card__meta-item">${escapeHtml(record.locationLabel)}</span>`)
}
metaParts.push(`<span class="ai-document-card__meta-item">${escapeHtml(record.time || '待补充')}</span>`)
const metaHtml = `<div class="ai-document-card__meta">${metaParts.join('<span class="ai-document-card__dot">·</span>')}</div>`
return [ return [
`<article class="ai-document-card ai-document-card--${typeClass} ${statusTone}" aria-label="单据详情">`, `<article class="ai-document-card ai-document-card--${typeClass} ${statusTone}" aria-label="单据详情">`,
'<header class="ai-document-card__head">', '<header class="ai-document-card__head">',
'<div class="ai-document-card__head-left">', `<strong class="ai-document-card__reason">${escapeHtml(record.reason)}</strong>`,
`<span class="ai-document-card__status">${escapeHtml(record.statusLabel)}</span>`, `<span class="ai-document-card__status">${escapeHtml(record.statusLabel)}</span>`,
`<span class="ai-document-card__type">${escapeHtml(record.documentTypeLabel)} · ${escapeHtml(record.typeLabel)}</span>`,
'</div>',
`<span class="ai-document-card__number">${escapeHtml(record.documentNo || '未编号单据')}</span>`,
'</header>', '</header>',
'<div class="ai-document-card__body">', '<div class="ai-document-card__body">',
`<strong class="ai-document-card__reason">${escapeHtml(record.reason)}</strong>`, '<div class="ai-document-card__details">',
'<div class="ai-document-card__owner-line">', '<div class="ai-document-card__field">',
`<span class="ai-document-card__owner">${escapeHtml(record.ownerLabel)}</span>`, '<span class="ai-document-card__label">单据类型</span>',
'<span class="ai-document-card__dot">·</span>', `<strong class="ai-document-card__value">${escapeHtml(record.documentTypeLabel)} · ${escapeHtml(record.typeLabel)}</strong>`,
`<span class="ai-document-card__dept">${escapeHtml(record.departmentLabel)}</span>`,
'</div>', '</div>',
'</div>', '<div class="ai-document-card__field">',
'<footer class="ai-document-card__foot">', `<span class="ai-document-card__label">${escapeHtml(amountLabel)}</span>`,
metaHtml,
'<div class="ai-document-card__amount-block">',
`<span class="ai-document-card__amount-label">${escapeHtml(amountLabel)}</span>`,
`<strong class="ai-document-card__amount">${escapeHtml(record.amountLabel)}</strong>`, `<strong class="ai-document-card__amount">${escapeHtml(record.amountLabel)}</strong>`,
'</div>', '</div>',
'<div class="ai-document-card__field">',
'<span class="ai-document-card__label">申请人</span>',
`<strong class="ai-document-card__value">${escapeHtml(ownerText)}</strong>`,
'</div>',
'<div class="ai-document-card__field">',
'<span class="ai-document-card__label">单据编号</span>',
`<strong class="ai-document-card__value ai-document-card__number">${escapeHtml(record.documentNo || '未编号单据')}</strong>`,
'</div>',
'<div class="ai-document-card__field ai-document-card__field--action">',
'<span class="ai-document-card__label">操作</span>',
href href
? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">查看详情</a>` ? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">查看详情</a>`
: '', : '<span class="ai-document-card__value">暂无详情</span>',
'</footer>', '</div>',
'</div>',
'</div>',
'</article>' '</article>'
].join('') ].join('')
} }
function buildDocumentCardsHtml(records = []) { function buildDocumentQuerySummaryHtml(scopeText = '', totalCount = 0, visibleCount = 0) {
return [
'<section class="ai-document-query-summary" aria-label="单据查询范围">',
'<span class="ai-document-query-summary__label">查询范围</span>',
`<strong class="ai-document-query-summary__scope">${escapeHtml(scopeText || '相关单据')}</strong>`,
'<span class="ai-document-query-summary__count">',
`共 <strong>${escapeHtml(String(totalCount))}</strong> 张`,
'</span>',
'<span class="ai-document-query-summary__count">',
`展示最近 <strong>${escapeHtml(String(visibleCount))}</strong> 张`,
'</span>',
'</section>'
].join('')
}
function buildDocumentCardsHtml(records = [], options = {}) {
const querySummaryHtml = options.querySummaryHtml || ''
return [ return [
'<!-- ai-trusted-html:start -->', '<!-- ai-trusted-html:start -->',
querySummaryHtml,
'<section class="ai-document-card-list" aria-label="单据查询结果">', '<section class="ai-document-card-list" aria-label="单据查询结果">',
...records.map((record) => buildDocumentCardHtml(record)), ...records.map((record) => buildDocumentCardHtml(record)),
'</section>', '</section>',
@@ -772,9 +789,9 @@ export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) {
const lines = [ const lines = [
'### 已查询到相关单据', '### 已查询到相关单据',
'', '',
`**查询范围**${scopeText || '相关单据'};共找到 **${records.length}** 张,先展示最近 **${visibleRecords.length}** 张。`, buildDocumentCardsHtml(visibleRecords, {
'', querySummaryHtml: buildDocumentQuerySummaryHtml(scopeText, records.length, visibleRecords.length)
buildDocumentCardsHtml(visibleRecords) })
] ]
if (records.length > visibleRecords.length) { if (records.length > visibleRecords.length) {

View File

@@ -1,5 +1,6 @@
import { filterActionableRiskFlags, normalizeRiskFlagTone } from './riskFlags.js' import { filterActionableRiskFlags, normalizeRiskFlagTone } from './riskFlags.js'
import { canViewRiskForContext } from './riskVisibility.js' import { canViewRiskForContext } from './riskVisibility.js'
import { isApplicationDocumentNo } from './documentClassification.js'
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance']) const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment']) const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment'])
@@ -37,8 +38,7 @@ function isApplicationDocumentRequest(request) {
return ( return (
documentType === 'application' documentType === 'application'
|| documentType === 'expense_application' || documentType === 'expense_application'
|| claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo)
|| claimNo.startsWith('APP-')
|| typeCode === 'application' || typeCode === 'application'
|| typeCode.endsWith('_application') || typeCode.endsWith('_application')
) )

View File

@@ -1,3 +1,14 @@
const APPLICATION_DOCUMENT_NO_PATTERN = /^A[A-HJ-NP-Z2-9]{8}$/i
export function isApplicationDocumentNo(value) {
const claimNo = String(value || '').trim().toUpperCase()
return (
APPLICATION_DOCUMENT_NO_PATTERN.test(claimNo)
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
)
}
export function isApplicationRequestLike(value) { export function isApplicationRequestLike(value) {
const explicitType = String( const explicitType = String(
value?.documentTypeCode value?.documentTypeCode
@@ -14,8 +25,7 @@ export function isApplicationRequestLike(value) {
return ( return (
explicitType === 'application' explicitType === 'application'
|| explicitType === 'expense_application' || explicitType === 'expense_application'
|| claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo)
|| claimNo.startsWith('APP-')
|| typeCode === 'application' || typeCode === 'application'
|| typeCode.endsWith('_application') || typeCode.endsWith('_application')
) )

View File

@@ -6,6 +6,7 @@ import {
isFinanceUser, isFinanceUser,
isPlatformAdminUser isPlatformAdminUser
} from './accessControl.js' } from './accessControl.js'
import { isApplicationDocumentNo } from './documentClassification.js'
const APPLICATION_STAGE_ALIASES = new Set([ const APPLICATION_STAGE_ALIASES = new Set([
'expense_application', 'expense_application',
@@ -159,8 +160,7 @@ export function isApplicationRiskStageRequest(request = {}) {
return ( return (
documentType === 'application' || documentType === 'application' ||
documentType === 'expense_application' || documentType === 'expense_application' ||
claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo) ||
claimNo.startsWith('APP-') ||
typeCode === 'application' || typeCode === 'application' ||
typeCode.endsWith('_application') typeCode.endsWith('_application')
) )

View File

@@ -273,7 +273,27 @@ const sidebarCollapsed = ref(false)
const sidebarCollapsedBeforeAiMode = ref(false) const sidebarCollapsedBeforeAiMode = ref(false)
const mobileSidebarOpen = ref(false) const mobileSidebarOpen = ref(false)
const overviewDashboard = ref('finance') const overviewDashboard = ref('finance')
const workbenchMode = ref('traditional') const { companyProfile, currentUser, logout } = useSystemState()
function resolveDefaultWorkbenchMode(user) {
return isPlatformAdminUser(user) ? 'traditional' : 'ai'
}
function resolveWorkbenchUserKey(user = {}) {
const roleCodes = Array.isArray(user?.roleCodes) ? user.roleCodes.join(',') : ''
return [
user?.id,
user?.userId,
user?.username,
user?.account,
user?.name,
user?.role,
roleCodes,
user?.isAdmin ? 'admin' : 'user'
].map((item) => String(item || '').trim()).join('|')
}
const workbenchMode = ref(resolveDefaultWorkbenchMode(currentUser.value))
const aiSidebarCommandSeq = ref(0) const aiSidebarCommandSeq = ref(0)
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null }) const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
const aiActiveConversationId = ref('') const aiActiveConversationId = ref('')
@@ -343,7 +363,6 @@ const {
topBarView topBarView
} = useAppShell() } = useAppShell()
const { companyProfile, currentUser, logout } = useSystemState()
const PRODUCT_DISPLAY_NAME = '易财费控' const PRODUCT_DISPLAY_NAME = '易财费控'
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司' const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value)) const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
@@ -496,7 +515,14 @@ function handleLogout() {
watch( watch(
() => currentUser.value, () => currentUser.value,
(user) => { (user, previousUser) => {
if (resolveWorkbenchUserKey(user) !== resolveWorkbenchUserKey(previousUser)) {
const nextMode = resolveDefaultWorkbenchMode(user)
workbenchMode.value = nextMode
if (nextMode === 'ai') {
sidebarCollapsed.value = false
}
}
aiConversationHistory.value = loadAiWorkbenchConversationHistory(user || {}) aiConversationHistory.value = loadAiWorkbenchConversationHistory(user || {})
}, },
{ immediate: true } { immediate: true }

View File

@@ -1,3 +1,5 @@
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
const REQUIRED_APPLICATION_EXPENSE_TYPES = new Set(['travel', 'meal']) const REQUIRED_APPLICATION_EXPENSE_TYPES = new Set(['travel', 'meal'])
const APPLICATION_TYPE_ALIASES = { const APPLICATION_TYPE_ALIASES = {
@@ -302,8 +304,7 @@ export function isExpenseApplicationClaim(claim) {
return documentType === 'application' return documentType === 'application'
|| documentType === 'expense_application' || documentType === 'expense_application'
|| claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo)
|| claimNo.startsWith('APP-')
|| expenseType === 'application' || expenseType === 'application'
|| expenseType.endsWith('_application') || expenseType.endsWith('_application')
} }

View File

@@ -1,3 +1,5 @@
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
function normalizeText(value) { function normalizeText(value) {
return String(value || '').trim() return String(value || '').trim()
} }
@@ -22,7 +24,7 @@ function isApplicationDocumentRequest(requestModel) {
|| requestModel?.document_type || requestModel?.document_type
).toLowerCase() ).toLowerCase()
const claimNo = normalizeText(requestModel?.claimNo || requestModel?.claim_no || requestModel?.documentNo).toUpperCase() const claimNo = normalizeText(requestModel?.claimNo || requestModel?.claim_no || requestModel?.documentNo).toUpperCase()
return documentType === 'application' || claimNo.startsWith('AP-') || claimNo.startsWith('APP-') return documentType === 'application' || isApplicationDocumentNo(claimNo)
} }
function isHotelExpenseItem(item) { function isHotelExpenseItem(item) {

View File

@@ -1,3 +1,5 @@
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
function normalizeText(value) { function normalizeText(value) {
return String(value || '').trim() return String(value || '').trim()
} }
@@ -112,7 +114,7 @@ export function resolveRequestBusinessStage(request = {}) {
|| request?.document_no || request?.document_no
|| request?.id || request?.id
).toUpperCase() ).toUpperCase()
if (claimNo.startsWith('AP-') || claimNo.startsWith('APP-')) { if (isApplicationDocumentNo(claimNo)) {
return 'expense_application' return 'expense_application'
} }

View File

@@ -1,3 +1,5 @@
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
export const EXPENSE_TYPE_OPTIONS = [ export const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' }, { value: 'travel', label: '差旅费' },
{ value: 'train_ticket', label: '火车票' }, { value: 'train_ticket', label: '火车票' },
@@ -83,8 +85,7 @@ export function isApplicationDocumentRequest(request) {
return ( return (
documentType === 'application' documentType === 'application'
|| documentType === 'expense_application' || documentType === 'expense_application'
|| claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo)
|| claimNo.startsWith('APP-')
|| typeCode === 'application' || typeCode === 'application'
|| typeCode.endsWith('_application') || typeCode.endsWith('_application')
) )

View File

@@ -718,7 +718,7 @@ export function useTravelReimbursementFlow({
function buildApplicationDuplicateDetail(payload) { function buildApplicationDuplicateDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {} const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const answer = String(result.answer || result.message || '').trim() const answer = String(result.answer || result.message || '').trim()
const claimNo = answer.match(/AP-\d{14}-[A-HJ-NP-Z2-9]{8}/)?.[0] || '' const claimNo = answer.match(/A[A-HJ-NP-Z2-9]{8}|AP-\d{14}-[A-HJ-NP-Z2-9]{8}|APP-\d{8}-[A-Z0-9]{6}/)?.[0] || ''
return claimNo return claimNo
? `已拦截重复申请,已有申请单:${claimNo}` ? `已拦截重复申请,已有申请单:${claimNo}`
: '已拦截重复申请,未创建新申请单' : '已拦截重复申请,未创建新申请单'

View File

@@ -5,6 +5,7 @@ import {
collectReceiptFiles collectReceiptFiles
} from './travelReimbursementAttachmentModel.js' } from './travelReimbursementAttachmentModel.js'
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js' import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
import { import {
APPLICATION_TRANSPORT_MODE_OPTIONS, APPLICATION_TRANSPORT_MODE_OPTIONS,
applyApplicationBusinessTimeContext, applyApplicationBusinessTimeContext,
@@ -148,8 +149,7 @@ function isApplicationClaimRecord(claim) {
expenseType === 'application' || expenseType === 'application' ||
expenseType === 'expense_application' || expenseType === 'expense_application' ||
expenseType.endsWith('_application') || expenseType.endsWith('_application') ||
claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo) ||
claimNo.startsWith('APP-') ||
Boolean(extractApplicationDetailFromClaim(claim)) Boolean(extractApplicationDetailFromClaim(claim))
) )
} }

View File

@@ -127,24 +127,33 @@ test('AI document query message renders html document cards with detail actions'
assert.match(message, /### 已查询到相关单据/) assert.match(message, /### 已查询到相关单据/)
assert.match(message, /<!-- ai-trusted-html:start -->/) assert.match(message, /<!-- ai-trusted-html:start -->/)
assert.match(message, /<section class="ai-document-query-summary" aria-label="单据查询范围">/)
assert.match(message, /<span class="ai-document-query-summary__label">查询范围<\/span>/)
assert.match(message, /<strong class="ai-document-query-summary__scope">/)
assert.match(message, /<span class="ai-document-query-summary__count">/)
assert.match(message, /<section class="ai-document-card-list" aria-label="单据查询结果">/) assert.match(message, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
// 申请单 app-1 状态为 approved → is-success 语义类 // 申请单 app-1 状态为 approved → is-success 语义类
assert.match(message, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/) assert.match(message, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/)
assert.match(message, /<header class="ai-document-card__head">/) assert.match(message, /<header class="ai-document-card__head">/)
assert.match(message, /<span class="ai-document-card__status">已审批<\/span>/) assert.match(message, /<span class="ai-document-card__status">已审批<\/span>/)
assert.match(message, /<strong class="ai-document-card__reason">辅助国网仿生产服务器部署<\/strong>/) assert.match(message, /<strong class="ai-document-card__reason">辅助国网仿生产服务器部署<\/strong>/)
assert.match(message, /<span class="ai-document-card__owner">曹小筑<\/span>/) assert.match(message, /<div class="ai-document-card__details">/)
assert.match(message, /<span class="ai-document-card__dept">交付部<\/span>/) assert.match(message, /<span class="ai-document-card__label">单据类型<\/span>/)
assert.match(message, /<span class="ai-document-card__number">AP-20260220001<\/span>/) assert.match(message, /<strong class="ai-document-card__value">申请单 · 差旅费用申请<\/strong>/)
assert.match(message, /<span class="ai-document-card__label">申请人<\/span>/)
assert.match(message, /<strong class="ai-document-card__value">曹小筑 · 交付部<\/strong>/)
assert.match(message, /<strong class="ai-document-card__value ai-document-card__number">AP-20260220001<\/strong>/)
assert.match(message, /<strong class="ai-document-card__amount">¥3,000\.00<\/strong>/) assert.match(message, /<strong class="ai-document-card__amount">¥3,000\.00<\/strong>/)
assert.match(message, /<div class="ai-document-card__meta">/) assert.match(message, /<div class="ai-document-card__field ai-document-card__field--action">/)
assert.match(message, /<span class="ai-document-card__meta-item">上海<\/span>/) assert.doesNotMatch(message, /ai-document-card__meta/)
assert.doesNotMatch(message, /ai-document-card__meta-item/)
assert.match(message, /href="#ai-open-document-detail:AP-20260220001"/) assert.match(message, /href="#ai-open-document-detail:AP-20260220001"/)
// 报销单 claim-1 状态为 submitted → is-pending 语义类 // 报销单 claim-1 状态为 submitted → is-pending 语义类
assert.match(message, /<article class="ai-document-card ai-document-card--reimbursement is-pending" aria-label="单据详情">/) assert.match(message, /<article class="ai-document-card ai-document-card--reimbursement is-pending" aria-label="单据详情">/)
assert.match(message, /href="#ai-open-document-detail:CL-20260221001"/) assert.match(message, /href="#ai-open-document-detail:CL-20260221001"/)
assert.doesNotMatch(message, /\| 单据编号 \|/) assert.doesNotMatch(message, /\| 单据编号 \|/)
assert.doesNotMatch(message, /^> /m) assert.doesNotMatch(message, /^> /m)
assert.doesNotMatch(message, /\*\*查询范围\*\*/)
}) })
test('AI document query html cards render as trusted card markup', () => { test('AI document query html cards render as trusted card markup', () => {
@@ -152,13 +161,16 @@ test('AI document query html cards render as trusted card markup', () => {
const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, claims)) const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, claims))
assert.match(rendered, /<h3 class="ai-html-title">已查询到相关单据<\/h3>/) assert.match(rendered, /<h3 class="ai-html-title">已查询到相关单据<\/h3>/)
assert.match(rendered, /<section class="ai-document-query-summary" aria-label="单据查询范围">/)
assert.match(rendered, /<section class="ai-document-card-list" aria-label="单据查询结果">/) assert.match(rendered, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
assert.match(rendered, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/) assert.match(rendered, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/)
assert.match(rendered, /class="ai-document-card__head"/) assert.match(rendered, /class="ai-document-card__head"/)
assert.match(rendered, /class="ai-document-card__meta"/) assert.match(rendered, /class="ai-document-card__details"/)
assert.match(rendered, /class="ai-document-card__meta-item"/) assert.match(rendered, /class="ai-document-card__field"/)
assert.match(rendered, /class="ai-document-card__label"/)
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document ai-document-card__action"/) assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document ai-document-card__action"/)
assert.match(rendered, /href="#ai-open-document-detail:CL-20260221001"/) assert.match(rendered, /href="#ai-open-document-detail:CL-20260221001"/)
assert.doesNotMatch(rendered, /ai-document-card__meta/)
assert.doesNotMatch(rendered, /&lt;section class=&quot;ai-document-card-list/) assert.doesNotMatch(rendered, /&lt;section class=&quot;ai-document-card-list/)
assert.doesNotMatch(rendered, /<blockquote>/) assert.doesNotMatch(rendered, /<blockquote>/)
}) })

View File

@@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
isApplicationDocumentNo,
isApplicationRequestLike
} from '../src/utils/documentClassification.js'
test('application document number detection supports short and legacy formats', () => {
assert.equal(isApplicationDocumentNo('A7K3M9Q2P'), true)
assert.equal(isApplicationDocumentNo('AP-20260525103045-ABCDEFGH'), true)
assert.equal(isApplicationDocumentNo('APP-20260525-ABC123'), true)
assert.equal(isApplicationDocumentNo('R7K3M9Q2P'), false)
assert.equal(isApplicationDocumentNo('RE-20260525103045-HGFEDCBA'), false)
})
test('application request classification can rely on a short document number', () => {
assert.equal(isApplicationRequestLike({ claim_no: 'A7K3M9Q2P' }), true)
assert.equal(isApplicationRequestLike({ claim_no: 'R7K3M9Q2P' }), false)
})

View File

@@ -172,7 +172,10 @@ const orbIconPngAsset = fileURLToPath(
const orbIconBuffer = readFileSync(orbIconAsset) const orbIconBuffer = readFileSync(orbIconAsset)
test('app shell owns the workbench mode and wires it through topbar and content', () => { test('app shell owns the workbench mode and wires it through topbar and content', () => {
assert.match(appShell, /const workbenchMode = ref\('traditional'\)/) assert.match(appShell, /function resolveDefaultWorkbenchMode\(user\)\s*\{[\s\S]*isPlatformAdminUser\(user\)[\s\S]*'traditional'[\s\S]*'ai'/)
assert.match(appShell, /const workbenchMode = ref\(resolveDefaultWorkbenchMode\(currentUser\.value\)\)/)
assert.doesNotMatch(appShell, /const workbenchMode = ref\('traditional'\)/)
assert.match(appShell, /watch\(\s*\(\) => currentUser\.value,[\s\S]*resolveDefaultWorkbenchMode\(user\)/)
assert.match(appShell, /function toggleWorkbenchMode\(\)/) assert.match(appShell, /function toggleWorkbenchMode\(\)/)
assert.match(appShell, /const nextMode = workbenchMode\.value === 'ai' \? 'traditional' : 'ai'/) assert.match(appShell, /const nextMode = workbenchMode\.value === 'ai' \? 'traditional' : 'ai'/)
assert.match(appShell, /sidebarCollapsedBeforeAiMode\.value = sidebarCollapsed\.value/) assert.match(appShell, /sidebarCollapsedBeforeAiMode\.value = sidebarCollapsed\.value/)
@@ -260,11 +263,21 @@ test('AI mode screen follows the approved reference structure', () => {
assert.doesNotMatch(aiMode, /message\.pending \?/) assert.doesNotMatch(aiMode, /message\.pending \?/)
assert.match(aiMode, /继续和小财管家对话\.\.\./) assert.match(aiMode, /继续和小财管家对话\.\.\./)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary__scope\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card-list\) \{[\s\S]*gap:\s*16px;/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\) \{[\s\S]*background: rgba\(37, 99, 235, 0\.11\);/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\.is-success \.ai-document-card__head\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__body\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__body\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__foot\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__details\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__field\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__label\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__amount\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__amount\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__meta\)/) assert.match(
aiModeStyles,
/\.workbench-ai-answer-markdown :deep\(\.ai-document-card__details\) \{[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/
)
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-list\)/)