Compare commits
2 Commits
0cda750ff0
...
3b74a330a3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b74a330a3 | ||
|
|
8158716e23 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,7 @@ from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, or_
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -36,6 +36,15 @@ def build_session() -> Session:
|
||||
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:
|
||||
role = Role(role_code="budget_monitor", name="预算管理员")
|
||||
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.status == "approved"
|
||||
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(
|
||||
isinstance(flag, dict)
|
||||
and flag.get("source") == "approval_routing"
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, or_
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
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.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.reimbursement import (
|
||||
ExpenseClaimItemCreate,
|
||||
ExpenseClaimItemUpdate,
|
||||
@@ -28,19 +28,19 @@ from app.schemas.reimbursement import (
|
||||
)
|
||||
from app.services.agent_conversations import AgentConversationService
|
||||
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_claims import ExpenseClaimService
|
||||
from app.services.expense_claim_budget_flow import ExpenseClaimBudgetFlowMixin
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
APPLICATION_ARCHIVE_STAGE,
|
||||
APPLICATION_LINK_STATUS_STAGE,
|
||||
APPROVAL_DONE_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_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.ontology import SemanticOntologyService
|
||||
from app.services.receipt_folder import ReceiptFolderService
|
||||
|
||||
|
||||
@@ -121,6 +121,15 @@ def build_session() -> Session:
|
||||
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:
|
||||
base_warning = {
|
||||
"source": "budget_control",
|
||||
@@ -1770,7 +1779,7 @@ def test_upsert_draft_from_ontology_updates_returned_claim_and_preserves_return_
|
||||
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:
|
||||
db.add_all(
|
||||
[
|
||||
@@ -1813,7 +1822,7 @@ def test_generate_claim_no_uses_re_prefix_timestamp_and_random_suffix() -> None:
|
||||
service = ExpenseClaimService(db)
|
||||
|
||||
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)),
|
||||
)
|
||||
|
||||
@@ -1831,7 +1840,7 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
|
||||
db.flush()
|
||||
db.add(
|
||||
ExpenseClaim(
|
||||
claim_no="RE-20260525101010-ABCDEFGH",
|
||||
claim_no="RABCDEFGH",
|
||||
employee_name="历史单据",
|
||||
department_name="财务部",
|
||||
project_code=None,
|
||||
@@ -1856,9 +1865,7 @@ def test_upsert_draft_from_ontology_retries_claim_no_conflict() -> None:
|
||||
)
|
||||
)
|
||||
service = ExpenseClaimService(db)
|
||||
generated_claim_nos = iter(
|
||||
["RE-20260525101010-ABCDEFGH", "RE-20260525101010-HGFEDCBA"]
|
||||
)
|
||||
generated_claim_nos = iter(["RABCDEFGH", "RHGFEDCBA"])
|
||||
service._generate_claim_no = lambda occurred_at: next(generated_claim_nos)
|
||||
|
||||
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"])
|
||||
assert created_claim is not None
|
||||
assert created_claim.claim_no == "RE-20260525101010-HGFEDCBA"
|
||||
assert result["claim_no"] == "RE-20260525101010-HGFEDCBA"
|
||||
assert created_claim.claim_no == "RHGFEDCBA"
|
||||
assert result["claim_no"] == "RHGFEDCBA"
|
||||
|
||||
|
||||
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.status == "submitted"
|
||||
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(
|
||||
isinstance(flag, dict)
|
||||
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)
|
||||
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.approval_stage == "待提交"
|
||||
assert generated_draft.expense_type == "travel"
|
||||
@@ -5949,7 +5956,7 @@ def test_direct_manager_cannot_route_application_to_missing_budget_approver() ->
|
||||
db.refresh(claim)
|
||||
assert claim.status == "submitted"
|
||||
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:
|
||||
@@ -6023,7 +6030,7 @@ def test_direct_manager_p8_executive_completes_application_without_duplicate_bud
|
||||
assert approved is not None
|
||||
assert approved.status == "approved"
|
||||
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(
|
||||
isinstance(flag, dict)
|
||||
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.status == "approved"
|
||||
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(
|
||||
isinstance(flag, dict)
|
||||
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"
|
||||
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.expense_type == "travel"
|
||||
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 reservation.source_type == "application"
|
||||
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="预算通过")
|
||||
generated_draft = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("RE-%")).one()
|
||||
generated_draft = reimbursement_claim_query(db).one()
|
||||
db.refresh(reservation)
|
||||
|
||||
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") == "预算管理者审批"
|
||||
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:
|
||||
|
||||
@@ -4,14 +4,14 @@ import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, or_
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.agent_enums import AgentAssetType
|
||||
from app.db.base import Base
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.core.agent_enums import AgentAssetType
|
||||
from app.schemas.ontology import OntologyParseRequest
|
||||
from app.schemas.user_agent import UserAgentCitation, UserAgentRequest, UserAgentReviewRiskBrief
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
db: Session,
|
||||
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 "申请信息:" 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 == []
|
||||
claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).one()
|
||||
claim = application_claim_query(db).one()
|
||||
assert claim.status == "submitted"
|
||||
assert claim.approval_stage == "直属领导审批"
|
||||
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": "陈硕"},
|
||||
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(
|
||||
db,
|
||||
@@ -684,7 +694,7 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
|
||||
history=history,
|
||||
)
|
||||
|
||||
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all()
|
||||
claims = application_claim_query(db).all()
|
||||
assert len(claims) == 1
|
||||
assert "申请单据已生成" in first_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 "已存在申请单" 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)
|
||||
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all()
|
||||
claims = application_claim_query(db).all()
|
||||
assert len(claims) == 1
|
||||
assert "申请单据已修改并重新提交" in response.answer
|
||||
assert response.draft_payload is not None
|
||||
|
||||
@@ -1094,30 +1094,84 @@
|
||||
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) {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
gap: 16px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card) {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
border-left: 3px solid #cbd5e1;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(203, 213, 225, 0.76);
|
||||
border-left: 0;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.04);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.035),
|
||||
0 10px 26px rgba(15, 23, 42, 0.045);
|
||||
color: #334155;
|
||||
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;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card:hover) {
|
||||
border-color: rgba(148, 163, 184, 0.7);
|
||||
box-shadow: 0 2px 4px rgba(15, 23, 42, 0.05), 0 8px 20px rgba(15, 23, 42, 0.07);
|
||||
border-color: rgba(148, 163, 184, 0.72);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.065);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -1133,186 +1187,161 @@
|
||||
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) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card__head-left) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
padding: 13px 18px;
|
||||
background: rgba(37, 99, 235, 0.11);
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card__status) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 6px;
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
min-height: 24px;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #1d4ed8;
|
||||
font-size: 15px;
|
||||
font-weight: 860;
|
||||
line-height: 1.3;
|
||||
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) {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__status) {
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__status) {
|
||||
background: rgba(217, 119, 6, 0.1);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__status) {
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
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) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card__reason) {
|
||||
display: -webkit-box;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
color: #1e40af;
|
||||
font-size: 15px;
|
||||
font-weight: 760;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card__owner-line) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__reason) {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card__owner) {
|
||||
color: #1e293b;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__reason) {
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card__dept) {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__reason) {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card__dot) {
|
||||
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) {
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card__details) {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 1px;
|
||||
flex: 0 0 auto;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 28px;
|
||||
padding-top: 2px;
|
||||
border-top: 1px solid rgba(203, 213, 225, 0.76);
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card__amount-label) {
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card__field) {
|
||||
display: grid;
|
||||
grid-template-columns: 86px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
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) {
|
||||
color: #0f172a;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
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) {
|
||||
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),
|
||||
@@ -1547,31 +1576,37 @@
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card) {
|
||||
padding: 14px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.workbench-ai-answer-markdown :deep(.ai-document-card__head) {
|
||||
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) {
|
||||
flex-basis: 100%;
|
||||
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 {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fetchOntologyParse } from '../services/ontology.js'
|
||||
import { fetchLatestConversation } from '../services/orchestrator.js'
|
||||
import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js'
|
||||
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
|
||||
import { isApplicationDocumentNo } from '../utils/documentClassification.js'
|
||||
import {
|
||||
ASSISTANT_SCOPE_SESSION_STEWARD,
|
||||
buildUnsupportedBusinessScopeConversation,
|
||||
@@ -428,8 +429,7 @@ export function useAppShell() {
|
||||
return (
|
||||
documentType === 'application'
|
||||
|| documentType === 'expense_application'
|
||||
|| normalizedClaimNo.startsWith('AP-')
|
||||
|| normalizedClaimNo.startsWith('APP-')
|
||||
|| isApplicationDocumentNo(normalizedClaimNo)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
import { fetchAllExpenseClaims } from '../services/reimbursements.js'
|
||||
import { isApplicationDocumentNo } from '../utils/documentClassification.js'
|
||||
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js'
|
||||
|
||||
const EXPENSE_TYPE_LABELS = {
|
||||
@@ -212,8 +213,7 @@ function resolveDocumentTypeMeta(claim, typeCode) {
|
||||
const isApplication =
|
||||
explicitType === DOCUMENT_TYPE_APPLICATION
|
||||
|| explicitType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| isApplicationDocumentNo(claimNo)
|
||||
|| normalizedType === 'application'
|
||||
|| normalizedType.endsWith('_application')
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { extractExpenseClaimItems } from '../services/reimbursements.js'
|
||||
import { isApplicationDocumentNo } from './documentClassification.js'
|
||||
|
||||
const DOCUMENT_QUERY_LIMIT = 8
|
||||
|
||||
@@ -336,8 +337,7 @@ function resolveDocumentTypeCode(claim = {}) {
|
||||
|| explicitType === 'expense_application'
|
||||
|| expenseType === 'application'
|
||||
|| expenseType.endsWith('_application')
|
||||
|| documentNo.startsWith('AP-')
|
||||
|| documentNo.startsWith('APP-')
|
||||
|| isApplicationDocumentNo(documentNo)
|
||||
) {
|
||||
return 'application'
|
||||
}
|
||||
@@ -679,49 +679,66 @@ function buildDocumentCardHtml(record = {}) {
|
||||
const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement'
|
||||
const statusTone = record.statusTone || 'is-pending'
|
||||
const amountLabel = record.documentType === 'application' ? '预计金额' : '报销金额'
|
||||
|
||||
// footer 左侧辅助元信息:业务地点(可选)+ 时间
|
||||
const metaParts = []
|
||||
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>`
|
||||
const ownerText = [record.ownerLabel, record.departmentLabel]
|
||||
.filter((item) => item && item !== '未显示')
|
||||
.join(' · ') || '未显示'
|
||||
|
||||
return [
|
||||
`<article class="ai-document-card ai-document-card--${typeClass} ${statusTone}" aria-label="单据详情">`,
|
||||
'<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__type">${escapeHtml(record.documentTypeLabel)} · ${escapeHtml(record.typeLabel)}</span>`,
|
||||
'</div>',
|
||||
`<span class="ai-document-card__number">${escapeHtml(record.documentNo || '未编号单据')}</span>`,
|
||||
'</header>',
|
||||
'<div class="ai-document-card__body">',
|
||||
`<strong class="ai-document-card__reason">${escapeHtml(record.reason)}</strong>`,
|
||||
'<div class="ai-document-card__owner-line">',
|
||||
`<span class="ai-document-card__owner">${escapeHtml(record.ownerLabel)}</span>`,
|
||||
'<span class="ai-document-card__dot">·</span>',
|
||||
`<span class="ai-document-card__dept">${escapeHtml(record.departmentLabel)}</span>`,
|
||||
'<div class="ai-document-card__details">',
|
||||
'<div class="ai-document-card__field">',
|
||||
'<span class="ai-document-card__label">单据类型</span>',
|
||||
`<strong class="ai-document-card__value">${escapeHtml(record.documentTypeLabel)} · ${escapeHtml(record.typeLabel)}</strong>`,
|
||||
'</div>',
|
||||
'</div>',
|
||||
'<footer class="ai-document-card__foot">',
|
||||
metaHtml,
|
||||
'<div class="ai-document-card__amount-block">',
|
||||
`<span class="ai-document-card__amount-label">${escapeHtml(amountLabel)}</span>`,
|
||||
'<div class="ai-document-card__field">',
|
||||
`<span class="ai-document-card__label">${escapeHtml(amountLabel)}</span>`,
|
||||
`<strong class="ai-document-card__amount">${escapeHtml(record.amountLabel)}</strong>`,
|
||||
'</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
|
||||
? `<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>`
|
||||
: '',
|
||||
'</footer>',
|
||||
: '<span class="ai-document-card__value">暂无详情</span>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'</article>'
|
||||
].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 [
|
||||
'<!-- ai-trusted-html:start -->',
|
||||
querySummaryHtml,
|
||||
'<section class="ai-document-card-list" aria-label="单据查询结果">',
|
||||
...records.map((record) => buildDocumentCardHtml(record)),
|
||||
'</section>',
|
||||
@@ -772,9 +789,9 @@ export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) {
|
||||
const lines = [
|
||||
'### 已查询到相关单据',
|
||||
'',
|
||||
`**查询范围**:${scopeText || '相关单据'};共找到 **${records.length}** 张,先展示最近 **${visibleRecords.length}** 张。`,
|
||||
'',
|
||||
buildDocumentCardsHtml(visibleRecords)
|
||||
buildDocumentCardsHtml(visibleRecords, {
|
||||
querySummaryHtml: buildDocumentQuerySummaryHtml(scopeText, records.length, visibleRecords.length)
|
||||
})
|
||||
]
|
||||
|
||||
if (records.length > visibleRecords.length) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { filterActionableRiskFlags, normalizeRiskFlagTone } from './riskFlags.js'
|
||||
import { canViewRiskForContext } from './riskVisibility.js'
|
||||
import { isApplicationDocumentNo } from './documentClassification.js'
|
||||
|
||||
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment'])
|
||||
@@ -37,8 +38,7 @@ function isApplicationDocumentRequest(request) {
|
||||
return (
|
||||
documentType === 'application'
|
||||
|| documentType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| isApplicationDocumentNo(claimNo)
|
||||
|| typeCode === 'application'
|
||||
|| typeCode.endsWith('_application')
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
const explicitType = String(
|
||||
value?.documentTypeCode
|
||||
@@ -14,8 +25,7 @@ export function isApplicationRequestLike(value) {
|
||||
return (
|
||||
explicitType === 'application'
|
||||
|| explicitType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| isApplicationDocumentNo(claimNo)
|
||||
|| typeCode === 'application'
|
||||
|| typeCode.endsWith('_application')
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isFinanceUser,
|
||||
isPlatformAdminUser
|
||||
} from './accessControl.js'
|
||||
import { isApplicationDocumentNo } from './documentClassification.js'
|
||||
|
||||
const APPLICATION_STAGE_ALIASES = new Set([
|
||||
'expense_application',
|
||||
@@ -159,8 +160,7 @@ export function isApplicationRiskStageRequest(request = {}) {
|
||||
return (
|
||||
documentType === 'application' ||
|
||||
documentType === 'expense_application' ||
|
||||
claimNo.startsWith('AP-') ||
|
||||
claimNo.startsWith('APP-') ||
|
||||
isApplicationDocumentNo(claimNo) ||
|
||||
typeCode === 'application' ||
|
||||
typeCode.endsWith('_application')
|
||||
)
|
||||
|
||||
@@ -273,7 +273,27 @@ const sidebarCollapsed = ref(false)
|
||||
const sidebarCollapsedBeforeAiMode = ref(false)
|
||||
const mobileSidebarOpen = ref(false)
|
||||
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 aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
|
||||
const aiActiveConversationId = ref('')
|
||||
@@ -343,7 +363,6 @@ const {
|
||||
topBarView
|
||||
} = useAppShell()
|
||||
|
||||
const { companyProfile, currentUser, logout } = useSystemState()
|
||||
const PRODUCT_DISPLAY_NAME = '易财费控'
|
||||
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
|
||||
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
||||
@@ -496,7 +515,14 @@ function handleLogout() {
|
||||
|
||||
watch(
|
||||
() => 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 || {})
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
|
||||
|
||||
const REQUIRED_APPLICATION_EXPENSE_TYPES = new Set(['travel', 'meal'])
|
||||
|
||||
const APPLICATION_TYPE_ALIASES = {
|
||||
@@ -302,8 +304,7 @@ export function isExpenseApplicationClaim(claim) {
|
||||
|
||||
return documentType === 'application'
|
||||
|| documentType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| isApplicationDocumentNo(claimNo)
|
||||
|| expenseType === 'application'
|
||||
|| expenseType.endsWith('_application')
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
@@ -22,7 +24,7 @@ function isApplicationDocumentRequest(requestModel) {
|
||||
|| requestModel?.document_type
|
||||
).toLowerCase()
|
||||
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) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
@@ -112,7 +114,7 @@ export function resolveRequestBusinessStage(request = {}) {
|
||||
|| request?.document_no
|
||||
|| request?.id
|
||||
).toUpperCase()
|
||||
if (claimNo.startsWith('AP-') || claimNo.startsWith('APP-')) {
|
||||
if (isApplicationDocumentNo(claimNo)) {
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
|
||||
|
||||
export const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
{ value: 'train_ticket', label: '火车票' },
|
||||
@@ -83,8 +85,7 @@ export function isApplicationDocumentRequest(request) {
|
||||
return (
|
||||
documentType === 'application'
|
||||
|| documentType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| isApplicationDocumentNo(claimNo)
|
||||
|| typeCode === 'application'
|
||||
|| typeCode.endsWith('_application')
|
||||
)
|
||||
|
||||
@@ -718,7 +718,7 @@ export function useTravelReimbursementFlow({
|
||||
function buildApplicationDuplicateDetail(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
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
|
||||
? `已拦截重复申请,已有申请单:${claimNo}`
|
||||
: '已拦截重复申请,未创建新申请单'
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
collectReceiptFiles
|
||||
} from './travelReimbursementAttachmentModel.js'
|
||||
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
|
||||
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
|
||||
import {
|
||||
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||||
applyApplicationBusinessTimeContext,
|
||||
@@ -148,8 +149,7 @@ function isApplicationClaimRecord(claim) {
|
||||
expenseType === 'application' ||
|
||||
expenseType === 'expense_application' ||
|
||||
expenseType.endsWith('_application') ||
|
||||
claimNo.startsWith('AP-') ||
|
||||
claimNo.startsWith('APP-') ||
|
||||
isApplicationDocumentNo(claimNo) ||
|
||||
Boolean(extractApplicationDetailFromClaim(claim))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -127,24 +127,33 @@ test('AI document query message renders html document cards with detail actions'
|
||||
|
||||
assert.match(message, /### 已查询到相关单据/)
|
||||
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="单据查询结果">/)
|
||||
// 申请单 app-1 状态为 approved → is-success 语义类
|
||||
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, /<span class="ai-document-card__status">已审批<\/span>/)
|
||||
assert.match(message, /<strong class="ai-document-card__reason">辅助国网仿生产服务器部署<\/strong>/)
|
||||
assert.match(message, /<span class="ai-document-card__owner">曹小筑<\/span>/)
|
||||
assert.match(message, /<span class="ai-document-card__dept">交付部<\/span>/)
|
||||
assert.match(message, /<span class="ai-document-card__number">AP-20260220001<\/span>/)
|
||||
assert.match(message, /<div class="ai-document-card__details">/)
|
||||
assert.match(message, /<span class="ai-document-card__label">单据类型<\/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, /<div class="ai-document-card__meta">/)
|
||||
assert.match(message, /<span class="ai-document-card__meta-item">上海<\/span>/)
|
||||
assert.match(message, /<div class="ai-document-card__field ai-document-card__field--action">/)
|
||||
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"/)
|
||||
// 报销单 claim-1 状态为 submitted → is-pending 语义类
|
||||
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.doesNotMatch(message, /\| 单据编号 \|/)
|
||||
assert.doesNotMatch(message, /^> /m)
|
||||
assert.doesNotMatch(message, /\*\*查询范围\*\*/)
|
||||
})
|
||||
|
||||
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))
|
||||
|
||||
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, /<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__meta"/)
|
||||
assert.match(rendered, /class="ai-document-card__meta-item"/)
|
||||
assert.match(rendered, /class="ai-document-card__details"/)
|
||||
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, /href="#ai-open-document-detail:CL-20260221001"/)
|
||||
assert.doesNotMatch(rendered, /ai-document-card__meta/)
|
||||
assert.doesNotMatch(rendered, /<section class="ai-document-card-list/)
|
||||
assert.doesNotMatch(rendered, /<blockquote>/)
|
||||
})
|
||||
|
||||
20
web/tests/document-classification.test.mjs
Normal file
20
web/tests/document-classification.test.mjs
Normal 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)
|
||||
})
|
||||
@@ -172,7 +172,10 @@ const orbIconPngAsset = fileURLToPath(
|
||||
const orbIconBuffer = readFileSync(orbIconAsset)
|
||||
|
||||
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, /const nextMode = workbenchMode\.value === 'ai' \? 'traditional' : 'ai'/)
|
||||
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.match(aiMode, /继续和小财管家对话\.\.\./)
|
||||
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__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__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-table-wrap\)/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-list\)/)
|
||||
|
||||
Reference in New Issue
Block a user