feat: 完善审批退回流程与报销申请关联
后端优化报销单访问策略和常量定义,增强退回原因和审批状态 流转,前端完善退回对话框和审批交互组件,新增报销申请关联 模型,优化文档中心行数据和审批收件箱工具函数,增强引导 流程和会话模型,补充单元测试覆盖。
This commit is contained in:
@@ -95,35 +95,19 @@ class ExpenseClaimAccessPolicy:
|
|||||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", "归档入账", "completed"}
|
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", "归档入账", "completed"}
|
||||||
|
|
||||||
def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||||
if self.has_privileged_claim_access(current_user):
|
normalized_status = str(claim.status or "").strip().lower()
|
||||||
return True
|
if normalized_status != "submitted":
|
||||||
|
|
||||||
role_codes = self.normalize_role_codes(current_user)
|
|
||||||
if not (role_codes & APPROVAL_VISIBLE_CLAIM_ROLE_CODES):
|
|
||||||
return False
|
|
||||||
if str(claim.status or "").strip().lower() != "submitted":
|
|
||||||
return False
|
|
||||||
if str(claim.approval_stage or "").strip() != "直属领导审批":
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
current_employee = self.resolve_current_employee(current_user)
|
stage = str(claim.approval_stage or "").strip()
|
||||||
if current_employee is not None and str(claim.employee_id or "").strip() == current_employee.id:
|
if stage == "直属领导审批":
|
||||||
return False
|
return self.is_current_direct_manager_approver(current_user, claim)
|
||||||
|
if stage == "财务审批":
|
||||||
claim_employee = claim.employee
|
return self.has_privileged_claim_access(current_user) and not self.is_claim_owned_by_current_user(
|
||||||
if current_employee is not None and claim_employee is not None:
|
claim,
|
||||||
if claim_employee.manager_id == current_employee.id:
|
current_user,
|
||||||
return True
|
)
|
||||||
if claim_employee.manager is not None and claim_employee.manager.id == current_employee.id:
|
return False
|
||||||
return True
|
|
||||||
|
|
||||||
approver_name = str(
|
|
||||||
current_employee.name if current_employee is not None and current_employee.name else current_user.name or ""
|
|
||||||
).strip()
|
|
||||||
if not approver_name:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return self.resolve_claim_manager_name(claim) == approver_name
|
|
||||||
|
|
||||||
def can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
def can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||||
stage = str(claim.approval_stage or "").strip()
|
stage = str(claim.approval_stage or "").strip()
|
||||||
@@ -131,7 +115,10 @@ class ExpenseClaimAccessPolicy:
|
|||||||
return self.is_current_direct_manager_approver(current_user, claim)
|
return self.is_current_direct_manager_approver(current_user, claim)
|
||||||
if stage == "财务审批":
|
if stage == "财务审批":
|
||||||
role_codes = self.normalize_role_codes(current_user)
|
role_codes = self.normalize_role_codes(current_user)
|
||||||
return current_user.is_admin or "finance" in role_codes
|
return (
|
||||||
|
(current_user.is_admin or "finance" in role_codes)
|
||||||
|
and not self.is_claim_owned_by_current_user(claim, current_user)
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def is_current_direct_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
def is_current_direct_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||||
|
|||||||
@@ -220,6 +220,11 @@ RETURN_REASON_OPTIONS = {
|
|||||||
"business_explanation": "业务事由/地点/人员信息不完整",
|
"business_explanation": "业务事由/地点/人员信息不完整",
|
||||||
"duplicate_or_abnormal": "疑似重复或异常票据",
|
"duplicate_or_abnormal": "疑似重复或异常票据",
|
||||||
"approval_question": "审批人需要补充说明",
|
"approval_question": "审批人需要补充说明",
|
||||||
|
"application_info_incomplete": "申请信息不完整",
|
||||||
|
"application_business_need_unclear": "业务必要性说明不足",
|
||||||
|
"application_budget_basis_missing": "预算测算依据不足",
|
||||||
|
"application_policy_mismatch": "制度口径不匹配",
|
||||||
|
"application_attachment_needed": "前置材料需补充",
|
||||||
}
|
}
|
||||||
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
|
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
|
||||||
DOCUMENT_DATE_PATTERN = re.compile(
|
DOCUMENT_DATE_PATTERN = re.compile(
|
||||||
|
|||||||
@@ -578,10 +578,26 @@ class ExpenseClaimService(
|
|||||||
previous_status = str(claim.status or "").strip()
|
previous_status = str(claim.status or "").strip()
|
||||||
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
|
previous_stage = str(claim.approval_stage or "").strip() or "未标记审批环节"
|
||||||
previous_stage_key = self._normalize_return_stage_key(previous_stage)
|
previous_stage_key = self._normalize_return_stage_key(previous_stage)
|
||||||
|
is_application_claim = self._is_expense_application_claim(claim)
|
||||||
|
is_direct_manager_return = previous_stage_key == "direct_manager"
|
||||||
|
return_event_type = (
|
||||||
|
"expense_application_return"
|
||||||
|
if is_application_claim and is_direct_manager_return
|
||||||
|
else "expense_claim_return"
|
||||||
|
)
|
||||||
|
return_label = (
|
||||||
|
"领导退回"
|
||||||
|
if is_application_claim and is_direct_manager_return
|
||||||
|
else "人工退回"
|
||||||
|
)
|
||||||
return_reason = str(reason or "").strip()
|
return_reason = str(reason or "").strip()
|
||||||
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
||||||
normalized_reason_codes = reason_code_payload["reason_codes"]
|
normalized_reason_codes = reason_code_payload["reason_codes"]
|
||||||
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
|
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
|
||||||
|
if is_application_claim and is_direct_manager_return and not any(
|
||||||
|
code.startswith("application_") for code in normalized_reason_codes
|
||||||
|
):
|
||||||
|
raise ValueError("申请单退回必须选择至少一个退单类型。")
|
||||||
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
|
risk_points = [RETURN_REASON_OPTIONS[code] for code in normalized_reason_codes]
|
||||||
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
|
existing_return_flags = self._collect_return_flags(claim.risk_flags_json)
|
||||||
return_count = len(existing_return_flags) + 1
|
return_count = len(existing_return_flags) + 1
|
||||||
@@ -600,12 +616,17 @@ class ExpenseClaimService(
|
|||||||
message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points)
|
message = return_reason or self._build_default_return_message(operator=operator, risk_points=risk_points)
|
||||||
return_flag = {
|
return_flag = {
|
||||||
"source": "manual_return",
|
"source": "manual_return",
|
||||||
"event_type": "expense_claim_return",
|
"event_type": return_event_type,
|
||||||
"return_event_id": str(uuid.uuid4()),
|
"return_event_id": str(uuid.uuid4()),
|
||||||
"severity": "medium",
|
"severity": "medium",
|
||||||
"label": "人工退回",
|
"label": return_label,
|
||||||
|
"node_key": "returned",
|
||||||
|
"node_label": "退回",
|
||||||
|
"approval_node": "退回",
|
||||||
"message": message,
|
"message": message,
|
||||||
"reason": return_reason,
|
"reason": return_reason,
|
||||||
|
"opinion": message,
|
||||||
|
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
|
||||||
"reason_codes": normalized_reason_codes,
|
"reason_codes": normalized_reason_codes,
|
||||||
"risk_points": risk_points,
|
"risk_points": risk_points,
|
||||||
"operator": operator,
|
"operator": operator,
|
||||||
|
|||||||
@@ -2791,7 +2791,7 @@ def test_finance_can_return_but_cannot_delete_submitted_claim() -> None:
|
|||||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||||
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
||||||
status="submitted",
|
status="submitted",
|
||||||
approval_stage="直属领导审批",
|
approval_stage="财务审批",
|
||||||
risk_flags_json=[],
|
risk_flags_json=[],
|
||||||
)
|
)
|
||||||
db.add(claim)
|
db.add(claim)
|
||||||
@@ -3050,6 +3050,63 @@ def test_direct_manager_can_approve_subordinate_claim_to_finance_review() -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_cannot_operate_own_claim_submitted_to_direct_manager() -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="manager-own-approval@example.com",
|
||||||
|
name="李经理",
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
superior = Employee(
|
||||||
|
employee_no="E8112",
|
||||||
|
name="王总",
|
||||||
|
email="superior-own-approval@example.com",
|
||||||
|
)
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="E8113",
|
||||||
|
name="李经理",
|
||||||
|
email="manager-own-approval@example.com",
|
||||||
|
manager=superior,
|
||||||
|
)
|
||||||
|
db.add_all([superior, manager])
|
||||||
|
db.flush()
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
claim_no="EXP-APP-SELF-201",
|
||||||
|
employee_id=manager.id,
|
||||||
|
employee_name="李经理",
|
||||||
|
department_name="市场部",
|
||||||
|
project_code="PRJ-A",
|
||||||
|
expense_type="transport",
|
||||||
|
reason="交通报销",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("66.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage="直属领导审批",
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
claim_id = claim.id
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="当前直属领导审批人"):
|
||||||
|
service.approve_claim(claim_id, current_user, opinion="同意")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="当前审批人"):
|
||||||
|
service.return_claim(claim_id, current_user, reason="退回")
|
||||||
|
|
||||||
|
db.refresh(claim)
|
||||||
|
assert claim.status == "submitted"
|
||||||
|
assert claim.approval_stage == "直属领导审批"
|
||||||
|
assert claim.risk_flags_json == []
|
||||||
|
|
||||||
|
|
||||||
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_application_submit_skips_ai_review_and_receipt_requirements(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="application-owner@example.com",
|
username="application-owner@example.com",
|
||||||
@@ -3359,6 +3416,92 @@ def test_direct_manager_can_approve_application_claim_to_reimbursement_draft() -
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_manager_return_application_claim_records_return_node_and_opinion() -> None:
|
||||||
|
manager_user = CurrentUserContext(
|
||||||
|
username="manager-application-return@example.com",
|
||||||
|
name="李经理",
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="E8114",
|
||||||
|
name="李经理",
|
||||||
|
email="manager-application-return@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E8115",
|
||||||
|
name="张三",
|
||||||
|
email="zhangsan-application-return@example.com",
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
db.add_all([manager, employee])
|
||||||
|
db.flush()
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
claim_no="APP-20260525-RETURN",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="张三",
|
||||||
|
department_name="交付部",
|
||||||
|
project_code="PRJ-A",
|
||||||
|
expense_type="travel_application",
|
||||||
|
reason="支撑国网服务器上线部署",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("12000.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=0,
|
||||||
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage="直属领导审批",
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="退单类型"):
|
||||||
|
ExpenseClaimService(db).return_claim(
|
||||||
|
claim.id,
|
||||||
|
manager_user,
|
||||||
|
reason="预算说明不够清楚,请补充项目必要性。",
|
||||||
|
)
|
||||||
|
db.refresh(claim)
|
||||||
|
assert claim.status == "submitted"
|
||||||
|
assert claim.risk_flags_json == []
|
||||||
|
|
||||||
|
returned = ExpenseClaimService(db).return_claim(
|
||||||
|
claim.id,
|
||||||
|
manager_user,
|
||||||
|
reason="预算说明不够清楚,请补充项目必要性。",
|
||||||
|
reason_codes=["application_business_need_unclear", "application_budget_basis_missing"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert returned is not None
|
||||||
|
assert returned.status == "returned"
|
||||||
|
assert returned.approval_stage == "待提交"
|
||||||
|
return_event = next(
|
||||||
|
flag
|
||||||
|
for flag in returned.risk_flags_json
|
||||||
|
if isinstance(flag, dict) and flag.get("event_type") == "expense_application_return"
|
||||||
|
)
|
||||||
|
assert return_event["label"] == "领导退回"
|
||||||
|
assert return_event["node_key"] == "returned"
|
||||||
|
assert return_event["node_label"] == "退回"
|
||||||
|
assert return_event["approval_node"] == "退回"
|
||||||
|
assert return_event["operator"] == "李经理"
|
||||||
|
assert return_event["opinion"] == "预算说明不够清楚,请补充项目必要性。"
|
||||||
|
assert return_event["leader_opinion"] == "预算说明不够清楚,请补充项目必要性。"
|
||||||
|
assert return_event["return_stage"] == "直属领导审批"
|
||||||
|
assert return_event["return_stage_key"] == "direct_manager"
|
||||||
|
assert return_event["reason_codes"] == [
|
||||||
|
"application_business_need_unclear",
|
||||||
|
"application_budget_basis_missing",
|
||||||
|
]
|
||||||
|
assert return_event["risk_points"] == ["业务必要性说明不足", "预算测算依据不足"]
|
||||||
|
assert return_event["next_status"] == "returned"
|
||||||
|
assert return_event["next_approval_stage"] == "待提交"
|
||||||
|
|
||||||
|
|
||||||
def test_application_approval_transfers_budget_reservation_to_reimbursement_draft() -> None:
|
def test_application_approval_transfers_budget_reservation_to_reimbursement_draft() -> None:
|
||||||
owner = CurrentUserContext(
|
owner = CurrentUserContext(
|
||||||
username="application-budget-owner-approve@example.com",
|
username="application-budget-owner-approve@example.com",
|
||||||
@@ -3554,6 +3697,55 @@ def test_finance_approve_reimbursement_consumes_budget_reservation() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finance_cannot_operate_own_claim_in_finance_stage() -> None:
|
||||||
|
current_user = CurrentUserContext(
|
||||||
|
username="finance-own-approval@example.com",
|
||||||
|
name="财务",
|
||||||
|
role_codes=["finance"],
|
||||||
|
is_admin=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E8124",
|
||||||
|
name="财务",
|
||||||
|
email="finance-own-approval@example.com",
|
||||||
|
)
|
||||||
|
db.add(employee)
|
||||||
|
db.flush()
|
||||||
|
claim = ExpenseClaim(
|
||||||
|
claim_no="RE-20260525-FINANCE-SELF",
|
||||||
|
employee_id=employee.id,
|
||||||
|
employee_name="财务",
|
||||||
|
department_name="财务部",
|
||||||
|
project_code=None,
|
||||||
|
expense_type="travel",
|
||||||
|
reason="差旅报销",
|
||||||
|
location="上海",
|
||||||
|
amount=Decimal("800.00"),
|
||||||
|
currency="CNY",
|
||||||
|
invoice_count=1,
|
||||||
|
occurred_at=datetime(2026, 5, 25, 9, 0, tzinfo=UTC),
|
||||||
|
submitted_at=datetime(2026, 5, 25, 10, 0, tzinfo=UTC),
|
||||||
|
status="submitted",
|
||||||
|
approval_stage="财务审批",
|
||||||
|
risk_flags_json=[],
|
||||||
|
)
|
||||||
|
db.add(claim)
|
||||||
|
db.commit()
|
||||||
|
service = ExpenseClaimService(db)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="财务终审"):
|
||||||
|
service.approve_claim(claim.id, current_user, opinion="同意入账")
|
||||||
|
with pytest.raises(ValueError, match="可以退回"):
|
||||||
|
service.return_claim(claim.id, current_user, reason="退回")
|
||||||
|
|
||||||
|
db.refresh(claim)
|
||||||
|
assert claim.status == "submitted"
|
||||||
|
assert claim.approval_stage == "财务审批"
|
||||||
|
assert claim.risk_flags_json == []
|
||||||
|
|
||||||
|
|
||||||
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
def test_finance_can_approve_claim_to_archive_stage() -> None:
|
||||||
current_user = CurrentUserContext(
|
current_user = CurrentUserContext(
|
||||||
username="finance-approve@example.com",
|
username="finance-approve@example.com",
|
||||||
@@ -3655,7 +3847,13 @@ def test_return_claim_rejects_already_returned_claim_without_adding_event() -> N
|
|||||||
|
|
||||||
|
|
||||||
def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -> None:
|
def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -> None:
|
||||||
current_user = CurrentUserContext(
|
manager_user = CurrentUserContext(
|
||||||
|
username="manager-return-count@example.com",
|
||||||
|
name="李经理",
|
||||||
|
role_codes=["manager"],
|
||||||
|
is_admin=False,
|
||||||
|
)
|
||||||
|
finance_user = CurrentUserContext(
|
||||||
username="finance-return@example.com",
|
username="finance-return@example.com",
|
||||||
name="财务复核",
|
name="财务复核",
|
||||||
role_codes=["finance"],
|
role_codes=["finance"],
|
||||||
@@ -3663,8 +3861,22 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -
|
|||||||
)
|
)
|
||||||
|
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
|
manager = Employee(
|
||||||
|
employee_no="E8130",
|
||||||
|
name="李经理",
|
||||||
|
email="manager-return-count@example.com",
|
||||||
|
)
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E8131",
|
||||||
|
name="张三",
|
||||||
|
email="zhangsan-return-count@example.com",
|
||||||
|
manager=manager,
|
||||||
|
)
|
||||||
|
db.add_all([manager, employee])
|
||||||
|
db.flush()
|
||||||
claim = ExpenseClaim(
|
claim = ExpenseClaim(
|
||||||
claim_no="EXP-RET-301",
|
claim_no="EXP-RET-301",
|
||||||
|
employee_id=employee.id,
|
||||||
employee_name="张三",
|
employee_name="张三",
|
||||||
department_name="市场部",
|
department_name="市场部",
|
||||||
project_code="PRJ-A",
|
project_code="PRJ-A",
|
||||||
@@ -3687,7 +3899,7 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -
|
|||||||
service = ExpenseClaimService(db)
|
service = ExpenseClaimService(db)
|
||||||
first_returned = service.return_claim(
|
first_returned = service.return_claim(
|
||||||
claim_id,
|
claim_id,
|
||||||
current_user,
|
manager_user,
|
||||||
reason="发票金额与明细金额不一致,请重新核对。",
|
reason="发票金额与明细金额不一致,请重新核对。",
|
||||||
reason_codes=["invoice_mismatch", "business_explanation"],
|
reason_codes=["invoice_mismatch", "business_explanation"],
|
||||||
)
|
)
|
||||||
@@ -3700,7 +3912,7 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -
|
|||||||
|
|
||||||
second_returned = service.return_claim(
|
second_returned = service.return_claim(
|
||||||
claim_id,
|
claim_id,
|
||||||
current_user,
|
finance_user,
|
||||||
reason="超标说明仍不完整,请补充制度例外依据。",
|
reason="超标说明仍不完整,请补充制度例外依据。",
|
||||||
reason_codes=["over_policy"],
|
reason_codes=["over_policy"],
|
||||||
)
|
)
|
||||||
@@ -3718,7 +3930,7 @@ def test_return_claim_records_each_return_event_with_stage_reason_and_counts() -
|
|||||||
assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"]
|
assert return_events[0]["reason_codes"] == ["invoice_mismatch", "business_explanation"]
|
||||||
assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"]
|
assert return_events[0]["risk_points"] == ["票据类型/金额与明细不一致", "业务事由/地点/人员信息不完整"]
|
||||||
assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。"
|
assert return_events[0]["reason"] == "发票金额与明细金额不一致,请重新核对。"
|
||||||
assert return_events[0]["operator_role_codes"] == ["finance"]
|
assert return_events[0]["operator_role_codes"] == ["manager"]
|
||||||
assert return_events[1]["return_count"] == 2
|
assert return_events[1]["return_count"] == 2
|
||||||
assert return_events[1]["stage_return_count"] == 1
|
assert return_events[1]["stage_return_count"] == 1
|
||||||
assert return_events[1]["return_stage"] == "财务审批"
|
assert return_events[1]["return_stage"] == "财务审批"
|
||||||
|
|||||||
@@ -691,41 +691,6 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leader-approval-card {
|
|
||||||
border-color: rgba(var(--theme-primary-rgb), .18);
|
|
||||||
background: linear-gradient(180deg, #ffffff 0%, var(--theme-primary-soft) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.leader-approval-card textarea {
|
|
||||||
min-height: 96px;
|
|
||||||
background: #fff;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leader-approval-card textarea:focus {
|
|
||||||
outline: 0;
|
|
||||||
border-color: rgba(var(--theme-primary-rgb), .5);
|
|
||||||
box-shadow: 0 0 0 3px var(--theme-focus-ring);
|
|
||||||
}
|
|
||||||
|
|
||||||
.leader-opinion-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leader-opinion-meta strong {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--theme-primary-active);
|
|
||||||
font-weight: 850;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.application-leader-opinion {
|
.application-leader-opinion {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -763,14 +728,103 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-leader-opinion {
|
.application-leader-opinion-timeline {
|
||||||
padding: 0;
|
position: relative;
|
||||||
border: 0;
|
display: grid;
|
||||||
background: transparent;
|
gap: 10px;
|
||||||
|
padding-left: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.application-leader-opinion-display {
|
.application-leader-opinion-timeline::before {
|
||||||
min-height: 64px;
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
left: 5px;
|
||||||
|
width: 1px;
|
||||||
|
background: #dbe4ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-leader-opinion-event {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-leader-opinion-event::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 17px;
|
||||||
|
left: -18px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--theme-primary, #3a7ca5);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--theme-primary-rgb, 58, 124, 165), .34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-leader-opinion-event.danger::before {
|
||||||
|
background: #dc2626;
|
||||||
|
box-shadow: 0 0 0 1px rgba(220, 38, 38, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-leader-opinion-event.success::before {
|
||||||
|
background: #16a34a;
|
||||||
|
box-shadow: 0 0 0 1px rgba(22, 163, 74, .32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-leader-opinion-event-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-leader-opinion-event-head span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-leader-opinion-event-head i {
|
||||||
|
color: var(--theme-primary-active, #255b7d);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-leader-opinion-event.danger .application-leader-opinion-event-head i {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-leader-opinion-event.success .application-leader-opinion-event-head i {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-leader-opinion-event-head time,
|
||||||
|
.application-leader-opinion-event footer {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-leader-opinion-event p {
|
||||||
|
margin: 0;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-leader-opinion-event footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-expense-table {
|
.detail-expense-table {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:open="open"
|
:open="open"
|
||||||
badge="退回单据"
|
:badge="dialogBadge"
|
||||||
badge-tone="warning"
|
badge-tone="warning"
|
||||||
:title="title"
|
:title="title"
|
||||||
:description="description"
|
:description="description"
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
>
|
>
|
||||||
<div class="return-reason-dialog">
|
<div class="return-reason-dialog">
|
||||||
<div class="return-reason-section">
|
<div class="return-reason-section">
|
||||||
<span>默认风险点</span>
|
<span>{{ optionsTitle }}</span>
|
||||||
<div class="return-reason-options" role="group" aria-label="默认退回风险点">
|
<div class="return-reason-options" role="group" :aria-label="optionsAriaLabel">
|
||||||
<label
|
<label
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.code"
|
:key="option.code"
|
||||||
@@ -29,11 +29,13 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
:value="option.code"
|
:value="option.code"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
|
@change="handleOptionChange"
|
||||||
/>
|
/>
|
||||||
<i :class="option.icon"></i>
|
<i :class="option.icon"></i>
|
||||||
<strong>{{ option.label }}</strong>
|
<strong>{{ option.label }}</strong>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<small v-if="selectionError" class="error">{{ selectionError }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="return-reason-section">
|
<label class="return-reason-section">
|
||||||
@@ -42,11 +44,11 @@
|
|||||||
v-model="reasonText"
|
v-model="reasonText"
|
||||||
rows="4"
|
rows="4"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
placeholder="请写清楚需要申请人补充或修改的内容,例如:发票金额与明细金额不一致,请重新上传正确票据。"
|
:placeholder="reasonPlaceholder"
|
||||||
@input="touched = true"
|
@input="touched = true"
|
||||||
></textarea>
|
></textarea>
|
||||||
<small :class="{ error: reasonError }">
|
<small :class="{ error: reasonError }">
|
||||||
{{ reasonError || '会同步记录到退单埋点,并展示给申请人。' }}
|
{{ validationMessage }}
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +60,7 @@ import { computed, ref, watch } from 'vue'
|
|||||||
|
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
|
|
||||||
const RETURN_REASON_OPTIONS = [
|
const CLAIM_RETURN_REASON_OPTIONS = [
|
||||||
{ code: 'missing_attachment', label: '附件缺失或不清晰', icon: 'mdi mdi-paperclip-alert' },
|
{ code: 'missing_attachment', label: '附件缺失或不清晰', icon: 'mdi mdi-paperclip-alert' },
|
||||||
{ code: 'invoice_mismatch', label: '票据类型/金额与明细不一致', icon: 'mdi mdi-file-compare' },
|
{ code: 'invoice_mismatch', label: '票据类型/金额与明细不一致', icon: 'mdi mdi-file-compare' },
|
||||||
{ code: 'over_policy', label: '超出制度标准或缺少超标说明', icon: 'mdi mdi-scale-unbalanced' },
|
{ code: 'over_policy', label: '超出制度标准或缺少超标说明', icon: 'mdi mdi-scale-unbalanced' },
|
||||||
@@ -67,10 +69,44 @@ const RETURN_REASON_OPTIONS = [
|
|||||||
{ code: 'approval_question', label: '审批人需要补充说明', icon: 'mdi mdi-comment-question-outline' }
|
{ code: 'approval_question', label: '审批人需要补充说明', icon: 'mdi mdi-comment-question-outline' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const APPLICATION_RETURN_REASON_OPTIONS = [
|
||||||
|
{
|
||||||
|
code: 'application_info_incomplete',
|
||||||
|
label: '申请信息不完整',
|
||||||
|
icon: 'mdi mdi-form-textbox',
|
||||||
|
defaultReason: '请补充出差时间、地点、事由、天数或出行方式等关键信息后重新提交。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'application_business_need_unclear',
|
||||||
|
label: '业务必要性说明不足',
|
||||||
|
icon: 'mdi mdi-briefcase-question-outline',
|
||||||
|
defaultReason: '请说明本次申请对应的项目、客户或任务背景,以及必须现场处理的原因。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'application_budget_basis_missing',
|
||||||
|
label: '预算测算依据不足',
|
||||||
|
icon: 'mdi mdi-calculator-variant-outline',
|
||||||
|
defaultReason: '请补充预计住宿、交通、补贴等费用构成及测算依据,便于判断预算合理性。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'application_policy_mismatch',
|
||||||
|
label: '制度口径不匹配',
|
||||||
|
icon: 'mdi mdi-scale-balance',
|
||||||
|
defaultReason: '当前申请与差旅制度口径存在不一致,请核对职级、目的地、天数或费用标准后调整。'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'application_attachment_needed',
|
||||||
|
label: '前置材料需补充',
|
||||||
|
icon: 'mdi mdi-file-document-plus-outline',
|
||||||
|
defaultReason: '请补充会议通知、客户邀约、项目安排或其他能支撑申请必要性的材料。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
busy: { type: Boolean, default: false },
|
busy: { type: Boolean, default: false },
|
||||||
claimNo: { type: String, default: '' },
|
claimNo: { type: String, default: '' },
|
||||||
|
application: { type: Boolean, default: false },
|
||||||
title: { type: String, default: '确认退回该单据吗?' },
|
title: { type: String, default: '确认退回该单据吗?' },
|
||||||
description: {
|
description: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -83,15 +119,40 @@ const emit = defineEmits(['close', 'confirm'])
|
|||||||
const selectedCodes = ref([])
|
const selectedCodes = ref([])
|
||||||
const reasonText = ref('')
|
const reasonText = ref('')
|
||||||
const touched = ref(false)
|
const touched = ref(false)
|
||||||
|
const selectionTouched = ref(false)
|
||||||
|
const lastAutoReason = ref('')
|
||||||
|
|
||||||
const options = computed(() => RETURN_REASON_OPTIONS)
|
const options = computed(() => (props.application ? APPLICATION_RETURN_REASON_OPTIONS : CLAIM_RETURN_REASON_OPTIONS))
|
||||||
|
const dialogBadge = computed(() => (props.application ? '退回申请' : '退回单据'))
|
||||||
|
const optionsTitle = computed(() => (props.application ? '退单选项' : '默认风险点'))
|
||||||
|
const optionsAriaLabel = computed(() => (props.application ? '申请退单选项' : '默认退回风险点'))
|
||||||
|
const reasonPlaceholder = computed(() => (
|
||||||
|
props.application
|
||||||
|
? '请选择退单选项,系统会自动带入默认理由。领导可按实际情况继续修改。'
|
||||||
|
: '请写清楚需要申请人补充或修改的内容,例如:发票金额与明细金额不一致,请重新上传正确票据。'
|
||||||
|
))
|
||||||
const trimmedReason = computed(() => reasonText.value.trim())
|
const trimmedReason = computed(() => reasonText.value.trim())
|
||||||
|
const selectionError = computed(() => {
|
||||||
|
if (!props.application || !selectionTouched.value || selectedCodes.value.length > 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return '请选择至少一个退单选项,便于后续看板统计。'
|
||||||
|
})
|
||||||
const reasonError = computed(() => {
|
const reasonError = computed(() => {
|
||||||
if (!touched.value || trimmedReason.value.length >= 6) {
|
if (!touched.value || trimmedReason.value.length >= 6) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
return '请至少填写 6 个字的明确退单理由。'
|
return '请至少填写 6 个字的明确退单理由。'
|
||||||
})
|
})
|
||||||
|
const validationMessage = computed(() => (
|
||||||
|
selectionError.value
|
||||||
|
|| reasonError.value
|
||||||
|
|| (
|
||||||
|
props.application
|
||||||
|
? '退单选项会写入结构化埋点,理由会展示给申请人。'
|
||||||
|
: '会同步记录到退单埋点,并展示给申请人。'
|
||||||
|
)
|
||||||
|
))
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
@@ -100,10 +161,33 @@ watch(
|
|||||||
selectedCodes.value = []
|
selectedCodes.value = []
|
||||||
reasonText.value = ''
|
reasonText.value = ''
|
||||||
touched.value = false
|
touched.value = false
|
||||||
|
selectionTouched.value = false
|
||||||
|
lastAutoReason.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(selectedCodes, () => {
|
||||||
|
if (!props.application) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultReason = selectedCodes.value
|
||||||
|
.map((code) => options.value.find((option) => option.code === code)?.defaultReason || '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
const canAutoFill = !touched.value || !reasonText.value.trim() || reasonText.value === lastAutoReason.value
|
||||||
|
if (canAutoFill) {
|
||||||
|
reasonText.value = defaultReason
|
||||||
|
}
|
||||||
|
lastAutoReason.value = defaultReason
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleOptionChange() {
|
||||||
|
selectionTouched.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
if (!props.busy) {
|
if (!props.busy) {
|
||||||
emit('close')
|
emit('close')
|
||||||
@@ -112,7 +196,8 @@ function handleClose() {
|
|||||||
|
|
||||||
function handleConfirm() {
|
function handleConfirm() {
|
||||||
touched.value = true
|
touched.value = true
|
||||||
if (trimmedReason.value.length < 6 || props.busy) {
|
selectionTouched.value = true
|
||||||
|
if ((props.application && selectedCodes.value.length === 0) || trimmedReason.value.length < 6 || props.busy) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,27 @@
|
|||||||
<span>{{ summaryLabel }}</span>
|
<span>{{ summaryLabel }}</span>
|
||||||
<strong>{{ nextStage }}</strong>
|
<strong>{{ nextStage }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="submit-confirm-row">
|
|
||||||
<span>{{ opinionTitle }}</span>
|
|
||||||
<strong>{{ normalizedOpinion }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label class="approval-opinion-field">
|
||||||
|
<span>
|
||||||
|
{{ opinionTitle }}
|
||||||
|
<em v-if="opinionRequired">必填</em>
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
:value="currentOpinion"
|
||||||
|
maxlength="500"
|
||||||
|
:required="opinionRequired"
|
||||||
|
:disabled="busy"
|
||||||
|
:placeholder="opinionPlaceholder"
|
||||||
|
:aria-label="opinionTitle"
|
||||||
|
@input="handleOpinionInput"
|
||||||
|
></textarea>
|
||||||
|
<small>
|
||||||
|
<span>{{ opinionHint }}</span>
|
||||||
|
<strong>{{ currentOpinion.length }}/500</strong>
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -53,10 +69,83 @@ const props = defineProps({
|
|||||||
summaryLabel: { type: String, required: true },
|
summaryLabel: { type: String, required: true },
|
||||||
nextStage: { type: String, required: true },
|
nextStage: { type: String, required: true },
|
||||||
opinionTitle: { type: String, required: true },
|
opinionTitle: { type: String, required: true },
|
||||||
opinion: { type: String, default: '' }
|
opinion: { type: String, default: '' },
|
||||||
|
opinionPlaceholder: { type: String, default: '' },
|
||||||
|
opinionHint: { type: String, default: '' },
|
||||||
|
opinionRequired: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'confirm'])
|
const emit = defineEmits(['close', 'confirm', 'update:opinion'])
|
||||||
|
|
||||||
const normalizedOpinion = computed(() => props.opinion.trim() || '未填写')
|
const currentOpinion = computed(() => String(props.opinion || ''))
|
||||||
|
|
||||||
|
function handleOpinionInput(event) {
|
||||||
|
emit('update:opinion', event.target.value)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.approval-opinion-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-opinion-field > span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-opinion-field em {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
background: rgba(var(--theme-primary-rgb), .1);
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-opinion-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 96px;
|
||||||
|
resize: vertical;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-opinion-field textarea:focus {
|
||||||
|
border-color: rgba(var(--theme-primary-rgb), .5);
|
||||||
|
box-shadow: 0 0 0 3px var(--theme-focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-opinion-field small {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-opinion-field small span {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-opinion-field small strong {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--theme-primary-active);
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
:title="title"
|
:title="title"
|
||||||
:description="description"
|
:description="description"
|
||||||
:busy="busy"
|
:busy="busy"
|
||||||
|
:application="application"
|
||||||
@close="emit('close')"
|
@close="emit('close')"
|
||||||
@confirm="emit('confirm', $event)"
|
@confirm="emit('confirm', $event)"
|
||||||
/>
|
/>
|
||||||
@@ -16,7 +17,8 @@ defineProps({
|
|||||||
open: { type: Boolean, required: true },
|
open: { type: Boolean, required: true },
|
||||||
title: { type: String, required: true },
|
title: { type: String, required: true },
|
||||||
description: { type: String, required: true },
|
description: { type: String, required: true },
|
||||||
busy: { type: Boolean, required: true }
|
busy: { type: Boolean, required: true },
|
||||||
|
application: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'confirm'])
|
const emit = defineEmits(['close', 'confirm'])
|
||||||
|
|||||||
@@ -426,6 +426,29 @@ function resolveDisplayName(...values) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveApplicationApproverName(claim) {
|
||||||
|
return resolveDisplayName(
|
||||||
|
claim?.manager_name,
|
||||||
|
claim?.managerName,
|
||||||
|
claim?.profile_manager,
|
||||||
|
claim?.profileManager,
|
||||||
|
claim?.direct_manager_name,
|
||||||
|
claim?.directManagerName
|
||||||
|
) || '直属领导'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
|
||||||
|
if (
|
||||||
|
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||||
|
&& approvalMeta.key !== 'completed'
|
||||||
|
&& normalizeText(label) === '直属领导审批'
|
||||||
|
) {
|
||||||
|
return `等待 ${resolveApplicationApproverName(claim)} 批复`
|
||||||
|
}
|
||||||
|
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
function getRiskFlags(claim) {
|
function getRiskFlags(claim) {
|
||||||
return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : []
|
return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : []
|
||||||
}
|
}
|
||||||
@@ -488,6 +511,25 @@ function findLatestReturnEvent(claim) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findLatestApplicationReturnEvent(claim) {
|
||||||
|
return getLatestEvent(
|
||||||
|
getRiskFlags(claim).filter((flag) => {
|
||||||
|
if (!flag || typeof flag !== 'object' || normalizeText(flag.source) !== 'manual_return') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const eventType = normalizeText(flag.event_type || flag.eventType)
|
||||||
|
const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage)
|
||||||
|
const stageKey = normalizeText(flag.return_stage_key || flag.returnStageKey)
|
||||||
|
return (
|
||||||
|
eventType === 'expense_application_return'
|
||||||
|
|| stageKey === 'direct_manager'
|
||||||
|
|| returnStage.includes('直属领导')
|
||||||
|
|| returnStage.includes('领导审批')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function buildProgressStepMeta(time, detail = '', title = '') {
|
function buildProgressStepMeta(time, detail = '', title = '') {
|
||||||
return {
|
return {
|
||||||
time,
|
time,
|
||||||
@@ -532,6 +574,28 @@ function buildCompletedStepMeta(claim, label) {
|
|||||||
const updatedAt = formatDateTime(claim?.updated_at)
|
const updatedAt = formatDateTime(claim?.updated_at)
|
||||||
return buildProgressStepMeta('财务通过', updatedAt, `财务审批通过 ${updatedAt}`.trim())
|
return buildProgressStepMeta('财务通过', updatedAt, `财务审批通过 ${updatedAt}`.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stepLabel === '直属领导审批') {
|
||||||
|
const returnEvent = findLatestApplicationReturnEvent(claim)
|
||||||
|
if (returnEvent) {
|
||||||
|
const handledAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt)
|
||||||
|
return buildProgressStepMeta('已处理', handledAt, `直属领导已处理 ${handledAt}`.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepLabel === '退回') {
|
||||||
|
const returnEvent = findLatestApplicationReturnEvent(claim) || findLatestReturnEvent(claim)
|
||||||
|
if (returnEvent) {
|
||||||
|
const operator = resolveDisplayName(
|
||||||
|
returnEvent.operator,
|
||||||
|
returnEvent.operator_name,
|
||||||
|
returnEvent.operatorName,
|
||||||
|
claim?.manager_name
|
||||||
|
) || '直属领导'
|
||||||
|
const returnedAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt)
|
||||||
|
return buildProgressStepMeta(`${operator}退回`, returnedAt, `${operator}退回 ${returnedAt}`.trim())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stepLabel === '归档入账') {
|
if (stepLabel === '归档入账') {
|
||||||
@@ -574,13 +638,22 @@ function resolveCurrentStepStartedAt(claim, label) {
|
|||||||
|
|
||||||
function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}) {
|
function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}) {
|
||||||
const documentTypeCode = String(options.documentTypeCode || '').trim()
|
const documentTypeCode = String(options.documentTypeCode || '').trim()
|
||||||
|
const hasApplicationReturnStep = (
|
||||||
|
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||||
|
&& Boolean(findLatestApplicationReturnEvent(claim))
|
||||||
|
&& approvalMeta.key === 'supplement'
|
||||||
|
)
|
||||||
const progressLabels =
|
const progressLabels =
|
||||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||||
? APPLICATION_PROGRESS_LABELS
|
? hasApplicationReturnStep
|
||||||
|
? ['创建申请', '直属领导审批', '退回', '待提交']
|
||||||
|
: APPLICATION_PROGRESS_LABELS
|
||||||
: REIMBURSEMENT_PROGRESS_LABELS
|
: REIMBURSEMENT_PROGRESS_LABELS
|
||||||
const currentIndex =
|
const currentIndex =
|
||||||
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
documentTypeCode === DOCUMENT_TYPE_APPLICATION
|
||||||
? resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode)
|
? hasApplicationReturnStep
|
||||||
|
? 3
|
||||||
|
: resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode)
|
||||||
: resolveProgressCurrentIndex(approvalMeta, workflowNode)
|
: resolveProgressCurrentIndex(approvalMeta, workflowNode)
|
||||||
const currentTime =
|
const currentTime =
|
||||||
approvalMeta.key === 'completed'
|
approvalMeta.key === 'completed'
|
||||||
@@ -592,11 +665,13 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
|
|||||||
: '进行中'
|
: '进行中'
|
||||||
|
|
||||||
return progressLabels.map((label, index) => {
|
return progressLabels.map((label, index) => {
|
||||||
|
const displayLabel = resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta)
|
||||||
if (approvalMeta.key === 'completed') {
|
if (approvalMeta.key === 'completed') {
|
||||||
const stepMeta = buildCompletedStepMeta(claim, label)
|
const stepMeta = buildCompletedStepMeta(claim, label)
|
||||||
return {
|
return {
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
label,
|
label: displayLabel,
|
||||||
|
rawLabel: label,
|
||||||
time: stepMeta.time,
|
time: stepMeta.time,
|
||||||
detail: stepMeta.detail,
|
detail: stepMeta.detail,
|
||||||
title: stepMeta.title,
|
title: stepMeta.title,
|
||||||
@@ -610,7 +685,8 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
|
|||||||
const stepMeta = buildCompletedStepMeta(claim, label)
|
const stepMeta = buildCompletedStepMeta(claim, label)
|
||||||
return {
|
return {
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
label,
|
label: displayLabel,
|
||||||
|
rawLabel: label,
|
||||||
time: stepMeta.time,
|
time: stepMeta.time,
|
||||||
detail: stepMeta.detail,
|
detail: stepMeta.detail,
|
||||||
title: stepMeta.title,
|
title: stepMeta.title,
|
||||||
@@ -624,10 +700,11 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
|
|||||||
const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label))
|
const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label))
|
||||||
return {
|
return {
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
label,
|
label: displayLabel,
|
||||||
|
rawLabel: label,
|
||||||
time: stayDuration ? `停留 ${stayDuration}` : currentTime,
|
time: stayDuration ? `停留 ${stayDuration}` : currentTime,
|
||||||
detail: '',
|
detail: '',
|
||||||
title: stayDuration ? `当前${label}已停留 ${stayDuration}` : currentTime,
|
title: stayDuration ? `当前${displayLabel}已停留 ${stayDuration}` : currentTime,
|
||||||
done: false,
|
done: false,
|
||||||
active: true,
|
active: true,
|
||||||
current: true
|
current: true
|
||||||
@@ -636,7 +713,8 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
index: index + 1,
|
index: index + 1,
|
||||||
label,
|
label: displayLabel,
|
||||||
|
rawLabel: label,
|
||||||
time: '待处理',
|
time: '待处理',
|
||||||
detail: '',
|
detail: '',
|
||||||
title: '待处理',
|
title: '待处理',
|
||||||
@@ -758,9 +836,13 @@ export function mapExpenseClaimToRequest(claim) {
|
|||||||
approvalTone: approvalMeta.tone,
|
approvalTone: approvalMeta.tone,
|
||||||
secondaryStatusLabel: isApplicationDocument ? '申请材料' : (typeCode === 'travel' ? '行程状态' : '票据状态'),
|
secondaryStatusLabel: isApplicationDocument ? '申请材料' : (typeCode === 'travel' ? '行程状态' : '票据状态'),
|
||||||
secondaryStatusValue: isApplicationDocument
|
secondaryStatusValue: isApplicationDocument
|
||||||
? '已进入审批流程'
|
? approvalMeta.key === 'supplement'
|
||||||
|
? '领导已退回,待重新提交'
|
||||||
|
: '已进入审批流程'
|
||||||
: (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'),
|
: (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'),
|
||||||
secondaryStatusTone: isApplicationDocument ? 'success' : (invoiceCount > 0 ? 'success' : 'warning'),
|
secondaryStatusTone: isApplicationDocument
|
||||||
|
? approvalMeta.key === 'supplement' ? 'warning' : 'success'
|
||||||
|
: (invoiceCount > 0 ? 'success' : 'warning'),
|
||||||
riskSummary,
|
riskSummary,
|
||||||
attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'),
|
attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'),
|
||||||
expenseTableSummary: isApplicationDocument
|
expenseTableSummary: isApplicationDocument
|
||||||
|
|||||||
@@ -40,6 +40,21 @@ function normalizeRoleCode(value) {
|
|||||||
return roleCode === 'auditor' ? 'budget_monitor' : roleCode
|
return roleCode === 'auditor' ? 'budget_monitor' : roleCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeComparableText(value) {
|
||||||
|
return String(value || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectIdentityNames(...values) {
|
||||||
|
return values
|
||||||
|
.map((value) => normalizeComparableText(value))
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityIntersects(leftValues, rightValues) {
|
||||||
|
const rightSet = new Set(rightValues)
|
||||||
|
return leftValues.some((item) => rightSet.has(item))
|
||||||
|
}
|
||||||
|
|
||||||
function hasPlatformAdminIdentity(user) {
|
function hasPlatformAdminIdentity(user) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return false
|
return false
|
||||||
@@ -111,10 +126,53 @@ export function canApproveLeaderExpenseClaims(user) {
|
|||||||
if (isPlatformAdminUser(user)) {
|
if (isPlatformAdminUser(user)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
|
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCurrentRequestApplicant(request, user) {
|
||||||
|
const applicantNames = collectIdentityNames(
|
||||||
|
request?.person,
|
||||||
|
request?.employeeName,
|
||||||
|
request?.employee_name,
|
||||||
|
request?.profileName,
|
||||||
|
request?.applicant
|
||||||
|
)
|
||||||
|
const currentNames = collectIdentityNames(
|
||||||
|
user?.name,
|
||||||
|
user?.username,
|
||||||
|
user?.email,
|
||||||
|
user?.employeeNo,
|
||||||
|
user?.employee_no
|
||||||
|
)
|
||||||
|
|
||||||
|
return applicantNames.length > 0 && identityIntersects(applicantNames, currentNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCurrentDirectManagerForRequest(request, user) {
|
||||||
|
if (isCurrentRequestApplicant(request, user)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const managerNames = collectIdentityNames(
|
||||||
|
request?.profileManager,
|
||||||
|
request?.managerName,
|
||||||
|
request?.manager_name,
|
||||||
|
request?.directManagerName,
|
||||||
|
request?.direct_manager_name,
|
||||||
|
request?.manager
|
||||||
|
)
|
||||||
|
const currentNames = collectIdentityNames(
|
||||||
|
user?.name,
|
||||||
|
user?.username,
|
||||||
|
user?.email,
|
||||||
|
user?.employeeNo,
|
||||||
|
user?.employee_no
|
||||||
|
)
|
||||||
|
|
||||||
|
return managerNames.length > 0 && identityIntersects(managerNames, currentNames)
|
||||||
|
}
|
||||||
|
|
||||||
export function canAccessAppView(user, viewId) {
|
export function canAccessAppView(user, viewId) {
|
||||||
if (!viewId || !user) {
|
if (!viewId || !user) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -51,27 +51,86 @@ function getLatestEvent(events) {
|
|||||||
return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
|
return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findLeaderApprovalEvent(request) {
|
function isLeaderApprovalEvent(flag) {
|
||||||
return getLatestEvent(
|
const source = normalizeText(flag?.source)
|
||||||
getRiskFlags(request).filter((flag) => {
|
const eventType = normalizeText(flag?.event_type || flag?.eventType)
|
||||||
const source = normalizeText(flag?.source)
|
const previousStage = normalizeText(flag?.previous_approval_stage || flag?.previousApprovalStage)
|
||||||
const eventType = normalizeText(flag?.event_type || flag?.eventType)
|
const nextStage = normalizeText(flag?.next_approval_stage || flag?.nextApprovalStage)
|
||||||
const previousStage = normalizeText(flag?.previous_approval_stage || flag?.previousApprovalStage)
|
return (
|
||||||
const nextStage = normalizeText(flag?.next_approval_stage || flag?.nextApprovalStage)
|
source === 'manual_approval'
|
||||||
return (
|
&& (
|
||||||
source === 'manual_approval'
|
eventType === 'expense_application_approval'
|
||||||
&& (
|
|| previousStage.includes('直属领导')
|
||||||
eventType === 'expense_application_approval'
|
|| previousStage.includes('领导审批')
|
||||||
|| previousStage.includes('直属领导')
|
|| nextStage.includes('财务')
|
||||||
|| previousStage.includes('领导审批')
|
|| nextStage.includes('审批完成')
|
||||||
|| nextStage.includes('财务')
|
)
|
||||||
|| nextStage.includes('审批完成')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLeaderReturnEvent(flag) {
|
||||||
|
const source = normalizeText(flag?.source)
|
||||||
|
const eventType = normalizeText(flag?.event_type || flag?.eventType)
|
||||||
|
const returnStage = normalizeText(flag?.return_stage || flag?.returnStage || flag?.previous_approval_stage)
|
||||||
|
const stageKey = normalizeText(flag?.return_stage_key || flag?.returnStageKey)
|
||||||
|
return (
|
||||||
|
source === 'manual_return'
|
||||||
|
&& (
|
||||||
|
eventType === 'expense_application_return'
|
||||||
|
|| stageKey === 'direct_manager'
|
||||||
|
|| returnStage.includes('直属领导')
|
||||||
|
|| returnStage.includes('领导审批')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLeaderApprovalEvent(request) {
|
||||||
|
return getLatestEvent(getRiskFlags(request).filter(isLeaderApprovalEvent))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLeaderApprovalEvents(request) {
|
||||||
|
return getRiskFlags(request)
|
||||||
|
.filter((flag) => isLeaderApprovalEvent(flag) || isLeaderReturnEvent(flag))
|
||||||
|
.map((event) => {
|
||||||
|
const returned = isLeaderReturnEvent(event)
|
||||||
|
const rawTime = event.created_at || event.createdAt
|
||||||
|
const operator = resolveDisplayName(
|
||||||
|
event.operator,
|
||||||
|
event.operator_name,
|
||||||
|
event.operatorName,
|
||||||
|
request?.profileManager,
|
||||||
|
request?.managerName
|
||||||
|
) || '直属领导'
|
||||||
|
const time = formatDateTime(rawTime)
|
||||||
|
const opinion = normalizeText(event.opinion)
|
||||||
|
|| normalizeText(event.leader_opinion || event.leaderOpinion)
|
||||||
|
|| normalizeText(event.reason)
|
||||||
|
|| normalizeText(event.message)
|
||||||
|
|| (returned ? '已退回申请,请申请人补充后重新提交。' : '已审批通过。')
|
||||||
|
const returnCount = Number(event.return_count || event.returnCount || 0)
|
||||||
|
return {
|
||||||
|
id: normalizeText(event.return_event_id || event.returnEventId || event.approval_event_id || event.approvalEventId)
|
||||||
|
|| `${returned ? 'return' : 'approval'}-${event.created_at || event.createdAt || opinion}`,
|
||||||
|
type: returned ? 'returned' : 'approved',
|
||||||
|
tone: returned ? 'danger' : 'success',
|
||||||
|
title: returned ? '领导退回' : '领导审批通过',
|
||||||
|
operator,
|
||||||
|
time,
|
||||||
|
sortAt: rawTime,
|
||||||
|
opinion,
|
||||||
|
returnCount,
|
||||||
|
meta: [operator ? `${operator}${returned ? '退回' : '通过'}` : '', time].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftDate = toDate(left.sortAt)
|
||||||
|
const rightDate = toDate(right.sortAt)
|
||||||
|
if (!leftDate || !rightDate) return 0
|
||||||
|
return leftDate.getTime() - rightDate.getTime()
|
||||||
|
})
|
||||||
|
.map(({ sortAt, ...event }) => event)
|
||||||
|
}
|
||||||
|
|
||||||
export function buildLeaderApprovalInfo(request) {
|
export function buildLeaderApprovalInfo(request) {
|
||||||
const event = findLeaderApprovalEvent(request)
|
const event = findLeaderApprovalEvent(request)
|
||||||
if (!event) {
|
if (!event) {
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||||
import {
|
import {
|
||||||
canApproveLeaderExpenseClaims,
|
canApproveLeaderExpenseClaims,
|
||||||
canManageExpenseClaims,
|
isCurrentDirectManagerForRequest,
|
||||||
|
isCurrentRequestApplicant,
|
||||||
isFinanceUser
|
isFinanceUser
|
||||||
} from './accessControl.js'
|
} from './accessControl.js'
|
||||||
|
|
||||||
export function canProcessApprovalRequest(request, currentUser) {
|
export function canProcessApprovalRequest(request, currentUser) {
|
||||||
const node = String(request?.workflowNode || '').trim()
|
const node = String(request?.workflowNode || '').trim()
|
||||||
const currentName = String(currentUser?.name || '').trim()
|
|
||||||
const applicantName = String(request?.person || request?.employeeName || '').trim()
|
|
||||||
|
|
||||||
if (currentName && applicantName && currentName === applicantName) {
|
if (isCurrentRequestApplicant(request, currentUser)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canManageExpenseClaims(currentUser)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFinanceUser(currentUser) && node.includes('财务')) {
|
if (isFinanceUser(currentUser) && node.includes('财务')) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -29,7 +24,11 @@ export function canProcessApprovalRequest(request, currentUser) {
|
|||||||
|| node.includes('负责人审批')
|
|| node.includes('负责人审批')
|
||||||
)
|
)
|
||||||
|
|
||||||
return canApproveLeaderExpenseClaims(currentUser) && isLeaderApprovalNode
|
return (
|
||||||
|
canApproveLeaderExpenseClaims(currentUser)
|
||||||
|
&& isLeaderApprovalNode
|
||||||
|
&& isCurrentDirectManagerForRequest(request, currentUser)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listPendingApprovalRequests(claimsPayload, currentUser) {
|
export function listPendingApprovalRequests(claimsPayload, currentUser) {
|
||||||
|
|||||||
@@ -45,3 +45,22 @@ export function isArchivedDocumentRow(row) {
|
|||||||
export function excludeArchivedDocumentRows(rows) {
|
export function excludeArchivedDocumentRows(rows) {
|
||||||
return (Array.isArray(rows) ? rows : []).filter((row) => !isArchivedDocumentRow(row))
|
return (Array.isArray(rows) ? rows : []).filter((row) => !isArchivedDocumentRow(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isApplicationApprovalRow(row) {
|
||||||
|
if (!row) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusGroup = String(row.statusGroup || '').trim()
|
||||||
|
return statusGroup === 'in_progress' && isApplicationRequestLike(row.rawRequest || row)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterApplicationScopeNewRows(rows) {
|
||||||
|
return (Array.isArray(rows) ? rows : []).filter((row) => !isApplicationApprovalRow(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareApplicationScopeRows(rows) {
|
||||||
|
return (Array.isArray(rows) ? rows : [])
|
||||||
|
.filter((row) => isApplicationRequestLike(row.rawRequest || row))
|
||||||
|
.map((row) => (isApplicationApprovalRow(row) ? { ...row, isNewDocument: false } : row))
|
||||||
|
}
|
||||||
|
|||||||
@@ -240,7 +240,6 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
|
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
|
||||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||||
@@ -248,9 +247,8 @@ import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
|||||||
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
|
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
|
||||||
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
|
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
|
||||||
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
|
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
|
||||||
import { excludeArchivedDocumentRows, isArchivedDocumentRow } from '../utils/documentCenterRows.js'
|
import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
|
||||||
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
|
||||||
|
|
||||||
const DOCUMENT_TYPE_ALL = 'all'
|
const DOCUMENT_TYPE_ALL = 'all'
|
||||||
const DOCUMENT_TYPE_APPLICATION = 'application'
|
const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||||
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||||
@@ -260,7 +258,6 @@ const DOCUMENT_SCOPE_APPLICATION = '申请单'
|
|||||||
const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
|
const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
|
||||||
const DOCUMENT_SCOPE_REVIEW = '审核单'
|
const DOCUMENT_SCOPE_REVIEW = '审核单'
|
||||||
const DOCUMENT_SCOPE_ARCHIVE = '归档'
|
const DOCUMENT_SCOPE_ARCHIVE = '归档'
|
||||||
|
|
||||||
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
|
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
|
||||||
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
|
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
|
||||||
const FILTER_CONFIG_BY_SCOPE = {
|
const FILTER_CONFIG_BY_SCOPE = {
|
||||||
@@ -311,14 +308,12 @@ const documentTypeOptions = [
|
|||||||
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
|
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
|
||||||
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
|
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
filteredRequests: { type: Array, required: true },
|
filteredRequests: { type: Array, required: true },
|
||||||
hasData: { type: Boolean, default: false },
|
hasData: { type: Boolean, default: false },
|
||||||
loading: { type: Boolean, default: false },
|
loading: { type: Boolean, default: false },
|
||||||
error: { type: String, default: '' }
|
error: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'open-document',
|
'open-document',
|
||||||
'create-request',
|
'create-request',
|
||||||
@@ -326,7 +321,6 @@ const emit = defineEmits([
|
|||||||
'reload',
|
'reload',
|
||||||
'summary-change'
|
'summary-change'
|
||||||
])
|
])
|
||||||
|
|
||||||
const activeScopeTab = ref(readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs))
|
const activeScopeTab = ref(readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs))
|
||||||
const activeStatusTab = ref('全部')
|
const activeStatusTab = ref('全部')
|
||||||
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
|
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
|
||||||
@@ -345,17 +339,13 @@ const approvalRows = ref([])
|
|||||||
const supportingLoading = ref(false)
|
const supportingLoading = ref(false)
|
||||||
const supportingError = ref('')
|
const supportingError = ref('')
|
||||||
const viewedDocumentKeys = ref(readViewedDocumentKeys())
|
const viewedDocumentKeys = ref(readViewedDocumentKeys())
|
||||||
|
|
||||||
const activeFilterConfig = computed(() =>
|
const activeFilterConfig = computed(() =>
|
||||||
FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION]
|
FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION]
|
||||||
)
|
)
|
||||||
|
|
||||||
const showDocumentTypeFilter = computed(() => Boolean(activeFilterConfig.value.showDocumentType))
|
const showDocumentTypeFilter = computed(() => Boolean(activeFilterConfig.value.showDocumentType))
|
||||||
|
|
||||||
const documentTypeFilterLabel = computed(() =>
|
const documentTypeFilterLabel = computed(() =>
|
||||||
documentTypeOptions.find((item) => item.value === activeDocumentType.value)?.label || '单据类型'
|
documentTypeOptions.find((item) => item.value === activeDocumentType.value)?.label || '单据类型'
|
||||||
)
|
)
|
||||||
|
|
||||||
const statusFilterOptions = computed(() =>
|
const statusFilterOptions = computed(() =>
|
||||||
activeFilterConfig.value.statusTabs.map((tab) => ({
|
activeFilterConfig.value.statusTabs.map((tab) => ({
|
||||||
value: tab,
|
value: tab,
|
||||||
@@ -380,10 +370,11 @@ const ownedRows = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const nonArchivedRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value]))
|
const nonArchivedRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value]))
|
||||||
|
const applicationScopeRows = computed(() => prepareApplicationScopeRows(ownedRows.value))
|
||||||
|
|
||||||
const scopeNewCountMap = computed(() => ({
|
const scopeNewCountMap = computed(() => ({
|
||||||
[DOCUMENT_SCOPE_ALL]: countNewDocuments(nonArchivedRows.value, viewedDocumentKeys.value),
|
[DOCUMENT_SCOPE_ALL]: countNewDocuments(nonArchivedRows.value, viewedDocumentKeys.value),
|
||||||
[DOCUMENT_SCOPE_APPLICATION]: countNewDocuments(nonArchivedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION), viewedDocumentKeys.value),
|
[DOCUMENT_SCOPE_APPLICATION]: countNewDocuments(filterApplicationScopeNewRows(applicationScopeRows.value), viewedDocumentKeys.value),
|
||||||
[DOCUMENT_SCOPE_REIMBURSEMENT]: countNewDocuments(ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT), viewedDocumentKeys.value),
|
[DOCUMENT_SCOPE_REIMBURSEMENT]: countNewDocuments(ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT), viewedDocumentKeys.value),
|
||||||
[DOCUMENT_SCOPE_REVIEW]: countNewDocuments(approvalRows.value, viewedDocumentKeys.value),
|
[DOCUMENT_SCOPE_REVIEW]: countNewDocuments(approvalRows.value, viewedDocumentKeys.value),
|
||||||
[DOCUMENT_SCOPE_ARCHIVE]: countNewDocuments(archiveRows.value, viewedDocumentKeys.value)
|
[DOCUMENT_SCOPE_ARCHIVE]: countNewDocuments(archiveRows.value, viewedDocumentKeys.value)
|
||||||
@@ -401,7 +392,7 @@ const activeScopeRows = computed(() => {
|
|||||||
if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value
|
if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value
|
||||||
|
|
||||||
if (activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION) {
|
if (activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION) {
|
||||||
return nonArchivedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
|
return applicationScopeRows.value
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeScopeTab.value === DOCUMENT_SCOPE_REIMBURSEMENT) {
|
if (activeScopeTab.value === DOCUMENT_SCOPE_REIMBURSEMENT) {
|
||||||
|
|||||||
@@ -182,21 +182,26 @@
|
|||||||
<span><i class="mdi mdi-account-tie-outline"></i>领导意见</span>
|
<span><i class="mdi mdi-account-tie-outline"></i>领导意见</span>
|
||||||
<strong v-if="leaderApprovalReadonlyMeta">{{ leaderApprovalReadonlyMeta }}</strong>
|
<strong v-if="leaderApprovalReadonlyMeta">{{ leaderApprovalReadonlyMeta }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showApplicationLeaderOpinionInput" class="leader-approval-card inline-leader-opinion">
|
<div v-if="hasLeaderApprovalEvents" class="application-leader-opinion-timeline" aria-label="领导批复事件流">
|
||||||
<textarea
|
<article
|
||||||
v-model="leaderOpinion"
|
v-for="event in leaderApprovalEvents"
|
||||||
maxlength="500"
|
:key="event.id"
|
||||||
:required="requiresApprovalOpinion"
|
class="application-leader-opinion-event"
|
||||||
:placeholder="approvalOpinionPlaceholder"
|
:class="event.tone"
|
||||||
:aria-label="approvalOpinionTitle"
|
>
|
||||||
></textarea>
|
<div class="application-leader-opinion-event-head">
|
||||||
<div class="leader-opinion-meta">
|
<span>
|
||||||
<span>{{ approvalOpinionHint }}</span>
|
<i :class="event.type === 'returned' ? 'mdi mdi-arrow-u-left-top' : 'mdi mdi-check-circle-outline'"></i>
|
||||||
<strong>{{ leaderOpinion.length }}/500</strong>
|
{{ event.title }}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<time v-if="event.time">{{ event.time }}</time>
|
||||||
<div v-else class="detail-note readonly application-leader-opinion-display">
|
</div>
|
||||||
<p>{{ leaderApprovalReadonlyText }}</p>
|
<p>{{ event.opinion }}</p>
|
||||||
|
<footer>
|
||||||
|
<span>{{ event.operator }}</span>
|
||||||
|
<span v-if="event.returnCount">第 {{ event.returnCount }} 次退回</span>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -475,20 +480,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card">
|
|
||||||
<h3>{{ approvalOpinionTitle }}</h3>
|
|
||||||
<textarea
|
|
||||||
v-model="leaderOpinion"
|
|
||||||
maxlength="500"
|
|
||||||
:required="requiresApprovalOpinion"
|
|
||||||
:placeholder="approvalOpinionPlaceholder"
|
|
||||||
:aria-label="approvalOpinionTitle"
|
|
||||||
></textarea>
|
|
||||||
<div class="leader-opinion-meta">
|
|
||||||
<span>{{ approvalOpinionHint }}</span>
|
|
||||||
<strong>{{ leaderOpinion.length }}/500</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -774,7 +765,10 @@
|
|||||||
:summary-label="approvalConfirmSummaryLabel"
|
:summary-label="approvalConfirmSummaryLabel"
|
||||||
:next-stage="approvalNextStage"
|
:next-stage="approvalNextStage"
|
||||||
:opinion-title="approvalOpinionTitle"
|
:opinion-title="approvalOpinionTitle"
|
||||||
:opinion="leaderOpinion"
|
v-model:opinion="leaderOpinion"
|
||||||
|
:opinion-placeholder="approvalOpinionPlaceholder"
|
||||||
|
:opinion-hint="approvalOpinionHint"
|
||||||
|
:opinion-required="requiresApprovalOpinion"
|
||||||
@close="closeApproveConfirmDialog"
|
@close="closeApproveConfirmDialog"
|
||||||
@confirm="confirmApproveRequest"
|
@confirm="confirmApproveRequest"
|
||||||
/>
|
/>
|
||||||
@@ -784,6 +778,7 @@
|
|||||||
:title="`确认退回 ${request.id} 吗?`"
|
:title="`确认退回 ${request.id} 吗?`"
|
||||||
:description="returnDialogDescription"
|
:description="returnDialogDescription"
|
||||||
:busy="returnBusy"
|
:busy="returnBusy"
|
||||||
|
:application="isApplicationDocument"
|
||||||
@close="closeReturnDialog"
|
@close="closeReturnDialog"
|
||||||
@confirm="confirmReturnRequest"
|
@confirm="confirmReturnRequest"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ import {
|
|||||||
buildWelcomeInsight,
|
buildWelcomeInsight,
|
||||||
createMessage,
|
createMessage,
|
||||||
filterAssistantSessionModes,
|
filterAssistantSessionModes,
|
||||||
|
hasMeaningfulSessionMessages,
|
||||||
resolveAssistantSessionMode,
|
resolveAssistantSessionMode,
|
||||||
resolveKnowledgeRankLabel,
|
resolveKnowledgeRankLabel,
|
||||||
resolveKnowledgeRankTone,
|
resolveKnowledgeRankTone,
|
||||||
@@ -718,7 +719,7 @@ export default {
|
|||||||
return labels[currentInsight.value.intent] ?? 'AI 处理中'
|
return labels[currentInsight.value.intent] ?? 'AI 处理中'
|
||||||
})
|
})
|
||||||
const canDeleteCurrentSession = computed(
|
const canDeleteCurrentSession = computed(
|
||||||
() => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user')
|
() => Boolean(conversationId.value) || hasMeaningfulSessionMessages(messages.value)
|
||||||
)
|
)
|
||||||
const latestReviewMessage = computed(() =>
|
const latestReviewMessage = computed(() =>
|
||||||
[...messages.value].reverse().find((item) =>
|
[...messages.value].reverse().find((item) =>
|
||||||
@@ -1029,6 +1030,7 @@ export default {
|
|||||||
handleGuidedShortcut,
|
handleGuidedShortcut,
|
||||||
handleGuidedComposerSubmit,
|
handleGuidedComposerSubmit,
|
||||||
handleGuidedSuggestedAction,
|
handleGuidedSuggestedAction,
|
||||||
|
handleSceneSelectionApplicationGate,
|
||||||
resetGuidedFlowState
|
resetGuidedFlowState
|
||||||
} = useTravelReimbursementGuidedFlow({
|
} = useTravelReimbursementGuidedFlow({
|
||||||
guidedFlowState,
|
guidedFlowState,
|
||||||
@@ -1470,6 +1472,7 @@ export default {
|
|||||||
if (message?.suggestedActionsLocked) return
|
if (message?.suggestedActionsLocked) return
|
||||||
if (applySuggestedActionPrefill(action)) return
|
if (applySuggestedActionPrefill(action)) return
|
||||||
if (await handleGuidedSuggestedAction(message, action)) return
|
if (await handleGuidedSuggestedAction(message, action)) return
|
||||||
|
if (await handleSceneSelectionApplicationGate(message, action)) return
|
||||||
|
|
||||||
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
|
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
|
||||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
|
|||||||
@@ -26,9 +26,15 @@ import {
|
|||||||
canDeleteArchivedExpenseClaims,
|
canDeleteArchivedExpenseClaims,
|
||||||
canManageExpenseClaims,
|
canManageExpenseClaims,
|
||||||
canReturnExpenseClaims,
|
canReturnExpenseClaims,
|
||||||
|
isCurrentDirectManagerForRequest,
|
||||||
|
isCurrentRequestApplicant,
|
||||||
isFinanceUser
|
isFinanceUser
|
||||||
} from '../../utils/accessControl.js'
|
} from '../../utils/accessControl.js'
|
||||||
import { buildLeaderApprovalInfo, resolveGeneratedDraftClaimNo } from '../../utils/applicationApproval.js'
|
import {
|
||||||
|
buildLeaderApprovalEvents,
|
||||||
|
buildLeaderApprovalInfo,
|
||||||
|
resolveGeneratedDraftClaimNo
|
||||||
|
} from '../../utils/applicationApproval.js'
|
||||||
import { buildApplicationDetailFactItems } from '../../utils/expenseApplicationDetail.js'
|
import { buildApplicationDetailFactItems } from '../../utils/expenseApplicationDetail.js'
|
||||||
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||||
import {
|
import {
|
||||||
@@ -484,11 +490,26 @@ export default {
|
|||||||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||||||
return node === '财务审批'
|
return node === '财务审批'
|
||||||
})
|
})
|
||||||
const canReturnRequest = computed(() =>
|
const isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value))
|
||||||
canReturnExpenseClaims(currentUser.value)
|
const isCurrentDirectManagerApprover = computed(() => (
|
||||||
&& request.value.approvalKey === 'in_progress'
|
canApproveLeaderExpenseClaims(currentUser.value)
|
||||||
&& Boolean(request.value.claimId)
|
&& isCurrentDirectManagerForRequest(request.value, currentUser.value)
|
||||||
)
|
))
|
||||||
|
const canProcessFinanceApprovalStage = computed(() => (
|
||||||
|
!isApplicationDocument.value
|
||||||
|
&& isFinanceApprovalStage.value
|
||||||
|
&& isFinanceUser(currentUser.value)
|
||||||
|
&& !isCurrentApplicant.value
|
||||||
|
))
|
||||||
|
const canReturnRequest = computed(() => {
|
||||||
|
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (isDirectManagerApprovalStage.value) {
|
||||||
|
return isCurrentDirectManagerApprover.value
|
||||||
|
}
|
||||||
|
return canProcessFinanceApprovalStage.value
|
||||||
|
})
|
||||||
const canApproveRequest = computed(() =>
|
const canApproveRequest = computed(() =>
|
||||||
(Boolean(props.approvalMode) || isApplicationDocument.value)
|
(Boolean(props.approvalMode) || isApplicationDocument.value)
|
||||||
&& request.value.approvalKey === 'in_progress'
|
&& request.value.approvalKey === 'in_progress'
|
||||||
@@ -496,32 +517,16 @@ export default {
|
|||||||
&& (
|
&& (
|
||||||
(
|
(
|
||||||
isDirectManagerApprovalStage.value
|
isDirectManagerApprovalStage.value
|
||||||
&& canApproveLeaderExpenseClaims(currentUser.value)
|
&& isCurrentDirectManagerApprover.value
|
||||||
)
|
|
||||||
|| (
|
|
||||||
!isApplicationDocument.value
|
|
||||||
&& isFinanceApprovalStage.value
|
|
||||||
&& isFinanceUser(currentUser.value)
|
|
||||||
)
|
)
|
||||||
|
|| canProcessFinanceApprovalStage.value
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
const showApplicationLeaderOpinionInput = computed(() => (
|
|
||||||
isApplicationDocument.value
|
|
||||||
&& canApproveRequest.value
|
|
||||||
&& isDirectManagerApprovalStage.value
|
|
||||||
))
|
|
||||||
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
|
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
|
||||||
const leaderApprovalReadonlyText = computed(() => {
|
const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value))
|
||||||
if (leaderApprovalInfo.value.opinion) {
|
const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0)
|
||||||
return leaderApprovalInfo.value.opinion
|
|
||||||
}
|
|
||||||
return isApplicationDocument.value ? '待直属领导填写审批意见。' : ''
|
|
||||||
})
|
|
||||||
const leaderApprovalReadonlyMeta = computed(() => {
|
const leaderApprovalReadonlyMeta = computed(() => {
|
||||||
const pieces = [
|
const pieces = hasLeaderApprovalEvents.value ? [`${leaderApprovalEvents.value.length} 条批复记录`] : []
|
||||||
leaderApprovalInfo.value.operator ? `${leaderApprovalInfo.value.operator}确认` : '',
|
|
||||||
leaderApprovalInfo.value.time
|
|
||||||
].filter(Boolean)
|
|
||||||
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
|
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
|
||||||
pieces.push(`已生成报销草稿 ${leaderApprovalInfo.value.generatedDraftClaimNo}`)
|
pieces.push(`已生成报销草稿 ${leaderApprovalInfo.value.generatedDraftClaimNo}`)
|
||||||
}
|
}
|
||||||
@@ -529,12 +534,8 @@ export default {
|
|||||||
})
|
})
|
||||||
const showApplicationLeaderOpinion = computed(() => (
|
const showApplicationLeaderOpinion = computed(() => (
|
||||||
isApplicationDocument.value
|
isApplicationDocument.value
|
||||||
&& (
|
&& hasLeaderApprovalEvents.value
|
||||||
showApplicationLeaderOpinionInput.value
|
|
||||||
|| leaderApprovalReadonlyText.value
|
|
||||||
)
|
|
||||||
))
|
))
|
||||||
const showLeaderApprovalPanel = computed(() => canApproveRequest.value && !showApplicationLeaderOpinionInput.value)
|
|
||||||
const requiresApprovalOpinion = computed(() => isDirectManagerApprovalStage.value)
|
const requiresApprovalOpinion = computed(() => isDirectManagerApprovalStage.value)
|
||||||
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
|
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
|
||||||
const approvalOpinionPlaceholder = computed(() => {
|
const approvalOpinionPlaceholder = computed(() => {
|
||||||
@@ -1726,11 +1727,6 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
|
|
||||||
toast('请先填写领导意见,填写后才能确认审核。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
approveConfirmDialogOpen.value = true
|
approveConfirmDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1757,7 +1753,6 @@ export default {
|
|||||||
|
|
||||||
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
|
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
|
||||||
toast('请先填写领导意见,填写后才能确认审核。')
|
toast('请先填写领导意见,填写后才能确认审核。')
|
||||||
approveConfirmDialogOpen.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1833,15 +1828,15 @@ export default {
|
|||||||
isMajorExpenseRisk,
|
isMajorExpenseRisk,
|
||||||
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||||
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||||
leaderApprovalReadonlyMeta, leaderApprovalReadonlyText,
|
hasLeaderApprovalEvents, leaderApprovalEvents, leaderApprovalReadonlyMeta,
|
||||||
resolveExpenseRiskIndicatorTitle,
|
resolveExpenseRiskIndicatorTitle,
|
||||||
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
||||||
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
||||||
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
||||||
requiresApprovalOpinion,
|
requiresApprovalOpinion,
|
||||||
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
||||||
showAiAdvicePanel, showApplicationLeaderOpinion, showApplicationLeaderOpinionInput,
|
showAiAdvicePanel, showApplicationLeaderOpinion,
|
||||||
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
|
||||||
submitRiskWarnings,
|
submitRiskWarnings,
|
||||||
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
||||||
}
|
}
|
||||||
|
|||||||
294
web/src/views/scripts/travelReimbursementApplicationLinkModel.js
Normal file
294
web/src/views/scripts/travelReimbursementApplicationLinkModel.js
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
const REQUIRED_APPLICATION_EXPENSE_TYPES = new Set(['travel', 'meal'])
|
||||||
|
|
||||||
|
const APPLICATION_TYPE_ALIASES = {
|
||||||
|
travel: new Set(['travel', 'travel_application']),
|
||||||
|
meal: new Set([
|
||||||
|
'meal',
|
||||||
|
'entertainment',
|
||||||
|
'meal_application',
|
||||||
|
'entertainment_application',
|
||||||
|
'business_entertainment_application',
|
||||||
|
'hospitality_application'
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENERIC_APPLICATION_TYPES = new Set(['application', 'expense_application'])
|
||||||
|
const BLOCKED_APPLICATION_STATUSES = new Set(['draft', 'returned', 'rejected', 'cancelled', 'canceled', 'deleted'])
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
submitted: '审批中',
|
||||||
|
approved: '已审批',
|
||||||
|
completed: '已完成',
|
||||||
|
archived: '已归档',
|
||||||
|
paid: '已入账'
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPENSE_TYPE_LABELS = {
|
||||||
|
travel: '差旅费',
|
||||||
|
meal: '业务招待费'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLower(value) {
|
||||||
|
return normalizeText(value).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueValues(values) {
|
||||||
|
return Array.from(new Set((Array.isArray(values) ? values : []).map(normalizeText).filter(Boolean)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClaimNo(claim) {
|
||||||
|
return normalizeText(claim?.claim_no || claim?.claimNo).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExpenseType(claim) {
|
||||||
|
return normalizeLower(claim?.expense_type || claim?.expenseType || claim?.type_code || claim?.typeCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApplicationStatus(claim) {
|
||||||
|
return normalizeLower(claim?.status || claim?.state || claim?.approval_status || claim?.approvalStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDocumentType(claim) {
|
||||||
|
return normalizeLower(
|
||||||
|
claim?.document_type_code
|
||||||
|
|| claim?.documentTypeCode
|
||||||
|
|| claim?.document_type
|
||||||
|
|| claim?.documentType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApplicationDate(claim) {
|
||||||
|
return normalizeText(
|
||||||
|
claim?.submitted_at
|
||||||
|
|| claim?.submittedAt
|
||||||
|
|| claim?.created_at
|
||||||
|
|| claim?.createdAt
|
||||||
|
|| claim?.occurred_at
|
||||||
|
|| claim?.occurredAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTimestamp(value) {
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(value) {
|
||||||
|
const numberValue = Number(String(value ?? '').replace(/[^\d.-]/g, ''))
|
||||||
|
if (!Number.isFinite(numberValue) || numberValue <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return `¥${new Intl.NumberFormat('zh-CN', {
|
||||||
|
minimumFractionDigits: Number.isInteger(numberValue) ? 0 : 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
}).format(numberValue)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesAny(text, keywords) {
|
||||||
|
const normalized = normalizeText(text)
|
||||||
|
return keywords.some((keyword) => normalized.includes(keyword))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApplicationKeywordText(claim) {
|
||||||
|
return [
|
||||||
|
claim?.reason,
|
||||||
|
claim?.business_reason,
|
||||||
|
claim?.title,
|
||||||
|
claim?.summary,
|
||||||
|
claim?.description,
|
||||||
|
claim?.location,
|
||||||
|
claim?.business_location,
|
||||||
|
claim?.expense_type_label,
|
||||||
|
claim?.expenseTypeLabel
|
||||||
|
].map(normalizeText).filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesGenericApplicationByText(claim, expenseType) {
|
||||||
|
const haystack = buildApplicationKeywordText(claim)
|
||||||
|
if (expenseType === 'travel') {
|
||||||
|
return includesAny(haystack, ['差旅', '出差', '住宿', '交通', '行程'])
|
||||||
|
}
|
||||||
|
if (expenseType === 'meal') {
|
||||||
|
return includesAny(haystack, ['招待', '客户', '接待', '宴请', '用餐', '餐饮'])
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requiresApplicationBeforeReimbursement(expenseType) {
|
||||||
|
return REQUIRED_APPLICATION_EXPENSE_TYPES.has(normalizeLower(expenseType))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequiredApplicationExpenseLabel(expenseType) {
|
||||||
|
return EXPENSE_TYPE_LABELS[normalizeLower(expenseType)] || '报销'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExpenseApplicationClaim(claim) {
|
||||||
|
const documentType = normalizeDocumentType(claim)
|
||||||
|
const expenseType = normalizeExpenseType(claim)
|
||||||
|
const claimNo = normalizeClaimNo(claim)
|
||||||
|
|
||||||
|
return documentType === 'application'
|
||||||
|
|| documentType === 'expense_application'
|
||||||
|
|| claimNo.startsWith('AP-')
|
||||||
|
|| claimNo.startsWith('APP-')
|
||||||
|
|| expenseType === 'application'
|
||||||
|
|| expenseType.endsWith('_application')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesRequiredApplicationExpenseType(claim, expenseType) {
|
||||||
|
const normalizedExpenseType = normalizeLower(expenseType)
|
||||||
|
const claimExpenseType = normalizeExpenseType(claim)
|
||||||
|
const aliases = APPLICATION_TYPE_ALIASES[normalizedExpenseType] || new Set()
|
||||||
|
|
||||||
|
if (aliases.has(claimExpenseType)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return GENERIC_APPLICATION_TYPES.has(claimExpenseType)
|
||||||
|
&& matchesGenericApplicationByText(claim, normalizedExpenseType)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
|
||||||
|
const userIds = uniqueValues([
|
||||||
|
currentUser.id,
|
||||||
|
currentUser.employeeId,
|
||||||
|
currentUser.employee_id,
|
||||||
|
currentUser.employeeNo,
|
||||||
|
currentUser.employee_no,
|
||||||
|
currentUser.username,
|
||||||
|
currentUser.email
|
||||||
|
])
|
||||||
|
const claimIds = uniqueValues([
|
||||||
|
claim?.employee_id,
|
||||||
|
claim?.employeeId,
|
||||||
|
claim?.employee_no,
|
||||||
|
claim?.employeeNo,
|
||||||
|
claim?.username,
|
||||||
|
claim?.user_id,
|
||||||
|
claim?.userId
|
||||||
|
])
|
||||||
|
if (userIds.length && claimIds.length && claimIds.some((item) => userIds.includes(item))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const userNames = uniqueValues([
|
||||||
|
currentUser.name,
|
||||||
|
currentUser.user_name,
|
||||||
|
currentUser.employeeName,
|
||||||
|
currentUser.employee_name,
|
||||||
|
currentUser.username
|
||||||
|
])
|
||||||
|
const claimNames = uniqueValues([
|
||||||
|
claim?.employee_name,
|
||||||
|
claim?.employeeName,
|
||||||
|
claim?.applicant,
|
||||||
|
claim?.applicant_name,
|
||||||
|
claim?.applicantName
|
||||||
|
])
|
||||||
|
if (userNames.length && claimNames.length) {
|
||||||
|
return claimNames.some((item) => userNames.includes(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUsableRequiredApplicationClaim(claim) {
|
||||||
|
const status = normalizeApplicationStatus(claim)
|
||||||
|
return !BLOCKED_APPLICATION_STATUSES.has(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRequiredApplicationCandidate(claim) {
|
||||||
|
const claimNo = normalizeText(claim?.claim_no || claim?.claimNo)
|
||||||
|
const location = normalizeText(claim?.location || claim?.business_location || claim?.businessLocation)
|
||||||
|
const amountText = formatAmount(claim?.amount || claim?.budget_amount || claim?.budgetAmount)
|
||||||
|
const status = normalizeApplicationStatus(claim)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: normalizeText(claim?.id || claim?.claim_id || claim?.claimId),
|
||||||
|
claim_no: claimNo,
|
||||||
|
expense_type: normalizeExpenseType(claim),
|
||||||
|
reason: normalizeText(claim?.reason || claim?.business_reason || claim?.description || claim?.title),
|
||||||
|
location,
|
||||||
|
amount: normalizeText(claim?.amount || claim?.budget_amount || claim?.budgetAmount),
|
||||||
|
amount_label: amountText,
|
||||||
|
status,
|
||||||
|
status_label: STATUS_LABELS[status] || normalizeText(claim?.approval_stage || claim?.approvalStage || status),
|
||||||
|
application_date: normalizeApplicationDate(claim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterRequiredApplicationCandidates(claimsPayload, expenseType, currentUser = {}) {
|
||||||
|
const claims = Array.isArray(claimsPayload)
|
||||||
|
? claimsPayload
|
||||||
|
: Array.isArray(claimsPayload?.items)
|
||||||
|
? claimsPayload.items
|
||||||
|
: Array.isArray(claimsPayload?.claims)
|
||||||
|
? claimsPayload.claims
|
||||||
|
: []
|
||||||
|
|
||||||
|
return claims
|
||||||
|
.filter((claim) => (
|
||||||
|
isExpenseApplicationClaim(claim)
|
||||||
|
&& isUsableRequiredApplicationClaim(claim)
|
||||||
|
&& isClaimOwnedByCurrentUser(claim, currentUser)
|
||||||
|
&& matchesRequiredApplicationExpenseType(claim, expenseType)
|
||||||
|
))
|
||||||
|
.map(normalizeRequiredApplicationCandidate)
|
||||||
|
.sort((left, right) => toTimestamp(right.application_date) - toTimestamp(left.application_date))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRequiredApplicationActions(applications, actionType) {
|
||||||
|
return (Array.isArray(applications) ? applications : []).map((application) => {
|
||||||
|
const claimNo = normalizeText(application.claim_no) || '未编号申请单'
|
||||||
|
const description = [
|
||||||
|
application.status_label,
|
||||||
|
application.location && `地点:${application.location}`,
|
||||||
|
application.amount_label && `预算:${application.amount_label}`,
|
||||||
|
application.reason && `事由:${application.reason}`
|
||||||
|
].filter(Boolean).join(' · ')
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: claimNo,
|
||||||
|
description,
|
||||||
|
icon: 'mdi mdi-file-link-outline',
|
||||||
|
action_type: actionType,
|
||||||
|
payload: {
|
||||||
|
application_claim_id: application.id,
|
||||||
|
application_claim_no: application.claim_no,
|
||||||
|
application_expense_type: application.expense_type,
|
||||||
|
application_reason: application.reason,
|
||||||
|
application_location: application.location,
|
||||||
|
application_amount: application.amount,
|
||||||
|
application_amount_label: application.amount_label,
|
||||||
|
application_status: application.status,
|
||||||
|
application_status_label: application.status_label,
|
||||||
|
application_date: application.application_date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRequiredApplicationSelectionText(expenseType, applications) {
|
||||||
|
const label = getRequiredApplicationExpenseLabel(expenseType)
|
||||||
|
return [
|
||||||
|
`发起“${label}”报销前,需要先关联对应的申请单。`,
|
||||||
|
'',
|
||||||
|
`我查到 ${applications.length} 个可关联申请单,请先选择其中一个。`,
|
||||||
|
'选择后,我再继续向你收集本次报销依据。'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRequiredApplicationMissingText(expenseType) {
|
||||||
|
const label = getRequiredApplicationExpenseLabel(expenseType)
|
||||||
|
return [
|
||||||
|
`发起“${label}”报销前,需要先关联对应的申请单。`,
|
||||||
|
'',
|
||||||
|
`我没有查到你名下可关联的“${label}”申请单,所以当前不能继续这类报销流程。`,
|
||||||
|
'请先切换到申请助手发起相关申请;申请单存在后,再回到报销助手继续。'
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
@@ -906,7 +906,10 @@ export function hasMeaningfulSessionMessages(messages) {
|
|||||||
|| message.reviewPayload
|
|| message.reviewPayload
|
||||||
|| message.queryPayload
|
|| message.queryPayload
|
||||||
|| message.draftPayload
|
|| message.draftPayload
|
||||||
|
|| message.applicationPreview
|
||||||
|| message.budgetReport
|
|| message.budgetReport
|
||||||
|
|| message.pendingAttachmentAssociation
|
||||||
|
|| (Array.isArray(message.riskFlags) && message.riskFlags.length)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const GUIDED_ACTION_START_APPLICATION = 'start_guided_application'
|
|||||||
export const GUIDED_ACTION_START_STATUS_QUERY = 'start_guided_status_query'
|
export const GUIDED_ACTION_START_STATUS_QUERY = 'start_guided_status_query'
|
||||||
export const GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR = 'open_travel_calculator'
|
export const GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR = 'open_travel_calculator'
|
||||||
export const GUIDED_ACTION_SELECT_EXPENSE_TYPE = 'guided_select_expense_type'
|
export const GUIDED_ACTION_SELECT_EXPENSE_TYPE = 'guided_select_expense_type'
|
||||||
|
export const GUIDED_ACTION_SELECT_REQUIRED_APPLICATION = 'guided_select_required_application'
|
||||||
export const GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW = 'guided_confirm_reimbursement_review'
|
export const GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW = 'guided_confirm_reimbursement_review'
|
||||||
export const GUIDED_ACTION_CONTINUE_FILLING = 'guided_continue_filling'
|
export const GUIDED_ACTION_CONTINUE_FILLING = 'guided_continue_filling'
|
||||||
export const GUIDED_ACTION_PROCESS_INTERRUPTION = 'guided_process_interruption'
|
export const GUIDED_ACTION_PROCESS_INTERRUPTION = 'guided_process_interruption'
|
||||||
@@ -109,13 +110,36 @@ function normalizeValues(values) {
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeApplicationCandidates(applications) {
|
||||||
|
if (!Array.isArray(applications)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return applications
|
||||||
|
.map((item) => (item && typeof item === 'object' ? item : null))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item) => ({
|
||||||
|
id: normalizeText(item.id || item.application_claim_id),
|
||||||
|
claim_no: normalizeText(item.claim_no || item.application_claim_no),
|
||||||
|
expense_type: normalizeText(item.expense_type || item.application_expense_type),
|
||||||
|
reason: normalizeText(item.reason || item.application_reason),
|
||||||
|
location: normalizeText(item.location || item.application_location),
|
||||||
|
amount: normalizeText(item.amount || item.application_amount),
|
||||||
|
amount_label: normalizeText(item.amount_label || item.application_amount_label),
|
||||||
|
status: normalizeText(item.status || item.application_status),
|
||||||
|
status_label: normalizeText(item.status_label || item.application_status_label),
|
||||||
|
application_date: normalizeText(item.application_date)
|
||||||
|
}))
|
||||||
|
.filter((item) => item.id || item.claim_no)
|
||||||
|
}
|
||||||
|
|
||||||
export function createEmptyGuidedFlowState() {
|
export function createEmptyGuidedFlowState() {
|
||||||
return {
|
return {
|
||||||
mode: GUIDED_FLOW_MODE_NONE,
|
mode: GUIDED_FLOW_MODE_NONE,
|
||||||
stepKey: '',
|
stepKey: '',
|
||||||
expenseType: '',
|
expenseType: '',
|
||||||
values: {},
|
values: {},
|
||||||
pendingInterruptionText: ''
|
pendingInterruptionText: '',
|
||||||
|
applicationCandidates: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +158,8 @@ export function normalizeGuidedFlowState(state) {
|
|||||||
stepKey: normalizeText(source.stepKey),
|
stepKey: normalizeText(source.stepKey),
|
||||||
expenseType: normalizeText(source.expenseType),
|
expenseType: normalizeText(source.expenseType),
|
||||||
values: normalizeValues(source.values),
|
values: normalizeValues(source.values),
|
||||||
pendingInterruptionText: normalizeText(source.pendingInterruptionText)
|
pendingInterruptionText: normalizeText(source.pendingInterruptionText),
|
||||||
|
applicationCandidates: normalizeApplicationCandidates(source.applicationCandidates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +216,44 @@ export function selectGuidedExpenseType(state, expenseType) {
|
|||||||
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||||
expenseType: type.key,
|
expenseType: type.key,
|
||||||
stepKey: steps[0]?.key || 'summary',
|
stepKey: steps[0]?.key || 'summary',
|
||||||
pendingInterruptionText: ''
|
pendingInterruptionText: '',
|
||||||
|
applicationCandidates: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function waitForGuidedApplicationSelection(state, expenseType, applications = []) {
|
||||||
|
const type = getGuidedExpenseType(expenseType)
|
||||||
|
if (!type) {
|
||||||
|
return normalizeGuidedFlowState(state)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...normalizeGuidedFlowState(state),
|
||||||
|
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
|
||||||
|
expenseType: type.key,
|
||||||
|
stepKey: 'application_selection',
|
||||||
|
pendingInterruptionText: '',
|
||||||
|
applicationCandidates: normalizeApplicationCandidates(applications)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectGuidedRequiredApplication(state, application = {}) {
|
||||||
|
const current = normalizeGuidedFlowState(state)
|
||||||
|
const steps = getGuidedReimbursementSteps(current.expenseType)
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
values: normalizeValues({
|
||||||
|
...current.values,
|
||||||
|
application_claim_id: application.application_claim_id || application.id || '',
|
||||||
|
application_claim_no: application.application_claim_no || application.claim_no || '',
|
||||||
|
application_reason: application.application_reason || application.reason || '',
|
||||||
|
application_location: application.application_location || application.location || '',
|
||||||
|
application_amount: application.application_amount || application.amount || '',
|
||||||
|
application_amount_label: application.application_amount_label || application.amount_label || '',
|
||||||
|
application_status_label: application.application_status_label || application.status_label || ''
|
||||||
|
}),
|
||||||
|
stepKey: steps[0]?.key || 'summary',
|
||||||
|
pendingInterruptionText: '',
|
||||||
|
applicationCandidates: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +352,16 @@ export function buildGuidedReimbursementSummaryText(state) {
|
|||||||
'请核查下面的关键信息:'
|
'请核查下面的关键信息:'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (current.values.application_claim_no) {
|
||||||
|
const applicationParts = [
|
||||||
|
current.values.application_claim_no,
|
||||||
|
current.values.application_reason,
|
||||||
|
current.values.application_location,
|
||||||
|
current.values.application_amount_label
|
||||||
|
].filter(Boolean)
|
||||||
|
lines.push(`- 关联申请单:${applicationParts.join(' / ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
steps.forEach((step) => {
|
steps.forEach((step) => {
|
||||||
const value = step.key === 'attachments'
|
const value = step.key === 'attachments'
|
||||||
? (current.values.attachment_names?.length
|
? (current.values.attachment_names?.length
|
||||||
@@ -324,6 +396,9 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
|||||||
: values[step.key]
|
: values[step.key]
|
||||||
return `${step.summaryLabel}:${value || '待补充'}`
|
return `${step.summaryLabel}:${value || '待补充'}`
|
||||||
})
|
})
|
||||||
|
if (values.application_claim_no) {
|
||||||
|
fieldLines.unshift(`关联申请单:${values.application_claim_no}`)
|
||||||
|
}
|
||||||
const rawText = [
|
const rawText = [
|
||||||
`报销类型:${typeLabel}`,
|
`报销类型:${typeLabel}`,
|
||||||
...fieldLines
|
...fieldLines
|
||||||
@@ -340,7 +415,12 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
|||||||
time_range: values.time_range || '',
|
time_range: values.time_range || '',
|
||||||
business_time: values.time_range || '',
|
business_time: values.time_range || '',
|
||||||
amount: values.amount || '',
|
amount: values.amount || '',
|
||||||
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : []
|
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [],
|
||||||
|
application_claim_id: values.application_claim_id || '',
|
||||||
|
application_claim_no: values.application_claim_no || '',
|
||||||
|
application_reason: values.application_reason || '',
|
||||||
|
application_location: values.application_location || '',
|
||||||
|
application_amount: values.application_amount || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -355,7 +435,9 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
|||||||
expense_scene_selection: {
|
expense_scene_selection: {
|
||||||
expense_type: type?.key || current.expenseType || 'other',
|
expense_type: type?.key || current.expenseType || 'other',
|
||||||
expense_type_label: typeLabel,
|
expense_type_label: typeLabel,
|
||||||
original_message: rawText
|
original_message: rawText,
|
||||||
|
application_claim_id: values.application_claim_id || '',
|
||||||
|
application_claim_no: values.application_claim_no || ''
|
||||||
},
|
},
|
||||||
review_form_values: reviewFormValues
|
review_form_values: reviewFormValues
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import {
|
|||||||
buildApplicationTemplatePreview,
|
buildApplicationTemplatePreview,
|
||||||
buildLocalApplicationPreviewMessage
|
buildLocalApplicationPreviewMessage
|
||||||
} from '../../utils/expenseApplicationPreview.js'
|
} from '../../utils/expenseApplicationPreview.js'
|
||||||
|
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||||
|
import {
|
||||||
|
buildRequiredApplicationActions,
|
||||||
|
buildRequiredApplicationMissingText,
|
||||||
|
buildRequiredApplicationSelectionText,
|
||||||
|
filterRequiredApplicationCandidates,
|
||||||
|
requiresApplicationBeforeReimbursement
|
||||||
|
} from './travelReimbursementApplicationLinkModel.js'
|
||||||
import {
|
import {
|
||||||
GUIDED_ACTION_START_APPLICATION,
|
GUIDED_ACTION_START_APPLICATION,
|
||||||
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||||||
@@ -11,6 +19,7 @@ import {
|
|||||||
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
|
||||||
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||||||
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||||||
|
GUIDED_ACTION_SELECT_REQUIRED_APPLICATION,
|
||||||
GUIDED_ACTION_SELECT_QUERY_MODE,
|
GUIDED_ACTION_SELECT_QUERY_MODE,
|
||||||
GUIDED_ACTION_SELECT_QUERY_STATUS,
|
GUIDED_ACTION_SELECT_QUERY_STATUS,
|
||||||
GUIDED_ACTION_START_REIMBURSEMENT,
|
GUIDED_ACTION_START_REIMBURSEMENT,
|
||||||
@@ -41,8 +50,10 @@ import {
|
|||||||
resolveGuidedExpenseTypeFromText,
|
resolveGuidedExpenseTypeFromText,
|
||||||
resolveGuidedQueryModeFromText,
|
resolveGuidedQueryModeFromText,
|
||||||
selectGuidedExpenseType,
|
selectGuidedExpenseType,
|
||||||
|
selectGuidedRequiredApplication,
|
||||||
selectGuidedQueryMode,
|
selectGuidedQueryMode,
|
||||||
shouldConfirmGuidedInterruption
|
shouldConfirmGuidedInterruption,
|
||||||
|
waitForGuidedApplicationSelection
|
||||||
} from './travelReimbursementGuidedFlowModel.js'
|
} from './travelReimbursementGuidedFlowModel.js'
|
||||||
|
|
||||||
function normalizeText(value) {
|
function normalizeText(value) {
|
||||||
@@ -211,7 +222,98 @@ export function useTravelReimbursementGuidedFlow({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReimbursementAnswer(answerText, files) {
|
async function selectExpenseTypeForGuidedReimbursement(currentState, expenseType, options = {}) {
|
||||||
|
const nextState = options.pendingSceneSelection
|
||||||
|
? {
|
||||||
|
...currentState,
|
||||||
|
values: {
|
||||||
|
...currentState.values,
|
||||||
|
pending_scene_original_message: normalizeText(options.pendingSceneSelection.originalMessage),
|
||||||
|
pending_scene_expense_type_label: normalizeText(options.pendingSceneSelection.expenseTypeLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: currentState
|
||||||
|
|
||||||
|
if (!requiresApplicationBeforeReimbursement(expenseType)) {
|
||||||
|
guidedFlowState.value = selectGuidedExpenseType(nextState, expenseType)
|
||||||
|
pushNextReimbursementPrompt()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let claimsPayload = null
|
||||||
|
try {
|
||||||
|
claimsPayload = await fetchExpenseClaims()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Fetch reimbursement applications failed:', error)
|
||||||
|
pushAssistant('查询可关联申请单时出现异常,请稍后再试。为避免直接报销,我先暂停当前流程。', {
|
||||||
|
meta: ['申请单查询失败']
|
||||||
|
})
|
||||||
|
toast?.('申请单查询失败,请稍后再试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const applications = filterRequiredApplicationCandidates(claimsPayload, expenseType, currentUser?.value || {})
|
||||||
|
if (!applications.length) {
|
||||||
|
guidedFlowState.value = createGuidedReimbursementState()
|
||||||
|
pushAssistant(buildRequiredApplicationMissingText(expenseType), {
|
||||||
|
meta: ['缺少可关联申请单'],
|
||||||
|
suggestedActions: buildGuidedExpenseTypeActions()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guidedFlowState.value = waitForGuidedApplicationSelection(nextState, expenseType, applications)
|
||||||
|
pushAssistant(buildRequiredApplicationSelectionText(expenseType, applications), {
|
||||||
|
meta: ['等待关联申请单'],
|
||||||
|
suggestedActions: buildRequiredApplicationActions(applications, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPendingSceneSubmitOptions(state) {
|
||||||
|
const current = normalizeGuidedFlowState(state)
|
||||||
|
const originalMessage = normalizeText(current.values.pending_scene_original_message)
|
||||||
|
const expenseTypeLabel = normalizeText(current.values.pending_scene_expense_type_label)
|
||||||
|
const applicationNo = normalizeText(current.values.application_claim_no)
|
||||||
|
const applicationId = normalizeText(current.values.application_claim_id)
|
||||||
|
if (!originalMessage || !expenseTypeLabel || !applicationNo) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawText = [
|
||||||
|
originalMessage,
|
||||||
|
`用户选择报销场景:${expenseTypeLabel}`,
|
||||||
|
`关联申请单:${applicationNo}`
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawText,
|
||||||
|
userText: `关联申请单 ${applicationNo}`,
|
||||||
|
pendingText: `已关联申请单,正在按${expenseTypeLabel}识别...`,
|
||||||
|
systemGenerated: true,
|
||||||
|
skipUserMessage: true,
|
||||||
|
extraContext: {
|
||||||
|
draft_claim_id: '',
|
||||||
|
user_input_text: originalMessage,
|
||||||
|
expense_scene_selection: {
|
||||||
|
expense_type: current.expenseType || 'other',
|
||||||
|
expense_type_label: expenseTypeLabel,
|
||||||
|
original_message: originalMessage,
|
||||||
|
application_claim_id: applicationId,
|
||||||
|
application_claim_no: applicationNo
|
||||||
|
},
|
||||||
|
review_form_values: {
|
||||||
|
expense_type: expenseTypeLabel,
|
||||||
|
application_claim_id: applicationId,
|
||||||
|
application_claim_no: applicationNo,
|
||||||
|
application_reason: current.values.application_reason || '',
|
||||||
|
application_location: current.values.application_location || '',
|
||||||
|
application_amount: current.values.application_amount || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReimbursementAnswer(answerText, files) {
|
||||||
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
|
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
|
||||||
const currentStep = getCurrentGuidedStep(currentState)
|
const currentStep = getCurrentGuidedStep(currentState)
|
||||||
const fileNames = buildFileNames(files)
|
const fileNames = buildFileNames(files)
|
||||||
@@ -225,8 +327,18 @@ export function useTravelReimbursementGuidedFlow({
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guidedFlowState.value = selectGuidedExpenseType(currentState, expenseType)
|
await selectExpenseTypeForGuidedReimbursement(currentState, expenseType)
|
||||||
pushNextReimbursementPrompt()
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentState.stepKey === 'application_selection') {
|
||||||
|
pushAssistant('请先点击上方列出的申请单完成关联。关联后,我再继续询问报销依据。', {
|
||||||
|
meta: ['等待关联申请单'],
|
||||||
|
suggestedActions: buildRequiredApplicationActions(
|
||||||
|
currentState.applicationCandidates,
|
||||||
|
GUIDED_ACTION_SELECT_REQUIRED_APPLICATION
|
||||||
|
)
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +450,7 @@ export function useTravelReimbursementGuidedFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currentState.mode === GUIDED_FLOW_MODE_REIMBURSEMENT) {
|
if (currentState.mode === GUIDED_FLOW_MODE_REIMBURSEMENT) {
|
||||||
handleReimbursementAnswer(answerText, files)
|
await handleReimbursementAnswer(answerText, files)
|
||||||
clearComposerRuntime()
|
clearComposerRuntime()
|
||||||
persistAndScroll()
|
persistAndScroll()
|
||||||
return true
|
return true
|
||||||
@@ -361,6 +473,7 @@ export function useTravelReimbursementGuidedFlow({
|
|||||||
}
|
}
|
||||||
const guidedActionTypes = new Set([
|
const guidedActionTypes = new Set([
|
||||||
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||||||
|
GUIDED_ACTION_SELECT_REQUIRED_APPLICATION,
|
||||||
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
|
||||||
GUIDED_ACTION_CONTINUE_FILLING,
|
GUIDED_ACTION_CONTINUE_FILLING,
|
||||||
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||||||
@@ -380,8 +493,23 @@ export function useTravelReimbursementGuidedFlow({
|
|||||||
if (actionType === GUIDED_ACTION_SELECT_EXPENSE_TYPE) {
|
if (actionType === GUIDED_ACTION_SELECT_EXPENSE_TYPE) {
|
||||||
const expenseType = normalizeText(action?.payload?.expense_type)
|
const expenseType = normalizeText(action?.payload?.expense_type)
|
||||||
const expenseTypeLabel = normalizeText(action?.payload?.expense_type_label || action?.label)
|
const expenseTypeLabel = normalizeText(action?.payload?.expense_type_label || action?.label)
|
||||||
guidedFlowState.value = selectGuidedExpenseType(guidedFlowState.value, expenseType)
|
|
||||||
pushUser(`选择${expenseTypeLabel || '报销类型'}`)
|
pushUser(`选择${expenseTypeLabel || '报销类型'}`)
|
||||||
|
await selectExpenseTypeForGuidedReimbursement(guidedFlowState.value, expenseType)
|
||||||
|
persistAndScroll()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionType === GUIDED_ACTION_SELECT_REQUIRED_APPLICATION) {
|
||||||
|
const applicationNo = normalizeText(action?.payload?.application_claim_no || action?.label)
|
||||||
|
pushUser(`关联申请单 ${applicationNo || ''}`.trim())
|
||||||
|
guidedFlowState.value = selectGuidedRequiredApplication(guidedFlowState.value, action?.payload || {})
|
||||||
|
const pendingSceneSubmitOptions = buildPendingSceneSubmitOptions(guidedFlowState.value)
|
||||||
|
if (pendingSceneSubmitOptions) {
|
||||||
|
resetGuidedFlowState()
|
||||||
|
persistAndScroll()
|
||||||
|
await submitExistingComposer(pendingSceneSubmitOptions)
|
||||||
|
return true
|
||||||
|
}
|
||||||
pushNextReimbursementPrompt()
|
pushNextReimbursementPrompt()
|
||||||
persistAndScroll()
|
persistAndScroll()
|
||||||
return true
|
return true
|
||||||
@@ -450,10 +578,42 @@ export function useTravelReimbursementGuidedFlow({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSceneSelectionApplicationGate(message, action) {
|
||||||
|
const actionType = normalizeText(action?.action_type)
|
||||||
|
if (actionType !== 'select_expense_type') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||||
|
const expenseType = normalizeText(actionPayload.expense_type)
|
||||||
|
if (!requiresApplicationBeforeReimbursement(expenseType)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const expenseTypeLabel = normalizeText(actionPayload.expense_type_label || action?.label)
|
||||||
|
const originalMessage = normalizeText(actionPayload.original_message || message?.text)
|
||||||
|
if (!expenseTypeLabel || !originalMessage) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!lockSuggestedActionMessage(message, action)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
guidedPendingFiles.value = []
|
||||||
|
pushUser(`选择${expenseTypeLabel}`)
|
||||||
|
await selectExpenseTypeForGuidedReimbursement(createGuidedReimbursementState(), expenseType, {
|
||||||
|
pendingSceneSelection: {
|
||||||
|
originalMessage,
|
||||||
|
expenseTypeLabel
|
||||||
|
}
|
||||||
|
})
|
||||||
|
persistAndScroll()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleGuidedShortcut,
|
handleGuidedShortcut,
|
||||||
handleGuidedComposerSubmit,
|
handleGuidedComposerSubmit,
|
||||||
handleGuidedSuggestedAction,
|
handleGuidedSuggestedAction,
|
||||||
|
handleSceneSelectionApplicationGate,
|
||||||
resetGuidedFlowState
|
resetGuidedFlowState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
canAccessAppView,
|
canAccessAppView,
|
||||||
canDeleteArchivedExpenseClaims,
|
canDeleteArchivedExpenseClaims,
|
||||||
canEditBudgetCenter,
|
canEditBudgetCenter,
|
||||||
|
isCurrentDirectManagerForRequest,
|
||||||
|
isCurrentRequestApplicant,
|
||||||
canManageExpenseClaims,
|
canManageExpenseClaims,
|
||||||
canReturnExpenseClaims,
|
canReturnExpenseClaims,
|
||||||
canSwitchBudgetDepartments
|
canSwitchBudgetDepartments
|
||||||
@@ -87,7 +89,33 @@ test('users with both finance and manager roles can process both relevant stages
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
canProcessApprovalRequest({ workflowNode: '直属领导审批', person: '张三' }, financeManagerUser),
|
canProcessApprovalRequest(
|
||||||
|
{ workflowNode: '直属领导审批', person: '张三', managerName: '李经理' },
|
||||||
|
financeManagerUser
|
||||||
|
),
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
assert.equal(
|
||||||
|
canProcessApprovalRequest(
|
||||||
|
{ workflowNode: '直属领导审批', person: '李经理', managerName: '王总' },
|
||||||
|
financeManagerUser
|
||||||
|
),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
canProcessApprovalRequest(
|
||||||
|
{ workflowNode: '直属领导审批', person: '张三', managerName: '王总' },
|
||||||
|
financeManagerUser
|
||||||
|
),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('direct-manager approval helpers only match claims pushed to the current user', () => {
|
||||||
|
const managerUser = { roleCodes: ['manager'], name: '李经理', username: 'manager@example.com' }
|
||||||
|
|
||||||
|
assert.equal(isCurrentRequestApplicant({ person: '李经理', managerName: '王总' }, managerUser), true)
|
||||||
|
assert.equal(isCurrentDirectManagerForRequest({ person: '李经理', managerName: '王总' }, managerUser), false)
|
||||||
|
assert.equal(isCurrentDirectManagerForRequest({ person: '张三', managerName: '李经理' }, managerUser), true)
|
||||||
|
assert.equal(isCurrentDirectManagerForRequest({ person: '张三', managerName: '王总' }, managerUser), false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildLeaderApprovalEvents,
|
||||||
buildLeaderApprovalInfo,
|
buildLeaderApprovalInfo,
|
||||||
resolveGeneratedDraftClaimNo
|
resolveGeneratedDraftClaimNo
|
||||||
} from '../src/utils/applicationApproval.js'
|
} from '../src/utils/applicationApproval.js'
|
||||||
@@ -52,3 +53,56 @@ test('resolveGeneratedDraftClaimNo reads approval response payload', () => {
|
|||||||
'EXP-202605-0012'
|
'EXP-202605-0012'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('buildLeaderApprovalEvents returns leader return and approval timeline in event order', () => {
|
||||||
|
const events = buildLeaderApprovalEvents({
|
||||||
|
profileManager: 'Fallback Manager',
|
||||||
|
riskFlags: [
|
||||||
|
{
|
||||||
|
source: 'manual_approval',
|
||||||
|
event_type: 'expense_application_approval',
|
||||||
|
operator: 'Leader Li',
|
||||||
|
opinion: 'Approved after supplement.',
|
||||||
|
approval_event_id: 'approval-1',
|
||||||
|
created_at: '2026-05-25T11:00:00'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: 'manual_return',
|
||||||
|
event_type: 'expense_application_return',
|
||||||
|
operator: 'manager@example.com',
|
||||||
|
operator_name: 'Leader Li',
|
||||||
|
reason: 'Need clearer budget explanation.',
|
||||||
|
return_count: 1,
|
||||||
|
return_event_id: 'return-1',
|
||||||
|
created_at: '2026-05-25T09:00:00'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(events.map((event) => event.id), ['return-1', 'approval-1'])
|
||||||
|
assert.deepEqual(events.map((event) => event.type), ['returned', 'approved'])
|
||||||
|
assert.deepEqual(events.map((event) => event.tone), ['danger', 'success'])
|
||||||
|
assert.equal(events[0].operator, 'Leader Li')
|
||||||
|
assert.equal(events[0].opinion, 'Need clearer budget explanation.')
|
||||||
|
assert.equal(events[0].returnCount, 1)
|
||||||
|
assert.equal(events[0].time, '2026-05-25 09:00')
|
||||||
|
assert.equal(Object.hasOwn(events[0], 'sortAt'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildLeaderApprovalEvents hides empty or unrelated return state', () => {
|
||||||
|
assert.deepEqual(buildLeaderApprovalEvents({ riskFlags: [] }), [])
|
||||||
|
assert.deepEqual(
|
||||||
|
buildLeaderApprovalEvents({
|
||||||
|
riskFlags: [
|
||||||
|
{
|
||||||
|
source: 'manual_return',
|
||||||
|
event_type: 'expense_claim_return',
|
||||||
|
return_stage_key: 'finance',
|
||||||
|
reason: 'Finance return should not render as leader application opinion.',
|
||||||
|
created_at: '2026-05-25T09:00:00'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import test from 'node:test'
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
excludeArchivedDocumentRows,
|
excludeArchivedDocumentRows,
|
||||||
|
filterApplicationScopeNewRows,
|
||||||
|
prepareApplicationScopeRows,
|
||||||
isArchivedDocumentRow
|
isArchivedDocumentRow
|
||||||
} from '../src/utils/documentCenterRows.js'
|
} from '../src/utils/documentCenterRows.js'
|
||||||
|
|
||||||
@@ -48,3 +50,34 @@ test('document center all scope excludes archived rows from merged lists', () =>
|
|||||||
|
|
||||||
assert.deepEqual(rows.map((row) => row.claimId), ['c'])
|
assert.deepEqual(rows.map((row) => row.claimId), ['c'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('application scope does not mark submitted approval application rows as new', () => {
|
||||||
|
const rows = prepareApplicationScopeRows([
|
||||||
|
{
|
||||||
|
claimId: 'draft-application',
|
||||||
|
documentTypeCode: 'application',
|
||||||
|
statusGroup: 'draft',
|
||||||
|
isNewDocument: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
claimId: 'submitted-application',
|
||||||
|
documentTypeCode: 'application',
|
||||||
|
statusGroup: 'in_progress',
|
||||||
|
isNewDocument: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
claimId: 'reimbursement',
|
||||||
|
documentTypeCode: 'reimbursement',
|
||||||
|
statusGroup: 'in_progress',
|
||||||
|
isNewDocument: true
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
assert.deepEqual(rows.map((row) => row.claimId), ['draft-application', 'submitted-application'])
|
||||||
|
assert.equal(rows.find((row) => row.claimId === 'draft-application')?.isNewDocument, true)
|
||||||
|
assert.equal(rows.find((row) => row.claimId === 'submitted-application')?.isNewDocument, false)
|
||||||
|
assert.deepEqual(
|
||||||
|
filterApplicationScopeNewRows(rows).map((row) => row.claimId),
|
||||||
|
['draft-application']
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ test('documents center category tabs map to the intended row sources', () => {
|
|||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*nonArchivedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION/
|
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*return applicationScopeRows\.value/
|
||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
@@ -122,7 +122,11 @@ test('documents center category tabs render bubble counts for new documents', ()
|
|||||||
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ALL\]: countNewDocuments\(nonArchivedRows\.value, viewedDocumentKeys\.value\)/)
|
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ALL\]: countNewDocuments\(nonArchivedRows\.value, viewedDocumentKeys\.value\)/)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
/\[DOCUMENT_SCOPE_APPLICATION\]: countNewDocuments\(nonArchivedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION\), viewedDocumentKeys\.value\)/
|
/const applicationScopeRows = computed\(\(\) => prepareApplicationScopeRows\(ownedRows\.value\)\)/
|
||||||
|
)
|
||||||
|
assert.match(
|
||||||
|
documentsCenterView,
|
||||||
|
/\[DOCUMENT_SCOPE_APPLICATION\]: countNewDocuments\(filterApplicationScopeNewRows\(applicationScopeRows\.value\), viewedDocumentKeys\.value\)/
|
||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
documentsCenterView,
|
documentsCenterView,
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import {
|
|||||||
shouldUseLocalApplicationPreview
|
shouldUseLocalApplicationPreview
|
||||||
} from '../src/utils/expenseApplicationPreview.js'
|
} from '../src/utils/expenseApplicationPreview.js'
|
||||||
import { renderMarkdown } from '../src/utils/markdown.js'
|
import { renderMarkdown } from '../src/utils/markdown.js'
|
||||||
|
import {
|
||||||
|
createMessage as createConversationMessage,
|
||||||
|
hasMeaningfulSessionMessages
|
||||||
|
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||||
|
|
||||||
const submitComposerScript = readFileSync(
|
const submitComposerScript = readFileSync(
|
||||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||||
@@ -217,6 +221,23 @@ test('application quick start renders a template without model review', () => {
|
|||||||
assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/)
|
assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('application quick start template counts as deletable session content', () => {
|
||||||
|
const welcomeMessage = createConversationMessage('assistant', '欢迎语', [], {
|
||||||
|
isWelcome: true,
|
||||||
|
welcomeQuickActions: [{ label: '快速发起申请', action: 'start_guided_application' }]
|
||||||
|
})
|
||||||
|
const templateMessage = createConversationMessage('assistant', '申请模板', [], {
|
||||||
|
applicationPreview: buildApplicationTemplatePreview({
|
||||||
|
name: '测试员工',
|
||||||
|
departmentName: '财务部',
|
||||||
|
grade: 'P5'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(hasMeaningfulSessionMessages([welcomeMessage]), false)
|
||||||
|
assert.equal(hasMeaningfulSessionMessages([welcomeMessage, templateMessage]), true)
|
||||||
|
})
|
||||||
|
|
||||||
test('application session shows intent flow, persists preview, and supports inline table edit', () => {
|
test('application session shows intent flow, persists preview, and supports inline table edit', () => {
|
||||||
assert.match(submitComposerScript, /shouldUseLocalApplicationPreview/)
|
assert.match(submitComposerScript, /shouldUseLocalApplicationPreview/)
|
||||||
assert.match(submitComposerScript, /buildLocalApplicationPreviewMessage/)
|
assert.match(submitComposerScript, /buildLocalApplicationPreviewMessage/)
|
||||||
@@ -228,7 +249,7 @@ test('application session shows intent flow, persists preview, and supports inli
|
|||||||
assert.match(submitComposerScript, /startFlowStep\('intent'/)
|
assert.match(submitComposerScript, /startFlowStep\('intent'/)
|
||||||
assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/)
|
assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/)
|
||||||
assert.match(submitComposerScript, /completeFlowStep\('intent'/)
|
assert.match(submitComposerScript, /completeFlowStep\('intent'/)
|
||||||
assert.match(submitComposerScript, /insightPanelCollapsed\.value = true/)
|
assert.doesNotMatch(submitComposerScript, /insightPanelCollapsed\.value = true/)
|
||||||
assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/)
|
assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/)
|
||||||
assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/)
|
assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -239,7 +260,7 @@ test('application session shows intent flow, persists preview, and supports inli
|
|||||||
assert.match(createViewScript, /const isApplicationSession = computed/)
|
assert.match(createViewScript, /const isApplicationSession = computed/)
|
||||||
assert.match(createViewScript, /insightPanelCollapsed,/)
|
assert.match(createViewScript, /insightPanelCollapsed,/)
|
||||||
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
|
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
|
||||||
assert.match(createViewScript, /flowSteps\.value\.length > 0/)
|
assert.match(createViewScript, /activeFlowSteps\.value\.length > 0/)
|
||||||
assert.match(createViewScript, /useApplicationPreviewEditor/)
|
assert.match(createViewScript, /useApplicationPreviewEditor/)
|
||||||
assert.match(createViewScript, /message-bubble-application-preview/)
|
assert.match(createViewScript, /message-bubble-application-preview/)
|
||||||
assert.match(createViewScript, /buildApplicationPreviewFooterMessage/)
|
assert.match(createViewScript, /buildApplicationPreviewFooterMessage/)
|
||||||
@@ -248,6 +269,8 @@ test('application session shows intent flow, persists preview, and supports inli
|
|||||||
assert.match(createViewScript, /user_input_text: applicationSubmitText/)
|
assert.match(createViewScript, /user_input_text: applicationSubmitText/)
|
||||||
assert.match(conversationModelScript, /applicationPreview: null/)
|
assert.match(conversationModelScript, /applicationPreview: null/)
|
||||||
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
|
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
|
||||||
|
assert.match(conversationModelScript, /\|\| message\.applicationPreview/)
|
||||||
|
assert.match(createViewScript, /hasMeaningfulSessionMessages\(messages\.value\)/)
|
||||||
|
|
||||||
assert.match(messageItemTemplate, /class="application-preview-table"/)
|
assert.match(messageItemTemplate, /class="application-preview-table"/)
|
||||||
assert.match(messageItemTemplate, /class="application-preview-footer application-preview-footer-missing"/)
|
assert.match(messageItemTemplate, /class="application-preview-footer application-preview-footer-missing"/)
|
||||||
|
|||||||
@@ -3,12 +3,21 @@ import test from 'node:test'
|
|||||||
|
|
||||||
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
|
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
|
||||||
|
|
||||||
|
const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
|
||||||
|
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
|
||||||
|
const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210'
|
||||||
|
const RETURNED = '\u9000\u56de'
|
||||||
|
const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4'
|
||||||
|
const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d'
|
||||||
|
const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4'
|
||||||
|
|
||||||
test('application claims are mapped as application documents', () => {
|
test('application claims are mapped as application documents', () => {
|
||||||
const request = mapExpenseClaimToRequest({
|
const request = mapExpenseClaimToRequest({
|
||||||
id: 'claim-application-1',
|
id: 'claim-application-1',
|
||||||
claim_no: 'AP-20260525103045-ABCDEFGH',
|
claim_no: 'AP-20260525103045-ABCDEFGH',
|
||||||
employee_name: '张三',
|
employee_name: '张三',
|
||||||
department_name: '交付部',
|
department_name: '交付部',
|
||||||
|
manager_name: 'Leader Li',
|
||||||
expense_type: 'travel_application',
|
expense_type: 'travel_application',
|
||||||
reason: '支撑国网服务器上线部署',
|
reason: '支撑国网服务器上线部署',
|
||||||
location: '上海',
|
location: '上海',
|
||||||
@@ -32,11 +41,59 @@ test('application claims are mapped as application documents', () => {
|
|||||||
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
|
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
request.progressSteps.map((step) => step.label),
|
request.progressSteps.map((step) => step.label),
|
||||||
['创建申请', '直属领导审批', '审批完成']
|
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED]
|
||||||
)
|
)
|
||||||
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
||||||
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
|
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
|
||||||
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.current, true)
|
assert.equal(request.progressSteps.some((step) => step.label === DIRECT_MANAGER_APPROVAL), false)
|
||||||
|
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.rawLabel, DIRECT_MANAGER_APPROVAL)
|
||||||
|
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.current, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returned application claims include leader return node and supplement status', () => {
|
||||||
|
const request = mapExpenseClaimToRequest({
|
||||||
|
id: 'claim-application-returned',
|
||||||
|
claim_no: 'APP-20260525-RETURNED',
|
||||||
|
employee_name: 'Applicant Zhang',
|
||||||
|
department_name: 'Delivery',
|
||||||
|
manager_name: 'Leader Li',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: 'Project onsite support',
|
||||||
|
location: 'Shanghai',
|
||||||
|
amount: 12000,
|
||||||
|
invoice_count: 0,
|
||||||
|
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||||
|
submitted_at: null,
|
||||||
|
created_at: '2026-05-25T01:30:00.000Z',
|
||||||
|
updated_at: '2026-05-25T04:00:00.000Z',
|
||||||
|
status: 'returned',
|
||||||
|
approval_stage: WAIT_SUBMIT,
|
||||||
|
risk_flags_json: [
|
||||||
|
{
|
||||||
|
source: 'manual_return',
|
||||||
|
event_type: 'expense_application_return',
|
||||||
|
operator: 'Leader Li',
|
||||||
|
opinion: 'Need clearer budget explanation.',
|
||||||
|
return_stage_key: 'direct_manager',
|
||||||
|
next_status: 'returned',
|
||||||
|
next_approval_stage: WAIT_SUBMIT,
|
||||||
|
return_count: 2,
|
||||||
|
created_at: '2026-05-25T04:00:00.000Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
items: []
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
request.progressSteps.map((step) => step.label),
|
||||||
|
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, RETURNED, WAIT_SUBMIT]
|
||||||
|
)
|
||||||
|
assert.equal(request.progressSteps.find((step) => step.label === RETURNED)?.time, 'Leader Li\u9000\u56de')
|
||||||
|
assert.match(request.progressSteps.find((step) => step.label === RETURNED)?.detail, /2026-05-25/)
|
||||||
|
assert.equal(request.progressSteps.find((step) => step.label === WAIT_SUBMIT)?.current, true)
|
||||||
|
assert.equal(request.secondaryStatusValue, LEADER_RETURNED_STATUS)
|
||||||
|
assert.equal(request.secondaryStatusTone, 'warning')
|
||||||
|
assert.equal(request.progressSteps.some((step) => step.label === APPROVAL_COMPLETED), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('approved application claims complete after direct manager approval only', () => {
|
test('approved application claims complete after direct manager approval only', () => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
GUIDED_ACTION_START_APPLICATION,
|
GUIDED_ACTION_START_APPLICATION,
|
||||||
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
GUIDED_ACTION_PROCESS_INTERRUPTION,
|
||||||
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
|
||||||
|
GUIDED_ACTION_SELECT_REQUIRED_APPLICATION,
|
||||||
GUIDED_ACTION_SELECT_QUERY_MODE,
|
GUIDED_ACTION_SELECT_QUERY_MODE,
|
||||||
GUIDED_ACTION_SELECT_QUERY_STATUS,
|
GUIDED_ACTION_SELECT_QUERY_STATUS,
|
||||||
GUIDED_ACTION_START_REIMBURSEMENT,
|
GUIDED_ACTION_START_REIMBURSEMENT,
|
||||||
@@ -42,10 +43,19 @@ import {
|
|||||||
createGuidedStatusQueryState,
|
createGuidedStatusQueryState,
|
||||||
isGuidedReimbursementReadyForReview,
|
isGuidedReimbursementReadyForReview,
|
||||||
normalizeGuidedFlowState,
|
normalizeGuidedFlowState,
|
||||||
|
selectGuidedRequiredApplication,
|
||||||
selectGuidedExpenseType,
|
selectGuidedExpenseType,
|
||||||
selectGuidedQueryMode,
|
selectGuidedQueryMode,
|
||||||
shouldConfirmGuidedInterruption
|
shouldConfirmGuidedInterruption,
|
||||||
|
waitForGuidedApplicationSelection
|
||||||
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
|
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
|
||||||
|
import {
|
||||||
|
buildRequiredApplicationActions,
|
||||||
|
buildRequiredApplicationMissingText,
|
||||||
|
buildRequiredApplicationSelectionText,
|
||||||
|
filterRequiredApplicationCandidates,
|
||||||
|
requiresApplicationBeforeReimbursement
|
||||||
|
} from '../src/views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||||
import {
|
import {
|
||||||
ASSISTANT_SCOPE_ACTION_SWITCH,
|
ASSISTANT_SCOPE_ACTION_SWITCH,
|
||||||
resolveAssistantScopeGuard
|
resolveAssistantScopeGuard
|
||||||
@@ -71,7 +81,7 @@ const submitComposerScript = readFileSync(
|
|||||||
test('assistant session modes expose independent quick actions', () => {
|
test('assistant session modes expose independent quick actions', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
ASSISTANT_SESSION_MODE_OPTIONS.map((item) => item.label),
|
ASSISTANT_SESSION_MODE_OPTIONS.map((item) => item.label),
|
||||||
['申请助手', '报销助手', '审核助手', '财务知识助手']
|
['申请助手', '报销助手', '审核助手', '财务知识助手', '预算编制助手']
|
||||||
)
|
)
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
|
||||||
@@ -181,6 +191,82 @@ test('guided reimbursement asks type first and walks travel fields in order', ()
|
|||||||
assert.match(submitOptions.rawText, /出差时间\/天数:2026-05-20 至 2026-05-23,出差 3 天/)
|
assert.match(submitOptions.rawText, /出差时间\/天数:2026-05-20 至 2026-05-23,出差 3 天/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('guided reimbursement requires application selection for travel and entertainment', () => {
|
||||||
|
assert.equal(requiresApplicationBeforeReimbursement('travel'), true)
|
||||||
|
assert.equal(requiresApplicationBeforeReimbursement('meal'), true)
|
||||||
|
assert.equal(requiresApplicationBeforeReimbursement('transport'), false)
|
||||||
|
|
||||||
|
const claimsPayload = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'app-travel',
|
||||||
|
claim_no: 'AP-202605-001',
|
||||||
|
employee_name: '张小青',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: '去上海支持项目部署',
|
||||||
|
location: '上海',
|
||||||
|
amount: 1800,
|
||||||
|
status: 'approved',
|
||||||
|
created_at: '2026-05-20T08:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-meal',
|
||||||
|
claim_no: 'AP-202605-002',
|
||||||
|
employee_name: '张小青',
|
||||||
|
expense_type: 'expense_application',
|
||||||
|
reason: '客户招待沟通项目',
|
||||||
|
location: '武汉',
|
||||||
|
amount: 600,
|
||||||
|
status: 'submitted',
|
||||||
|
created_at: '2026-05-21T08:00:00Z'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-draft',
|
||||||
|
claim_no: 'AP-202605-003',
|
||||||
|
employee_name: '张小青',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: '草稿出差申请',
|
||||||
|
status: 'draft'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-other-user',
|
||||||
|
claim_no: 'AP-202605-004',
|
||||||
|
employee_name: '李四',
|
||||||
|
expense_type: 'travel_application',
|
||||||
|
reason: '其他员工出差申请',
|
||||||
|
status: 'approved'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = { name: '张小青', username: 'xiaoqing.zhang' }
|
||||||
|
const travelApplications = filterRequiredApplicationCandidates(claimsPayload, 'travel', currentUser)
|
||||||
|
assert.deepEqual(travelApplications.map((item) => item.claim_no), ['AP-202605-001'])
|
||||||
|
assert.match(buildRequiredApplicationSelectionText('travel', travelApplications), /需要先关联对应的申请单/)
|
||||||
|
assert.match(buildRequiredApplicationMissingText('meal'), /不能继续这类报销流程/)
|
||||||
|
|
||||||
|
const mealApplications = filterRequiredApplicationCandidates(claimsPayload, 'meal', currentUser)
|
||||||
|
assert.deepEqual(mealApplications.map((item) => item.claim_no), ['AP-202605-002'])
|
||||||
|
|
||||||
|
const actions = buildRequiredApplicationActions(travelApplications, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION)
|
||||||
|
assert.equal(actions[0].action_type, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION)
|
||||||
|
assert.equal(actions[0].payload.application_claim_no, 'AP-202605-001')
|
||||||
|
|
||||||
|
let state = waitForGuidedApplicationSelection(createGuidedReimbursementState(), 'travel', travelApplications)
|
||||||
|
assert.equal(state.stepKey, 'application_selection')
|
||||||
|
assert.equal(state.applicationCandidates[0].claim_no, 'AP-202605-001')
|
||||||
|
|
||||||
|
state = selectGuidedRequiredApplication(state, actions[0].payload)
|
||||||
|
assert.equal(state.stepKey, 'reason')
|
||||||
|
assert.equal(state.values.application_claim_no, 'AP-202605-001')
|
||||||
|
assert.match(buildGuidedReimbursementSummaryText(state), /关联申请单:AP-202605-001/)
|
||||||
|
|
||||||
|
const submitOptions = buildGuidedReviewSubmitOptions(state)
|
||||||
|
assert.equal(submitOptions.extraContext.review_form_values.application_claim_no, 'AP-202605-001')
|
||||||
|
assert.equal(submitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')
|
||||||
|
assert.match(submitOptions.rawText, /关联申请单:AP-202605-001/)
|
||||||
|
})
|
||||||
|
|
||||||
test('guided reimbursement interrupts suspicious questions before expensive flow', () => {
|
test('guided reimbursement interrupts suspicious questions before expensive flow', () => {
|
||||||
const state = selectGuidedExpenseType(createGuidedReimbursementState(), 'transport')
|
const state = selectGuidedExpenseType(createGuidedReimbursementState(), 'transport')
|
||||||
assert.equal(shouldConfirmGuidedInterruption('送客户去机场', state), false)
|
assert.equal(shouldConfirmGuidedInterruption('送客户去机场', state), false)
|
||||||
@@ -232,7 +318,8 @@ test('guided flow state is serializable and restored through session state', ()
|
|||||||
amount: '200',
|
amount: '200',
|
||||||
attachment_names: ['a.pdf']
|
attachment_names: ['a.pdf']
|
||||||
},
|
},
|
||||||
pendingInterruptionText: '查询状态?'
|
pendingInterruptionText: '查询状态?',
|
||||||
|
applicationCandidates: []
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -241,7 +328,7 @@ test('guided flow state is serializable and restored through session state', ()
|
|||||||
assert.match(sessionStateScript, /guidedFlowState,\s*\n\s*insightPanelCollapsed/)
|
assert.match(sessionStateScript, /guidedFlowState,\s*\n\s*insightPanelCollapsed/)
|
||||||
assert.match(sessionStateScript, /function refreshWelcomeQuickActions/)
|
assert.match(sessionStateScript, /function refreshWelcomeQuickActions/)
|
||||||
assert.match(sessionStateScript, /buildWelcomeQuickActions\(/)
|
assert.match(sessionStateScript, /buildWelcomeQuickActions\(/)
|
||||||
assert.match(sessionStateScript, /ASSISTANT_SESSION_TYPES\.reduce/)
|
assert.match(sessionStateScript, /resolveAccessibleSessionTypes\(\)\.reduce/)
|
||||||
assert.match(sessionStateScript, /props\.entrySource === 'application' \? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE/)
|
assert.match(sessionStateScript, /props\.entrySource === 'application' \? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE/)
|
||||||
assert.match(sessionStateScript, /const canRestorePersistedInitialState =[\s\S]*shouldPersistLocalSnapshot/)
|
assert.match(sessionStateScript, /const canRestorePersistedInitialState =[\s\S]*shouldPersistLocalSnapshot/)
|
||||||
})
|
})
|
||||||
@@ -250,6 +337,10 @@ test('guided flow is local until final confirmation or collected query handoff',
|
|||||||
assert.doesNotMatch(guidedFlowScript, /runOrchestrator/)
|
assert.doesNotMatch(guidedFlowScript, /runOrchestrator/)
|
||||||
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
|
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
|
||||||
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
|
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
|
||||||
|
assert.match(guidedFlowScript, /fetchExpenseClaims/)
|
||||||
|
assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/)
|
||||||
|
assert.match(guidedFlowScript, /handleSceneSelectionApplicationGate/)
|
||||||
|
assert.match(createViewScript, /handleSceneSelectionApplicationGate/)
|
||||||
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
||||||
assert.match(createViewScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
assert.match(createViewScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
|
||||||
assert.match(createViewScript, /actionPayload\.carry_text/)
|
assert.match(createViewScript, /actionPayload\.carry_text/)
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const detailStyles = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
|
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const approvalDialog = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/components/travel/TravelRequestApprovalDialog.vue', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
const reimbursementService = readFileSync(
|
const reimbursementService = readFileSync(
|
||||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -43,34 +47,57 @@ function extractFunction(source, name) {
|
|||||||
assert.fail(`${name} body should be closed`)
|
assert.fail(`${name} body should be closed`)
|
||||||
}
|
}
|
||||||
|
|
||||||
test('approval-mode detail collects leader opinion and confirms approval before API call', () => {
|
test('approval-mode detail collects leader opinion inside confirm dialog before API call', () => {
|
||||||
assert.match(detailScript, /approvalMode:/)
|
assert.match(detailScript, /approvalMode:/)
|
||||||
assert.match(detailScript, /const leaderOpinion = ref\(''\)/)
|
assert.match(detailScript, /const leaderOpinion = ref\(''\)/)
|
||||||
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
|
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
|
||||||
assert.match(detailScript, /const canApproveRequest = computed/)
|
assert.match(detailScript, /const canApproveRequest = computed/)
|
||||||
assert.match(detailScript, /canApproveLeaderExpenseClaims/)
|
assert.match(detailScript, /canApproveLeaderExpenseClaims/)
|
||||||
|
assert.match(detailScript, /isCurrentDirectManagerForRequest/)
|
||||||
|
assert.match(detailScript, /isCurrentRequestApplicant/)
|
||||||
assert.match(detailScript, /isFinanceApprovalStage/)
|
assert.match(detailScript, /isFinanceApprovalStage/)
|
||||||
|
assert.match(detailScript, /const isCurrentApplicant = computed/)
|
||||||
|
assert.match(detailScript, /const isCurrentDirectManagerApprover = computed/)
|
||||||
|
assert.match(detailScript, /const canProcessFinanceApprovalStage = computed/)
|
||||||
assert.match(detailScript, /approvalOpinionTitle/)
|
assert.match(detailScript, /approvalOpinionTitle/)
|
||||||
assert.match(detailScript, /approvalConfirmDescription/)
|
assert.match(detailScript, /approvalConfirmDescription/)
|
||||||
assert.match(detailScript, /approvalNextStage/)
|
assert.match(detailScript, /approvalNextStage/)
|
||||||
assert.match(detailScript, /showApplicationLeaderOpinionInput/)
|
assert.doesNotMatch(detailScript, /showApplicationLeaderOpinionInput/)
|
||||||
|
assert.doesNotMatch(detailScript, /showLeaderApprovalPanel/)
|
||||||
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => isDirectManagerApprovalStage\.value\)/)
|
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => isDirectManagerApprovalStage\.value\)/)
|
||||||
|
assert.match(detailScript, /buildLeaderApprovalEvents/)
|
||||||
assert.match(detailScript, /buildLeaderApprovalInfo/)
|
assert.match(detailScript, /buildLeaderApprovalInfo/)
|
||||||
|
assert.match(detailScript, /const leaderApprovalEvents = computed/)
|
||||||
|
assert.match(detailScript, /const hasLeaderApprovalEvents = computed/)
|
||||||
|
assert.match(
|
||||||
|
detailScript,
|
||||||
|
/const showApplicationLeaderOpinion = computed\(\(\) => \(\s*isApplicationDocument\.value\s*&& hasLeaderApprovalEvents\.value\s*\)\)/
|
||||||
|
)
|
||||||
|
assert.match(detailScript, /isDirectManagerApprovalStage\.value\)[\s\S]*return isCurrentDirectManagerApprover\.value/)
|
||||||
|
assert.match(detailScript, /isDirectManagerApprovalStage\.value[\s\S]*&& isCurrentDirectManagerApprover\.value/)
|
||||||
|
assert.match(detailScript, /canProcessFinanceApprovalStage\.value/)
|
||||||
|
assert.doesNotMatch(detailScript, /leaderApprovalReadonlyText/)
|
||||||
assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
|
assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
|
||||||
assert.match(detailScript, /approveActionLabel/)
|
assert.match(detailScript, /approveActionLabel/)
|
||||||
assert.match(detailScript, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
|
|
||||||
assert.match(detailScript, /请先填写领导意见,填写后才能确认审核。/)
|
|
||||||
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
|
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
|
||||||
assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/)
|
assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/)
|
||||||
|
|
||||||
assert.match(detailTemplate, /v-if="showLeaderApprovalPanel"/)
|
assert.doesNotMatch(detailTemplate, /v-if="showLeaderApprovalPanel"/)
|
||||||
|
assert.doesNotMatch(detailTemplate, /showApplicationLeaderOpinionInput/)
|
||||||
|
assert.doesNotMatch(detailTemplate, /class="leader-approval-card/)
|
||||||
|
assert.doesNotMatch(detailTemplate, /class="inline-leader-opinion/)
|
||||||
assert.match(detailTemplate, /v-if="showApplicationLeaderOpinion"/)
|
assert.match(detailTemplate, /v-if="showApplicationLeaderOpinion"/)
|
||||||
assert.match(detailTemplate, /class="application-leader-opinion"/)
|
assert.match(detailTemplate, /class="application-leader-opinion"/)
|
||||||
|
assert.match(detailTemplate, /v-if="hasLeaderApprovalEvents"/)
|
||||||
|
assert.match(detailTemplate, /class="application-leader-opinion-timeline"/)
|
||||||
|
assert.match(detailTemplate, /v-for="event in leaderApprovalEvents"/)
|
||||||
|
assert.match(detailTemplate, /class="application-leader-opinion-event"/)
|
||||||
|
assert.match(detailTemplate, /event\.type === 'returned'/)
|
||||||
|
assert.doesNotMatch(detailTemplate, /leaderApprovalReadonlyText/)
|
||||||
|
assert.doesNotMatch(detailTemplate, /\u5f85\u76f4\u5c5e\u9886\u5bfc\u586b\u5199\u5ba1\u6279\u610f\u89c1/)
|
||||||
assert.match(detailTemplate, /领导意见/)
|
assert.match(detailTemplate, /领导意见/)
|
||||||
assert.match(detailTemplate, /\{\{ approvalOpinionTitle \}\}/)
|
assert.match(approvalDialog, /\{\{ opinionTitle \}\}/)
|
||||||
assert.match(detailTemplate, /v-model="leaderOpinion"/)
|
assert.doesNotMatch(detailTemplate, /v-model="leaderOpinion"/)
|
||||||
assert.match(detailTemplate, /maxlength="500"\s+:required="requiresApprovalOpinion"/)
|
|
||||||
assert.match(detailTemplate, /:placeholder="approvalOpinionPlaceholder"/)
|
|
||||||
assert.match(detailTemplate, /@click="handleApproveRequest"/)
|
assert.match(detailTemplate, /@click="handleApproveRequest"/)
|
||||||
assert.match(detailTemplate, /\{\{ approveBusy \? approveBusyLabel : approveActionLabel \}\}/)
|
assert.match(detailTemplate, /\{\{ approveBusy \? approveBusyLabel : approveActionLabel \}\}/)
|
||||||
assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/)
|
assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/)
|
||||||
@@ -78,18 +105,39 @@ test('approval-mode detail collects leader opinion and confirms approval before
|
|||||||
assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
|
assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
|
||||||
assert.match(detailTemplate, /:confirm-text="approveConfirmText"/)
|
assert.match(detailTemplate, /:confirm-text="approveConfirmText"/)
|
||||||
assert.match(detailTemplate, /:busy-text="approveBusyText"/)
|
assert.match(detailTemplate, /:busy-text="approveBusyText"/)
|
||||||
assert.match(detailTemplate, /\{\{ approvalNextStage \}\}/)
|
assert.match(detailTemplate, /:next-stage="approvalNextStage"/)
|
||||||
|
assert.match(detailTemplate, /v-model:opinion="leaderOpinion"/)
|
||||||
|
assert.match(detailTemplate, /:opinion-placeholder="approvalOpinionPlaceholder"/)
|
||||||
|
assert.match(detailTemplate, /:opinion-hint="approvalOpinionHint"/)
|
||||||
|
assert.match(detailTemplate, /:opinion-required="requiresApprovalOpinion"/)
|
||||||
assert.match(detailTemplate, /@confirm="confirmApproveRequest"/)
|
assert.match(detailTemplate, /@confirm="confirmApproveRequest"/)
|
||||||
assert.match(detailTemplate, /:description="returnDialogDescription"/)
|
assert.match(detailTemplate, /:description="returnDialogDescription"/)
|
||||||
|
assert.match(detailTemplate, /:application="isApplicationDocument"/)
|
||||||
|
|
||||||
const handleApproveRequest = extractFunction(detailScript, 'handleApproveRequest')
|
const handleApproveRequest = extractFunction(detailScript, 'handleApproveRequest')
|
||||||
const confirmApproveRequest = extractFunction(detailScript, 'confirmApproveRequest')
|
const confirmApproveRequest = extractFunction(detailScript, 'confirmApproveRequest')
|
||||||
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
|
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
|
||||||
|
assert.doesNotMatch(handleApproveRequest, /leaderOpinion\.value\.trim/)
|
||||||
assert.match(confirmApproveRequest, /approveExpenseClaim/)
|
assert.match(confirmApproveRequest, /approveExpenseClaim/)
|
||||||
|
assert.match(confirmApproveRequest, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
|
||||||
|
assert.match(confirmApproveRequest, /请先填写领导意见,填写后才能确认审核。/)
|
||||||
|
|
||||||
|
assert.match(approvalDialog, /<textarea/)
|
||||||
|
assert.match(approvalDialog, /update:opinion/)
|
||||||
|
assert.match(approvalDialog, /opinionPlaceholder/)
|
||||||
|
assert.match(approvalDialog, /opinionHint/)
|
||||||
|
assert.match(approvalDialog, /opinionRequired/)
|
||||||
|
assert.match(approvalDialog, /\{\{ currentOpinion\.length \}\}\/500/)
|
||||||
|
|
||||||
assert.match(detailStyles, /\.detail-card-title-with-icon \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
|
assert.match(detailStyles, /\.detail-card-title-with-icon \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
|
||||||
assert.match(detailStyles, /\.detail-card-title-with-icon i \{[\s\S]*font-size: 18px;[\s\S]*line-height: 1;/)
|
assert.match(detailStyles, /\.detail-card-title-with-icon i \{[\s\S]*font-size: 18px;[\s\S]*line-height: 1;/)
|
||||||
assert.match(detailStyles, /\.application-leader-opinion-head span \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
|
assert.match(detailStyles, /\.application-leader-opinion-head span \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
|
||||||
|
assert.doesNotMatch(detailStyles, /\.leader-approval-card/)
|
||||||
|
assert.doesNotMatch(detailStyles, /\.inline-leader-opinion/)
|
||||||
|
assert.match(detailStyles, /\.application-leader-opinion-timeline \{/)
|
||||||
|
assert.match(detailStyles, /\.application-leader-opinion-event \{/)
|
||||||
|
assert.match(detailStyles, /\.application-leader-opinion-event\.danger::before \{/)
|
||||||
|
assert.match(detailStyles, /\.application-leader-opinion-event\.success::before \{/)
|
||||||
|
|
||||||
assert.match(reimbursementService, /export function approveExpenseClaim\(claimId, payload = \{\}\)/)
|
assert.match(reimbursementService, /export function approveExpenseClaim\(claimId, payload = \{\}\)/)
|
||||||
assert.match(reimbursementService, /\/approve/)
|
assert.match(reimbursementService, /\/approve/)
|
||||||
|
|||||||
@@ -678,10 +678,21 @@ test('transport ticket items no longer generate business location completion adv
|
|||||||
test('return reason dialog is wired into approval and detail return actions', () => {
|
test('return reason dialog is wired into approval and detail return actions', () => {
|
||||||
assert.match(returnReasonDialog, /missing_attachment/)
|
assert.match(returnReasonDialog, /missing_attachment/)
|
||||||
assert.match(returnReasonDialog, /invoice_mismatch/)
|
assert.match(returnReasonDialog, /invoice_mismatch/)
|
||||||
|
assert.match(returnReasonDialog, /APPLICATION_RETURN_REASON_OPTIONS/)
|
||||||
|
assert.match(returnReasonDialog, /application_info_incomplete/)
|
||||||
|
assert.match(returnReasonDialog, /application_business_need_unclear/)
|
||||||
|
assert.match(returnReasonDialog, /application_budget_basis_missing/)
|
||||||
|
assert.match(returnReasonDialog, /application_policy_mismatch/)
|
||||||
|
assert.match(returnReasonDialog, /application_attachment_needed/)
|
||||||
|
assert.match(returnReasonDialog, /退单选项/)
|
||||||
|
assert.match(returnReasonDialog, /selectionError/)
|
||||||
|
assert.match(returnReasonDialog, /selectedCodes\.value\.length === 0/)
|
||||||
|
assert.match(returnReasonDialog, /lastAutoReason/)
|
||||||
assert.match(returnReasonDialog, /reason_codes/)
|
assert.match(returnReasonDialog, /reason_codes/)
|
||||||
assert.match(approvalCenterTemplate, /<TravelRequestDetailView/)
|
assert.match(approvalCenterTemplate, /<TravelRequestDetailView/)
|
||||||
assert.doesNotMatch(approvalCenterTemplate, /<ReturnReasonDialog/)
|
assert.doesNotMatch(approvalCenterTemplate, /<ReturnReasonDialog/)
|
||||||
assert.match(detailViewTemplate, /<ReturnReasonDialog/)
|
assert.match(detailViewTemplate, /<TravelRequestReturnDialog/)
|
||||||
|
assert.match(detailViewTemplate, /:application="isApplicationDocument"/)
|
||||||
assert.doesNotMatch(approvalCenterScript, /returnExpenseClaim/)
|
assert.doesNotMatch(approvalCenterScript, /returnExpenseClaim/)
|
||||||
assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, payload\)/)
|
assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, payload\)/)
|
||||||
assert.doesNotMatch(approvalCenterScript, /审批中心退回/)
|
assert.doesNotMatch(approvalCenterScript, /审批中心退回/)
|
||||||
|
|||||||
Reference in New Issue
Block a user