Compare commits
3 Commits
96c2e1099a
...
0cda750ff0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cda750ff0 | ||
|
|
81e990ab72 | ||
|
|
47c6a4bb73 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -10,16 +10,17 @@ from app.api.deps import CurrentUserContext, get_current_user, get_db
|
|||||||
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
|
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
|
||||||
from app.schemas.budget import BudgetClaimAnalysisRead
|
from app.schemas.budget import BudgetClaimAnalysisRead
|
||||||
from app.schemas.common import ErrorResponse, PaginatedResponse
|
from app.schemas.common import ErrorResponse, PaginatedResponse
|
||||||
|
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||||
from app.schemas.reimbursement import (
|
from app.schemas.reimbursement import (
|
||||||
ExpenseApplicationPreviewActionPayload,
|
ExpenseApplicationPreviewActionPayload,
|
||||||
ExpenseApplicationPreviewActionResponse,
|
ExpenseApplicationPreviewActionResponse,
|
||||||
ExpenseApplicationPreviewActionResult,
|
ExpenseApplicationPreviewActionResult,
|
||||||
ExpenseClaimAttachmentActionResponse,
|
|
||||||
ExpenseClaimActionResponse,
|
ExpenseClaimActionResponse,
|
||||||
ExpenseClaimAttachmentRead,
|
|
||||||
ExpenseClaimApprovalPayload,
|
ExpenseClaimApprovalPayload,
|
||||||
ExpenseClaimItemCreate,
|
ExpenseClaimAttachmentActionResponse,
|
||||||
|
ExpenseClaimAttachmentRead,
|
||||||
ExpenseClaimItemActionResponse,
|
ExpenseClaimItemActionResponse,
|
||||||
|
ExpenseClaimItemCreate,
|
||||||
ExpenseClaimItemUpdate,
|
ExpenseClaimItemUpdate,
|
||||||
ExpenseClaimRead,
|
ExpenseClaimRead,
|
||||||
ExpenseClaimReturnPayload,
|
ExpenseClaimReturnPayload,
|
||||||
@@ -30,9 +31,9 @@ from app.schemas.reimbursement import (
|
|||||||
TravelReimbursementCalculatorRequest,
|
TravelReimbursementCalculatorRequest,
|
||||||
TravelReimbursementCalculatorResponse,
|
TravelReimbursementCalculatorResponse,
|
||||||
)
|
)
|
||||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
|
||||||
from app.schemas.user_agent import UserAgentRequest
|
from app.schemas.user_agent import UserAgentRequest
|
||||||
from app.services.budget import BudgetService
|
from app.services.budget import BudgetService
|
||||||
|
from app.services.document_numbering import is_application_claim_no
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
from app.services.reimbursement import ReimbursementService
|
from app.services.reimbursement import ReimbursementService
|
||||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||||
@@ -119,7 +120,10 @@ def _build_application_preview_action_context(
|
|||||||
"/application-preview-action",
|
"/application-preview-action",
|
||||||
response_model=ExpenseApplicationPreviewActionResponse,
|
response_model=ExpenseApplicationPreviewActionResponse,
|
||||||
summary="按申请核对预览快速保存或提交申请单",
|
summary="按申请核对预览快速保存或提交申请单",
|
||||||
description="用于 AI 工作台已完成表格核对后的轻量建单/提交流程,避免重复进入通用 Orchestrator 编排。",
|
description=(
|
||||||
|
"用于 AI 工作台已完成表格核对后的轻量建单/提交流程,"
|
||||||
|
"避免重复进入通用 Orchestrator 编排。"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
def run_application_preview_action(
|
def run_application_preview_action(
|
||||||
payload: ExpenseApplicationPreviewActionPayload,
|
payload: ExpenseApplicationPreviewActionPayload,
|
||||||
@@ -831,7 +835,7 @@ def pay_expense_claim(
|
|||||||
"/claims/{claim_id}",
|
"/claims/{claim_id}",
|
||||||
response_model=ExpenseClaimActionResponse,
|
response_model=ExpenseClaimActionResponse,
|
||||||
summary="删除报销单",
|
summary="删除报销单",
|
||||||
description="申请人可删除自己的草稿、待补充或退回单据(含申请单和报销单);高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
|
description="申请人可删除自己的草稿、待补充或退回单据(含申请单和报销单);系统管理员可删除单据;已归档单据仅系统管理员可删除。",
|
||||||
responses={
|
responses={
|
||||||
status.HTTP_404_NOT_FOUND: {
|
status.HTTP_404_NOT_FOUND: {
|
||||||
"model": ErrorResponse,
|
"model": ErrorResponse,
|
||||||
@@ -855,7 +859,11 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
|
|||||||
|
|
||||||
claim_no = str(claim.claim_no or "").strip()
|
claim_no = str(claim.claim_no or "").strip()
|
||||||
expense_type = str(claim.expense_type or "").strip().lower()
|
expense_type = str(claim.expense_type or "").strip().lower()
|
||||||
document_label = "申请单" if claim_no.upper().startswith(("AP-", "APP-")) or expense_type.endswith("_application") else "报销单"
|
document_label = (
|
||||||
|
"申请单"
|
||||||
|
if is_application_claim_no(claim_no) or expense_type.endswith("_application")
|
||||||
|
else "报销单"
|
||||||
|
)
|
||||||
return ExpenseClaimActionResponse(
|
return ExpenseClaimActionResponse(
|
||||||
message=f"{claim.claim_no} {document_label}已删除。",
|
message=f"{claim.claim_no} {document_label}已删除。",
|
||||||
claim_id=claim.id,
|
claim_id=claim.id,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.services.budget_types import (
|
|||||||
SUBJECT_CODE_ALIASES,
|
SUBJECT_CODE_ALIASES,
|
||||||
SUPPORTED_BUDGET_SUBJECT_CODES,
|
SUPPORTED_BUDGET_SUBJECT_CODES,
|
||||||
)
|
)
|
||||||
|
from app.services.document_numbering import is_application_claim_no
|
||||||
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
||||||
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
|
from app.services.expense_claim_risk_stage import enrich_risk_flag_semantics
|
||||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||||
@@ -349,7 +350,11 @@ class BudgetSupportMixin:
|
|||||||
def _reservation_source_type_from_claim(claim: ExpenseClaim) -> str:
|
def _reservation_source_type_from_claim(claim: ExpenseClaim) -> str:
|
||||||
claim_no = str(claim.claim_no or "").strip().upper()
|
claim_no = str(claim.claim_no or "").strip().upper()
|
||||||
expense_type = str(claim.expense_type or "").strip().lower()
|
expense_type = str(claim.expense_type or "").strip().lower()
|
||||||
if claim_no.startswith(("AP-", "APP-")) or expense_type == "application" or expense_type.endswith("_application"):
|
if (
|
||||||
|
is_application_claim_no(claim_no)
|
||||||
|
or expense_type == "application"
|
||||||
|
or expense_type.endswith("_application")
|
||||||
|
):
|
||||||
return "application"
|
return "application"
|
||||||
return "claim"
|
return "claim"
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime
|
from datetime import datetime
|
||||||
from typing import Callable, Literal
|
from typing import Callable, Literal
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -13,20 +13,29 @@ from app.models.financial_record import ExpenseClaim
|
|||||||
DocumentNumberKind = Literal["application", "reimbursement", "audit"]
|
DocumentNumberKind = Literal["application", "reimbursement", "audit"]
|
||||||
|
|
||||||
DOCUMENT_NUMBER_PREFIXES: dict[DocumentNumberKind, str] = {
|
DOCUMENT_NUMBER_PREFIXES: dict[DocumentNumberKind, str] = {
|
||||||
"application": "AP",
|
"application": "A",
|
||||||
"reimbursement": "RE",
|
"reimbursement": "R",
|
||||||
"audit": "AD",
|
"audit": "D",
|
||||||
}
|
}
|
||||||
DOCUMENT_NUMBER_TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
DOCUMENT_NUMBER_TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
DOCUMENT_NUMBER_TOKEN_LENGTH = 8
|
DOCUMENT_NUMBER_TOKEN_LENGTH = 8
|
||||||
|
DOCUMENT_NUMBER_SHORT_BODY = (
|
||||||
|
rf"(?:A|R|D)[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
|
||||||
|
)
|
||||||
|
DOCUMENT_NUMBER_LEGACY_BODY = (
|
||||||
|
rf"(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
|
||||||
|
)
|
||||||
DOCUMENT_NUMBER_PATTERN = re.compile(
|
DOCUMENT_NUMBER_PATTERN = re.compile(
|
||||||
rf"^(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}$",
|
rf"^(?:{DOCUMENT_NUMBER_SHORT_BODY}|{DOCUMENT_NUMBER_LEGACY_BODY})$",
|
||||||
flags=re.IGNORECASE,
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
DOCUMENT_NUMBER_EXTRACT_PATTERN = re.compile(
|
DOCUMENT_NUMBER_EXTRACT_PATTERN = re.compile(
|
||||||
rf"(?:AP|RE|AD)-\d{{14}}-[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}"
|
rf"(?<![A-Z0-9])(?:"
|
||||||
|
rf"{DOCUMENT_NUMBER_SHORT_BODY}"
|
||||||
|
rf"|{DOCUMENT_NUMBER_LEGACY_BODY}"
|
||||||
r"|APP-\d{8}-[A-Z0-9]{6}"
|
r"|APP-\d{8}-[A-Z0-9]{6}"
|
||||||
r"|EXP-\d{6}-\d{3}",
|
r"|EXP-\d{6}-\d{3}"
|
||||||
|
r")(?![A-Z0-9])",
|
||||||
flags=re.IGNORECASE,
|
flags=re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,16 +54,13 @@ def build_document_number(
|
|||||||
token: str | None = None,
|
token: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
prefix = DOCUMENT_NUMBER_PREFIXES[kind]
|
prefix = DOCUMENT_NUMBER_PREFIXES[kind]
|
||||||
generated_at = timestamp or datetime.now(UTC)
|
|
||||||
if generated_at.tzinfo is None:
|
|
||||||
generated_at = generated_at.replace(tzinfo=UTC)
|
|
||||||
normalized_token = (token or generate_document_token()).strip().upper()
|
normalized_token = (token or generate_document_token()).strip().upper()
|
||||||
if not re.fullmatch(
|
if not re.fullmatch(
|
||||||
rf"[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
|
rf"[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
|
||||||
normalized_token,
|
normalized_token,
|
||||||
):
|
):
|
||||||
raise ValueError("document number token must be 8 chars from the configured alphabet")
|
raise ValueError("document number token must be 8 chars from the configured alphabet")
|
||||||
return f"{prefix}-{generated_at.astimezone(UTC):%Y%m%d%H%M%S}-{normalized_token}"
|
return f"{prefix}{normalized_token}"
|
||||||
|
|
||||||
|
|
||||||
def generate_unique_expense_claim_no(
|
def generate_unique_expense_claim_no(
|
||||||
@@ -83,4 +89,9 @@ def generate_unique_expense_claim_no(
|
|||||||
|
|
||||||
def is_application_claim_no(value: object) -> bool:
|
def is_application_claim_no(value: object) -> bool:
|
||||||
normalized = str(value or "").strip().upper()
|
normalized = str(value or "").strip().upper()
|
||||||
return normalized.startswith(("AP-", "APP-"))
|
return bool(
|
||||||
|
re.fullmatch(
|
||||||
|
rf"A[{DOCUMENT_NUMBER_TOKEN_ALPHABET}]{{{DOCUMENT_NUMBER_TOKEN_LENGTH}}}",
|
||||||
|
normalized,
|
||||||
|
)
|
||||||
|
) or normalized.startswith(("AP-", "APP-"))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.models.employee import Employee
|
|||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.models.organization import OrganizationUnit
|
from app.models.organization import OrganizationUnit
|
||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
|
from app.services.document_numbering import is_application_claim_no
|
||||||
from app.services.expense_claim_workflow_constants import (
|
from app.services.expense_claim_workflow_constants import (
|
||||||
APPLICATION_ARCHIVE_STAGE,
|
APPLICATION_ARCHIVE_STAGE,
|
||||||
ARCHIVE_ACCOUNTING_STAGE,
|
ARCHIVE_ACCOUNTING_STAGE,
|
||||||
@@ -42,6 +43,14 @@ class ExpenseClaimAccessPolicy:
|
|||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_application_claim_no_condition(claim_no: Any) -> Any:
|
||||||
|
return or_(
|
||||||
|
claim_no.like("AP-%"),
|
||||||
|
claim_no.like("APP-%"),
|
||||||
|
claim_no.like("A________"),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def has_privileged_claim_access(current_user: CurrentUserContext) -> bool:
|
def has_privileged_claim_access(current_user: CurrentUserContext) -> bool:
|
||||||
if current_user.is_admin:
|
if current_user.is_admin:
|
||||||
@@ -61,8 +70,7 @@ class ExpenseClaimAccessPolicy:
|
|||||||
normalized_type = func.lower(func.coalesce(ExpenseClaim.expense_type, ""))
|
normalized_type = func.lower(func.coalesce(ExpenseClaim.expense_type, ""))
|
||||||
claim_no = func.upper(func.coalesce(ExpenseClaim.claim_no, ""))
|
claim_no = func.upper(func.coalesce(ExpenseClaim.claim_no, ""))
|
||||||
application_condition = or_(
|
application_condition = or_(
|
||||||
claim_no.like("AP-%"),
|
ExpenseClaimAccessPolicy._build_application_claim_no_condition(claim_no),
|
||||||
claim_no.like("APP-%"),
|
|
||||||
normalized_type == "application",
|
normalized_type == "application",
|
||||||
normalized_type.like("%\\_application", escape="\\"),
|
normalized_type.like("%\\_application", escape="\\"),
|
||||||
)
|
)
|
||||||
@@ -101,9 +109,9 @@ class ExpenseClaimAccessPolicy:
|
|||||||
normalized_status = str(claim.status or "").strip().lower()
|
normalized_status = str(claim.status or "").strip().lower()
|
||||||
stage = str(claim.approval_stage or "").strip()
|
stage = str(claim.approval_stage or "").strip()
|
||||||
normalized_type = str(claim.expense_type or "").strip().lower()
|
normalized_type = str(claim.expense_type or "").strip().lower()
|
||||||
claim_no = str(claim.claim_no or "").strip().upper()
|
claim_no = str(claim.claim_no or "").strip()
|
||||||
is_application_claim = (
|
is_application_claim = (
|
||||||
claim_no.startswith(("AP-", "APP-"))
|
is_application_claim_no(claim_no)
|
||||||
or normalized_type == "application"
|
or normalized_type == "application"
|
||||||
or normalized_type.endswith("_application")
|
or normalized_type.endswith("_application")
|
||||||
)
|
)
|
||||||
@@ -715,8 +723,9 @@ class ExpenseClaimAccessPolicy:
|
|||||||
"%\\_application",
|
"%\\_application",
|
||||||
escape="\\",
|
escape="\\",
|
||||||
),
|
),
|
||||||
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("AP-%"),
|
~self._build_application_claim_no_condition(
|
||||||
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("APP-%"),
|
func.upper(func.coalesce(ExpenseClaim.claim_no, ""))
|
||||||
|
),
|
||||||
~self.build_archived_claim_condition(),
|
~self.build_archived_claim_condition(),
|
||||||
)
|
)
|
||||||
conditions.append(company_reimbursement_condition)
|
conditions.append(company_reimbursement_condition)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.services.expense_claim_workflow_constants import (
|
|||||||
PAYMENT_PAID_STAGE,
|
PAYMENT_PAID_STAGE,
|
||||||
PAYMENT_PENDING_STAGE,
|
PAYMENT_PENDING_STAGE,
|
||||||
)
|
)
|
||||||
|
from app.services.document_numbering import is_application_claim_no
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -158,7 +159,7 @@ def is_application_claim_reference(
|
|||||||
normalized_no = str(claim_no or "").strip().upper()
|
normalized_no = str(claim_no or "").strip().upper()
|
||||||
normalized_type = str(expense_type or "").strip().lower()
|
normalized_type = str(expense_type or "").strip().lower()
|
||||||
return (
|
return (
|
||||||
normalized_no.startswith(("AP-", "APP-"))
|
is_application_claim_no(normalized_no)
|
||||||
or normalized_type == "application"
|
or normalized_type == "application"
|
||||||
or normalized_type.endswith("_application")
|
or normalized_type.endswith("_application")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -862,16 +862,13 @@ class ExpenseClaimService(
|
|||||||
if claim is None:
|
if claim is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not self._access_policy.has_claim_delete_access(current_user):
|
|
||||||
raise ValueError("只有 admin 管理员可以删除单据。")
|
|
||||||
|
|
||||||
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
|
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
|
||||||
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
|
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
|
||||||
|
|
||||||
if not self._access_policy.has_claim_delete_access(current_user):
|
if not self._access_policy.has_claim_delete_access(current_user):
|
||||||
self._ensure_draft_claim(claim)
|
self._ensure_draft_claim(claim)
|
||||||
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
if not self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
||||||
raise ValueError("只有高级财务人员可以删除非本人单据,申请人仅可删除自己的草稿、待补充或退回单据。")
|
raise ValueError("只有系统管理员或草稿、待补充、退回待提交阶段的申请人本人可以删除单据。")
|
||||||
|
|
||||||
before_json = self._serialize_claim(claim)
|
before_json = self._serialize_claim(claim)
|
||||||
resource_id = claim.id
|
resource_id = claim.id
|
||||||
@@ -1039,4 +1036,3 @@ class ExpenseClaimService(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ APPLICATION_SUBMIT_KEYWORDS = (
|
|||||||
"确认无误提交",
|
"确认无误提交",
|
||||||
"直接提交",
|
"直接提交",
|
||||||
)
|
)
|
||||||
|
APPLICATION_SAVE_DRAFT_KEYWORDS = (
|
||||||
|
"保存草稿",
|
||||||
|
"保存申请草稿",
|
||||||
|
"存草稿",
|
||||||
|
"先保存",
|
||||||
|
)
|
||||||
APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "是", "好的", "可以", "没问题"}
|
APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "是", "好的", "可以", "没问题"}
|
||||||
APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "无", "null", "none"}
|
APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "无", "null", "none"}
|
||||||
APPLICATION_DUPLICATE_IGNORED_STATUSES = {
|
APPLICATION_DUPLICATE_IGNORED_STATUSES = {
|
||||||
@@ -197,29 +203,45 @@ class UserAgentApplicationMixin:
|
|||||||
facts = self._resolve_expense_application_facts(payload)
|
facts = self._resolve_expense_application_facts(payload)
|
||||||
step = self._resolve_expense_application_step(payload, facts)
|
step = self._resolve_expense_application_step(payload, facts)
|
||||||
application_claim = None
|
application_claim = None
|
||||||
if step == "submitted":
|
if step in {"draft", "submitted"}:
|
||||||
editable_claim = self._find_editable_expense_application_record(payload)
|
editable_claim = self._find_editable_expense_application_record(payload)
|
||||||
if editable_claim is not None:
|
if editable_claim is not None:
|
||||||
application_claim = self._update_expense_application_record(payload, facts, editable_claim)
|
application_claim = self._update_expense_application_record(
|
||||||
|
payload,
|
||||||
|
facts,
|
||||||
|
editable_claim,
|
||||||
|
submit=step == "submitted",
|
||||||
|
)
|
||||||
facts["application_edit_mode"] = "true"
|
facts["application_edit_mode"] = "true"
|
||||||
else:
|
elif step == "submitted":
|
||||||
application_claim = self._find_duplicate_expense_application_record(payload, facts)
|
application_claim = self._find_duplicate_expense_application_record(payload, facts)
|
||||||
if application_claim is not None:
|
if application_claim is not None:
|
||||||
step = "duplicate"
|
step = "duplicate"
|
||||||
facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
|
facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
|
||||||
else:
|
else:
|
||||||
application_claim = self._create_expense_application_record(payload, facts)
|
application_claim = self._create_expense_application_record(
|
||||||
facts["application_no"] = application_claim.claim_no
|
payload,
|
||||||
facts["application_claim_id"] = application_claim.id
|
facts,
|
||||||
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
|
submit=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
application_claim = self._create_expense_application_record(
|
||||||
|
payload,
|
||||||
|
facts,
|
||||||
|
submit=False,
|
||||||
|
)
|
||||||
|
if application_claim is not None:
|
||||||
|
facts["application_no"] = application_claim.claim_no
|
||||||
|
facts["application_claim_id"] = application_claim.id
|
||||||
|
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
|
||||||
return UserAgentResponse(
|
return UserAgentResponse(
|
||||||
answer=self._build_expense_application_answer(payload, facts=facts, step=step),
|
answer=self._build_expense_application_answer(payload, facts=facts, step=step),
|
||||||
citations=[],
|
citations=[],
|
||||||
suggested_actions=self._build_expense_application_actions(step, facts),
|
suggested_actions=self._build_expense_application_actions(step, facts),
|
||||||
query_payload=None,
|
query_payload=None,
|
||||||
draft_payload=(
|
draft_payload=(
|
||||||
self._build_submitted_application_payload(application_claim, facts)
|
self._build_persisted_application_payload(application_claim, facts)
|
||||||
if step == "submitted"
|
if step in {"draft", "submitted"}
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
review_payload=None,
|
review_payload=None,
|
||||||
@@ -251,6 +273,17 @@ class UserAgentApplicationMixin:
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if step == "draft":
|
||||||
|
application_no = str(facts.get("application_no") or "").strip()
|
||||||
|
return "\n\n".join(
|
||||||
|
[
|
||||||
|
"申请草稿已保存。",
|
||||||
|
f"草稿单号:{application_no}" if application_no else "草稿单号:待生成",
|
||||||
|
"当前节点:待提交。",
|
||||||
|
"后续可进入单据详情继续核对、补充或提交审批。",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
if step == "submitted":
|
if step == "submitted":
|
||||||
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
|
application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts)
|
||||||
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
|
manager_name = str(facts.get("manager_name") or "").strip() or "直属领导"
|
||||||
@@ -534,6 +567,8 @@ class UserAgentApplicationMixin:
|
|||||||
payload: UserAgentRequest,
|
payload: UserAgentRequest,
|
||||||
facts: dict[str, str],
|
facts: dict[str, str],
|
||||||
) -> str:
|
) -> str:
|
||||||
|
if self._is_application_save_draft_action(payload):
|
||||||
|
return "draft"
|
||||||
if self._resolve_application_missing_base_fields(facts):
|
if self._resolve_application_missing_base_fields(facts):
|
||||||
return "ask_missing"
|
return "ask_missing"
|
||||||
if self._resolve_application_missing_followup_fields(facts):
|
if self._resolve_application_missing_followup_fields(facts):
|
||||||
@@ -1058,6 +1093,8 @@ class UserAgentApplicationMixin:
|
|||||||
payload: UserAgentRequest,
|
payload: UserAgentRequest,
|
||||||
facts: dict[str, str],
|
facts: dict[str, str],
|
||||||
claim: ExpenseClaim,
|
claim: ExpenseClaim,
|
||||||
|
*,
|
||||||
|
submit: bool,
|
||||||
) -> ExpenseClaim:
|
) -> ExpenseClaim:
|
||||||
current_user = self._build_application_current_user(payload)
|
current_user = self._build_application_current_user(payload)
|
||||||
flags = claim.risk_flags_json
|
flags = claim.risk_flags_json
|
||||||
@@ -1080,6 +1117,14 @@ class UserAgentApplicationMixin:
|
|||||||
claim.occurred_at = self._parse_application_occurred_at(facts.get("time", ""))
|
claim.occurred_at = self._parse_application_occurred_at(facts.get("time", ""))
|
||||||
claim.risk_flags_json = [*preserved_flags, self._build_application_detail_flag(facts)]
|
claim.risk_flags_json = [*preserved_flags, self._build_application_detail_flag(facts)]
|
||||||
|
|
||||||
|
if not submit:
|
||||||
|
claim.status = "draft"
|
||||||
|
claim.approval_stage = "待提交"
|
||||||
|
claim.submitted_at = None
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(claim)
|
||||||
|
return claim
|
||||||
|
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
|
|
||||||
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
|
submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user)
|
||||||
@@ -1091,6 +1136,8 @@ class UserAgentApplicationMixin:
|
|||||||
self,
|
self,
|
||||||
payload: UserAgentRequest,
|
payload: UserAgentRequest,
|
||||||
facts: dict[str, str],
|
facts: dict[str, str],
|
||||||
|
*,
|
||||||
|
submit: bool,
|
||||||
) -> ExpenseClaim:
|
) -> ExpenseClaim:
|
||||||
claim_no = self._build_application_claim_no(payload, facts)
|
claim_no = self._build_application_claim_no(payload, facts)
|
||||||
existing = self.db.scalar(
|
existing = self.db.scalar(
|
||||||
@@ -1130,22 +1177,23 @@ class UserAgentApplicationMixin:
|
|||||||
currency="CNY",
|
currency="CNY",
|
||||||
invoice_count=0,
|
invoice_count=0,
|
||||||
occurred_at=self._parse_application_occurred_at(facts.get("time", "")),
|
occurred_at=self._parse_application_occurred_at(facts.get("time", "")),
|
||||||
submitted_at=datetime.now(UTC),
|
submitted_at=datetime.now(UTC) if submit else None,
|
||||||
status="submitted",
|
status="submitted" if submit else "draft",
|
||||||
approval_stage="直属领导审批",
|
approval_stage="直属领导审批" if submit else "待提交",
|
||||||
risk_flags_json=[self._build_application_detail_flag(facts)],
|
risk_flags_json=[self._build_application_detail_flag(facts)],
|
||||||
)
|
)
|
||||||
self.db.add(claim)
|
self.db.add(claim)
|
||||||
self.db.flush()
|
self.db.flush()
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
if submit:
|
||||||
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
|
|
||||||
platform_review = ExpenseClaimService(self.db).evaluate_platform_risk_rules(
|
platform_review = ExpenseClaimService(self.db).evaluate_platform_risk_rules(
|
||||||
claim,
|
claim,
|
||||||
business_stage="expense_application",
|
business_stage="expense_application",
|
||||||
)
|
)
|
||||||
platform_flags = list(platform_review.get("flags") or [])
|
platform_flags = list(platform_review.get("flags") or [])
|
||||||
if platform_flags:
|
if platform_flags:
|
||||||
claim.risk_flags_json = [*list(claim.risk_flags_json or []), *platform_flags]
|
claim.risk_flags_json = [*list(claim.risk_flags_json or []), *platform_flags]
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(claim)
|
self.db.refresh(claim)
|
||||||
return claim
|
return claim
|
||||||
@@ -1382,7 +1430,7 @@ class UserAgentApplicationMixin:
|
|||||||
return datetime(year, month, day, tzinfo=UTC)
|
return datetime(year, month, day, tzinfo=UTC)
|
||||||
return datetime.now(UTC)
|
return datetime.now(UTC)
|
||||||
|
|
||||||
def _build_submitted_application_payload(
|
def _build_persisted_application_payload(
|
||||||
self,
|
self,
|
||||||
claim: ExpenseClaim | None,
|
claim: ExpenseClaim | None,
|
||||||
facts: dict[str, str],
|
facts: dict[str, str],
|
||||||
@@ -1400,6 +1448,21 @@ class UserAgentApplicationMixin:
|
|||||||
approval_stage=claim.approval_stage,
|
approval_stage=claim.approval_stage,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_application_save_draft_action(payload: UserAgentRequest) -> bool:
|
||||||
|
context_json = payload.context_json or {}
|
||||||
|
action = str(
|
||||||
|
context_json.get("application_action")
|
||||||
|
or context_json.get("applicationAction")
|
||||||
|
or ""
|
||||||
|
).strip().lower()
|
||||||
|
if action in {"save_draft", "application_save_draft", "draft"}:
|
||||||
|
return True
|
||||||
|
if bool(context_json.get("application_save_mode") or context_json.get("applicationSaveMode")):
|
||||||
|
return True
|
||||||
|
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
|
||||||
|
return any(keyword in compact_message for keyword in APPLICATION_SAVE_DRAFT_KEYWORDS)
|
||||||
|
|
||||||
def _is_application_submit_confirmation(self, payload: UserAgentRequest) -> bool:
|
def _is_application_submit_confirmation(self, payload: UserAgentRequest) -> bool:
|
||||||
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
|
compact_message = re.sub(r"\s+", "", str(payload.message or ""))
|
||||||
if any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS):
|
if any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS):
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from sqlalchemy.pool import StaticPool
|
|||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.services.document_numbering import (
|
from app.services.document_numbering import (
|
||||||
|
DOCUMENT_NUMBER_EXTRACT_PATTERN,
|
||||||
|
DOCUMENT_NUMBER_PATTERN,
|
||||||
build_document_number,
|
build_document_number,
|
||||||
generate_unique_expense_claim_no,
|
generate_unique_expense_claim_no,
|
||||||
is_application_claim_no,
|
is_application_claim_no,
|
||||||
@@ -32,19 +34,37 @@ def test_build_document_number_uses_kind_prefix_timestamp_and_token() -> None:
|
|||||||
timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC)
|
timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
build_document_number("application", timestamp=timestamp, token="ABCDEFGH")
|
build_document_number("application", timestamp=timestamp, token="7K3M9Q2P")
|
||||||
== "AP-20260525103045-ABCDEFGH"
|
== "A7K3M9Q2P"
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
build_document_number("reimbursement", timestamp=timestamp, token="ABCDEFGH")
|
build_document_number("reimbursement", timestamp=timestamp, token="7K3M9Q2P")
|
||||||
== "RE-20260525103045-ABCDEFGH"
|
== "R7K3M9Q2P"
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
build_document_number("audit", timestamp=timestamp, token="ABCDEFGH")
|
build_document_number("audit", timestamp=timestamp, token="7K3M9Q2P")
|
||||||
== "AD-20260525103045-ABCDEFGH"
|
== "D7K3M9Q2P"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_number_pattern_accepts_short_and_legacy_numbers() -> None:
|
||||||
|
assert DOCUMENT_NUMBER_PATTERN.fullmatch("A7K3M9Q2P")
|
||||||
|
assert DOCUMENT_NUMBER_PATTERN.fullmatch("R7K3M9Q2P")
|
||||||
|
assert DOCUMENT_NUMBER_PATTERN.fullmatch("D7K3M9Q2P")
|
||||||
|
assert DOCUMENT_NUMBER_PATTERN.fullmatch("AP-20260525103045-ABCDEFGH")
|
||||||
|
assert DOCUMENT_NUMBER_PATTERN.fullmatch("RE-20260525103045-HGFEDCBA")
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_number_extract_pattern_finds_short_and_legacy_numbers() -> None:
|
||||||
|
query = "查看 A7K3M9Q2P、R7K3M9Q2P 和 AP-20260525103045-ABCDEFGH 的状态"
|
||||||
|
|
||||||
|
assert [match.group(0) for match in DOCUMENT_NUMBER_EXTRACT_PATTERN.finditer(query)] == [
|
||||||
|
"A7K3M9Q2P",
|
||||||
|
"R7K3M9Q2P",
|
||||||
|
"AP-20260525103045-ABCDEFGH",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_build_document_number_rejects_ambiguous_token_chars() -> None:
|
def test_build_document_number_rejects_ambiguous_token_chars() -> None:
|
||||||
timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC)
|
timestamp = datetime(2026, 5, 25, 10, 30, 45, tzinfo=UTC)
|
||||||
|
|
||||||
@@ -57,7 +77,7 @@ def test_generate_unique_expense_claim_no_retries_existing_candidate() -> None:
|
|||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
db.add(
|
db.add(
|
||||||
ExpenseClaim(
|
ExpenseClaim(
|
||||||
claim_no="RE-20260525103045-ABCDEFGH",
|
claim_no="RABCDEFGH",
|
||||||
employee_name="张三",
|
employee_name="张三",
|
||||||
department_name="市场部",
|
department_name="市场部",
|
||||||
project_code=None,
|
project_code=None,
|
||||||
@@ -84,11 +104,13 @@ def test_generate_unique_expense_claim_no_retries_existing_candidate() -> None:
|
|||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
token_factory=lambda: next(tokens),
|
token_factory=lambda: next(tokens),
|
||||||
)
|
)
|
||||||
== "RE-20260525103045-HGFEDCBA"
|
== "RHGFEDCBA"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_is_application_claim_no_supports_new_and_legacy_prefixes() -> None:
|
def test_is_application_claim_no_supports_new_and_legacy_prefixes() -> None:
|
||||||
|
assert is_application_claim_no("A7K3M9Q2P")
|
||||||
assert is_application_claim_no("AP-20260525103045-ABCDEFGH")
|
assert is_application_claim_no("AP-20260525103045-ABCDEFGH")
|
||||||
assert is_application_claim_no("APP-20260525-ABC123")
|
assert is_application_claim_no("APP-20260525-ABC123")
|
||||||
|
assert not is_application_claim_no("R7K3M9Q2P")
|
||||||
assert not is_application_claim_no("RE-20260525103045-ABCDEFGH")
|
assert not is_application_claim_no("RE-20260525103045-ABCDEFGH")
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
|||||||
from app.models.role import Role
|
from app.models.role import Role
|
||||||
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
|
||||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
|
||||||
from app.services.ocr import OcrService
|
from app.services.ocr import OcrService
|
||||||
|
|
||||||
|
|
||||||
@@ -814,6 +813,37 @@ def test_claim_delete_allows_admin_and_cleans_risk_observations(monkeypatch, tmp
|
|||||||
assert db.get(RiskObservationFeedback, "risk-observation-feedback-delete-1") is None
|
assert db.get(RiskObservationFeedback, "risk-observation-feedback-delete-1") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path) -> None:
|
||||||
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||||
|
|
||||||
|
client, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
claim, _ = seed_claim(db)
|
||||||
|
claim.claim_no = "AP-20260620-DRAFT"
|
||||||
|
claim.expense_type = "travel_application"
|
||||||
|
claim_id = claim.id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
f"/api/v1/reimbursements/claims/{claim_id}",
|
||||||
|
headers={
|
||||||
|
"x-auth-username": "zhangsan@example.com",
|
||||||
|
"x-auth-name": "张三",
|
||||||
|
"x-auth-employee-no": "E10001",
|
||||||
|
"x-auth-role-codes": "user",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["claim_id"] == claim_id
|
||||||
|
assert payload["status"] == "deleted"
|
||||||
|
assert "申请单已删除" in payload["message"]
|
||||||
|
|
||||||
|
with session_factory() as db:
|
||||||
|
assert db.get(ExpenseClaim, claim_id) is None
|
||||||
|
|
||||||
|
|
||||||
def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(monkeypatch, tmp_path) -> None:
|
def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(monkeypatch, tmp_path) -> None:
|
||||||
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||||
|
|
||||||
@@ -859,7 +889,19 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
|
|||||||
"source": "user_message",
|
"source": "user_message",
|
||||||
"user_id": "zhangsan@example.com",
|
"user_id": "zhangsan@example.com",
|
||||||
"conversation_id": "conversation-fast-submit",
|
"conversation_id": "conversation-fast-submit",
|
||||||
"message": "差旅费用申请提交审批\n申请类型:差旅费用申请\n申请时间:2026-07-01 至 2026-07-03\n地点:北京\n事由:项目实施\n天数:3天\n出行方式:火车\n申请金额:1000元\n直接提交",
|
"message": "\n".join(
|
||||||
|
[
|
||||||
|
"差旅费用申请提交审批",
|
||||||
|
"申请类型:差旅费用申请",
|
||||||
|
"申请时间:2026-07-01 至 2026-07-03",
|
||||||
|
"地点:北京",
|
||||||
|
"事由:项目实施",
|
||||||
|
"天数:3天",
|
||||||
|
"出行方式:火车",
|
||||||
|
"申请金额:1000元",
|
||||||
|
"直接提交",
|
||||||
|
]
|
||||||
|
),
|
||||||
"context_json": {
|
"context_json": {
|
||||||
"session_type": "application",
|
"session_type": "application",
|
||||||
"entry_source": "workbench_ai_inline",
|
"entry_source": "workbench_ai_inline",
|
||||||
@@ -899,3 +941,81 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
|
|||||||
assert claim is not None
|
assert claim is not None
|
||||||
assert claim.status == "submitted"
|
assert claim.status == "submitted"
|
||||||
assert claim.employee_name == "张三"
|
assert claim.employee_name == "张三"
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_preview_action_saves_draft_with_detail_reference(monkeypatch, tmp_path) -> None:
|
||||||
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||||
|
|
||||||
|
client, session_factory = build_client()
|
||||||
|
with session_factory() as db:
|
||||||
|
seed_claim(db)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/reimbursements/application-preview-action",
|
||||||
|
headers={
|
||||||
|
"x-auth-username": "zhangsan@example.com",
|
||||||
|
"x-auth-name": "Zhang San",
|
||||||
|
"x-auth-employee-no": "E10001",
|
||||||
|
"x-auth-role-codes": "user",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"source": "user_message",
|
||||||
|
"user_id": "zhangsan@example.com",
|
||||||
|
"conversation_id": "conversation-fast-save",
|
||||||
|
"message": "\n".join(
|
||||||
|
[
|
||||||
|
"费用申请保存草稿",
|
||||||
|
"申请类型:差旅费用申请",
|
||||||
|
"申请时间:2026-07-04 至 2026-07-05",
|
||||||
|
"地点:上海",
|
||||||
|
"事由:项目验收",
|
||||||
|
"天数:2天",
|
||||||
|
"出行方式:火车",
|
||||||
|
"申请金额:800元",
|
||||||
|
"保存草稿",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
"context_json": {
|
||||||
|
"session_type": "application",
|
||||||
|
"entry_source": "workbench_ai_inline",
|
||||||
|
"document_type": "expense_application",
|
||||||
|
"application_stage": "expense_application",
|
||||||
|
"application_action": "save_draft",
|
||||||
|
"application_save_mode": True,
|
||||||
|
"application_preview": {
|
||||||
|
"fields": {
|
||||||
|
"applicationType": "差旅费用申请",
|
||||||
|
"time": "2026-07-04 至 2026-07-05",
|
||||||
|
"location": "上海",
|
||||||
|
"reason": "项目验收",
|
||||||
|
"days": "2天",
|
||||||
|
"transportMode": "火车",
|
||||||
|
"amount": "800元",
|
||||||
|
"applicant": "张三",
|
||||||
|
"department": "市场部",
|
||||||
|
"position": "招商主管",
|
||||||
|
"grade": "P4",
|
||||||
|
"managerName": "李总",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["status"] == "succeeded"
|
||||||
|
draft_payload = payload["result"]["draft_payload"]
|
||||||
|
assert draft_payload["draft_type"] == "expense_application"
|
||||||
|
assert draft_payload["status"] == "draft"
|
||||||
|
assert draft_payload["approval_stage"] == "待提交"
|
||||||
|
assert draft_payload["claim_id"]
|
||||||
|
assert draft_payload["claim_no"].startswith("AP-")
|
||||||
|
|
||||||
|
with session_factory() as db:
|
||||||
|
claim = db.get(ExpenseClaim, draft_payload["claim_id"])
|
||||||
|
assert claim is not None
|
||||||
|
assert claim.status == "draft"
|
||||||
|
assert claim.approval_stage == "待提交"
|
||||||
|
assert claim.submitted_at is None
|
||||||
|
assert claim.employee_name == "张三"
|
||||||
|
|||||||
@@ -1321,6 +1321,8 @@
|
|||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(table) {
|
.workbench-ai-answer-markdown :deep(table) {
|
||||||
@@ -1342,6 +1344,123 @@
|
|||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-list) {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-item) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1.15fr) minmax(260px, 0.85fr) auto;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px 16px;
|
||||||
|
border: 1px solid rgba(203, 213, 225, 0.86);
|
||||||
|
border-left: 3px solid #60a5fa;
|
||||||
|
border-radius: 14px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.9));
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-main) {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-kicker) {
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-id) {
|
||||||
|
color: #0f172a;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 860;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-reason) {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 660;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-meta) {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-meta-item) {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-meta-item small) {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 760;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-meta-item b) {
|
||||||
|
color: #334155;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 780;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-action) {
|
||||||
|
justify-self: end;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-action .ai-html-action-link) {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-action .ai-html-action-link:hover) {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #ffffff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-action-link.is-disabled) {
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
background: rgba(100, 116, 139, 0.14);
|
||||||
|
color: #64748b;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-action .ai-html-action-link.is-disabled:hover) {
|
||||||
|
background: rgba(100, 116, 139, 0.14);
|
||||||
|
color: #64748b;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-html-image-frame) {
|
.workbench-ai-answer-markdown :deep(.ai-html-image-frame) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1418,6 +1537,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-item) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-ai-answer-markdown :deep(.ai-html-record-action) {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
.workbench-ai-answer-markdown :deep(.ai-document-card) {
|
.workbench-ai-answer-markdown :deep(.ai-document-card) {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.employee-risk-profile-card {
|
.employee-risk-profile-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
padding: 14px 16px;
|
padding: 16px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-head {
|
.employee-risk-head {
|
||||||
@@ -74,17 +74,17 @@
|
|||||||
|
|
||||||
.employee-risk-body {
|
.employee-risk-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-decision-panel {
|
.employee-risk-decision-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.15fr) minmax(220px, .85fr);
|
grid-template-columns: minmax(0, 1fr) minmax(320px, .72fr);
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 12px;
|
gap: 18px;
|
||||||
padding: 12px;
|
padding: 16px 18px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 2px;
|
border-radius: 4px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,9 @@
|
|||||||
.employee-risk-decision-main {
|
.employee-risk-decision-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
align-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-decision-main > span,
|
.employee-risk-decision-main > span,
|
||||||
@@ -117,7 +119,7 @@
|
|||||||
.employee-risk-decision-main strong {
|
.employee-risk-decision-main strong {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
@@ -143,10 +145,10 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 5px;
|
gap: 7px;
|
||||||
padding: 10px 12px;
|
padding: 13px 15px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 2px;
|
border-radius: 4px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,20 +177,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-review-summary {
|
.employee-risk-review-summary {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-review-item {
|
.employee-risk-review-item {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 180px;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
align-content: start;
|
||||||
padding: 9px 10px;
|
gap: 7px;
|
||||||
|
min-height: 66px;
|
||||||
|
padding: 12px 14px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 2px;
|
border-radius: 4px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,10 +235,10 @@
|
|||||||
|
|
||||||
.employee-risk-profile-section {
|
.employee-risk-profile-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 14px 16px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 2px;
|
border-radius: 4px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +267,7 @@
|
|||||||
.employee-risk-profile-list {
|
.employee-risk-profile-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-evidence-row {
|
.employee-risk-evidence-row {
|
||||||
@@ -272,7 +275,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 2px;
|
border-radius: 4px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -301,14 +304,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-evidence-title {
|
.employee-risk-evidence-title {
|
||||||
min-height: 40px;
|
min-height: 48px;
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(72px, auto) 48px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
column-gap: 14px;
|
||||||
gap: 8px;
|
padding: 10px 14px;
|
||||||
padding: 8px 10px;
|
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,10 +322,11 @@
|
|||||||
|
|
||||||
.employee-risk-evidence-title strong {
|
.employee-risk-evidence-title strong {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
flex: 0 0 auto;
|
min-width: 48px;
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 0 6px;
|
justify-self: center;
|
||||||
|
padding: 0 7px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #eef2f7;
|
background: #eef2f7;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
@@ -343,10 +347,11 @@
|
|||||||
|
|
||||||
.employee-risk-evidence-title::after {
|
.employee-risk-evidence-title::after {
|
||||||
content: '展开';
|
content: '展开';
|
||||||
flex: 0 0 auto;
|
justify-self: end;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-evidence-row[open] .employee-risk-evidence-title::after {
|
.employee-risk-evidence-row[open] .employee-risk-evidence-title::after {
|
||||||
@@ -355,9 +360,9 @@
|
|||||||
|
|
||||||
.employee-risk-evidence-row ul {
|
.employee-risk-evidence-row ul {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 6px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 10px 10px 10px;
|
padding: 0 14px 14px 14px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
border-top: 1px solid #e2e8f0;
|
border-top: 1px solid #e2e8f0;
|
||||||
@@ -366,8 +371,8 @@
|
|||||||
.employee-risk-evidence-row li {
|
.employee-risk-evidence-row li {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
line-height: 1.45;
|
line-height: 1.58;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
@@ -383,8 +388,8 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-review-item {
|
.employee-risk-review-summary {
|
||||||
flex-basis: 100%;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-risk-title-wrap,
|
.employee-risk-title-wrap,
|
||||||
|
|||||||
@@ -1140,8 +1140,11 @@ function canShowInlineSuggestedActions(message = {}) {
|
|||||||
function isInlineSuggestedActionDisabled(action = {}, message = {}) {
|
function isInlineSuggestedActionDisabled(action = {}, message = {}) {
|
||||||
const actionType = String(action?.action_type || '').trim()
|
const actionType = String(action?.action_type || '').trim()
|
||||||
return (
|
return (
|
||||||
[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) &&
|
Boolean(action?.disabled) ||
|
||||||
isApplicationPreviewEstimatePending(message)
|
(
|
||||||
|
[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) &&
|
||||||
|
isApplicationPreviewEstimatePending(message)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1267,8 +1270,21 @@ function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildInlineApplicationActionDetailHref(reference = '') {
|
function buildInlineApplicationActionDetailHref(reference = '') {
|
||||||
const value = String(reference || '').trim()
|
const source = reference && typeof reference === 'object' ? reference : { reference }
|
||||||
return value ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(value)}` : ''
|
const claimId = String(source.claimId || source.claim_id || source.id || '').trim()
|
||||||
|
const claimNo = String(source.claimNo || source.claim_no || source.documentNo || source.document_no || '').trim()
|
||||||
|
const fallback = String(source.reference || '').trim()
|
||||||
|
if (claimId || claimNo) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (claimId) {
|
||||||
|
params.set('claim_id', claimId)
|
||||||
|
}
|
||||||
|
if (claimNo) {
|
||||||
|
params.set('claim_no', claimNo)
|
||||||
|
}
|
||||||
|
return `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(params.toString())}`
|
||||||
|
}
|
||||||
|
return fallback ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(fallback)}` : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
|
function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
|
||||||
@@ -1293,7 +1309,7 @@ function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
|
|||||||
function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
|
function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
|
||||||
const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
|
const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
|
||||||
const reference = info.claimNo || info.claimId
|
const reference = info.claimNo || info.claimId
|
||||||
const href = buildInlineApplicationActionDetailHref(reference)
|
const href = buildInlineApplicationActionDetailHref(info)
|
||||||
const actionText = href ? `[查看](${href})` : '-'
|
const actionText = href ? `[查看](${href})` : '-'
|
||||||
return [
|
return [
|
||||||
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
|
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
|
||||||
@@ -1945,22 +1961,40 @@ function parseAiApplicationDetailHref(href = '') {
|
|||||||
if (!encodedReference) {
|
if (!encodedReference) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
let reference = ''
|
||||||
try {
|
try {
|
||||||
const reference = decodeURIComponent(encodedReference).trim()
|
reference = decodeURIComponent(encodedReference).trim()
|
||||||
return reference ? { reference } : null
|
|
||||||
} catch {
|
} catch {
|
||||||
return { reference: encodedReference }
|
reference = encodedReference.trim()
|
||||||
}
|
}
|
||||||
|
if (!reference) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams(reference)
|
||||||
|
const claimId = String(params.get('claim_id') || '').trim()
|
||||||
|
const claimNo = String(params.get('claim_no') || '').trim()
|
||||||
|
if (claimId || claimNo) {
|
||||||
|
return {
|
||||||
|
reference: claimNo || claimId,
|
||||||
|
claimId,
|
||||||
|
claimNo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { reference }
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAiDocumentDetailRequest(detailReference = {}) {
|
function buildAiDocumentDetailRequest(detailReference = {}) {
|
||||||
const reference = String(detailReference.reference || '').trim()
|
const reference = String(detailReference.reference || '').trim()
|
||||||
const isApplication = /^APP?-/i.test(reference)
|
const claimId = String(detailReference.claimId || detailReference.claim_id || '').trim()
|
||||||
|
const claimNo = String(detailReference.claimNo || detailReference.claim_no || '').trim()
|
||||||
|
const lookupReference = claimId || reference
|
||||||
|
const displayReference = claimNo || reference
|
||||||
|
const isApplication = /^APP?-/i.test(displayReference) || Boolean(claimId || claimNo)
|
||||||
return {
|
return {
|
||||||
id: reference,
|
id: lookupReference,
|
||||||
claimId: reference,
|
claimId: claimId || reference,
|
||||||
claimNo: reference,
|
claimNo: claimNo || reference,
|
||||||
documentNo: reference,
|
documentNo: displayReference,
|
||||||
documentType: isApplication ? 'application' : 'reimbursement',
|
documentType: isApplication ? 'application' : 'reimbursement',
|
||||||
documentTypeCode: isApplication ? 'application' : 'reimbursement',
|
documentTypeCode: isApplication ? 'application' : 'reimbursement',
|
||||||
detailLookupOnly: true,
|
detailLookupOnly: true,
|
||||||
@@ -2371,7 +2405,11 @@ function handleInlineSuggestedAction(action = {}, sourceMessage = null) {
|
|||||||
if (actionType === 'open_application_detail') {
|
if (actionType === 'open_application_detail') {
|
||||||
const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim()
|
const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim()
|
||||||
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
|
const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
|
||||||
emit('open-document', buildAiDocumentDetailRequest({ reference: claimNo || claimId }))
|
emit('open-document', buildAiDocumentDetailRequest({
|
||||||
|
reference: claimNo || claimId,
|
||||||
|
claimId,
|
||||||
|
claimNo
|
||||||
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
|
if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
|
||||||
|
|||||||
@@ -321,6 +321,55 @@ function handleCancel() {
|
|||||||
max-height: min(420px, calc(100dvh - 292px));
|
max-height: min(420px, calc(100dvh - 292px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card--destructive {
|
||||||
|
width: min(420px, calc(100vw - 40px));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border-color: rgba(var(--danger-rgb), 0.16);
|
||||||
|
border-radius: 6px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(248, 250, 252, 0.97));
|
||||||
|
box-shadow:
|
||||||
|
0 18px 42px rgba(15, 23, 42, 0.16),
|
||||||
|
0 1px 0 rgba(255, 255, 255, 0.92) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card--destructive .shared-confirm-badge {
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card--destructive h4 {
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.42;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card--destructive p {
|
||||||
|
max-width: 34em;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card--destructive .shared-confirm-actions {
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card--destructive .shared-confirm-btn {
|
||||||
|
min-width: 112px;
|
||||||
|
min-height: 38px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-confirm-card--destructive .shared-confirm-btn.confirm.danger {
|
||||||
|
box-shadow: 0 10px 20px rgba(var(--danger-rgb), 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
.shared-confirm-card--compact h4 {
|
.shared-confirm-card--compact h4 {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
badge-tone="danger"
|
badge-tone="danger"
|
||||||
:title="title"
|
:title="title"
|
||||||
:description="description"
|
:description="description"
|
||||||
|
size="destructive"
|
||||||
|
actions-align="end"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
confirm-text="确认删除"
|
confirm-text="确认删除"
|
||||||
busy-text="删除中..."
|
busy-text="删除中..."
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useToast } from './useToast.js'
|
|||||||
import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../services/reimbursements.js'
|
import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../services/reimbursements.js'
|
||||||
import { fetchOntologyParse } from '../services/ontology.js'
|
import { fetchOntologyParse } from '../services/ontology.js'
|
||||||
import { fetchLatestConversation } from '../services/orchestrator.js'
|
import { fetchLatestConversation } from '../services/orchestrator.js'
|
||||||
|
import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js'
|
||||||
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
|
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
|
||||||
import {
|
import {
|
||||||
ASSISTANT_SCOPE_SESSION_STEWARD,
|
ASSISTANT_SCOPE_SESSION_STEWARD,
|
||||||
@@ -607,6 +608,7 @@ export function useAppShell() {
|
|||||||
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
|
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
|
||||||
if (deletedClaimId) {
|
if (deletedClaimId) {
|
||||||
clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE)
|
clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE)
|
||||||
|
markAiWorkbenchConversationDraftDeleted(currentUser.value || {}, payload)
|
||||||
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
|
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { apiRequest } from './api.js'
|
import { apiRequest } from './api.js'
|
||||||
import { runOrchestrator } from './orchestrator.js'
|
|
||||||
import {
|
import {
|
||||||
buildApplicationPreviewRows,
|
buildApplicationPreviewRows,
|
||||||
buildApplicationPreviewSubmitText,
|
buildApplicationPreviewSubmitText,
|
||||||
@@ -128,19 +127,12 @@ export function buildAiApplicationPreviewActionPayload({
|
|||||||
|
|
||||||
export function runAiApplicationPreviewAction(params = {}, options = {}) {
|
export function runAiApplicationPreviewAction(params = {}, options = {}) {
|
||||||
const payload = buildAiApplicationPreviewActionPayload(params)
|
const payload = buildAiApplicationPreviewActionPayload(params)
|
||||||
if (params.actionType === AI_APPLICATION_ACTION_SUBMIT) {
|
const isSubmit = params.actionType === AI_APPLICATION_ACTION_SUBMIT
|
||||||
return apiRequest('/reimbursements/application-preview-action', {
|
return apiRequest('/reimbursements/application-preview-action', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
timeoutMs: 45000,
|
timeoutMs: isSubmit ? 45000 : 30000,
|
||||||
timeoutMessage: '申请提交处理超时,请稍后重试。',
|
timeoutMessage: isSubmit ? '申请提交处理超时,请稍后重试。' : '申请草稿保存超时,请稍后重试。',
|
||||||
...options
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return runOrchestrator(payload, {
|
|
||||||
timeoutMs: 75000,
|
|
||||||
timeoutMessage: '申请草稿保存超时,请稍后重试。',
|
|
||||||
...options
|
...options
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const BUSINESS_FIELD_LABELS = new Set([
|
|||||||
])
|
])
|
||||||
|
|
||||||
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
||||||
|
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
||||||
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
||||||
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
|
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
|
||||||
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
|
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
|
||||||
@@ -62,6 +63,10 @@ function isApplicationDetailHref(href = '') {
|
|||||||
return String(href || '').trim().startsWith(APPLICATION_DETAIL_HREF_PREFIX)
|
return String(href || '').trim().startsWith(APPLICATION_DETAIL_HREF_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDeletedApplicationDetailHref(href = '') {
|
||||||
|
return String(href || '').trim().startsWith(DELETED_APPLICATION_DETAIL_HREF_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
function isDocumentDetailHref(href = '') {
|
function isDocumentDetailHref(href = '') {
|
||||||
return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX)
|
return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX)
|
||||||
}
|
}
|
||||||
@@ -79,6 +84,17 @@ function sanitizeImageSrc(src = '') {
|
|||||||
|
|
||||||
function renderLinkHtml(label = '', href = '') {
|
function renderLinkHtml(label = '', href = '') {
|
||||||
const sanitizedHref = sanitizeHref(href)
|
const sanitizedHref = sanitizeHref(href)
|
||||||
|
if (isDeletedApplicationDetailHref(href)) {
|
||||||
|
return [
|
||||||
|
'<span',
|
||||||
|
' class="ai-html-action-link ai-html-action-link-application is-disabled"',
|
||||||
|
' data-ai-action="deleted-application-detail"',
|
||||||
|
' aria-disabled="true"',
|
||||||
|
'>',
|
||||||
|
label,
|
||||||
|
'</span>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
if (isApplicationDetailHref(href)) {
|
if (isApplicationDetailHref(href)) {
|
||||||
return [
|
return [
|
||||||
`<a href="${sanitizedHref}"`,
|
`<a href="${sanitizedHref}"`,
|
||||||
@@ -482,6 +498,80 @@ function renderOrderedList(items = []) {
|
|||||||
].join('')
|
].join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTableHeaderCell(value = '') {
|
||||||
|
return String(value || '').replace(/\s+/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTableColumnIndex(normalizedHeader = [], labels = []) {
|
||||||
|
return labels
|
||||||
|
.map((label) => normalizedHeader.indexOf(label))
|
||||||
|
.find((index) => index >= 0) ?? -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTableCell(row = [], normalizedHeader = [], labels = []) {
|
||||||
|
const columnIndex = findTableColumnIndex(normalizedHeader, labels)
|
||||||
|
return columnIndex >= 0 ? String(row[columnIndex] || '').trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMeaningfulTableValue(value = '') {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
return Boolean(text && text !== '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDocumentRecordTable(normalizedHeader = []) {
|
||||||
|
return (
|
||||||
|
normalizedHeader.includes('单据编号') &&
|
||||||
|
normalizedHeader.includes('操作') &&
|
||||||
|
normalizedHeader.some((label) => ['单据类型', '申请时间', '单据状态', '状态', '当前节点', '事由'].includes(label))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecordMeta(label = '', value = '') {
|
||||||
|
if (!hasMeaningfulTableValue(value)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'<span class="ai-html-record-meta-item">',
|
||||||
|
`<small>${escapeHtml(label)}</small>`,
|
||||||
|
`<b>${renderInlineHtml(value)}</b>`,
|
||||||
|
'</span>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDocumentRecordList(header = [], bodyRows = []) {
|
||||||
|
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
|
||||||
|
const items = bodyRows.map((row) => {
|
||||||
|
const documentType = resolveTableCell(row, normalizedHeader, ['单据类型'])
|
||||||
|
const documentNo = resolveTableCell(row, normalizedHeader, ['单据编号'])
|
||||||
|
const applyTime = resolveTableCell(row, normalizedHeader, ['申请时间'])
|
||||||
|
const status = resolveTableCell(row, normalizedHeader, ['单据状态', '状态'])
|
||||||
|
const stage = resolveTableCell(row, normalizedHeader, ['当前节点'])
|
||||||
|
const reason = resolveTableCell(row, normalizedHeader, ['事由'])
|
||||||
|
const action = resolveTableCell(row, normalizedHeader, ['操作'])
|
||||||
|
return [
|
||||||
|
'<article class="ai-html-record-item" role="listitem">',
|
||||||
|
'<div class="ai-html-record-main">',
|
||||||
|
hasMeaningfulTableValue(documentType) ? `<span class="ai-html-record-kicker">${renderInlineHtml(documentType)}</span>` : '',
|
||||||
|
hasMeaningfulTableValue(documentNo) ? `<strong class="ai-html-record-id">${renderInlineHtml(documentNo)}</strong>` : '',
|
||||||
|
hasMeaningfulTableValue(reason) ? `<p class="ai-html-record-reason">${renderInlineHtml(reason)}</p>` : '',
|
||||||
|
'</div>',
|
||||||
|
'<div class="ai-html-record-meta">',
|
||||||
|
renderRecordMeta('申请时间', applyTime),
|
||||||
|
renderRecordMeta('状态', status),
|
||||||
|
renderRecordMeta('当前节点', stage),
|
||||||
|
'</div>',
|
||||||
|
hasMeaningfulTableValue(action) ? `<div class="ai-html-record-action">${renderInlineHtml(action)}</div>` : '',
|
||||||
|
'</article>'
|
||||||
|
].join('')
|
||||||
|
}).filter(Boolean)
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<div class="ai-html-record-list" role="list">',
|
||||||
|
...items,
|
||||||
|
'</div>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
function renderTable(lines = []) {
|
function renderTable(lines = []) {
|
||||||
const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length)
|
const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length)
|
||||||
if (rows.length < 2) {
|
if (rows.length < 2) {
|
||||||
@@ -489,6 +579,10 @@ function renderTable(lines = []) {
|
|||||||
}
|
}
|
||||||
const header = rows[0]
|
const header = rows[0]
|
||||||
const bodyRows = rows.slice(2)
|
const bodyRows = rows.slice(2)
|
||||||
|
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
|
||||||
|
if (isDocumentRecordTable(normalizedHeader)) {
|
||||||
|
return renderDocumentRecordList(header, bodyRows)
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'<div class="ai-html-table-wrap">',
|
'<div class="ai-html-table-wrap">',
|
||||||
|
|||||||
@@ -1,11 +1,144 @@
|
|||||||
const STORAGE_KEY_PREFIX = 'x-financial:workbench-ai-conversations'
|
const STORAGE_KEY_PREFIX = 'x-financial:workbench-ai-conversations'
|
||||||
const MAX_CONVERSATION_HISTORY = 30
|
const MAX_CONVERSATION_HISTORY = 30
|
||||||
const MAX_STORED_MESSAGES = 80
|
const MAX_STORED_MESSAGES = 80
|
||||||
|
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:'
|
||||||
|
const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
||||||
|
const APPLICATION_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-application-detail:[^)]+)\)/g
|
||||||
|
|
||||||
function safeString(value) {
|
function safeString(value) {
|
||||||
return String(value || '').trim()
|
return String(value || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeIdentifier(value) {
|
||||||
|
return safeString(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDeletedDraftIdentifiers(payload = {}) {
|
||||||
|
return new Set([
|
||||||
|
payload.claimId,
|
||||||
|
payload.claim_id,
|
||||||
|
payload.id,
|
||||||
|
payload.claimNo,
|
||||||
|
payload.claim_no,
|
||||||
|
payload.documentNo,
|
||||||
|
payload.document_no
|
||||||
|
].map((item) => normalizeIdentifier(item)).filter(Boolean))
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeApplicationDetailHref(href = '') {
|
||||||
|
const value = safeString(href)
|
||||||
|
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const encodedReference = value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)
|
||||||
|
if (!encodedReference) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let reference = ''
|
||||||
|
try {
|
||||||
|
reference = decodeURIComponent(encodedReference).trim()
|
||||||
|
} catch {
|
||||||
|
reference = encodedReference.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifiers = new Set([reference, encodedReference].map((item) => normalizeIdentifier(item)).filter(Boolean))
|
||||||
|
const params = new URLSearchParams(reference)
|
||||||
|
const detailParamKeys = ['claim_id', 'claim_no', 'document_no']
|
||||||
|
detailParamKeys.forEach((key) => {
|
||||||
|
const paramValue = normalizeIdentifier(params.get(key))
|
||||||
|
if (paramValue) {
|
||||||
|
identifiers.add(paramValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return [...identifiers]
|
||||||
|
}
|
||||||
|
|
||||||
|
function applicationDetailHrefMatchesDeletedDraft(href = '', identifiers = new Set()) {
|
||||||
|
return decodeApplicationDetailHref(href).some((item) => identifiers.has(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeletedApplicationDetailHref(href = '') {
|
||||||
|
const value = safeString(href)
|
||||||
|
if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return `${DELETED_APPLICATION_DETAIL_HREF_PREFIX}${value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function markApplicationDetailLinksDeleted(content = '', identifiers = new Set()) {
|
||||||
|
let changed = false
|
||||||
|
const nextContent = String(content || '').replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => {
|
||||||
|
if (!applicationDetailHrefMatchesDeletedDraft(href, identifiers)) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
const deletedHref = buildDeletedApplicationDetailHref(href)
|
||||||
|
if (!deletedHref) {
|
||||||
|
return '草稿已删除'
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
return `[草稿已删除](${deletedHref})`
|
||||||
|
})
|
||||||
|
return { content: nextContent, changed }
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) {
|
||||||
|
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
|
return [
|
||||||
|
payload.claim_id,
|
||||||
|
payload.claimId,
|
||||||
|
payload.id,
|
||||||
|
payload.claim_no,
|
||||||
|
payload.claimNo,
|
||||||
|
payload.document_no,
|
||||||
|
payload.documentNo
|
||||||
|
].map((item) => normalizeIdentifier(item)).some((item) => item && identifiers.has(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) {
|
||||||
|
let changed = false
|
||||||
|
const nextActions = (Array.isArray(actions) ? actions : []).map((action) => {
|
||||||
|
if (String(action?.action_type || '').trim() !== 'open_application_detail') {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
if (!actionMatchesDeletedDraft(action, identifiers)) {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
return {
|
||||||
|
...action,
|
||||||
|
label: '草稿已删除',
|
||||||
|
description: '草稿单据已经删除,请重新再次申请。',
|
||||||
|
icon: 'mdi mdi-trash-can-outline',
|
||||||
|
disabled: true,
|
||||||
|
action_type: 'deleted_application_detail'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { actions: nextActions, changed }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDraftDeletedMessage(payload = {}) {
|
||||||
|
const claimNo = safeString(payload.claimNo || payload.claim_no || payload.documentNo || payload.document_no)
|
||||||
|
return {
|
||||||
|
id: `draft-deleted-${safeString(payload.claimId || payload.claim_id || payload.id || claimNo) || Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
`用户已经删除了草稿单据${claimNo ? ` ${claimNo}` : ''}。`,
|
||||||
|
'草稿单据已经删除,请重新再次申请。'
|
||||||
|
].join('\n\n'),
|
||||||
|
feedback: '',
|
||||||
|
stewardPlan: null,
|
||||||
|
suggestedActions: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversationHasDeletionNotice(messages = [], identifiers = new Set()) {
|
||||||
|
return messages.some((message) => {
|
||||||
|
const content = safeString(message?.content)
|
||||||
|
return content.includes('用户已经删除了草稿单据') && [...identifiers].some((item) => content.includes(item))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function resolveUserStorageKey(user = {}) {
|
function resolveUserStorageKey(user = {}) {
|
||||||
const identity = safeString(user.username || user.email || user.name || 'anonymous')
|
const identity = safeString(user.username || user.email || user.name || 'anonymous')
|
||||||
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
|
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
|
||||||
@@ -153,3 +286,46 @@ export function deleteAiWorkbenchConversation(user = {}, conversationId = '') {
|
|||||||
writeStoredList(user, nextList)
|
writeStoredList(user, nextList)
|
||||||
return loadAiWorkbenchConversationHistory(user)
|
return loadAiWorkbenchConversationHistory(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function markAiWorkbenchConversationDraftDeleted(user = {}, payload = {}) {
|
||||||
|
const identifiers = collectDeletedDraftIdentifiers(payload)
|
||||||
|
if (!identifiers.size) {
|
||||||
|
return loadAiWorkbenchConversationHistory(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextList = readStoredList(user).map((conversation) => {
|
||||||
|
const normalized = normalizeConversation(conversation)
|
||||||
|
let conversationChanged = false
|
||||||
|
const messages = normalized.messages.map((message) => {
|
||||||
|
const contentResult = markApplicationDetailLinksDeleted(message.content, identifiers)
|
||||||
|
const actionsResult = markSuggestedActionsDeleted(message.suggestedActions, identifiers)
|
||||||
|
if (!contentResult.changed && !actionsResult.changed) {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
conversationChanged = true
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
content: contentResult.content,
|
||||||
|
suggestedActions: actionsResult.actions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!conversationChanged) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conversationHasDeletionNotice(messages, identifiers)) {
|
||||||
|
messages.push(buildDraftDeletedMessage(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
desc: '草稿单据已经删除,请重新再次申请。',
|
||||||
|
messages,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
writeStoredList(user, nextList)
|
||||||
|
return loadAiWorkbenchConversationHistory(user)
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
@back-to-requests="closeRequestDetail"
|
@back-to-requests="closeRequestDetail"
|
||||||
@open-assistant="openSmartEntry"
|
@open-assistant="openSmartEntry"
|
||||||
@request-updated="handleRequestUpdated"
|
@request-updated="handleRequestUpdated"
|
||||||
@request-deleted="handleRequestDeleted"
|
@request-deleted="handleDetailRequestDeleted"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -460,6 +460,11 @@ function handleAiConversationHistoryChange(payload = []) {
|
|||||||
aiConversationHistory.value = Array.isArray(payload) ? payload : []
|
aiConversationHistory.value = Array.isArray(payload) ? payload : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDetailRequestDeleted(payload = {}) {
|
||||||
|
await handleRequestDeleted(payload)
|
||||||
|
aiConversationHistory.value = loadAiWorkbenchConversationHistory(currentUser.value || {})
|
||||||
|
}
|
||||||
|
|
||||||
function handleAiConversationRename(payload = {}) {
|
function handleAiConversationRename(payload = {}) {
|
||||||
const conversationId = String(payload.id || '').trim()
|
const conversationId = String(payload.id || '').trim()
|
||||||
const title = String(payload.title || '').trim()
|
const title = String(payload.title || '').trim()
|
||||||
|
|||||||
@@ -713,7 +713,19 @@ export default {
|
|||||||
))
|
))
|
||||||
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||||||
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
|
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
|
||||||
const canDeleteRequest = computed(() => isPlatformAdminUser(currentUser.value))
|
const isApplicantDeletableRequest = computed(() => {
|
||||||
|
if (!isCurrentApplicant.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const status = String(request.value.status || request.value.approvalKey || '').trim().toLowerCase()
|
||||||
|
return ['draft', 'supplement', 'returned'].includes(status)
|
||||||
|
})
|
||||||
|
const canDeleteRequest = computed(() => {
|
||||||
|
if (isPlatformAdminUser(currentUser.value)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isApplicantDeletableRequest.value
|
||||||
|
})
|
||||||
const isDirectManagerApprovalStage = computed(() => {
|
const isDirectManagerApprovalStage = computed(() => {
|
||||||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||||||
return node === '直属领导审批'
|
return node === '直属领导审批'
|
||||||
@@ -926,11 +938,12 @@ export default {
|
|||||||
}
|
}
|
||||||
return isDraftRequest.value ? '删除草稿' : '删除单据'
|
return isDraftRequest.value ? '删除草稿' : '删除单据'
|
||||||
})
|
})
|
||||||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
const deleteDialogTarget = computed(() => request.value.documentNo || request.value.id || '当前单据')
|
||||||
|
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value}吗?`)
|
||||||
const deleteDialogDescription = computed(() =>
|
const deleteDialogDescription = computed(() =>
|
||||||
isDraftRequest.value
|
isDraftRequest.value
|
||||||
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
|
? `${deleteDialogTarget.value} 删除后,该草稿及其当前费用明细将不可恢复。`
|
||||||
: `删除后该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复,请确认本次操作。`
|
: `${deleteDialogTarget.value} 删除后,该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复。`
|
||||||
)
|
)
|
||||||
const actionBusy = computed(() =>
|
const actionBusy = computed(() =>
|
||||||
Boolean(savingExpenseId.value)
|
Boolean(savingExpenseId.value)
|
||||||
@@ -2514,8 +2527,8 @@ export default {
|
|||||||
isArchivedRequest.value
|
isArchivedRequest.value
|
||||||
? '已归档单据不能删除,只有高级管理员可以执行删除。'
|
? '已归档单据不能删除,只有高级管理员可以执行删除。'
|
||||||
: isApplicationDocument.value
|
: isApplicationDocument.value
|
||||||
? '当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。'
|
? '当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。'
|
||||||
: '当前单据已进入流程,只有高级财务人员可以删除。'
|
: '当前单据已进入流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。'
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2542,7 +2555,11 @@ export default {
|
|||||||
const payload = await deleteExpenseClaim(request.value.claimId)
|
const payload = await deleteExpenseClaim(request.value.claimId)
|
||||||
deleteDialogOpen.value = false
|
deleteDialogOpen.value = false
|
||||||
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`)
|
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`)
|
||||||
emit('request-deleted', { claimId: request.value.claimId })
|
emit('request-deleted', {
|
||||||
|
claimId: request.value.claimId,
|
||||||
|
claimNo: request.value.claimNo || request.value.documentNo || request.value.id,
|
||||||
|
documentNo: request.value.documentNo || request.value.id
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error?.message || '删除单据失败,请稍后重试。')
|
toast(error?.message || '删除单据失败,请稍后重试。')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -56,15 +56,27 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
|
|||||||
assert.equal(body.context_json.application_preview.fields.transportMode, '火车')
|
assert.equal(body.context_json.application_preview.fields.transportMode, '火车')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testSaveDraftActionKeepsOrchestratorPath() {
|
async function testSaveDraftActionUsesFastPreviewEndpoint() {
|
||||||
let capturedUrl = ''
|
let capturedUrl = ''
|
||||||
|
let capturedOptions = null
|
||||||
|
|
||||||
global.fetch = async (url) => {
|
global.fetch = async (url, options) => {
|
||||||
capturedUrl = String(url)
|
capturedUrl = String(url)
|
||||||
|
capturedOptions = options
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
async json() {
|
async json() {
|
||||||
return { status: 'succeeded', result: {} }
|
return {
|
||||||
|
status: 'succeeded',
|
||||||
|
result: {
|
||||||
|
draft_payload: {
|
||||||
|
claim_id: 'claim-fast-draft',
|
||||||
|
claim_no: 'AP-20260620-DRAFT',
|
||||||
|
status: 'draft',
|
||||||
|
approval_stage: '待提交'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,12 +87,17 @@ async function testSaveDraftActionKeepsOrchestratorPath() {
|
|||||||
currentUser: { username: 'zhangsan@example.com', name: '张三' }
|
currentUser: { username: 'zhangsan@example.com', name: '张三' }
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.equal(capturedUrl, '/api/v1/orchestrator/run')
|
assert.equal(capturedUrl, '/api/v1/reimbursements/application-preview-action')
|
||||||
|
assert.equal(capturedOptions.method, 'POST')
|
||||||
|
const body = JSON.parse(capturedOptions.body)
|
||||||
|
assert.equal(body.context_json.application_action, 'save_draft')
|
||||||
|
assert.equal(body.context_json.application_save_mode, true)
|
||||||
|
assert.equal(body.context_json.application_stage, 'expense_application')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
await testSubmitActionUsesFastPreviewEndpoint()
|
await testSubmitActionUsesFastPreviewEndpoint()
|
||||||
await testSaveDraftActionKeepsOrchestratorPath()
|
await testSaveDraftActionUsesFastPreviewEndpoint()
|
||||||
console.log('ai-application-preview-actions tests passed')
|
console.log('ai-application-preview-actions tests passed')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,17 +47,49 @@ test('AI conversation renderer supports tables and escapes unsafe HTML', () => {
|
|||||||
|
|
||||||
test('AI conversation renderer renders application detail action links as buttons', () => {
|
test('AI conversation renderer renders application detail action links as buttons', () => {
|
||||||
const rendered = renderAiConversationHtml([
|
const rendered = renderAiConversationHtml([
|
||||||
'| 单据编号 | 操作 |',
|
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
|
||||||
'| --- | --- |',
|
'| --- | --- | --- | --- | --- |',
|
||||||
'| AP-OVERLAP | [查看](#ai-open-application-detail:AP-OVERLAP) |'
|
'| 出差申请 | AP-OVERLAP | 草稿 | 待提交 | [查看](#ai-open-application-detail:AP-OVERLAP) |'
|
||||||
].join('\n'))
|
].join('\n'))
|
||||||
|
|
||||||
|
assert.match(rendered, /<div class="ai-html-record-list" role="list">/)
|
||||||
|
assert.match(rendered, /<article class="ai-html-record-item" role="listitem">/)
|
||||||
|
assert.match(rendered, /<strong class="ai-html-record-id">AP-OVERLAP<\/strong>/)
|
||||||
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-application"/)
|
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-application"/)
|
||||||
assert.match(rendered, /data-ai-action="open-application-detail"/)
|
assert.match(rendered, /data-ai-action="open-application-detail"/)
|
||||||
assert.match(rendered, /href="#ai-open-application-detail:AP-OVERLAP"/)
|
assert.match(rendered, /href="#ai-open-application-detail:AP-OVERLAP"/)
|
||||||
|
assert.doesNotMatch(rendered, /<table>/)
|
||||||
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-application-detail/)
|
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-application-detail/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('AI conversation renderer renders deleted application detail actions as disabled buttons', () => {
|
||||||
|
const rendered = renderAiConversationHtml([
|
||||||
|
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
|
||||||
|
'| --- | --- | --- | --- | --- |',
|
||||||
|
'| 出差申请 | AP-20260620-DRAFT | 已删除 | 已删除 | [草稿已删除](#ai-deleted-application-detail:claim-draft-1) |'
|
||||||
|
].join('\n'))
|
||||||
|
|
||||||
|
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-application is-disabled"/)
|
||||||
|
assert.match(rendered, /aria-disabled="true"/)
|
||||||
|
assert.match(rendered, /data-ai-action="deleted-application-detail"/)
|
||||||
|
assert.doesNotMatch(rendered, /href="#ai-deleted-application-detail/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('AI conversation renderer turns application conflict tables into record lists', () => {
|
||||||
|
const rendered = renderAiConversationHtml([
|
||||||
|
'| 单据编号 | 申请时间 | 状态 | 事由 | 操作 |',
|
||||||
|
'| --- | --- | --- | --- | --- |',
|
||||||
|
'| AP-20260620063557-4JU2MWEF | 2026-02-20 至 2026-02-23 | 审批中 | 辅助国网仿生产服务器部署 | [查看](#ai-open-application-detail:AP-20260620063557-4JU2MWEF) |'
|
||||||
|
].join('\n'))
|
||||||
|
|
||||||
|
assert.match(rendered, /<div class="ai-html-record-list" role="list">/)
|
||||||
|
assert.match(rendered, /申请时间/)
|
||||||
|
assert.match(rendered, /2026-02-20 至 2026-02-23/)
|
||||||
|
assert.match(rendered, /辅助国网仿生产服务器部署/)
|
||||||
|
assert.match(rendered, /<div class="ai-html-record-action">/)
|
||||||
|
assert.doesNotMatch(rendered, /<table>/)
|
||||||
|
})
|
||||||
|
|
||||||
test('AI conversation renderer renders document detail action links as buttons', () => {
|
test('AI conversation renderer renders document detail action links as buttons', () => {
|
||||||
const rendered = renderAiConversationHtml('[查看单据](#ai-open-document-detail:CL-20260221001)')
|
const rendered = renderAiConversationHtml('[查看单据](#ai-open-document-detail:CL-20260221001)')
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { readFileSync } from 'node:fs'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import {
|
||||||
|
markAiWorkbenchConversationDraftDeleted,
|
||||||
|
loadAiWorkbenchConversationHistory,
|
||||||
|
saveAiWorkbenchConversation
|
||||||
|
} from '../src/utils/aiWorkbenchConversationStore.js'
|
||||||
import {
|
import {
|
||||||
clearAssistantSessionSnapshotForDraftClaim,
|
clearAssistantSessionSnapshotForDraftClaim,
|
||||||
readAssistantSessionSnapshot,
|
readAssistantSessionSnapshot,
|
||||||
@@ -79,6 +84,42 @@ test('claim delete flow invalidates the matching financial assistant session', (
|
|||||||
assert.match(createViewScript, /toast\('该草稿单据已删除,相关财务助手会话已清空。'\)/)
|
assert.match(createViewScript, /toast\('该草稿单据已删除,相关财务助手会话已清空。'\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('deleting an application draft marks AI workbench detail links as unavailable', () => {
|
||||||
|
installWindowStub()
|
||||||
|
const user = { username: 'zhangsan@example.com' }
|
||||||
|
|
||||||
|
saveAiWorkbenchConversation(user, {
|
||||||
|
id: 'conversation-application-draft',
|
||||||
|
title: '申请草稿',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'assistant-draft-saved',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
'### 申请草稿已保存',
|
||||||
|
'',
|
||||||
|
'| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |',
|
||||||
|
'| --- | --- | --- | --- | --- |',
|
||||||
|
'| 出差申请 | AP-20260620-DRAFT | 草稿 | 待提交 | [查看](#ai-open-application-detail:claim_id%3Dclaim-draft-1%26claim_no%3DAP-20260620-DRAFT) |'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextHistory = markAiWorkbenchConversationDraftDeleted(user, {
|
||||||
|
claimId: 'claim-draft-1',
|
||||||
|
claimNo: 'AP-20260620-DRAFT'
|
||||||
|
})
|
||||||
|
const conversation = nextHistory.find((item) => item.id === 'conversation-application-draft')
|
||||||
|
|
||||||
|
assert.ok(conversation)
|
||||||
|
assert.match(conversation.messages[0].content, /#ai-deleted-application-detail:/)
|
||||||
|
assert.doesNotMatch(conversation.messages[0].content, /#ai-open-application-detail:/)
|
||||||
|
assert.match(conversation.messages.at(-1).content, /用户已经删除了草稿单据 AP-20260620-DRAFT/)
|
||||||
|
assert.match(conversation.messages.at(-1).content, /草稿单据已经删除,请重新再次申请。/)
|
||||||
|
assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
test('saving a draft keeps the financial assistant open for continued work', () => {
|
test('saving a draft keeps the financial assistant open for continued work', () => {
|
||||||
const appShellScript = readFileSync(
|
const appShellScript = readFileSync(
|
||||||
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||||
|
|||||||
@@ -322,11 +322,12 @@ test('stage risk advice card focuses on document risks without profile or budget
|
|||||||
assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/)
|
assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/)
|
||||||
assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/)
|
assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/)
|
||||||
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
|
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
|
||||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1\.15fr\) minmax\(220px, \.85fr\);/)
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(320px, \.72fr\);/)
|
||||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-summary \{[\s\S]*display: flex;[\s\S]*flex-wrap: wrap;/)
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-summary \{[\s\S]*display: grid;[\s\S]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\);/)
|
||||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-item \{[\s\S]*flex: 1 1 180px;/)
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-review-item \{[\s\S]*min-height: 66px;/)
|
||||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/)
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/)
|
||||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row summary \{[\s\S]*cursor: pointer;/)
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row summary \{[\s\S]*cursor: pointer;/)
|
||||||
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(72px, auto\) 48px;/)
|
||||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title::after \{[\s\S]*content: '展开';/)
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title::after \{[\s\S]*content: '展开';/)
|
||||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/)
|
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/)
|
||||||
assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)
|
assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ const confirmDialogComponent = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/components/shared/ConfirmDialog.vue', import.meta.url)),
|
fileURLToPath(new URL('../src/components/shared/ConfirmDialog.vue', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const deleteDialogComponent = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/components/travel/TravelRequestDeleteDialog.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
function extractFunction(source, name) {
|
function extractFunction(source, name) {
|
||||||
let signatureIndex = source.indexOf(`function ${name}(`)
|
let signatureIndex = source.indexOf(`function ${name}(`)
|
||||||
@@ -138,6 +142,17 @@ test('submit confirm dialog is constrained for laptop viewport height', () => {
|
|||||||
assert.match(confirmDialogComponent, /@media \(max-width: 720px\) \{[\s\S]*max-height: calc\(100dvh - 28px\)/)
|
assert.match(confirmDialogComponent, /@media \(max-width: 720px\) \{[\s\S]*max-height: calc\(100dvh - 28px\)/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('delete request dialog uses a compact destructive confirmation layout', () => {
|
||||||
|
assert.match(deleteDialogComponent, /size="destructive"/)
|
||||||
|
assert.match(deleteDialogComponent, /actions-align="end"/)
|
||||||
|
assert.match(detailViewScript, /const deleteDialogTarget = computed\(\(\) => request\.value\.documentNo \|\| request\.value\.id \|\| '当前单据'\)/)
|
||||||
|
assert.match(detailViewScript, /const deleteDialogTitle = computed\(\(\) => `确认\$\{deleteActionLabel\.value\}吗?`\)/)
|
||||||
|
assert.doesNotMatch(detailViewScript, /const deleteDialogTitle = computed\(\(\) => `确认\$\{deleteActionLabel\.value\} \$\{request\.value\.id\} 吗?`\)/)
|
||||||
|
assert.match(confirmDialogComponent, /\.shared-confirm-card--destructive \{[\s\S]*width: min\(420px, calc\(100vw - 40px\)\);/)
|
||||||
|
assert.match(confirmDialogComponent, /\.shared-confirm-card--destructive h4 \{[\s\S]*font-size: 19px;/)
|
||||||
|
assert.match(confirmDialogComponent, /\.shared-confirm-card--destructive \.shared-confirm-btn \{[\s\S]*min-width: 112px;[\s\S]*min-height: 38px;/)
|
||||||
|
})
|
||||||
|
|
||||||
test('detail header and fallback progress use reimbursement wording', () => {
|
test('detail header and fallback progress use reimbursement wording', () => {
|
||||||
assert.match(detailViewScript, /label:\s*'单据申请日期'/)
|
assert.match(detailViewScript, /label:\s*'单据申请日期'/)
|
||||||
assert.match(detailExpenseModelScript, /label:\s*'关联单据'/)
|
assert.match(detailExpenseModelScript, /label:\s*'关联单据'/)
|
||||||
@@ -145,15 +160,24 @@ test('detail header and fallback progress use reimbursement wording', () => {
|
|||||||
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
|
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('detail delete action is gated by admin-only permission', () => {
|
test('detail delete action allows admins or the applicant while the request is editable', () => {
|
||||||
assert.match(detailViewScript, /const canDeleteRequest = computed\(\(\) => isPlatformAdminUser\(currentUser\.value\)\)/)
|
assert.match(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{/)
|
||||||
|
assert.match(detailViewScript, /if \(isPlatformAdminUser\(currentUser\.value\)\) \{[\s\S]*return true/)
|
||||||
|
assert.match(detailViewScript, /return isApplicantDeletableRequest\.value/)
|
||||||
|
assert.match(detailViewScript, /const isApplicantDeletableRequest = computed\(\(\) => \{/)
|
||||||
|
assert.match(detailViewScript, /isCurrentApplicant\.value/)
|
||||||
|
assert.match(detailViewScript, /\['draft', 'supplement', 'returned'\]\.includes\(status\)/)
|
||||||
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/)
|
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/)
|
||||||
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
|
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('detail delete action does not allow applicant or claim manager fallback', () => {
|
test('detail delete action does not allow in-progress applicant or claim manager fallback', () => {
|
||||||
assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*isCurrentApplicant[\s\S]*\}\)/)
|
const canDeleteStart = detailViewScript.indexOf('const canDeleteRequest = computed')
|
||||||
assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*canManageCurrentClaim[\s\S]*\}\)/)
|
const canDeleteEnd = detailViewScript.indexOf('\n const isDirectManagerApprovalStage', canDeleteStart)
|
||||||
|
assert.ok(canDeleteStart >= 0)
|
||||||
|
assert.ok(canDeleteEnd > canDeleteStart)
|
||||||
|
const canDeleteBlock = detailViewScript.slice(canDeleteStart, canDeleteEnd)
|
||||||
|
assert.doesNotMatch(canDeleteBlock, /canManageCurrentClaim/)
|
||||||
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/)
|
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/)
|
||||||
assert.match(detailViewScript, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/)
|
assert.match(detailViewScript, /当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -181,6 +181,9 @@ test('AI mode formats saved application draft as a detail table without continui
|
|||||||
assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/)
|
assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/)
|
||||||
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 操作 \|/)
|
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 操作 \|/)
|
||||||
assert.match(aiMode, /\[查看\]\(\$\{href\}\)/)
|
assert.match(aiMode, /\[查看\]\(\$\{href\}\)/)
|
||||||
|
assert.match(aiMode, /buildInlineApplicationActionDetailHref\(info\)/)
|
||||||
|
assert.match(aiMode, /params\.set\('claim_id', claimId\)/)
|
||||||
|
assert.match(aiMode, /params\.set\('claim_no', claimNo\)/)
|
||||||
|
|
||||||
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
|
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
|
||||||
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)
|
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)
|
||||||
|
|||||||
@@ -267,6 +267,18 @@ test('AI mode screen follows the approved reference structure', () => {
|
|||||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__meta\)/)
|
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__meta\)/)
|
||||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-action-link\)/)
|
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-action-link\)/)
|
||||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/)
|
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-list\)/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-item\)/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-meta\)/)
|
||||||
|
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-action \.ai-html-action-link\)/)
|
||||||
|
assert.match(
|
||||||
|
aiModeStyles,
|
||||||
|
/\.workbench-ai-answer-markdown :deep\(\.ai-html-record-item\)\s*\{[\s\S]*grid-template-columns:\s*minmax\(220px,\s*1\.15fr\)\s*minmax\(260px,\s*0\.85fr\)\s*auto;/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
aiModeStyles,
|
||||||
|
/\.workbench-ai-answer-markdown :deep\(\.ai-html-record-action \.ai-html-action-link\)[\s\S]*background:\s*#2563eb;/
|
||||||
|
)
|
||||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/)
|
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/)
|
||||||
assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
|
assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
|
||||||
assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/)
|
assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/)
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ test('workbench document detail keeps workbench as the return target', () => {
|
|||||||
|
|
||||||
test('AI detail links wait for full document detail instead of rendering a half snapshot', () => {
|
test('AI detail links wait for full document detail instead of rendering a half snapshot', () => {
|
||||||
assert.match(aiMode, /detailLookupOnly:\s*true/)
|
assert.match(aiMode, /detailLookupOnly:\s*true/)
|
||||||
|
assert.match(aiMode, /params\.get\('claim_id'\)/)
|
||||||
|
assert.match(aiMode, /params\.get\('claim_no'\)/)
|
||||||
|
assert.match(aiMode, /claimId:\s*claimId \|\| reference/)
|
||||||
|
assert.match(aiMode, /claimNo:\s*claimNo \|\| reference/)
|
||||||
assert.match(
|
assert.match(
|
||||||
appShell,
|
appShell,
|
||||||
/v-else-if="activeView === 'documents' && detailMode && !selectedRequest"[\s\S]*正在加载完整单据详情/
|
/v-else-if="activeView === 'documents' && detailMode && !selectedRequest"[\s\S]*正在加载完整单据详情/
|
||||||
|
|||||||
Reference in New Issue
Block a user