diff --git a/server/src/app/api/deps.py b/server/src/app/api/deps.py index 7036be4..f2af6b0 100644 --- a/server/src/app/api/deps.py +++ b/server/src/app/api/deps.py @@ -17,11 +17,12 @@ def get_db() -> Generator[Session, None, None]: @dataclass(slots=True) -class CurrentUserContext: - username: str - name: str - role_codes: list[str] - is_admin: bool +class CurrentUserContext: + username: str + name: str + role_codes: list[str] + is_admin: bool + department_name: str = "" def get_current_user( @@ -41,6 +42,10 @@ def get_current_user( str | None, Header(description="是否管理员,支持 `true/false/1/0`。"), ] = None, + x_auth_department: Annotated[ + str | None, + Header(description="当前登录人的所属部门。"), + ] = None, ) -> CurrentUserContext: role_codes = [item.strip() for item in (x_auth_role_codes or "").split(",") if item.strip()] is_admin = str(x_auth_is_admin or "").strip().lower() in {"1", "true", "yes", "on"} @@ -56,10 +61,11 @@ def get_current_user( return CurrentUserContext( username=username or name, - name=name or username, - role_codes=role_codes, - is_admin=is_admin, - ) + name=name or username, + role_codes=role_codes, + is_admin=is_admin, + department_name=(x_auth_department or "").strip(), + ) def require_admin_user( diff --git a/server/src/app/schemas/auth.py b/server/src/app/schemas/auth.py index 1518a95..b0eb257 100644 --- a/server/src/app/schemas/auth.py +++ b/server/src/app/schemas/auth.py @@ -12,6 +12,8 @@ class AuthUserRead(BaseModel): username: str name: str role: str + department: str = "" + departmentName: str = "" position: str = "" grade: str = "" roleCodes: list[str] = Field(default_factory=list) diff --git a/server/src/app/services/agent_foundation.py b/server/src/app/services/agent_foundation.py index 9f273fb..e2c9067 100644 --- a/server/src/app/services/agent_foundation.py +++ b/server/src/app/services/agent_foundation.py @@ -93,9 +93,11 @@ LEGACY_RULE_CODES = ( "rule.ap.payment_dual_review", ) -ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements" -COMPANY_TRAVEL_RULE_VERSION = "v1.0.0" -COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0" +ATTACHMENT_RULE_ASSET_CODE = "rule.expense.attachment_submission_requirements" +COMPANY_TRAVEL_RULE_VERSION = "v1.0.0" +COMPANY_COMMUNICATION_RULE_VERSION = "v1.0.0" +COMPANY_TRAVEL_RULE_SCENARIO_JSON = ("差旅",) +COMPANY_COMMUNICATION_RULE_SCENARIO_JSON = ("费用科目",) ATTACHMENT_RULE_RUNTIME_CONFIG = { "kind": "policy_rule_draft", @@ -267,49 +269,53 @@ class AgentFoundationService: ) company_travel_rule = AgentAsset( asset_type=AgentAssetType.RULE.value, - code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, - name="公司差旅费报销规则", - description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。", - domain=AgentAssetDomain.EXPENSE.value, - scenario_json=["expense", "travel_policy", "travel_standard"], - owner="财务制度管理组", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, + code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, + name="公司差旅费报销规则", + description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=list(COMPANY_TRAVEL_RULE_SCENARIO_JSON), + owner="财务制度管理组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, current_version=COMPANY_TRAVEL_RULE_VERSION, published_version=COMPANY_TRAVEL_RULE_VERSION, working_version=COMPANY_TRAVEL_RULE_VERSION, config_json={ "severity": "medium", "enabled": True, - "tag": "财务规则", - "detail_mode": "spreadsheet", - "rule_library": FINANCE_RULES_LIBRARY, - "rule_template_label": "差旅报销 Excel 模板", - }, - ) + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + "ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + "rule_template_label": "差旅报销 Excel 模板", + }, + ) platform_risk_assets = self._build_platform_risk_seed_assets() company_communication_rule = AgentAsset( asset_type=AgentAssetType.RULE.value, - code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, - name="公司通信费报销规则", - description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。", - domain=AgentAssetDomain.EXPENSE.value, - scenario_json=["expense", "communication_expense", "expense_standard"], - owner="财务制度管理组", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, + code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, + name="公司通信费报销规则", + description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON), + owner="财务制度管理组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, current_version=COMPANY_COMMUNICATION_RULE_VERSION, published_version=COMPANY_COMMUNICATION_RULE_VERSION, working_version=COMPANY_COMMUNICATION_RULE_VERSION, config_json={ "severity": "medium", "enabled": True, - "tag": "财务规则", - "detail_mode": "spreadsheet", - "rule_library": FINANCE_RULES_LIBRARY, - "rule_template_label": "通信费报销 Excel 模板", - }, - ) + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], + "ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], + "rule_template_label": "通信费报销 Excel 模板", + }, + ) skill_expense_asset = AgentAsset( asset_type=AgentAssetType.SKILL.value, code="skill.expense.summary_lookup", @@ -1266,47 +1272,52 @@ class AgentFoundationService: if COMPANY_TRAVEL_EXPENSE_RULE_CODE not in existing_codes: company_travel_rule = self._create_seed_asset( asset_type=AgentAssetType.RULE.value, - code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, - name="公司差旅费报销规则", - description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。", - domain=AgentAssetDomain.EXPENSE.value, - scenario_json=["expense", "travel_policy", "travel_standard"], - owner="财务制度管理组", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, + code=COMPANY_TRAVEL_EXPENSE_RULE_CODE, + name="公司差旅费报销规则", + description="通过 Excel 明细表维护差旅费报销标准、票据要求和审批口径。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=list(COMPANY_TRAVEL_RULE_SCENARIO_JSON), + owner="财务制度管理组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, current_version=COMPANY_TRAVEL_RULE_VERSION, config_json={ "severity": "medium", - "enabled": True, - "tag": "财务规则", - "detail_mode": "spreadsheet", - "rule_template_label": "差旅报销 Excel 模板", - }, - ) + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + "ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + "rule_template_label": "差旅报销 Excel 模板", + }, + ) if COMPANY_COMMUNICATION_EXPENSE_RULE_CODE not in existing_codes: company_communication_rule = self._create_seed_asset( asset_type=AgentAssetType.RULE.value, - code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, - name="公司通信费报销规则", - description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。", - domain=AgentAssetDomain.EXPENSE.value, - scenario_json=["expense", "communication_expense", "expense_standard"], - owner="财务制度管理组", - reviewer="顾承宇", - status=AgentAssetStatus.ACTIVE.value, + code=COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, + name="公司通信费报销规则", + description="通过 Excel 明细表维护员工通信费报销标准、专项补充口径和审批要求。", + domain=AgentAssetDomain.EXPENSE.value, + scenario_json=list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON), + owner="财务制度管理组", + reviewer="顾承宇", + status=AgentAssetStatus.ACTIVE.value, current_version=COMPANY_COMMUNICATION_RULE_VERSION, config_json={ "severity": "medium", - "enabled": True, - "tag": "财务规则", - "detail_mode": "spreadsheet", - "rule_template_label": "通信费报销 Excel 模板", - }, - ) - - if company_travel_rule is not None: - if not str(company_travel_rule.current_version or "").strip(): - company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION + "enabled": True, + "tag": "财务规则", + "detail_mode": "spreadsheet", + "scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], + "ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], + "rule_template_label": "通信费报销 Excel 模板", + }, + ) + + if company_travel_rule is not None: + company_travel_rule.scenario_json = list(COMPANY_TRAVEL_RULE_SCENARIO_JSON) + if not str(company_travel_rule.current_version or "").strip(): + company_travel_rule.current_version = COMPANY_TRAVEL_RULE_VERSION if not str(company_travel_rule.working_version or "").strip(): company_travel_rule.working_version = company_travel_rule.current_version if not str(company_travel_rule.published_version or "").strip(): @@ -1318,11 +1329,13 @@ class AgentFoundationService: **(company_travel_rule.config_json or {}), "severity": "medium", "enabled": True, - "tag": "财务规则", - "detail_mode": "spreadsheet", - "rule_library": FINANCE_RULES_LIBRARY, - "rule_template_label": "差旅报销 Excel 模板", - } + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "scenario_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + "ai_review_category": COMPANY_TRAVEL_RULE_SCENARIO_JSON[0], + "rule_template_label": "差旅报销 Excel 模板", + } company_travel_rule_meta = self._ensure_company_travel_rule_spreadsheet_seed( company_travel_rule, version=str(company_travel_rule.current_version or COMPANY_TRAVEL_RULE_VERSION), @@ -1350,9 +1363,10 @@ class AgentFoundationService: reviewed_at=datetime.now(UTC), ) - if company_communication_rule is not None: - if not str(company_communication_rule.current_version or "").strip(): - company_communication_rule.current_version = COMPANY_COMMUNICATION_RULE_VERSION + if company_communication_rule is not None: + company_communication_rule.scenario_json = list(COMPANY_COMMUNICATION_RULE_SCENARIO_JSON) + if not str(company_communication_rule.current_version or "").strip(): + company_communication_rule.current_version = COMPANY_COMMUNICATION_RULE_VERSION if not str(company_communication_rule.working_version or "").strip(): company_communication_rule.working_version = company_communication_rule.current_version if not str(company_communication_rule.published_version or "").strip(): @@ -1364,11 +1378,13 @@ class AgentFoundationService: **(company_communication_rule.config_json or {}), "severity": "medium", "enabled": True, - "tag": "财务规则", - "detail_mode": "spreadsheet", - "rule_library": FINANCE_RULES_LIBRARY, - "rule_template_label": "通信费报销 Excel 模板", - } + "tag": "财务规则", + "detail_mode": "spreadsheet", + "rule_library": FINANCE_RULES_LIBRARY, + "scenario_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], + "ai_review_category": COMPANY_COMMUNICATION_RULE_SCENARIO_JSON[0], + "rule_template_label": "通信费报销 Excel 模板", + } company_communication_rule_meta = self._ensure_company_communication_rule_spreadsheet_seed( company_communication_rule, version=str(company_communication_rule.current_version or COMPANY_COMMUNICATION_RULE_VERSION), diff --git a/server/src/app/services/auth.py b/server/src/app/services/auth.py index f888d84..d946eb4 100644 --- a/server/src/app/services/auth.py +++ b/server/src/app/services/auth.py @@ -31,6 +31,7 @@ class AuthenticatedUser: username: str name: str role: str + department: str position: str grade: str role_codes: list[str] @@ -78,6 +79,7 @@ class AuthService: username=admin_username or admin_email, name=display_name, role="管理员", + department="", position="系统管理员", grade="", role_codes=["manager"], @@ -94,7 +96,7 @@ class AuthService: stmt = ( select(Employee) - .options(selectinload(Employee.roles)) + .options(selectinload(Employee.organization_unit), selectinload(Employee.roles)) .where(func.lower(Employee.email) == identifier.lower()) ) employee = self.db.execute(stmt).scalars().first() @@ -120,6 +122,7 @@ class AuthService: username=employee.email, name=employee.name, role=ROLE_LABELS.get(primary_role_code, "使用者"), + department=employee.organization_unit.name if employee.organization_unit is not None else "", position=employee.position, grade=employee.grade, role_codes=role_codes or ["user"], @@ -134,6 +137,8 @@ class AuthService: username=user.username, name=user.name, role=user.role, + department=user.department, + departmentName=user.department, position=user.position, grade=user.grade, roleCodes=user.role_codes, diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 08a89b4..b2a0229 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -14,20 +14,24 @@ from types import SimpleNamespace from typing import Any from urllib.parse import quote -from sqlalchemy import and_, func, or_, select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session, selectinload - -from app.api.deps import CurrentUserContext -from app.core.config import get_settings -from app.models.employee import Employee -from app.models.financial_record import ExpenseClaim, ExpenseClaimItem -from app.models.organization import OrganizationUnit -from app.schemas.ontology import OntologyEntity, OntologyParseResult -from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate -from app.services.agent_foundation import AgentFoundationService -from app.services.audit import AuditLogService -from app.services.document_intelligence import build_document_insight +from sqlalchemy import and_, func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, selectinload + +from app.api.deps import CurrentUserContext +from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType +from app.core.config import get_settings +from app.models.agent_asset import AgentAsset +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.models.organization import OrganizationUnit +from app.schemas.ontology import OntologyEntity, OntologyParseResult +from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate +from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager +from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY +from app.services.agent_foundation import AgentFoundationService +from app.services.audit import AuditLogService +from app.services.document_intelligence import build_document_insight from app.services.expense_rule_runtime import ( DEFAULT_SCENE_RULE_ASSET_CODE, ExpenseRuleRuntimeService, @@ -53,16 +57,19 @@ EXPENSE_TYPE_LABELS = { PRIVILEGED_CLAIM_ROLE_CODES = {"finance"} APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"} MAX_DRAFT_CLAIMS_PER_USER = 3 -LOCATION_REQUIRED_EXPENSE_TYPES = { - "travel", - "hotel", - "transport", - "meal", - "meeting", - "entertainment", -} - -EXPENSE_SCENE_KEYWORDS = { +LOCATION_REQUIRED_EXPENSE_TYPES = { + "travel", + "meeting", + "entertainment", +} + + +class ExpenseClaimSubmissionBlockedError(ValueError): + def __init__(self, issues: list[str]) -> None: + self.issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()] + super().__init__("提交前请先补全信息:" + ";".join(self.issues)) + +EXPENSE_SCENE_KEYWORDS = { "travel": ("差旅", "出差", "行程"), "hotel": ("酒店", "住宿", "房费", "客房", "入住", "离店"), "transport": ( @@ -675,23 +682,23 @@ class ExpenseClaimService: if claim is None: return None - self._ensure_draft_claim(claim) - self._sync_claim_from_items(claim) - missing_fields = self._validate_claim_for_submission(claim) - if missing_fields: - raise ValueError("提交前请先补全信息:" + ";".join(missing_fields)) + self._ensure_draft_claim(claim) + self._backfill_claim_identity_from_current_user(claim, current_user) + self._sync_claim_from_items(claim) + missing_fields = self._validate_claim_for_submission(claim) + if missing_fields: + raise ExpenseClaimSubmissionBlockedError(missing_fields) - before_json = self._serialize_claim(claim) - # TODO: 后续恢复 AI 验审逻辑 - # review_result = self._run_ai_submission_review(claim) - manager_name = self._resolve_claim_manager_name(claim) or "审批人" - claim.status = "submitted" - claim.approval_stage = "直属领导审批" - claim.risk_flags_json = list(claim.risk_flags_json or []) - claim.submitted_at = datetime.now(UTC) - - self.db.commit() - self.db.refresh(claim) + before_json = self._serialize_claim(claim) + review_result = self._run_ai_submission_review(claim) + + claim.status = str(review_result.get("status") or "supplement") + claim.approval_stage = str(review_result.get("approval_stage") or "待补充") + claim.risk_flags_json = list(review_result.get("risk_flags") or []) + claim.submitted_at = datetime.now(UTC) if claim.status == "submitted" else None + + self.db.commit() + self.db.refresh(claim) self.audit_service.log_action( actor=current_user.name or current_user.username, @@ -736,19 +743,32 @@ class ExpenseClaimService: str(item).strip() for item in list(context_json.get("role_codes") or []) if str(item).strip() - ], - is_admin=bool(context_json.get("is_admin")), - ) + ], + is_admin=bool(context_json.get("is_admin")), + department_name=str(context_json.get("department_name") or context_json.get("department") or "").strip(), + ) - try: - claim = self.submit_claim(claim_id, current_user) - except ValueError as exc: - return { - **result, - "message": str(exc), - "submission_blocked": True, - "draft_only": False, - } + try: + claim = self.submit_claim(claim_id, current_user) + except ExpenseClaimSubmissionBlockedError as exc: + return { + **result, + "message": self._format_submission_blocked_message(exc.issues), + "submission_blocked": True, + "submission_blocked_reasons": exc.issues, + "missing_fields": exc.issues, + "draft_only": False, + } + except ValueError as exc: + message = str(exc) + return { + **result, + "message": message, + "submission_blocked": True, + "submission_blocked_reasons": [message] if message else [], + "missing_fields": [message] if message else [], + "draft_only": False, + } if claim is None: return { @@ -769,7 +789,7 @@ class ExpenseClaimService: if review_message: break return { - "message": review_message or f"报销单 {claim.claim_no} 经 AI验审后转为待补充,请先修正后再提交。", + "message": review_message or f"报销单 {claim.claim_no} 经 AI预审后转为待补充,请先修正后再提交。", "submission_blocked": True, "draft_only": False, "claim_id": claim.id, @@ -782,7 +802,7 @@ class ExpenseClaimService: return { "message": ( - f"报销单 {claim.claim_no} 已完成 AI验审," + f"报销单 {claim.claim_no} 已完成 AI预审," f"当前节点为 {claim.approval_stage or '审批中'}。" ), "draft_only": False, @@ -1603,12 +1623,17 @@ class ExpenseClaimService: context_json: dict[str, Any], user_id: str | None, ) -> Employee | None: - normalized_user_id = str(user_id or "").strip() - if normalized_user_id: - stmt = select(Employee).where(func.lower(Employee.email) == normalized_user_id.lower()).limit(1) - employee = self.db.scalar(stmt) - if employee is not None: - return employee + normalized_user_id = str(user_id or "").strip() + if normalized_user_id: + stmt = ( + select(Employee) + .options(selectinload(Employee.organization_unit), selectinload(Employee.manager)) + .where(func.lower(Employee.email) == normalized_user_id.lower()) + .limit(1) + ) + employee = self.db.scalar(stmt) + if employee is not None: + return employee employee_name = self._resolve_employee_name( ontology=ontology, @@ -1618,8 +1643,13 @@ class ExpenseClaimService: if not employee_name: return None - stmt = select(Employee).where(Employee.name == employee_name).limit(1) - return self.db.scalar(stmt) + stmt = ( + select(Employee) + .options(selectinload(Employee.organization_unit), selectinload(Employee.manager)) + .where(Employee.name == employee_name) + .limit(1) + ) + return self.db.scalar(stmt) @staticmethod def _resolve_employee_name( @@ -2568,8 +2598,8 @@ class ExpenseClaimService: if not (isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review") ] - review_flags: list[dict[str, Any]] = [] - blocking_reasons: list[str] = [] + review_flags: list[dict[str, Any]] = [] + attention_reasons: list[str] = [] high_attachment_flags = [ flag @@ -2580,38 +2610,41 @@ class ExpenseClaimService: flag for flag in attachment_flags if str(flag.get("severity") or "").strip().lower() == "medium" - ] - if high_attachment_flags: - blocking_reasons.append("存在高风险票据,需先补充或更换附件后再提交。") - review_flags.append( - { - "source": "submission_review", - "severity": "high", - "label": "AI验审拦截", - "message": f"AI验审发现 {len(high_attachment_flags)} 条高风险附件,已退回待补充。", - } - ) + ] + if high_attachment_flags: + attention_reasons.append("存在高风险票据,需审批人重点复核。") + review_flags.append( + { + "source": "submission_review", + "severity": "high", + "label": "AI预审重点复核", + "message": ( + f"AI预审发现 {len(high_attachment_flags)} 条高风险附件," + "已随单流转给审批人重点复核。" + ), + } + ) elif medium_attachment_flags: review_flags.append( { "source": "submission_review", "severity": "medium", - "label": "AI验审提醒", - "message": f"AI验审发现 {len(medium_attachment_flags)} 条中风险附件,已随单流转给审批人复核。", + "label": "AI预审提醒", + "message": f"AI预审发现 {len(medium_attachment_flags)} 条中风险附件,已随单流转给审批人复核。", } ) - manager_name = self._resolve_claim_manager_name(claim) - if not manager_name: - blocking_reasons.append("未识别到该员工的直属领导,暂时无法流转到领导审批。") - review_flags.append( - { - "source": "submission_review", - "severity": "high", - "label": "审批链缺失", - "message": "AI验审通过前检查到直属领导缺失,当前无法继续流转审批链。", - } - ) + manager_name = self._resolve_claim_manager_name(claim) + if not manager_name: + attention_reasons.append("未识别到该员工的直属领导,需审批环节补充分配。") + review_flags.append( + { + "source": "submission_review", + "severity": "medium", + "label": "审批链待分配", + "message": "AI预审发现直属领导缺失,已提交到审批环节等待分配或复核。", + } + ) historical_risk_count = self._count_recent_risky_claims(claim) if historical_risk_count >= AI_REVIEW_REPEAT_RISK_BLOCK_COUNT: @@ -2639,39 +2672,38 @@ class ExpenseClaimService: } ) - travel_review = self._run_travel_policy_review(claim) - blocking_reasons.extend(travel_review["blocking_reasons"]) - review_flags.extend(travel_review["flags"]) - - scene_policy_review = self._run_scene_policy_review(claim) - blocking_reasons.extend(scene_policy_review["blocking_reasons"]) - review_flags.extend(scene_policy_review["flags"]) - - if blocking_reasons: - summary_message = "AI验审未通过:" + ";".join(dict.fromkeys(blocking_reasons)) - review_flags.insert( - 0, - { - "source": "submission_review", - "severity": "high", - "label": "AI验审未通过", - "message": summary_message, - }, - ) - return { - "status": "supplement", - "approval_stage": "待补充", - "risk_flags": preserved_flags + review_flags, - "message": summary_message, - "passed": False, - } - - return { - "status": "submitted", + travel_review = self._run_travel_policy_review(claim) + attention_reasons.extend(travel_review["blocking_reasons"]) + review_flags.extend(travel_review["flags"]) + + scene_policy_review = self._run_scene_policy_review(claim) + attention_reasons.extend(scene_policy_review["blocking_reasons"]) + review_flags.extend(scene_policy_review["flags"]) + + platform_risk_review = self.evaluate_platform_risk_rules(claim) + attention_reasons.extend(platform_risk_review["blocking_reasons"]) + review_flags.extend(platform_risk_review["flags"]) + + if attention_reasons: + summary_message = "AI预审发现需审批重点关注事项:" + ";".join( + dict.fromkeys(attention_reasons) + ) + review_flags.insert( + 0, + { + "source": "submission_review", + "severity": "medium", + "label": "AI预审重点复核", + "message": summary_message, + }, + ) + + return { + "status": "submitted", "approval_stage": "直属领导审批", "risk_flags": preserved_flags + review_flags, "message": ( - f"报销单 {claim.claim_no} 已完成 AI验审," + f"报销单 {claim.claim_no} 已完成 AI预审," f"现已提交给直属领导 {manager_name or '审批人'} 审批。" ), "passed": True, @@ -2702,10 +2734,708 @@ class ExpenseClaimService: .where(ExpenseClaim.id != claim.id) .where(ExpenseClaim.occurred_at >= since) ) - recent_claims = list(self.db.scalars(stmt).all()) - return sum(1 for item in recent_claims if list(item.risk_flags_json or [])) - - def _run_scene_policy_review(self, claim: ExpenseClaim) -> dict[str, list[Any]]: + recent_claims = list(self.db.scalars(stmt).all()) + return sum(1 for item in recent_claims if list(item.risk_flags_json or [])) + + def evaluate_platform_risk_rules( + self, + claim: ExpenseClaim, + *, + rule_codes: list[str] | None = None, + ) -> dict[str, list[Any]]: + manifests = self._load_platform_risk_rule_manifests(rule_codes=rule_codes) + if not manifests: + return {"flags": [], "blocking_reasons": []} + + contexts = self._build_claim_attachment_contexts(claim) + flags: list[dict[str, Any]] = [] + blocking_reasons: list[str] = [] + + for manifest in manifests: + if not self._risk_manifest_applies_to_claim(manifest, claim=claim, contexts=contexts): + continue + + flag = self._evaluate_platform_risk_manifest( + manifest, + claim=claim, + contexts=contexts, + ) + if flag is None: + continue + + flags.append(flag) + severity = str(flag.get("severity") or "").strip().lower() + action = str(flag.get("action") or "").strip().lower() + if severity == "high" or action == "block": + blocking_reasons.append(str(flag.get("message") or flag.get("label") or "").strip()) + + deduplicated_reasons = list( + dict.fromkeys(reason for reason in blocking_reasons if reason) + ) + return {"flags": flags, "blocking_reasons": deduplicated_reasons} + + def _load_platform_risk_rule_manifests( + self, + *, + rule_codes: list[str] | None, + ) -> list[dict[str, Any]]: + code_filter = { + str(code or "").strip() + for code in list(rule_codes or []) + if str(code or "").strip() + } + manifests_by_code: dict[str, dict[str, Any]] = {} + + assets = list( + self.db.scalars( + select(AgentAsset) + .where(AgentAsset.asset_type == AgentAssetType.RULE.value) + .where(AgentAsset.status == AgentAssetStatus.ACTIVE.value) + .where(AgentAsset.domain == AgentAssetDomain.EXPENSE.value) + .order_by(AgentAsset.updated_at.desc(), AgentAsset.created_at.desc()) + ).all() + ) + library_manager = AgentAssetRuleLibraryManager() + + for asset in assets: + config_json = asset.config_json if isinstance(asset.config_json, dict) else {} + if str(config_json.get("detail_mode") or "").strip().lower() != "json_risk": + continue + rule_code = str(asset.code or "").strip() + if code_filter and rule_code not in code_filter: + continue + + rule_document = config_json.get("rule_document") + if not isinstance(rule_document, dict): + continue + file_name = str(rule_document.get("file_name") or "").strip() + rule_library = ( + str(config_json.get("rule_library") or RISK_RULES_LIBRARY).strip() + or RISK_RULES_LIBRARY + ) + if not file_name: + continue + + try: + payload = library_manager.read_rule_library_json( + library=rule_library, + file_name=file_name, + ) + except (FileNotFoundError, ValueError): + continue + + manifest_code = str(payload.get("rule_code") or rule_code).strip() + if not manifest_code or (code_filter and manifest_code not in code_filter): + continue + if payload.get("enabled") is False: + continue + + payload = dict(payload) + payload.setdefault("rule_code", manifest_code) + payload["_rule_version"] = str( + asset.published_version or asset.current_version or "v1.0.0" + ) + payload["_rule_asset_id"] = asset.id + manifests_by_code[manifest_code] = payload + + missing_codes = code_filter - set(manifests_by_code) + should_load_fallback = not code_filter or bool(missing_codes) + if should_load_fallback: + try: + files = library_manager.list_rule_library_json_files(library=RISK_RULES_LIBRARY) + except ValueError: + files = [] + for file_name in files: + try: + payload = library_manager.read_rule_library_json( + library=RISK_RULES_LIBRARY, + file_name=file_name, + ) + except (FileNotFoundError, ValueError): + continue + rule_code = str(payload.get("rule_code") or "").strip() + if not rule_code or rule_code in manifests_by_code: + continue + if code_filter and rule_code not in missing_codes: + continue + if payload.get("enabled") is False: + continue + payload = dict(payload) + payload["_rule_version"] = "v1.0.0" + manifests_by_code[rule_code] = payload + + return list(manifests_by_code.values()) + + def _risk_manifest_applies_to_claim( + self, + manifest: dict[str, Any], + *, + claim: ExpenseClaim, + contexts: list[dict[str, Any]], + ) -> bool: + applies_to = manifest.get("applies_to") + if not isinstance(applies_to, dict): + applies_to = {} + + try: + min_attachments = int(applies_to.get("min_attachments") or 0) + except (TypeError, ValueError): + min_attachments = 0 + if min_attachments and int(claim.invoice_count or 0) < min_attachments and not contexts: + return False + + expense_types = { + str(claim.expense_type or "").strip().lower(), + *{ + str(item.item_type or "").strip().lower() + for item in list(claim.items or []) + if str(item.item_type or "").strip() + }, + } + domains = { + str(value or "").strip().lower() + for value in list(applies_to.get("domains") or []) + if str(value or "").strip() + } + configured_expense_types = { + str(value or "").strip().lower() + for value in list(applies_to.get("expense_types") or []) + if str(value or "").strip() + } + + if configured_expense_types and not (expense_types & configured_expense_types): + return False + if domains and not self._risk_domains_match_claim( + domains, + expense_types=expense_types, + contexts=contexts, + ): + return False + + return True + + def _risk_domains_match_claim( + self, + domains: set[str], + *, + expense_types: set[str], + contexts: list[dict[str, Any]], + ) -> bool: + normalized_contexts: list[dict[str, str]] = [] + for context in contexts: + document_info = context.get("document_info") or {} + normalized_contexts.append( + { + "scene_code": str(document_info.get("scene_code") or "").strip().lower(), + "document_type": str( + document_info.get("document_type") or "" + ).strip().lower(), + "item_type": str( + getattr(context.get("item"), "item_type", "") or "" + ).strip().lower(), + } + ) + + if "travel" in domains: + if expense_types & {"travel", "hotel", "transport"}: + return True + if any( + item["scene_code"] in {"travel", "hotel", "transport"} + or item["document_type"] + in { + "flight_itinerary", + "train_ticket", + "hotel_invoice", + "taxi_receipt", + } + for item in normalized_contexts + ): + return True + if "meal" in domains: + if expense_types & {"meal", "entertainment"}: + return True + if any( + item["scene_code"] == "meal" or item["document_type"] == "meal_receipt" + for item in normalized_contexts + ): + return True + return bool(domains & expense_types) + + def _evaluate_platform_risk_manifest( + self, + manifest: dict[str, Any], + *, + claim: ExpenseClaim, + contexts: list[dict[str, Any]], + ) -> dict[str, Any] | None: + evaluator = str(manifest.get("evaluator") or "").strip().lower() + if evaluator == "reason_too_brief": + return self._evaluate_reason_too_brief_risk(manifest, claim=claim) + if evaluator == "entertainment_reason_missing": + return self._evaluate_entertainment_reason_missing_risk(manifest, claim=claim) + if evaluator == "document_expense_mismatch": + return self._evaluate_document_expense_mismatch_risk( + manifest, + claim=claim, + contexts=contexts, + ) + if evaluator == "location_consistency": + return self._evaluate_location_consistency_risk( + manifest, + claim=claim, + contexts=contexts, + ) + if evaluator == "duplicate_invoice": + return self._evaluate_duplicate_invoice_risk(manifest, claim=claim, contexts=contexts) + if evaluator == "identity_consistency": + return self._evaluate_identity_consistency_risk( + manifest, + claim=claim, + contexts=contexts, + ) + if evaluator == "cross_year_invoice": + return self._evaluate_cross_year_invoice_risk(manifest, claim=claim, contexts=contexts) + if evaluator == "void_or_red_invoice": + return self._evaluate_text_keyword_risk( + manifest, + contexts=contexts, + keywords=["作废", "红冲", "红字", "冲红"], + fallback_message="票据文本中出现作废、红冲或红字发票相关信息,建议退回补充或人工复核。", + ) + if evaluator == "vague_goods_description": + return self._evaluate_text_keyword_risk( + manifest, + contexts=contexts, + keywords=["详见清单", "服务费", "咨询费", "其他", "办公用品"], + fallback_message="票据商品或服务描述较笼统,建议审批人核对真实用途和明细清单。", + ) + if evaluator == "multi_city_reason_required": + return self._evaluate_multi_city_reason_required_risk( + manifest, + claim=claim, + contexts=contexts, + ) + return None + + def _evaluate_reason_too_brief_risk( + self, + manifest: dict[str, Any], + *, + claim: ExpenseClaim, + ) -> dict[str, Any] | None: + params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {} + try: + min_reason_length = max(1, int(params.get("min_reason_length") or 6)) + except (TypeError, ValueError): + min_reason_length = 6 + reason_corpus = re.sub(r"\s+", "", self._build_scene_reason_corpus(claim)) + if len(reason_corpus) >= min_reason_length: + return None + return self._build_platform_risk_flag( + manifest, + message=f"报销事由有效描述不足 {min_reason_length} 个字符,暂不足以支撑真实性判断。", + evidence={"reason_length": len(reason_corpus), "min_reason_length": min_reason_length}, + ) + + def _evaluate_entertainment_reason_missing_risk( + self, + manifest: dict[str, Any], + *, + claim: ExpenseClaim, + ) -> dict[str, Any] | None: + expense_types = { + str(claim.expense_type or "").strip().lower(), + *{str(item.item_type or "").strip().lower() for item in list(claim.items or [])}, + } + reason_corpus = self._build_scene_reason_corpus(claim) + compact_reason = re.sub(r"\s+", "", reason_corpus) + looks_like_entertainment = ( + "entertainment" in expense_types + or "招待" in compact_reason + or "客户" in compact_reason + ) + if not looks_like_entertainment: + return None + required_keywords = ("客户", "项目", "参与", "人员", "对象", "商务", "会议") + has_detail = any(keyword in compact_reason for keyword in required_keywords) + if has_detail: + return None + return self._build_platform_risk_flag( + manifest, + message="招待或餐饮类费用未识别到客户、项目、参与人员等必要说明,建议补充后再流转。", + evidence={"reason": reason_corpus[:300]}, + ) + + def _evaluate_document_expense_mismatch_risk( + self, + manifest: dict[str, Any], + *, + claim: ExpenseClaim, + contexts: list[dict[str, Any]], + ) -> dict[str, Any] | None: + mismatches: list[str] = [] + for context in contexts: + item = context["item"] + item_type = ( + str(item.item_type or claim.expense_type or "other").strip().lower() + or "other" + ) + policy = self._get_expense_scene_policy(item_type) + if policy is None: + continue + document_info = context.get("document_info") or {} + recognized_scene_code = ( + str(document_info.get("scene_code") or "other").strip().lower() + or "other" + ) + recognized_document_type = ( + str(document_info.get("document_type") or "other").strip().lower() + or "other" + ) + if ( + recognized_scene_code in set(policy.allowed_scene_codes) + or recognized_document_type in set(policy.allowed_document_types) + ): + continue + recognized_label = str( + document_info.get("document_type_label") + or recognized_document_type + or "未知票据" + ) + mismatches.append(f"第 {context['index']} 条明细为{policy.label},附件识别为{recognized_label}") + + if not mismatches: + return None + return self._build_platform_risk_flag( + manifest, + message=";".join(mismatches[:3]) + ",与当前费用场景不匹配。", + evidence={"mismatches": mismatches[:5]}, + ) + + def _evaluate_location_consistency_risk( + self, + manifest: dict[str, Any], + *, + claim: ExpenseClaim, + contexts: list[dict[str, Any]], + ) -> dict[str, Any] | None: + policy = self._get_expense_rule_catalog().travel_policy + if policy is None: + return None + declared_cities = self._extract_known_cities_from_text( + " ".join( + [ + str(claim.location or ""), + *[str(item.item_location or "") for item in list(claim.items or [])], + ] + ), + policy, + ) + evidence_cities = self._collect_attachment_cities(contexts, policy) + if not declared_cities or not evidence_cities: + return None + if set(declared_cities) & set(evidence_cities): + return None + declared_text = "、".join(declared_cities) + evidence_text = "、".join(evidence_cities[:5]) + return self._build_platform_risk_flag( + manifest, + message=f"申报地点 {declared_text} 与票据识别地点 {evidence_text} 不一致,建议补充异地说明或更换附件。", + evidence={"declared_cities": declared_cities, "evidence_cities": evidence_cities}, + ) + + def _evaluate_duplicate_invoice_risk( + self, + manifest: dict[str, Any], + *, + claim: ExpenseClaim, + contexts: list[dict[str, Any]], + ) -> dict[str, Any] | None: + invoice_keys = self._collect_invoice_keys_from_contexts(contexts) + duplicate_keys = [ + key + for key, count in self._count_values(invoice_keys).items() + if count > 1 + ] + if duplicate_keys: + return self._build_platform_risk_flag( + manifest, + message=f"当前报销单内存在重复票据号码:{'、'.join(duplicate_keys[:3])}。", + evidence={"duplicate_invoice_keys": duplicate_keys[:5]}, + ) + + if not invoice_keys: + return None + + other_items = list( + self.db.scalars( + select(ExpenseClaimItem) + .where(ExpenseClaimItem.claim_id != claim.id) + .where(ExpenseClaimItem.invoice_id.is_not(None)) + ).all() + ) + matched_claim_ids: set[str] = set() + for other_item in other_items: + other_path = self._resolve_attachment_path(other_item.invoice_id) + if other_path is None or not other_path.exists(): + continue + other_meta = self._read_attachment_meta(other_path) + other_document_info = other_meta.get("document_info") + if not isinstance(other_document_info, dict): + continue + other_keys = self._collect_invoice_keys_from_document_info(other_document_info) + if set(invoice_keys) & set(other_keys): + matched_claim_ids.add(str(other_item.claim_id or "")) + + if not matched_claim_ids: + return None + return self._build_platform_risk_flag( + manifest, + message=f"票据号码已在其他报销单中出现,疑似重复报销:{'、'.join(invoice_keys[:3])}。", + evidence={ + "invoice_keys": invoice_keys[:5], + "matched_claim_ids": sorted(matched_claim_ids)[:5], + }, + ) + + def _evaluate_identity_consistency_risk( + self, + manifest: dict[str, Any], + *, + claim: ExpenseClaim, + contexts: list[dict[str, Any]], + ) -> dict[str, Any] | None: + params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {} + allow_keywords = [ + str(value) + for value in list(params.get("allow_keywords") or []) + if str(value).strip() + ] + claimant = str(claim.employee_name or "").strip() + if not claimant: + return None + mismatched_buyers: list[str] = [] + for context in contexts: + buyer = self._resolve_first_document_field_value( + context.get("document_info") or {}, + keys={"buyer_name", "buyer", "purchaser_name", "claimant"}, + labels={"购买方", "抬头", "买方", "购方"}, + ) + if not buyer: + continue + if claimant in buyer or any(keyword in buyer for keyword in allow_keywords): + continue + mismatched_buyers.append(buyer) + if not mismatched_buyers: + return None + return self._build_platform_risk_flag( + manifest, + message=f"发票抬头 {mismatched_buyers[0]} 与报销人 {claimant} 不一致,建议人工复核。", + evidence={"claimant": claimant, "buyers": mismatched_buyers[:5]}, + ) + + def _evaluate_cross_year_invoice_risk( + self, + manifest: dict[str, Any], + *, + claim: ExpenseClaim, + contexts: list[dict[str, Any]], + ) -> dict[str, Any] | None: + claim_year = claim.occurred_at.year if claim.occurred_at is not None else None + if claim_year is None: + return None + issue_years: list[int] = [] + for context in contexts: + text = " ".join( + [ + self._resolve_first_document_field_value( + context.get("document_info") or {}, + keys={"date", "issue_date", "invoice_date"}, + labels={"日期", "开票日期", "发生时间"}, + ), + str(context.get("ocr_summary") or ""), + str(context.get("ocr_text") or ""), + ] + ) + for match in re.findall(r"(20\d{2}|19\d{2})[年/\-.]", text): + try: + issue_years.append(int(match)) + except ValueError: + continue + mismatch_years = sorted({year for year in issue_years if year != claim_year}) + if not mismatch_years: + return None + return self._build_platform_risk_flag( + manifest, + message=f"票据年份 {mismatch_years[0]} 与费用发生年份 {claim_year} 不一致,建议确认是否跨年报销。", + evidence={"claim_year": claim_year, "invoice_years": mismatch_years}, + ) + + def _evaluate_text_keyword_risk( + self, + manifest: dict[str, Any], + *, + contexts: list[dict[str, Any]], + keywords: list[str], + fallback_message: str, + ) -> dict[str, Any] | None: + matched: list[str] = [] + for context in contexts: + text = f"{context.get('ocr_summary') or ''}\n{context.get('ocr_text') or ''}" + for keyword in keywords: + if keyword in text and keyword not in matched: + matched.append(keyword) + if not matched: + return None + return self._build_platform_risk_flag( + manifest, + message=fallback_message, + evidence={"matched_keywords": matched}, + ) + + def _evaluate_multi_city_reason_required_risk( + self, + manifest: dict[str, Any], + *, + claim: ExpenseClaim, + contexts: list[dict[str, Any]], + ) -> dict[str, Any] | None: + policy = self._get_expense_rule_catalog().travel_policy + if policy is None: + return None + cities = self._collect_attachment_cities(contexts, policy) + for item in list(claim.items or []): + for city in self._extract_known_cities_from_text(str(item.item_location or ""), policy): + if city not in cities: + cities.append(city) + if len(cities) <= 2: + return None + reason_corpus = self._build_travel_reason_corpus(claim) + if self._text_contains_keywords(reason_corpus, policy.route_exception_keywords): + return None + return self._build_platform_risk_flag( + manifest, + message=f"本次报销识别到多城市行程({'、'.join(cities[:5])}),但事由中未说明中转、多地拜访或改签原因。", + evidence={"cities": cities[:8]}, + ) + + def _build_platform_risk_flag( + self, + manifest: dict[str, Any], + *, + message: str, + evidence: dict[str, Any], + ) -> dict[str, Any]: + outcomes = manifest.get("outcomes") if isinstance(manifest.get("outcomes"), dict) else {} + fail_outcome = outcomes.get("fail") if isinstance(outcomes.get("fail"), dict) else {} + severity = str(fail_outcome.get("severity") or "medium").strip().lower() or "medium" + default_action = "block" if severity == "high" else "manual_review" + action = str(fail_outcome.get("action") or default_action).strip() + label = str(manifest.get("name") or manifest.get("rule_code") or "风险规则命中").strip() + + return { + "source": "submission_review", + "hit_source": "rule_center", + "rule_type": "risk", + "rule_code": str(manifest.get("rule_code") or "").strip(), + "rule_version": str(manifest.get("_rule_version") or "v1.0.0").strip(), + "severity": severity, + "action": action, + "label": label, + "message": message, + "evidence": evidence, + } + + @staticmethod + def _count_values(values: list[str]) -> dict[str, int]: + counts: dict[str, int] = {} + for value in values: + normalized = str(value or "").strip() + if not normalized: + continue + counts[normalized] = counts.get(normalized, 0) + 1 + return counts + + def _collect_invoice_keys_from_contexts(self, contexts: list[dict[str, Any]]) -> list[str]: + invoice_keys: list[str] = [] + for context in contexts: + document_info = context.get("document_info") or {} + for key in self._collect_invoice_keys_from_document_info(document_info): + if key not in invoice_keys: + invoice_keys.append(key) + return invoice_keys + + def _collect_invoice_keys_from_document_info(self, document_info: dict[str, Any]) -> list[str]: + keys: list[str] = [] + for field in list(document_info.get("fields") or []): + if not isinstance(field, dict): + continue + field_key = str(field.get("key") or "").strip().lower().replace("_", "") + label = str(field.get("label") or "").replace(" ", "") + value = str(field.get("value") or "").strip() + if not value: + continue + if field_key in {"invoiceno", "invoicenumber", "number", "code"} or any( + token in label for token in ("发票号码", "票号", "发票代码", "号码") + ): + normalized = re.sub(r"\s+", "", value) + if normalized and normalized not in keys: + keys.append(normalized) + return keys + + def _collect_attachment_cities( + self, + contexts: list[dict[str, Any]], + policy: RuntimeTravelPolicy, + ) -> list[str]: + cities: list[str] = [] + for context in contexts: + document_info = context.get("document_info") or {} + parts = [ + str(context.get("ocr_summary") or ""), + str(context.get("ocr_text") or ""), + str(context.get("item").item_location if context.get("item") is not None else ""), + ] + for field in list(document_info.get("fields") or []): + if isinstance(field, dict): + parts.append(str(field.get("value") or "")) + for city in self._extract_known_cities_from_text(" ".join(parts), policy): + if city not in cities: + cities.append(city) + return cities + + @staticmethod + def _extract_known_cities_from_text(text: str, policy: RuntimeTravelPolicy) -> list[str]: + normalized = str(text or "").strip() + if not normalized: + return [] + cities: list[str] = [] + for city in sorted(policy.city_tiers.keys(), key=lambda item: len(item), reverse=True): + if city in normalized and city not in cities: + cities.append(city) + return cities + + @staticmethod + def _resolve_first_document_field_value( + document_info: dict[str, Any], + *, + keys: set[str], + labels: set[str], + ) -> str: + normalized_keys = {key.replace("_", "").lower() for key in keys} + for field in list(document_info.get("fields") or []): + if not isinstance(field, dict): + continue + field_key = str(field.get("key") or "").strip().lower().replace("_", "") + label = str(field.get("label") or "").replace(" ", "") + value = str(field.get("value") or "").strip() + if not value: + continue + if field_key in normalized_keys or any(token in label for token in labels): + return value + return "" + + def _run_scene_policy_review(self, claim: ExpenseClaim) -> dict[str, list[Any]]: catalog = self._get_expense_rule_catalog() flags: list[dict[str, Any]] = [] blocking_reasons: list[str] = [] @@ -3445,20 +4175,30 @@ class ExpenseClaimService: parts.append(str(item.item_location or "").strip()) return "\n".join(part for part in parts if part) - @staticmethod - def _merge_claim_attachment_risk_flags( - claim: ExpenseClaim, - attachment_risk_flags: list[dict[str, Any]], - ) -> list[Any]: + @staticmethod + def _merge_claim_attachment_risk_flags( + claim: ExpenseClaim, + attachment_risk_flags: list[dict[str, Any]], + ) -> list[Any]: preserved_flags = [ flag for flag in list(claim.risk_flags_json or []) if not (isinstance(flag, dict) and str(flag.get("source") or "").strip() == "attachment_analysis") - ] - return preserved_flags + attachment_risk_flags - - def _validate_claim_for_submission(self, claim: ExpenseClaim) -> list[str]: - issues: list[str] = [] + ] + return preserved_flags + attachment_risk_flags + + @staticmethod + def _format_submission_blocked_message(issues: list[str]) -> str: + normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()] + if not normalized_issues: + return "AI预审未通过,但没有返回明确原因,请刷新草稿后重试。" + + return "AI预审暂未通过,原因如下:\n" + "\n".join( + f"{index}. {issue}" for index, issue in enumerate(normalized_issues, start=1) + ) + + def _validate_claim_for_submission(self, claim: ExpenseClaim) -> list[str]: + issues: list[str] = [] claim_location_required = self._is_location_required_expense_type(claim.expense_type) claim_min_attachment_count = self._resolve_min_attachment_count(claim.expense_type) @@ -3524,20 +4264,140 @@ class ExpenseClaimService: if str(item).strip() } - def _resolve_current_employee(self, current_user: CurrentUserContext) -> Employee | None: - username = str(current_user.username or "").strip() - if not username: - return None - return self.db.scalar( - select(Employee) - .where(func.lower(Employee.email) == username.lower()) - .limit(1) - ) - - def _employee_name_is_unique(self, employee: Employee) -> bool: - normalized_name = str(employee.name or "").strip() - if not normalized_name: - return False + def _resolve_current_employee(self, current_user: CurrentUserContext) -> Employee | None: + return self._resolve_employee_by_identity_candidates( + [ + str(current_user.username or "").strip(), + str(current_user.name or "").strip(), + ] + ) + + def _resolve_claim_employee_for_backfill(self, claim: ExpenseClaim) -> Employee | None: + if claim.employee is not None: + employee = self.db.scalar( + select(Employee) + .options( + selectinload(Employee.organization_unit), + selectinload(Employee.manager), + selectinload(Employee.roles), + ) + .where(Employee.id == claim.employee.id) + .limit(1) + ) + return employee or claim.employee + + employee_id = str(claim.employee_id or "").strip() + if employee_id: + employee = self.db.scalar( + select(Employee) + .options( + selectinload(Employee.organization_unit), + selectinload(Employee.manager), + selectinload(Employee.roles), + ) + .where(Employee.id == employee_id) + .limit(1) + ) + if employee is not None: + return employee + + return self._resolve_employee_by_identity_candidates([str(claim.employee_name or "").strip()]) + + def _resolve_employee_by_identity_candidates(self, candidates: list[str]) -> Employee | None: + normalized_candidates = [ + item + for item in dict.fromkeys(str(candidate or "").strip() for candidate in candidates) + if item + ] + if not normalized_candidates: + return None + + load_options = ( + selectinload(Employee.organization_unit), + selectinload(Employee.manager), + selectinload(Employee.roles), + ) + + for candidate in normalized_candidates: + employee = self.db.scalar( + select(Employee) + .options(*load_options) + .where( + or_( + func.lower(Employee.email) == candidate.lower(), + func.lower(Employee.employee_no) == candidate.lower(), + ) + ) + .limit(1) + ) + if employee is not None: + return employee + + for candidate in normalized_candidates: + matches = list( + self.db.scalars( + select(Employee) + .options(*load_options) + .where(Employee.name == candidate) + .limit(2) + ).all() + ) + if len(matches) == 1: + return matches[0] + + return None + + def _backfill_claim_identity_from_current_user( + self, + claim: ExpenseClaim, + current_user: CurrentUserContext, + ) -> None: + employee = self._resolve_claim_employee_for_backfill(claim) or self._resolve_current_employee(current_user) + + if employee is not None: + claim_employee_id = str(claim.employee_id or "").strip() + claim_employee_name = str(claim.employee_name or "").strip() + employee_names = { + str(employee.name or "").strip(), + str(employee.email or "").strip(), + str(employee.employee_no or "").strip(), + } + employee_names.discard("") + + can_apply_employee = ( + not claim_employee_id + or claim_employee_id == employee.id + or self._is_missing_value(claim_employee_name) + or claim_employee_name in employee_names + ) + + if can_apply_employee: + claim.employee = employee + claim.employee_id = employee.id + if employee.name: + claim.employee_name = employee.name + if employee.organization_unit is not None: + claim.department_id = employee.organization_unit_id + claim.department_name = employee.organization_unit.name + return + + context_department = str( + getattr(current_user, "department_name", "") + or getattr(current_user, "department", "") + or getattr(current_user, "departmentName", "") + or "" + ).strip() + if context_department and self._is_missing_value(claim.department_name): + claim.department_name = context_department + + context_name = str(current_user.name or current_user.username or "").strip() + if context_name and self._is_missing_value(claim.employee_name): + claim.employee_name = context_name + + def _employee_name_is_unique(self, employee: Employee) -> bool: + normalized_name = str(employee.name or "").strip() + if not normalized_name: + return False same_name_count = int( self.db.scalar( diff --git a/server/src/app/services/ontology.py b/server/src/app/services/ontology.py index 81d2211..6347177 100644 --- a/server/src/app/services/ontology.py +++ b/server/src/app/services/ontology.py @@ -120,11 +120,13 @@ EXPLAIN_KEYWORDS = ("为什么", "依据", "原因", "怎么处理", "是否可 COMPARE_KEYWORDS = ("对比", "比较", "相比", "差异", "变化") RISK_KEYWORDS = ("风险", "异常", "重复", "超标", "超预算", "逾期", "验真", "巡检") DRAFT_KEYWORDS = ("生成", "草稿", "起草", "拟一份", "创建", "发起", "准备") -DRAFT_FOLLOW_UP_KEYWORDS = ( - "继续", - "补充", - "补一下", - "修改", +DRAFT_FOLLOW_UP_KEYWORDS = ( + "继续", + "下一步", + "核对", + "补充", + "补一下", + "修改", "改成", "改为", "换成", @@ -136,9 +138,16 @@ DRAFT_FOLLOW_UP_KEYWORDS = ( "地点是", "金额是", "日期是", - "时间是", -) -OPERATE_KEYWORDS = ( + "时间是", +) +EXPENSE_REVIEW_ACTIONS = { + "save_draft", + "next_step", + "edit_review", + "link_to_existing_draft", + "create_new_claim_from_documents", +} +OPERATE_KEYWORDS = ( "直接付款", "帮我付款", "安排付款", @@ -636,12 +645,17 @@ class SemanticOntologyService: def _compact(text: str) -> str: return re.sub(r"\s+", "", text).lower() - @staticmethod - def _resolve_context_scenario(context_json: dict[str, Any]) -> str | None: - value = str(context_json.get("conversation_scenario") or "").strip() - if value in CONTEXTUAL_SCENARIOS: - return value - return None + @staticmethod + def _resolve_context_scenario(context_json: dict[str, Any]) -> str | None: + value = str(context_json.get("conversation_scenario") or "").strip() + if value in CONTEXTUAL_SCENARIOS: + return value + review_action = str(context_json.get("review_action") or "").strip() + if review_action in EXPENSE_REVIEW_ACTIONS: + return "expense" + if str(context_json.get("draft_claim_id") or "").strip(): + return "expense" + return None @staticmethod def _resolve_session_type_scenario(context_json: dict[str, Any]) -> str | None: @@ -728,19 +742,22 @@ class SemanticOntologyService: ) return len(compact_query) <= 12 and not has_domain_keyword - def _should_inherit_expense_draft( - self, - compact_query: str, + def _should_inherit_expense_draft( + self, + compact_query: str, *, scenario: str, entities: list[OntologyEntity], time_range: OntologyTimeRange, - context_json: dict[str, Any], - ) -> bool: - context_scenario = self._resolve_context_scenario(context_json) - draft_claim_id = str(context_json.get("draft_claim_id") or "").strip() - if context_scenario != "expense" and not draft_claim_id: - return False + context_json: dict[str, Any], + ) -> bool: + context_scenario = self._resolve_context_scenario(context_json) + draft_claim_id = str(context_json.get("draft_claim_id") or "").strip() + review_action = str(context_json.get("review_action") or "").strip() + if review_action in EXPENSE_REVIEW_ACTIONS: + return True + if context_scenario != "expense" and not draft_claim_id: + return False if any(keyword in compact_query for keyword in DRAFT_FOLLOW_UP_KEYWORDS): return True @@ -1674,15 +1691,16 @@ class SemanticOntologyService: return False, None @staticmethod - def _allow_incomplete_draft( - context_json: dict[str, Any], - *, - scenario: str, - intent: str, + def _allow_incomplete_draft( + context_json: dict[str, Any], + *, + scenario: str, + intent: str, ) -> bool: - if scenario != "expense" or intent != "draft": - return False - return str(context_json.get("review_action") or "").strip() == "save_draft" + if scenario != "expense" or intent != "draft": + return False + review_action = str(context_json.get("review_action") or "").strip() + return review_action in EXPENSE_REVIEW_ACTIONS @staticmethod def _display_slot_label(slot: str) -> str: diff --git a/server/src/app/services/orchestrator.py b/server/src/app/services/orchestrator.py index 59d619d..883f128 100644 --- a/server/src/app/services/orchestrator.py +++ b/server/src/app/services/orchestrator.py @@ -173,9 +173,11 @@ class OrchestratorService: task_asset=task_asset, ) selected_capability_codes = self._flatten_capability_codes(capabilities) - requires_confirmation = ( - ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value - ) + is_expense_review_action = self._is_expense_review_action(context_json) + requires_confirmation = ( + ontology.permission.level == AgentPermissionLevel.APPROVAL_REQUIRED.value + and not is_expense_review_action + ) route_json = { "orchestrated_by": AgentName.ORCHESTRATOR.value, @@ -526,7 +528,11 @@ class OrchestratorService: failed_tool_count=1 if degraded else 0, ) - next_step = self._resolve_next_step(ontology, payload.source) + next_step = self._resolve_next_step( + ontology, + payload.source, + context_json=context_json, + ) if next_step == "query_database": tool_payload, degraded = self._invoke_tool( run_id=run_id, @@ -662,9 +668,9 @@ class OrchestratorService: "degraded": True, } - if ontology.scenario == "expense": - tool_type = AgentToolType.DATABASE.value - tool_name = "database.expense_claims.save_or_submit" + if ontology.scenario == "expense" or self._is_expense_review_action(context_json): + tool_type = AgentToolType.DATABASE.value + tool_name = "database.expense_claims.save_or_submit" executor = lambda: self.expense_claim_service.save_or_submit_from_ontology( run_id=run_id, user_id=payload.user_id, @@ -781,10 +787,17 @@ class OrchestratorService: failed_tool_count=failed_tool_count, ) - @staticmethod - def _resolve_next_step(ontology: OntologyParseResult, source: str) -> str: - if ontology.clarification_required: - return "ask_clarification" + @staticmethod + def _resolve_next_step( + ontology: OntologyParseResult, + source: str, + *, + context_json: dict[str, Any] | None = None, + ) -> str: + if OrchestratorService._is_expense_review_action(context_json or {}): + return "create_draft" + if ontology.clarification_required: + return "ask_clarification" if ontology.intent == "draft": return "create_draft" if ontology.scenario == "knowledge" or ontology.intent == "explain": @@ -793,7 +806,18 @@ class OrchestratorService: return "run_rule" if ontology.intent in {"query", "compare"}: return "query_database" - return "create_draft" + return "create_draft" + + @staticmethod + def _is_expense_review_action(context_json: dict[str, Any]) -> bool: + review_action = str((context_json or {}).get("review_action") or "").strip() + return review_action in { + "save_draft", + "next_step", + "edit_review", + "link_to_existing_draft", + "create_new_claim_from_documents", + } @staticmethod def _flatten_capability_codes( diff --git a/server/src/app/services/user_agent.py b/server/src/app/services/user_agent.py index 0b5c0e5..0809cff 100644 --- a/server/src/app/services/user_agent.py +++ b/server/src/app/services/user_agent.py @@ -255,7 +255,7 @@ class UserAgentService: query_payload = self._build_query_payload(payload) draft_payload = ( self._build_draft_payload(payload) - if payload.ontology.intent == "draft" + if self._should_build_draft_payload(payload) else None ) review_payload = self._build_review_payload( @@ -1683,7 +1683,10 @@ class UserAgentService: if not risk_flags and not platform_messages: return "当前未识别到明确风险标签,建议继续查看原始明细或补充更多上下文。" - reasons = [RISK_REASON_MAP.get(flag, f"{flag} 需要人工进一步确认。") for flag in risk_flags] + reasons = [ + f"{flag}:{RISK_REASON_MAP.get(flag, f'{flag} 需要人工进一步确认。')}" + for flag in risk_flags + ] if platform_messages: reasons.extend(platform_messages) citation_text = ( @@ -1764,6 +1767,17 @@ class UserAgentService: approval_stage=approval_stage, ) + @staticmethod + def _should_build_draft_payload(payload: UserAgentRequest) -> bool: + if payload.ontology.intent == "draft": + return True + if payload.ontology.scenario != "expense": + return False + return any( + str(payload.tool_payload.get(key) or "").strip() + for key in ("claim_id", "claim_no", "status") + ) + def _build_suggested_actions( self, payload: UserAgentRequest, @@ -1868,6 +1882,7 @@ class UserAgentService: payload, slot_cards=slot_cards, ) + submission_blocked = bool(payload.tool_payload.get("submission_blocked")) risk_briefs = self._build_review_risk_briefs( payload, citations=citations, @@ -1877,7 +1892,7 @@ class UserAgentService: association_choice_pending = self._is_review_association_choice_pending(payload) can_proceed = ( False - if association_choice_pending + if association_choice_pending or submission_blocked else self._can_proceed_review( payload, missing_slot_keys=missing_slot_keys, @@ -2157,6 +2172,15 @@ class UserAgentService: claim_groups: list[UserAgentReviewClaimGroup], ) -> list[UserAgentReviewRiskBrief]: briefs: list[UserAgentReviewRiskBrief] = [] + for reason in self._resolve_submission_blocked_reasons(payload): + briefs.append( + UserAgentReviewRiskBrief( + title="AI预审未通过", + level="high", + content=reason, + ) + ) + employee_name = self._collect_entity_values(payload).get("employee_name") or str( payload.context_json.get("name") or "" ).strip() @@ -2229,6 +2253,36 @@ class UserAgentService: return briefs[:4] + @staticmethod + def _resolve_submission_blocked_reasons(payload: UserAgentRequest) -> list[str]: + raw_reasons = payload.tool_payload.get("submission_blocked_reasons") + if raw_reasons is None: + raw_reasons = payload.tool_payload.get("missing_fields") + + reasons: list[str] = [] + if isinstance(raw_reasons, list): + reasons.extend(str(item or "").strip() for item in raw_reasons) + elif isinstance(raw_reasons, str): + reasons.extend( + item.strip() + for item in re.split(r"[;;\n]+", raw_reasons) + if item.strip() + ) + + if not reasons: + message = str(payload.tool_payload.get("message") or "").strip() + prefix = "提交前请先补全信息:" + if message.startswith(prefix): + message = message[len(prefix):].strip() + if message: + reasons.extend( + item.strip() + for item in re.split(r"[;;\n]+", message) + if item.strip() and not item.strip().startswith("AI预审暂未通过") + ) + + return list(dict.fromkeys(reason for reason in reasons if reason)) + def _build_review_confirmation_actions( self, payload: UserAgentRequest, @@ -2383,6 +2437,16 @@ class UserAgentService: stage_text = draft_payload.approval_stage or "审批中" return f"报销单 {draft_payload.claim_no or ''} 已提交,当前节点为 {stage_text}。".strip() if payload.tool_payload.get("submission_blocked"): + reasons = self._resolve_submission_blocked_reasons(payload) + if reasons: + reason_lines = "\n".join( + f"{index}. {reason}" for index, reason in enumerate(reasons, start=1) + ) + return ( + "AI预审暂未通过,所以还没有提交到审批人。\n" + f"{reason_lines}\n" + "请先处理以上项目;处理完成后再点继续下一步。" + ) return str(payload.tool_payload.get("message") or "").strip() or "当前报销单暂时还不能提交审批。" return ( f"{self._build_review_intent_summary(payload, slot_cards=review_payload.slot_cards, claim_groups=review_payload.claim_groups)} " diff --git a/server/tests/test_agent_asset_service.py b/server/tests/test_agent_asset_service.py index 774ac6b..caf0ff4 100644 --- a/server/tests/test_agent_asset_service.py +++ b/server/tests/test_agent_asset_service.py @@ -30,7 +30,9 @@ from app.schemas.agent_asset import ( AgentAssetVersionCreate, ) from app.services.agent_asset_spreadsheet import ( + COMPANY_COMMUNICATION_EXPENSE_RULE_CODE, COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME, + COMPANY_TRAVEL_EXPENSE_RULE_CODE, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, FINANCE_RULES_LIBRARY, ) @@ -145,6 +147,26 @@ def test_agent_asset_service_seeds_all_foundation_asset_types() -> None: assert len(service.list_assets(asset_type=AgentAssetType.TASK.value)) >= 3 +def test_finance_rules_use_risk_rule_scenario_categories() -> None: + with build_session() as db: + service = AgentAssetService(db) + + rules = service.list_assets(asset_type=AgentAssetType.RULE.value) + travel_rule = next(item for item in rules if item.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE) + communication_rule = next( + item for item in rules if item.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE + ) + travel_config = travel_rule.config_json or {} + communication_config = communication_rule.config_json or {} + + assert travel_rule.scenario_json == ["差旅"] + assert travel_config["scenario_category"] == "差旅" + assert travel_config["ai_review_category"] == "差旅" + assert communication_rule.scenario_json == ["费用科目"] + assert communication_config["scenario_category"] == "费用科目" + assert communication_config["ai_review_category"] == "费用科目" + + def test_agent_asset_service_can_activate_rule_after_review() -> None: with build_session() as db: service = AgentAssetService(db) diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 474dfae..841168f 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -11,6 +11,7 @@ from app.api.deps import CurrentUserContext from app.db.base import Base from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.models.organization import OrganizationUnit from app.schemas.ontology import OntologyParseRequest from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate @@ -76,6 +77,16 @@ def test_validate_claim_for_submission_allows_office_claim_without_location() -> assert not any("缺少地点" in item for item in issues) +def test_validate_claim_for_submission_allows_transport_claim_without_location() -> None: + service = ExpenseClaimService.__new__(ExpenseClaimService) + claim = build_claim(expense_type="transport", location="待补充") + + issues = service._validate_claim_for_submission(claim) + + assert "业务地点未完善" not in issues + assert not any("缺少地点" in item for item in issues) + + def test_validate_claim_for_submission_still_requires_location_for_travel_claim() -> None: service = ExpenseClaimService.__new__(ExpenseClaimService) claim = build_claim(expense_type="travel", location="待补充") @@ -666,7 +677,54 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None: assert submitted.submitted_at is not None -def test_submit_claim_blocks_high_risk_attachment_at_ai_review(monkeypatch, tmp_path) -> None: +def test_submit_claim_backfills_department_from_current_employee() -> None: + current_user = CurrentUserContext( + username="emp-dept@example.com", + name="张三", + role_codes=[], + is_admin=False, + ) + + with build_session() as db: + department = OrganizationUnit( + unit_code="D7200", + name="销售部", + ) + manager = Employee( + employee_no="E7200", + name="李经理", + email="manager-dept@example.com", + ) + employee = Employee( + employee_no="E7201", + name="张三", + email="emp-dept@example.com", + organization_unit=department, + manager=manager, + ) + claim = build_claim(expense_type="transport", location="待补充") + claim.employee = None + claim.employee_id = None + claim.employee_name = "张三" + claim.department_id = None + claim.department_name = "待补充" + claim.items[0].item_location = "待补充" + db.add_all([department, manager, employee, claim]) + db.commit() + + submitted = ExpenseClaimService(db).submit_claim(claim.id, current_user) + + assert submitted is not None + assert submitted.status == "submitted" + assert submitted.department_id == department.id + assert submitted.department_name == "销售部" + assert submitted.approval_stage == "直属领导审批" + + +def test_submit_claim_routes_high_risk_attachment_to_approval_with_review_flag( + monkeypatch, + tmp_path, +) -> None: current_user = CurrentUserContext( username="emp-risk@example.com", name="张三", @@ -732,16 +790,19 @@ def test_submit_claim_blocks_high_risk_attachment_at_ai_review(monkeypatch, tmp_ submitted = service.submit_claim(claim.id, current_user) assert submitted is not None - assert submitted.status == "supplement" - assert submitted.approval_stage == "待补充" - assert submitted.submitted_at is None + assert submitted.status == "submitted" + assert submitted.approval_stage == "直属领导审批" + assert submitted.submitted_at is not None assert any( isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review" for flag in list(submitted.risk_flags_json or []) ) -def test_submit_claim_blocks_travel_route_mismatch_without_explanation(monkeypatch, tmp_path) -> None: +def test_submit_claim_routes_travel_route_mismatch_to_approval_with_review_flag( + monkeypatch, + tmp_path, +) -> None: current_user = CurrentUserContext( username="emp-travel@example.com", name="张三", @@ -876,8 +937,8 @@ def test_submit_claim_blocks_travel_route_mismatch_without_explanation(monkeypat submitted = service.submit_claim(claim.id, current_user) assert submitted is not None - assert submitted.status == "supplement" - assert submitted.approval_stage == "待补充" + assert submitted.status == "submitted" + assert submitted.approval_stage == "直属领导审批" assert any( isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review" @@ -889,7 +950,10 @@ def test_submit_claim_blocks_travel_route_mismatch_without_explanation(monkeypat ) -def test_submit_claim_blocks_hotel_amount_over_travel_policy_without_explanation(monkeypatch, tmp_path) -> None: +def test_submit_claim_routes_hotel_amount_over_travel_policy_to_approval_with_review_flag( + monkeypatch, + tmp_path, +) -> None: current_user = CurrentUserContext( username="emp-hotel@example.com", name="张三", @@ -1024,8 +1088,8 @@ def test_submit_claim_blocks_hotel_amount_over_travel_policy_without_explanation submitted = service.submit_claim(claim.id, current_user) assert submitted is not None - assert submitted.status == "supplement" - assert submitted.approval_stage == "待补充" + assert submitted.status == "submitted" + assert submitted.approval_stage == "直属领导审批" assert any( isinstance(flag, dict) and str(flag.get("source") or "").strip() == "submission_review" diff --git a/server/tests/test_ontology_service.py b/server/tests/test_ontology_service.py index 55a855b..95fd49a 100644 --- a/server/tests/test_ontology_service.py +++ b/server/tests/test_ontology_service.py @@ -310,7 +310,9 @@ def test_semantic_ontology_service_keeps_travel_amount_follow_up_in_knowledge_qu assert result.clarification_required is False -def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session(monkeypatch) -> None: +def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session( + monkeypatch, +) -> None: session_factory = build_session_factory() with session_factory() as db: service = SemanticOntologyService(db) @@ -341,11 +343,33 @@ def test_semantic_ontology_service_rejects_draft_intent_inside_knowledge_session }, ) ) - - assert result.scenario == "knowledge" - assert result.intent == "query" - assert result.clarification_required is False - assert result.clarification_question is None + + assert result.scenario == "knowledge" + assert result.intent == "query" + assert result.clarification_required is False + assert result.clarification_question is None + + +def test_review_next_step_context_inherits_expense_draft_flow() -> None: + session_factory = build_session_factory() + with session_factory() as db: + result = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我已核对右侧识别结果,请进入下一步。", + user_id="pytest", + context_json={ + "review_action": "next_step", + "draft_claim_id": "claim-1", + "attachment_count": 1, + }, + ) + ) + + assert result.scenario == "expense" + assert result.intent == "draft" + assert result.permission.level == "draft_write" + assert result.clarification_required is False + assert result.clarification_question is None def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_narrative() -> None: diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py new file mode 100644 index 0000000..fdbc00f --- /dev/null +++ b/server/tests/test_orchestrator_review_flow.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from datetime import UTC, date, datetime +from decimal import Decimal + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.models.employee import Employee +from app.models.financial_record import ExpenseClaim, ExpenseClaimItem +from app.schemas.orchestrator import OrchestratorRequest +from app.services.orchestrator import OrchestratorService + + +def build_session_factory() -> sessionmaker[Session]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def test_review_next_step_run_submits_existing_claim_and_returns_draft_payload( + monkeypatch, +) -> None: + monkeypatch.setattr( + "app.services.runtime_chat.RuntimeChatService.complete", + lambda *_args, **_kwargs: None, + ) + session_factory = build_session_factory() + with session_factory() as db: + manager = Employee( + employee_no="E9000", + name="李经理", + email="manager-next@example.com", + ) + employee = Employee( + employee_no="E9001", + name="张三", + email="emp-next@example.com", + manager=manager, + ) + claim = ExpenseClaim( + id="claim-next-step", + claim_no="EXP-202605-001", + employee=employee, + employee_id=employee.id, + employee_name="张三", + department_name="销售部", + expense_type="office", + reason="采购办公用品", + location="上海", + amount=Decimal("128.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC), + status="draft", + approval_stage="待提交", + items=[ + ExpenseClaimItem( + item_date=date(2026, 5, 20), + item_type="office", + item_reason="采购办公用品", + item_location="上海", + item_amount=Decimal("128.00"), + invoice_id="office-invoice.png", + ) + ], + ) + db.add_all([manager, employee, claim]) + db.commit() + + response = OrchestratorService(db).run( + OrchestratorRequest( + source="user_message", + user_id="emp-next@example.com", + message="我已核对右侧识别结果,请进入下一步。", + context_json={ + "review_action": "next_step", + "draft_claim_id": claim.id, + "attachment_count": 1, + "name": "张三", + }, + ) + ) + + db.refresh(claim) + assert response.status == "succeeded" + assert response.requires_confirmation is False + assert response.result["draft_payload"]["status"] == "submitted" + assert response.result["draft_payload"]["approval_stage"] == "直属领导审批" + assert claim.status == "submitted" + assert claim.approval_stage == "直属领导审批" + assert claim.submitted_at is not None + + +def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action( + monkeypatch, +) -> None: + monkeypatch.setattr( + "app.services.runtime_chat.RuntimeChatService.complete", + lambda *_args, **_kwargs: None, + ) + session_factory = build_session_factory() + with session_factory() as db: + employee = Employee( + employee_no="E9011", + name="张三", + email="emp-blocked@example.com", + ) + claim = ExpenseClaim( + id="claim-next-step-blocked", + claim_no="EXP-202605-002", + employee=employee, + employee_id=employee.id, + employee_name="张三", + department_name="待补充", + expense_type="office", + reason="采购办公用品", + location="上海", + amount=Decimal("128.00"), + currency="CNY", + invoice_count=1, + occurred_at=datetime(2026, 5, 20, 9, 0, tzinfo=UTC), + status="draft", + approval_stage="待提交", + items=[ + ExpenseClaimItem( + item_date=date(2026, 5, 20), + item_type="office", + item_reason="采购办公用品", + item_location="上海", + item_amount=Decimal("128.00"), + invoice_id="office-invoice.png", + ) + ], + ) + db.add_all([employee, claim]) + db.commit() + + response = OrchestratorService(db).run( + OrchestratorRequest( + source="user_message", + user_id="emp-blocked@example.com", + message="我已核对右侧识别结果,请进入下一步。", + context_json={ + "review_action": "next_step", + "draft_claim_id": claim.id, + "attachment_count": 1, + "name": "张三", + }, + ) + ) + + result = response.result + review_payload = result["review_payload"] + actions = { + str(item.get("action_type") or "").strip() + for item in review_payload["confirmation_actions"] + } + + assert response.status == "succeeded" + assert result["draft_payload"]["status"] == "draft" + assert "AI预审暂未通过" in result["answer"] + assert "所属部门未完善" in result["answer"] + assert "next_step" not in actions + assert "save_draft" in actions + assert any( + "所属部门未完善" in str(item.get("content") or "") + for item in review_payload["risk_briefs"] + ) diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index cbc6ad2..95f930e 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -727,7 +727,7 @@ def test_user_agent_draft_returns_structured_payload() -> None: assert response.answer == response.review_payload.body_message -def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None: +def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None: session_factory = build_session_factory() with session_factory() as db: ontology = SemanticOntologyService(db).parse( @@ -751,13 +751,50 @@ def test_user_agent_returns_draft_limit_message_when_save_is_blocked() -> None: ) ) - assert ( - response.answer - == "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" - ) - - -def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> None: + assert ( + response.answer + == "你当前已保存 3 个草稿,请先完成已保存的草稿,才能再次新建草稿。" + ) + + +def test_user_agent_returns_submitted_draft_payload_for_review_next_step() -> None: + session_factory = build_session_factory() + with session_factory() as db: + context_json = { + "review_action": "next_step", + "draft_claim_id": "claim-1", + "attachment_count": 1, + } + ontology = SemanticOntologyService(db).parse( + OntologyParseRequest( + query="我已核对右侧识别结果,请进入下一步。", + user_id="pytest", + context_json=context_json, + ) + ) + response = UserAgentService(db).respond( + UserAgentRequest( + run_id=ontology.run_id, + user_id="pytest", + message="我已核对右侧识别结果,请进入下一步。", + ontology=ontology, + context_json=context_json, + tool_payload={ + "claim_id": "claim-1", + "claim_no": "BX202605200001", + "status": "submitted", + "approval_stage": "直属领导审批", + }, + ) + ) + + assert response.draft_payload is not None + assert response.draft_payload.status == "submitted" + assert response.draft_payload.confirmation_required is False + assert "当前节点为 直属领导审批" in response.answer + + +def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> None: session_factory = build_session_factory() with session_factory() as db: yesterday = (datetime.now(UTC).date() - timedelta(days=1)).isoformat() diff --git a/web/src/composables/useAppShell.js b/web/src/composables/useAppShell.js index ae7ff5a..12ef8df 100644 --- a/web/src/composables/useAppShell.js +++ b/web/src/composables/useAppShell.js @@ -1,4 +1,4 @@ -import { computed, ref } from 'vue' +import { computed, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useNavigation, navItems } from './useNavigation.js' @@ -127,6 +127,14 @@ export function useAppShell() { const logDetailMode = computed(() => route.name === 'app-log-detail') const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : [])) + const requestsListActive = computed(() => activeView.value === 'requests' && !detailMode.value) + + watch(requestsListActive, (isActive, wasActive) => { + if (isActive && !wasActive) { + void reloadRequests() + } + }) + const topBarView = computed(() => { if (detailMode.value) { return { @@ -243,7 +251,7 @@ export function useAppShell() { smartEntryOpen.value = false await reloadRequests() if (status === 'submitted') { - toast(`${claimNo || '该'}单据已完成 AI验审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`) + toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`) } else { toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`) } diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js index 8940c99..d2073ed 100644 --- a/web/src/composables/useRequests.js +++ b/web/src/composables/useRequests.js @@ -16,9 +16,6 @@ const EXPENSE_TYPE_LABELS = { const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ 'travel', - 'hotel', - 'transport', - 'meal', 'meeting', 'entertainment' ]) @@ -26,7 +23,7 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ const REIMBURSEMENT_PROGRESS_LABELS = [ '保存草稿', '待提交', - 'AI验审', + 'AI预审', '直属领导审批', '财务审批', '归档入账' @@ -135,10 +132,10 @@ function resolveWorkflowNode(claim, approvalMeta) { if (rawNode) { if (rawNode === '审批流转') { - return 'AI验审' + return 'AI预审' } if (rawNode === '待补充') { - return approvalMeta.key === 'draft' ? '待提交' : 'AI验审' + return approvalMeta.key === 'draft' ? '待提交' : 'AI预审' } return rawNode } @@ -151,7 +148,7 @@ function resolveWorkflowNode(claim, approvalMeta) { return '归档入账' } - return 'AI验审' + return 'AI预审' } function stringifyRiskFlag(value) { @@ -220,7 +217,7 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) { ) { return 3 } - if (normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) { + if (normalizedNode.includes('AI预审') || normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) { return 2 } if (normalizedNode.includes('待提交')) { diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js index 0a40435..7b89ddf 100644 --- a/web/src/composables/useSystemState.js +++ b/web/src/composables/useSystemState.js @@ -90,6 +90,8 @@ function buildAnonymousUser() { username: '', name: '', role: '', + department: '', + departmentName: '', position: '', grade: '', roleCodes: [], @@ -107,6 +109,8 @@ function buildLegacyAdminUser(username = '') { username: normalized, name, role: DEFAULT_USER_ROLE, + department: '', + departmentName: '', position: DEFAULT_USER_ROLE, grade: '', roleCodes: ['manager'], @@ -135,6 +139,8 @@ function readStoredUser() { username, name, role: String(payload.role || DEFAULT_USER_ROLE), + department: String(payload.department || payload.departmentName || ''), + departmentName: String(payload.departmentName || payload.department || ''), position: String(payload.position || ''), grade: String(payload.grade || ''), roleCodes, diff --git a/web/src/services/api.js b/web/src/services/api.js index dd546e7..684cad8 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -47,8 +47,10 @@ function readCurrentUserHeaders() { const name = String(payload?.name || username).trim() const roleCodes = Array.isArray(payload?.roleCodes) ? payload.roleCodes.filter(Boolean) : [] const isAdmin = Boolean(payload?.isAdmin) + const department = String(payload?.department || payload?.departmentName || '').trim() const safeUsername = pickSafeHeaderValue(username) const safeName = pickSafeHeaderValue(name) + const safeDepartment = pickSafeHeaderValue(department) if (!safeUsername && !safeName) { return {} @@ -67,6 +69,10 @@ function readCurrentUserHeaders() { headers['x-auth-name'] = safeName } + if (safeDepartment) { + headers['x-auth-department'] = safeDepartment + } + return headers } catch { return {} diff --git a/web/src/views/AuditView.vue b/web/src/views/AuditView.vue index 1e624cd..c7492ca 100644 --- a/web/src/views/AuditView.vue +++ b/web/src/views/AuditView.vue @@ -64,38 +64,6 @@
{{ selectedSkillIsRule ? '当前展示版本' : '当前版本' }} {{ selectedSkill.displayVersion || selectedSkill.version }} -
-
- 最近更新 - {{ selectedSkill.updatedAt }} -
- - - -
- -
- 资产详情加载失败 -

{{ detailError }}

-
-
- -
- -
- 正在加载资产详情 -

列表数据已就绪,正在补充版本、审核和运行信息。

-
-
- -
-
-
-
{{ selectedSkill.typeLabel }}
-

{{ selectedSkill.name }}

{{ selectedSkill.summary || '当前资产尚未补充说明。' }}

@@ -831,11 +799,11 @@
-
+
diff --git a/web/src/views/scripts/ApprovalCenterView.js b/web/src/views/scripts/ApprovalCenterView.js index ec36fcc..243c152 100644 --- a/web/src/views/scripts/ApprovalCenterView.js +++ b/web/src/views/scripts/ApprovalCenterView.js @@ -91,7 +91,7 @@ function resolveRiskItems(request) { return [ { - text: 'AI验审已通过,当前未发现额外风险。', + text: 'AI预审已通过,当前未发现额外风险。', level: '低', tone: 'low', icon: 'mdi mdi-shield-check' diff --git a/web/src/views/scripts/AuditView.js b/web/src/views/scripts/AuditView.js index 8841d7b..9df28c6 100644 --- a/web/src/views/scripts/AuditView.js +++ b/web/src/views/scripts/AuditView.js @@ -303,6 +303,8 @@ const RISK_SCENARIO_OPTIONS = [ { value: '通用', label: '通用' } ] +const RISK_SCENARIO_VALUES = new Set(RISK_SCENARIO_OPTIONS.map((item) => item.value).filter(Boolean)) + const LEGACY_RISK_SCENARIO_KEYS = new Set([ 'expense', 'risk_check', @@ -313,7 +315,10 @@ const LEGACY_RISK_SCENARIO_KEYS = new Set([ 'travel_standard', 'attachment_policy', 'scene_policy', - 'invoice_anomaly' + 'invoice_anomaly', + 'communication_expense', + 'expense_standard', + 'approval_required' ]) const SPREADSHEET_DETAIL_MODE = 'spreadsheet' @@ -409,7 +414,7 @@ function createPreviewRuleDetailPayload() { name: '公司差旅费报销规则', description: '前端预览态:先展示 Excel 规则详情页布局、版本卡片和编辑入口位置。', domain: 'expense', - scenario_json: ['expense', 'travel_policy', 'travel_standard'], + scenario_json: ['差旅'], owner: '财务制度管理组', reviewer: '顾承宇', status: 'active', @@ -422,6 +427,8 @@ function createPreviewRuleDetailPayload() { tag: '财务规则', detail_mode: 'spreadsheet', runtime_kind: 'travel_policy', + scenario_category: '差旅', + ai_review_category: '差旅', rule_template_label: '差旅报销 Excel 模板', rule_document: { ...currentMeta, @@ -588,26 +595,37 @@ function inferRiskCategoryFromCode(code) { return '通用' } +function normalizeRiskScenarioCategory(value) { + const normalized = normalizeText(value) + return RISK_SCENARIO_VALUES.has(normalized) ? normalized : '' +} + +function readScenarioItems(source) { + if (Array.isArray(source?.scenario_json)) { + return source.scenario_json + } + if (Array.isArray(source?.scenarioList)) { + return source.scenarioList + } + return [] +} + function resolveRiskRuleCategory(source) { const configJson = readConfigJson(source) - const explicit = normalizeText(configJson.risk_category) + const explicit = normalizeRiskScenarioCategory(configJson.risk_category) if (explicit) { return explicit } - const payloadCategory = normalizeText(source?.risk_category) + const payloadCategory = normalizeRiskScenarioCategory(source?.risk_category) if (payloadCategory) { return payloadCategory } - const scenarioItems = Array.isArray(source?.scenario_json) - ? source.scenario_json - : Array.isArray(source?.scenarioList) - ? source.scenarioList - : [] + const scenarioItems = readScenarioItems(source) const businessScenario = scenarioItems .map((item) => normalizeText(item)) - .find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item)) + .find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item) && RISK_SCENARIO_VALUES.has(item)) if (businessScenario) { return businessScenario } @@ -615,6 +633,75 @@ function resolveRiskRuleCategory(source) { return inferRiskCategoryFromCode(source?.code) } +function inferFinancialRuleCategory(source) { + const configJson = readConfigJson(source) + const explicit = + normalizeRiskScenarioCategory(configJson.scenario_category) || + normalizeRiskScenarioCategory(configJson.ai_review_category) || + normalizeRiskScenarioCategory(configJson.risk_category) || + normalizeRiskScenarioCategory(source?.scenario_category) || + normalizeRiskScenarioCategory(source?.risk_category) + if (explicit) { + return explicit + } + + const scenarioCategory = readScenarioItems(source) + .map((item) => normalizeRiskScenarioCategory(item)) + .find(Boolean) + if (scenarioCategory) { + return scenarioCategory + } + + const configRuntimeRule = isPlainObject(configJson.runtime_rule) ? configJson.runtime_rule : {} + const haystack = [ + source?.code, + source?.name, + source?.description, + configJson.runtime_kind, + configRuntimeRule.kind, + configRuntimeRule.scenario, + configRuntimeRule.template_key, + ...readScenarioItems(source) + ] + .map((item) => normalizeText(item).toLowerCase()) + .filter(Boolean) + .join(' ') + + if (!haystack) { + return '通用' + } + if (/(travel|trip|差旅|出差|住宿|酒店)/i.test(haystack)) { + return '差旅' + } + if (/(invoice|receipt|attachment|票据|发票|单据|附件)/i.test(haystack)) { + return '发票' + } + if (/(meal|dining|entertainment|餐饮|招待|餐费|用餐)/i.test(haystack)) { + return '餐饮招待' + } + if (/(transport|traffic|taxi|交通|出行|打车|机票|火车|高铁|地铁|公交)/i.test(haystack)) { + return '交通出行' + } + if (/(office|material|suppl|办公|物料|耗材)/i.test(haystack)) { + return '办公物料' + } + if (/(communication|telecom|phone|expense_standard|费用科目|费用标准|通信|通讯|手机|补贴|福利|科目)/i.test(haystack)) { + return '费用科目' + } + return '通用' +} + +function resolveRuleScenarioCategory(source, tabId = '') { + const resolvedTabId = tabId || resolveRuleTabId(source) + if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) { + return resolveRiskRuleCategory(source) + } + if (resolvedTabId === 'financialRules') { + return inferFinancialRuleCategory(source) + } + return '' +} + function buildRiskListSubtitle(text, maxLength = 42) { const normalized = normalizeText(text) if (!normalized) { @@ -1006,7 +1093,7 @@ function buildListItem(asset) { const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(asset) const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(asset) const ruleDocument = readRuleDocumentMeta(asset) - const riskCategory = isRiskRule ? resolveRiskRuleCategory(asset) : '' + const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(asset, tabId) : '' const listSubtitle = isRiskRule ? buildRiskListSubtitle(asset.description) : normalizeText(asset.description) @@ -1028,8 +1115,8 @@ function buildListItem(asset) { category: resolveDomainLabel(asset.domain), owner: asset.owner, reviewer: asset.reviewer || '待分配', - scope: isRiskRule ? riskCategory || '通用' : formatScenarioList(asset.scenario_json), - riskCategory, + scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json), + riskCategory: ruleScenarioCategory, model: buildRowRuntime(asset, typeKey), version: workingVersion, versionDisplay: typeKey === 'rules' ? `${changeCount} 次` : workingVersion, @@ -1050,6 +1137,7 @@ function buildListItem(asset) { function buildRuleFields(detail) { const ruleDocument = readRuleDocumentMeta(detail) + const ruleScenarioCategory = resolveRuleScenarioCategory(detail) return [ { label: '规则编码', value: detail.code }, { @@ -1073,7 +1161,7 @@ function buildRuleFields(detail) { label: '运行时类型', value: normalizeText(detail.config_json?.runtime_kind) || 'policy_rule_draft' }, - { label: '适用场景', value: formatScenarioList(detail.scenario_json) }, + { label: '适用场景', value: ruleScenarioCategory || '通用' }, { label: '线上版本', value: detail.published_version || '-' }, { label: '工作版本', value: detail.working_version || detail.current_version || '-' } ] @@ -1417,6 +1505,7 @@ function buildDetailViewModel(detail, runs) { const ruleTemplateKey = normalizeText(configJson.rule_template_key || previewRuntimeRule.template_key) const ruleTemplateLabel = normalizeText(configJson.rule_template_label) || resolveRuleTemplateLabel(ruleTemplateKey) const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft' + const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : '' return { id: detail.id, @@ -1431,7 +1520,7 @@ function buildDetailViewModel(detail, runs) { owner: detail.owner, reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配', category: resolveDomainLabel(detail.domain), - scope: usesJsonRiskRule ? resolveRiskRuleCategory(detail) || '通用' : formatScenarioList(detail.scenario_json), + scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(detail.scenario_json), version: detail.working_version || detail.current_version || '-', currentVersion: detail.current_version || '-', publishedVersion: detail.published_version || '-', @@ -1451,9 +1540,13 @@ function buildDetailViewModel(detail, runs) { riskRuleDescription: '', riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '', riskRuleSourceRef: '', - riskCategory: usesJsonRiskRule ? resolveRiskRuleCategory(detail) : '', + riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '', ruleDocument, - scenarioList: Array.isArray(detail.scenario_json) ? [...detail.scenario_json] : [], + scenarioList: typeKey === 'rules' && ruleScenarioCategory + ? [ruleScenarioCategory] + : Array.isArray(detail.scenario_json) + ? [...detail.scenario_json] + : [], markdownContent: previewMarkdown, runtimeRuleText: stringifyRuntimeRule(previewRuntimeRule), ruleTemplateKey, @@ -1474,7 +1567,12 @@ function buildDetailViewModel(detail, runs) { typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey, latestRun, latestCall), outputRules: buildOutputRules(detail, typeKey, latestRun, latestCall), tests: buildTests(detail, typeKey, latestRun, latestCall), - triggers: detail.scenario_json?.length ? detail.scenario_json.map((item) => SCENARIO_LABELS[item] || item) : ['未配置场景'], + triggers: + typeKey === 'rules' + ? [ruleScenarioCategory || '通用'] + : detail.scenario_json?.length + ? detail.scenario_json.map((item) => SCENARIO_LABELS[item] || item) + : ['未配置场景'], tools: typeKey === 'rules' ? [ @@ -1769,7 +1867,9 @@ export default { const selectedStatusLabel = computed( () => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态' ) - const showRiskScenarioFilter = computed(() => activeType.value === 'riskRules') + const showRiskScenarioFilter = computed(() => + ['financialRules', 'riskRules'].includes(activeType.value) + ) const showStatusFilter = computed(() => activeType.value !== 'riskRules') const selectedRiskScenarioLabel = computed( () => @@ -1799,6 +1899,13 @@ export default { }) const auditEmptyState = computed(() => { const hasFilters = activeFilterTokens.value.length > 0 + const supportedFilters = [ + '业务域', + '负责人', + ...(showRiskScenarioFilter.value ? ['使用场景'] : []), + ...(showStatusFilter.value ? ['状态'] : []), + '关键词' + ] if (!currentAssets.value.length) { return { @@ -1810,10 +1917,10 @@ export default { actionIcon: '', tone: 'amber', artLabel: 'ASSET', - tips: - activeType.value === 'riskRules' - ? ['切换页签可查看其他资产类型', '支持按业务域、负责人和使用场景做过滤'] - : ['切换页签可查看其他资产类型', '支持按业务域、负责人和状态做过滤'] + tips: [ + '切换页签可查看其他资产类型', + `支持按${supportedFilters.slice(0, -1).join('、')}和关键词做过滤` + ] } } @@ -1821,9 +1928,7 @@ export default { eyebrow: '筛选结果为空', title: `没有找到匹配的${activeTabLabel.value}`, desc: hasFilters - ? showRiskScenarioFilter.value - ? '试试清空业务域、负责人、使用场景或关键词筛选,再重新查看。' - : '试试清空业务域、负责人、状态或关键词筛选,再重新查看。' + ? `试试清空${supportedFilters.join('、')}筛选,再重新查看。` : `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`, icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline', actionLabel: hasFilters ? '清空筛选' : '', @@ -1831,9 +1936,12 @@ export default { tone: hasFilters ? 'emerald' : 'slate', artLabel: hasFilters ? 'FILTER' : 'QUEUE', tips: hasFilters - ? showRiskScenarioFilter.value - ? ['业务域、负责人、使用场景与关键词会叠加过滤', '可以换个规则名称或场景分类继续搜索'] - : ['业务域、负责人、状态与关键词会叠加过滤', '可以换个编码、名称或负责人关键词继续搜索'] + ? [ + `${supportedFilters.join('、')}会叠加过滤`, + showRiskScenarioFilter.value + ? '可以换个规则名称或场景分类继续搜索' + : '可以换个编码、名称或负责人关键词继续搜索' + ] : ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据'] } }) diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index 56feed0..ad6689c 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -209,17 +209,11 @@ const FLOW_STEP_FALLBACKS = { runningText: '正在识别票据附件...', completedText: '票据识别完成' }, - agent: { - title: '智能体编排', - tool: 'UserAgent', - runningText: '正在调用财务智能体...', - completedText: '智能体处理完成' - }, - result: { - title: '生成结果', - tool: 'ResultGenerator', - runningText: '正在生成解释与草稿...', - completedText: '结果已生成' + 'expense-claim-draft': { + title: '报销草稿处理', + tool: 'database.expense_claims.save_or_submit', + runningText: '正在根据识别结果更新草稿和右侧核对信息...', + completedText: '草稿和核对信息已更新' } } const ASSISTANT_DISPLAY_NAME = '财务助手' @@ -345,41 +339,6 @@ function formatMessageTime(value) { }) } -function createFlowSteps(options = {}) { - const keys = [] - if (options.includeIntent) { - keys.push('intent') - } - if (options.includeOcr) { - keys.push('ocr') - } - if (options.includeExtraction) { - keys.push('extraction') - } - if (options.includeAgent) { - keys.push('agent') - } - if (options.includeResult) { - keys.push('result') - } - - return keys.map((key, index) => { - const definition = FLOW_STEP_FALLBACKS[key] || {} - return { - key, - index: index + 1, - title: definition.title || '智能体工具调用', - tool: definition.tool || 'AgentTool', - status: FLOW_STEP_STATUS_PENDING, - detail: '', - durationMs: null, - startedAt: 0, - finishedAt: 0, - error: '' - } - }) -} - function formatSemanticEntityValue(entity) { const normalizedValue = String(entity?.normalized_value || '').trim() const rawValue = String(entity?.value || '').trim() @@ -577,11 +536,8 @@ function formatFlowDuration(ms) { if (!Number.isFinite(numericValue) || numericValue < 0) { return '--' } - if (numericValue < 100) { - return '<0.1s' - } if (numericValue < 1000) { - return `${(numericValue / 1000).toFixed(1)}s` + return `${Math.max(0.1, numericValue / 1000).toFixed(1)}s` } if (numericValue < 10000) { return `${(numericValue / 1000).toFixed(1)}s` @@ -639,34 +595,6 @@ function resolveToolCallDurationMs(toolCall, index, toolCalls, run) { return finishedAt - startedAt } -function resolveResultStepDurationMs(run) { - const runFinishedAt = parseFlowTimestamp(run?.finished_at) - if (!runFinishedAt) { - return null - } - - const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : [] - const semanticFinishedAt = ( - toolCalls - .map((item, index) => { - const startedAt = parseFlowTimestamp(item?.created_at) - const durationMs = resolveToolCallDurationMs(item, index, toolCalls, run) - if (!startedAt || !durationMs) { - return 0 - } - return startedAt + durationMs - }) - .filter((value) => value > 0) - .sort((left, right) => right - left)[0] - ) || parseFlowTimestamp(run?.started_at) - - if (!semanticFinishedAt || runFinishedAt <= semanticFinishedAt) { - return null - } - - return runFinishedAt - semanticFinishedAt -} - function sanitizeRequest(request) { if (!request || typeof request !== 'object') return null @@ -1559,7 +1487,7 @@ function buildDraftSavedPayload({ status: String(draftPayload?.status || '').trim(), approvalStage: String(draftPayload?.approval_stage || '').trim(), person: String(currentUser?.name || '').trim() || '当前用户', - dept: String(currentUser?.role || '').trim() || '待补充部门', + dept: String(currentUser?.department || currentUser?.departmentName || '').trim() || '待补充部门', entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.', typeCode, typeLabel, @@ -2525,11 +2453,103 @@ function buildReviewRiskSummary(reviewPayload) { function buildReviewRiskItems(reviewPayload) { return resolveReviewRiskBriefs(reviewPayload) - .map((brief) => String(brief?.content || '').trim()) + .map((brief) => { + const title = String(brief?.title || '').trim() + const content = String(brief?.content || '').trim() + if (title && content) return `${title}:${content}` + return content || title + }) .filter(Boolean) .slice(0, 4) } +function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) { + const state = inlineState || createEmptyInlineReviewState() + if (slotKey === 'expense_type') return String(state.expense_type || '').trim() + if (slotKey === 'customer_name') return String(state.customer_name || '').trim() + if (slotKey === 'time_range') return String(state.occurred_date || '').trim() + if (slotKey === 'location') return String(state.location || '').trim() + if (slotKey === 'merchant_name') return String(state.merchant_name || '').trim() + if (slotKey === 'amount') return String(state.amount || '').trim() + if (slotKey === 'reason') return String(state.reason_value || state.scene_label || '').trim() + if (slotKey === 'participants') return String(state.participants || '').trim() + if (slotKey === 'attachments') { + return String(state.attachment_names || '').trim() + || (Number(state.attachment_count || 0) > 0 ? `${Number(state.attachment_count)} 份附件` : '') + || (Number(state.pending_attachment_count || 0) > 0 ? `${Number(state.pending_attachment_count)} 份待上传附件` : '') + } + return '' +} + +function buildLocallySyncedReviewActions(reviewPayload, canProceed) { + const actions = Array.isArray(reviewPayload?.confirmation_actions) + ? reviewPayload.confirmation_actions.map((item) => ({ ...item })) + : [] + const actionTypes = new Set(actions.map((item) => String(item?.action_type || '').trim())) + const associationPending = actionTypes.has('link_to_existing_draft') || actionTypes.has('create_new_claim_from_documents') + + if (!canProceed || associationPending) { + return actions + } + + return [ + ...actions.filter((item) => !['save_draft', 'next_step'].includes(String(item?.action_type || '').trim())), + { + label: '继续下一步', + action_type: 'next_step', + description: '当前信息已齐全,进入 AI 预审、风险校验和审批路径确认。', + emphasis: 'primary' + } + ] +} + +function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) { + if (!reviewPayload || typeof reviewPayload !== 'object') { + return reviewPayload + } + + const nextSlotCards = (Array.isArray(reviewPayload.slot_cards) ? reviewPayload.slot_cards : []).map((slot) => { + const value = resolveInlineReviewSlotValue(slot.key, inlineState) + const required = Boolean(slot.required) + const filled = Boolean(value) + return { + ...slot, + value: value || slot.value || '', + normalized_value: value || slot.normalized_value || '', + raw_value: value || slot.raw_value || '', + source: filled ? 'user_form' : slot.source, + source_label: filled ? '用户修改' : slot.source_label, + confidence: filled ? Math.max(Number(slot.confidence || 0), 0.98) : Number(slot.confidence || 0), + confirmed: filled || Boolean(slot.confirmed), + status: required && !filled ? 'missing' : filled ? 'identified' : slot.status, + hint: required && !filled ? slot.hint : '' + } + }) + const missingSlots = nextSlotCards + .filter((slot) => slot.required && slot.status === 'missing') + .map((slot) => slot.label || slot.key) + const canProceed = missingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true) + + return { + ...reviewPayload, + can_proceed: canProceed, + missing_slots: missingSlots, + slot_cards: nextSlotCards, + confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed) + } +} + +function buildLocalReviewCompletionMessage(reviewPayload) { + const missingSlots = Array.isArray(reviewPayload?.missing_slots) ? reviewPayload.missing_slots : [] + if (reviewPayload?.can_proceed && !missingSlots.length) { + return '当前所有必填信息已处理完成,可以点击“继续下一步”进入 AI 预审。' + } + if (missingSlots.length) { + return `当前还剩 ${missingSlots.length} 项待补充:${missingSlots.join('、')}。` + } + return '当前信息已保存,可以继续核对右侧状态。' +} + function normalizeInlineReviewComparableState(state) { const source = state && typeof state === 'object' ? state : {} return { @@ -2583,6 +2603,49 @@ function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = []) return lines } +function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = []) { + const base = normalizeInlineReviewComparableState(baseState) + const next = normalizeInlineReviewComparableState(nextState) + const fieldConfigs = [ + { key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' }, + { key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' }, + { key: 'scene_label', label: '场景', format: (value) => value || '待补充' }, + { key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' }, + { key: 'location', label: '业务地点', format: (value) => value || '待补充' }, + { key: 'merchant_name', label: '酒店/商户', format: (value) => value || '待补充' }, + { key: 'participants', label: '同行人员', format: (value) => value || '待补充' }, + { key: 'expense_type', label: '报销分类', format: (value) => value || '待补充' } + ] + + const phrases = fieldConfigs.reduce((result, item) => { + if (base[item.key] !== next[item.key]) { + result.push(`${item.label}修改为 ${item.format(next[item.key])}`) + } + return result + }, []) + + if (base.attachment_names !== next.attachment_names || pendingFiles.length) { + phrases.push(`票据修改为 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`) + } + + return phrases +} + +function buildLocalReviewSavedMessage(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) { + const phrases = buildInlineReviewChangePhrases(baseState, nextState, pendingFiles) + const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) + + if (documentLines.length) { + phrases.push(`${documentLines.length} 张票据识别信息更新为最新修改`) + } + + if (!phrases.length) { + return '右侧核对信息已保存。' + } + + return `已将${phrases.join(',')}。` +} + function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) { const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) if (!lines.length) { @@ -2894,7 +2957,7 @@ export default { const flowRunId = ref('') const flowStartedAt = ref(0) const flowFinishedAt = ref(0) - const flowSteps = ref(createFlowSteps()) + const flowSteps = ref([]) const flowRefreshBusy = ref(false) const flowTick = ref(Date.now()) let flowTickTimer = 0 @@ -3415,7 +3478,7 @@ export default { flowRunId.value = '' flowStartedAt.value = 0 flowFinishedAt.value = 0 - flowSteps.value = createFlowSteps() + flowSteps.value = [] } function adjustComposerTextareaHeight() { @@ -3454,22 +3517,14 @@ export default { } } - function resetFlowRun(options = {}) { + function resetFlowRun() { clearFlowSimulationTimers() flowRunId.value = '' flowStartedAt.value = Date.now() flowFinishedAt.value = 0 reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW insightPanelCollapsed.value = false - const hasText = Boolean(String(options.rawText || '').trim()) - const attachmentCount = Number(options.attachmentCount || 0) - flowSteps.value = createFlowSteps({ - includeIntent: hasText, - includeOcr: attachmentCount > 0, - includeExtraction: hasText || attachmentCount > 0, - includeAgent: true, - includeResult: true - }) + flowSteps.value = [] } function findFlowDefinition(key) { @@ -3511,13 +3566,6 @@ export default { const existingStep = flowSteps.value.find((step) => step.key === key) if (!existingStep) { const nextStep = createFlowStep(key, patch) - const resultIndex = flowSteps.value.findIndex((step) => step.key === 'result') - if (resultIndex !== -1 && key !== 'result') { - const nextSteps = [...flowSteps.value] - nextSteps.splice(resultIndex, 0, nextStep) - flowSteps.value = normalizeFlowStepIndexes(nextSteps) - return - } flowSteps.value = normalizeFlowStepIndexes([...flowSteps.value, nextStep]) return } @@ -3529,11 +3577,15 @@ export default { function startFlowStep(key, patch = {}) { const normalizedPatch = normalizeFlowStepPatch(key, patch) + const explicitStartedAt = Number(normalizedPatch.startedAt) + const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0 + ? explicitStartedAt + : Date.now() upsertFlowStep(key, { ...normalizedPatch, status: FLOW_STEP_STATUS_RUNNING, detail: normalizedPatch.detail, - startedAt: Date.now(), + startedAt, finishedAt: 0, durationMs: null, error: '' @@ -3544,14 +3596,16 @@ export default { const now = Date.now() const definition = findFlowDefinition(key) const currentStep = flowSteps.value.find((step) => step.key === key) - const startedAt = currentStep?.startedAt || now + const explicitDuration = Number(durationMs) + const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0 + const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now) upsertFlowStep(key, { ...patch, status: FLOW_STEP_STATUS_COMPLETED, detail: detail || definition?.completedText || '', startedAt, finishedAt: now, - durationMs: Number.isFinite(Number(durationMs)) ? Number(durationMs) : now - startedAt, + durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt), error: '' }) } @@ -3601,7 +3655,12 @@ export default { function failCurrentFlowStep(error) { clearFlowSimulationTimers() const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING) - failFlowStep(currentStep?.key || 'result', error?.message || '智能体调用失败', error?.message || '') + failFlowStep( + currentStep?.key || 'orchestrator-error', + error?.message || '智能体调用失败', + error?.message || '', + currentStep ? {} : { title: '流程调用', tool: 'Orchestrator' } + ) } function startSemanticFlowPreview(rawText, options = {}) { @@ -3646,9 +3705,63 @@ export default { flowSimulationTimers.push(startExtractionTimer) } + function startReviewActionFlowStep(reviewAction) { + if (reviewAction !== 'next_step') { + return + } + + startFlowStep('pre-submit-review', { + title: 'AI预审与风险识别', + tool: 'ExpenseClaimService.submit_claim', + detail: '正在校验财务规则、风险规则和审批路径...' + }) + } + + function startExpenseClaimDraftFlowStep(reviewAction, options = {}) { + if (isKnowledgeSession.value) { + return + } + if (reviewAction === 'next_step') { + startReviewActionFlowStep(reviewAction) + return + } + + const attachmentCount = Math.max(0, Number(options.attachmentCount || 0)) + const configs = { + save_draft: { + title: '报销草稿保存', + detail: '正在保存当前核对结果...' + }, + link_to_existing_draft: { + title: '票据关联草稿', + detail: '正在把本次票据关联到现有草稿...' + }, + create_new_claim_from_documents: { + title: '新建报销草稿', + detail: '正在根据当前票据新建报销草稿...' + } + } + const config = configs[reviewAction] || { + title: '报销草稿处理', + detail: attachmentCount + ? '正在根据 OCR 结果更新草稿和右侧核对信息...' + : '正在更新草稿和右侧核对信息...' + } + + startFlowStep('expense-claim-draft', { + title: config.title, + tool: 'database.expense_claims.save_or_submit', + detail: config.detail + }) + } + function resolveToolCallFlowMeta(toolCall, index) { const toolType = String(toolCall?.tool_type || '').toLowerCase() const toolName = String(toolCall?.tool_name || '').toLowerCase() + const response = toolCall?.response_json && typeof toolCall.response_json === 'object' + ? toolCall.response_json + : {} + const responseMessage = String(response.message || '').trim() const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}` if (toolType.includes('rule')) { return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' } @@ -3660,7 +3773,15 @@ export default { return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' } } if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) { - return { key, title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' } + if ( + response.submission_blocked || + String(response.status || '').trim() === 'submitted' || + responseMessage.includes('AI预审') || + responseMessage.includes('审批') + ) { + return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' } + } + return { key: 'expense-claim-draft', title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' } } if (toolType.includes('database')) { return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' } @@ -3675,6 +3796,12 @@ export default { const response = toolCall?.response_json && typeof toolCall.response_json === 'object' ? toolCall.response_json : {} + if (String(response.status || '').trim() === 'submitted') { + return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}` + } + if (response.submission_blocked) { + return String(response.message || '').trim() || 'AI预审发现待补充项,暂未提交审批' + } return ( String(response.message || response.summary || response.result_summary || '').trim() || String(toolCall?.tool_name || '').trim() @@ -3687,22 +3814,17 @@ export default { if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) { clearFlowSimulationTimers() const semanticDurations = resolveSemanticPhaseDurations(run) + const intentStep = flowSteps.value.find((step) => step.key === 'intent') + const extractionStep = flowSteps.value.find((step) => step.key === 'extraction') completePendingFlowStep( 'intent', summarizeSemanticIntentDetail(run.semantic_parse), - semanticDurations.intentMs + intentStep?.startedAt ? null : semanticDurations.intentMs ) completePendingFlowStep( 'extraction', summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}), - semanticDurations.extractionMs - ) - } - - if (flowSteps.value.some((step) => step.key === 'agent')) { - completePendingFlowStep( - 'agent', - toolCalls.length ? `已完成 ${toolCalls.length} 个工具调用` : FLOW_STEP_FALLBACKS.agent.completedText + extractionStep?.startedAt ? null : semanticDurations.extractionMs ) } @@ -3734,12 +3856,10 @@ export default { return } flowSteps.value - .filter((step) => step.key !== 'result' && ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)) + .filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)) .forEach((step) => { completeFlowStep(step.key, resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })) }) - startFlowStep('result', '正在返回处理结果...') - completeFlowStep('result', '结果已返回到对话区', resolveResultStepDurationMs(run)) flowFinishedAt.value = Date.now() if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) { reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW @@ -4419,7 +4539,7 @@ export default { } } - async function saveInlineReviewChanges() { + function saveInlineReviewChanges() { if (!activeReviewPayload.value || !reviewHasUnsavedChanges.value || reviewActionBusy.value) return if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { @@ -4429,28 +4549,36 @@ export default { reviewActionBusy.value = true try { const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value) - const documentCorrectionMessage = buildReviewDocumentCorrectionMessage( + const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, reviewInlineForm.value) + const messageText = `${buildLocalReviewSavedMessage( + reviewInlineBaseForm.value, + reviewInlineForm.value, + reviewInlinePendingFiles.value, reviewDocumentBaseDrafts.value, reviewDocumentDrafts.value - ) - await submitComposer({ - rawText: [buildReviewCorrectionMessage(fields), documentCorrectionMessage].filter(Boolean).join('\n'), - userText: buildReviewSubmitUserText( - reviewInlineBaseForm.value, - reviewInlineForm.value, - reviewInlinePendingFiles.value, - reviewDocumentBaseDrafts.value, - reviewDocumentDrafts.value - ), - pendingText: '正在保存修改并刷新右侧核对信息...', - files: reviewInlinePendingFiles.value, - systemGenerated: true, - extraContext: { - review_action: 'edit_review', - review_form_values: buildReviewFormValues(fields), - review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) + )} ${buildLocalReviewCompletionMessage(nextReviewPayload)}` + + reviewInlineBaseFields.value = cloneReviewEditFields(fields) + reviewInlineBaseForm.value = { ...reviewInlineForm.value } + reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(reviewDocumentDrafts.value) + if (latestReviewMessage.value) { + latestReviewMessage.value.reviewPayload = nextReviewPayload + } + if (currentInsight.value?.agent) { + currentInsight.value = { + ...currentInsight.value, + agent: { + ...currentInsight.value.agent, + reviewPayload: nextReviewPayload + } } - }) + } + messages.value.push(createMessage('assistant', messageText, [], { + meta: ['本地修改'], + draftPayload: latestReviewMessage.value?.draftPayload || null, + reviewPayload: nextReviewPayload + })) + nextTick(scrollToBottom) } finally { reviewActionBusy.value = false } @@ -4517,6 +4645,7 @@ export default { const extraContext = options.extraContext && typeof options.extraContext === 'object' ? { ...options.extraContext } : {} + const reviewAction = String(extraContext.review_action || '').trim() const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value) const hasExistingDocumentEvent = Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0 @@ -4527,14 +4656,14 @@ export default { hasExistingDocumentEvent && !resolvedUploadDisposition && !options.skipUploadDecisionPrompt && - !String(extraContext.review_action || '').trim() + !reviewAction ) { uploadDecisionDialogOpen.value = true return null } - resetFlowRun({ rawText, attachmentCount: files.length }) - if (rawText) { + resetFlowRun() + if (rawText && !reviewAction) { startFlowStep('intent', '正在识别业务意图...') startSemanticFlowPreview(rawText, { attachmentCount: files.length }) } @@ -4589,17 +4718,18 @@ export default { let ocrFilePreviews = [] if (files.length) { - startFlowStep('ocr', `正在识别 ${files.length} 份附件...`) + const ocrStartedAt = Date.now() + startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt }) try { ocrPayload = await recognizeOcrFiles(files) ocrSummary = buildOcrSummary(ocrPayload) ocrDocuments = normalizeOcrDocuments(ocrPayload) ocrFilePreviews = buildOcrFilePreviews(ocrPayload) rememberFilePreviews(ocrFilePreviews) - completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`) + completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt) } catch (error) { console.warn('OCR request failed:', error) - completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称') + completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt) } } @@ -4619,16 +4749,9 @@ export default { extraContext.review_action = 'create_new_claim_from_documents' } - const runningExtractionStep = flowSteps.value.find( - (step) => step.key === 'extraction' && step.status === FLOW_STEP_STATUS_RUNNING - ) - if (runningExtractionStep) { - completeFlowStep( - 'extraction', - runningExtractionStep.detail || FLOW_STEP_FALLBACKS.extraction.completedText - ) - } - startFlowStep('agent', FLOW_STEP_FALLBACKS.agent.runningText) + startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), { + attachmentCount: effectiveFileNames.length + }) const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary) const payload = await runOrchestrator( @@ -4642,6 +4765,8 @@ export default { is_admin: Boolean(user.isAdmin), name: user.name || '', role: user.role || '', + department: user.department || user.departmentName || '', + department_name: user.department || user.departmentName || '', position: user.position || '', grade: user.grade || '', ...buildClientTimeContext(), @@ -4749,7 +4874,10 @@ export default { } function openEditReviewDialog(message) { - reviewEditFields.value = cloneReviewEditFields(message?.reviewPayload?.edit_fields) + const sourceFields = reviewInlineBaseFields.value.length + ? mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value) + : cloneReviewEditFields(message?.reviewPayload?.edit_fields) + reviewEditFields.value = cloneReviewEditFields(sourceFields) reviewActionMessageId.value = String(message?.id || '') reviewEditDialogOpen.value = true } @@ -4761,22 +4889,46 @@ export default { reviewActionMessageId.value = '' } - async function applyEditedReview() { + function applyEditedReview() { if (reviewActionBusy.value) return reviewActionBusy.value = true try { const fields = cloneReviewEditFields(reviewEditFields.value) - await submitComposer({ - rawText: buildReviewCorrectionMessage(fields), - userText: '我已修改识别信息,请按最新内容更新。', - pendingText: '正在根据修改内容重新识别...', - systemGenerated: true, - extraContext: { - review_action: 'edit_review', - review_form_values: buildReviewFormValues(fields) - } + const nextInlineState = buildInlineReviewState({ + ...(activeReviewPayload.value || {}), + edit_fields: fields }) + const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState) + const messageText = `${buildLocalReviewSavedMessage( + reviewInlineForm.value, + nextInlineState, + [], + reviewDocumentBaseDrafts.value, + reviewDocumentDrafts.value + )} ${buildLocalReviewCompletionMessage(nextReviewPayload)}` + + reviewInlineForm.value = { ...nextInlineState } + reviewInlineBaseForm.value = { ...nextInlineState } + reviewInlineBaseFields.value = cloneReviewEditFields(fields) + if (latestReviewMessage.value) { + latestReviewMessage.value.reviewPayload = nextReviewPayload + } + if (currentInsight.value?.agent) { + currentInsight.value = { + ...currentInsight.value, + agent: { + ...currentInsight.value.agent, + reviewPayload: nextReviewPayload + } + } + } + messages.value.push(createMessage('assistant', messageText, [], { + meta: ['本地修改'], + draftPayload: latestReviewMessage.value?.draftPayload || null, + reviewPayload: nextReviewPayload + })) + nextTick(scrollToBottom) } finally { reviewActionBusy.value = false } diff --git a/web/src/views/scripts/TravelRequestDetailView.js b/web/src/views/scripts/TravelRequestDetailView.js index 0bce47d..a89d08c 100644 --- a/web/src/views/scripts/TravelRequestDetailView.js +++ b/web/src/views/scripts/TravelRequestDetailView.js @@ -44,9 +44,6 @@ const DOCUMENT_TYPE_LABELS = { const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ 'travel', - 'hotel', - 'transport', - 'meal', 'meeting', 'entertainment' ]) @@ -100,7 +97,7 @@ function buildFallbackProgressSteps() { return [ { index: 1, label: '保存草稿', time: '已完成', done: true, active: true }, { index: 2, label: '待提交', time: '进行中', active: true, current: true }, - { index: 3, label: 'AI验审', time: '待处理' }, + { index: 3, label: 'AI预审', time: '待处理' }, { index: 4, label: '直属领导审批', time: '待处理' }, { index: 5, label: '财务审批', time: '待处理' }, { index: 6, label: '归档入账', time: '待处理' } @@ -281,9 +278,6 @@ function buildDraftBlockingIssues(request, expenseItems) { if (isPlaceholderValue(request.profileName)) { issues.push('申请人未完善') } - if (isPlaceholderValue(request.profileDepartment)) { - issues.push('所属部门未完善') - } if (isPlaceholderValue(request.typeLabel)) { issues.push('报销类型未完善') } @@ -1097,9 +1091,9 @@ export default { const claimStatus = String(payload?.status || '').trim().toLowerCase() const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim() if (claimStatus === 'submitted') { - toast(`${request.value.id} 已完成 AI验审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`) + toast(`${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`) } else if (claimStatus === 'supplement') { - toast(`${request.value.id} AI验审未通过,已转待补充。`) + toast(`${request.value.id} AI预审未通过,已转待补充。`) } else { toast(`${request.value.id} 提交结果已更新。`) }