diff --git a/server/rules/finance-rules/交通工具等级标准.xlsx b/server/rules/finance-rules/交通工具等级标准.xlsx index e65800c..2b094ad 100644 Binary files a/server/rules/finance-rules/交通工具等级标准.xlsx and b/server/rules/finance-rules/交通工具等级标准.xlsx differ diff --git a/server/rules/finance-rules/交通费用预估表.xlsx b/server/rules/finance-rules/交通费用预估表.xlsx index 701ba4b..9dfbd18 100644 Binary files a/server/rules/finance-rules/交通费用预估表.xlsx and b/server/rules/finance-rules/交通费用预估表.xlsx differ diff --git a/server/rules/finance-rules/公司通信费报销规则.xlsx b/server/rules/finance-rules/公司通信费报销规则.xlsx index 769b594..3e0d9e5 100644 Binary files a/server/rules/finance-rules/公司通信费报销规则.xlsx and b/server/rules/finance-rules/公司通信费报销规则.xlsx differ diff --git a/server/rules/finance-rules/出差补助标准.xlsx b/server/rules/finance-rules/出差补助标准.xlsx index fbf9bec..485a6cf 100644 Binary files a/server/rules/finance-rules/出差补助标准.xlsx and b/server/rules/finance-rules/出差补助标准.xlsx differ diff --git a/server/rules/finance-rules/地区淡旺季映射表.xlsx b/server/rules/finance-rules/地区淡旺季映射表.xlsx index f48c015..9d989ec 100644 Binary files a/server/rules/finance-rules/地区淡旺季映射表.xlsx and b/server/rules/finance-rules/地区淡旺季映射表.xlsx differ diff --git a/server/rules/finance-rules/差旅职级映射表.xlsx b/server/rules/finance-rules/差旅职级映射表.xlsx index b3a737e..ec76db7 100644 Binary files a/server/rules/finance-rules/差旅职级映射表.xlsx and b/server/rules/finance-rules/差旅职级映射表.xlsx differ diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index c6571bb..968271d 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -10,16 +10,17 @@ from app.api.deps import CurrentUserContext, get_current_user, get_db from app.api.pagination import PageNumber, PageSize, page_payload, wants_page from app.schemas.budget import BudgetClaimAnalysisRead from app.schemas.common import ErrorResponse, PaginatedResponse +from app.schemas.ontology import OntologyParseResult, OntologyPermission from app.schemas.reimbursement import ( ExpenseApplicationPreviewActionPayload, ExpenseApplicationPreviewActionResponse, ExpenseApplicationPreviewActionResult, - ExpenseClaimAttachmentActionResponse, ExpenseClaimActionResponse, - ExpenseClaimAttachmentRead, ExpenseClaimApprovalPayload, - ExpenseClaimItemCreate, + ExpenseClaimAttachmentActionResponse, + ExpenseClaimAttachmentRead, ExpenseClaimItemActionResponse, + ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimRead, ExpenseClaimReturnPayload, @@ -30,9 +31,9 @@ from app.schemas.reimbursement import ( TravelReimbursementCalculatorRequest, TravelReimbursementCalculatorResponse, ) -from app.schemas.ontology import OntologyParseResult, OntologyPermission from app.schemas.user_agent import UserAgentRequest from app.services.budget import BudgetService +from app.services.document_numbering import is_application_claim_no from app.services.expense_claims import ExpenseClaimService from app.services.reimbursement import ReimbursementService from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService @@ -119,7 +120,10 @@ def _build_application_preview_action_context( "/application-preview-action", response_model=ExpenseApplicationPreviewActionResponse, summary="按申请核对预览快速保存或提交申请单", - description="用于 AI 工作台已完成表格核对后的轻量建单/提交流程,避免重复进入通用 Orchestrator 编排。", + description=( + "用于 AI 工作台已完成表格核对后的轻量建单/提交流程," + "避免重复进入通用 Orchestrator 编排。" + ), ) def run_application_preview_action( payload: ExpenseApplicationPreviewActionPayload, @@ -831,7 +835,7 @@ def pay_expense_claim( "/claims/{claim_id}", response_model=ExpenseClaimActionResponse, summary="删除报销单", - description="申请人可删除自己的草稿、待补充或退回单据(含申请单和报销单);高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限。", + description="申请人可删除自己的草稿、待补充或退回单据(含申请单和报销单);系统管理员可删除单据;已归档单据仅系统管理员可删除。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, @@ -855,7 +859,11 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser claim_no = str(claim.claim_no or "").strip() expense_type = str(claim.expense_type or "").strip().lower() - document_label = "申请单" if claim_no.upper().startswith(("AP-", "APP-")) or expense_type.endswith("_application") else "报销单" + document_label = ( + "申请单" + if is_application_claim_no(claim_no) or expense_type.endswith("_application") + else "报销单" + ) return ExpenseClaimActionResponse( message=f"{claim.claim_no} {document_label}已删除。", claim_id=claim.id, diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py index 90735db..f708386 100644 --- a/server/src/app/services/user_agent_application.py +++ b/server/src/app/services/user_agent_application.py @@ -130,6 +130,12 @@ APPLICATION_SUBMIT_KEYWORDS = ( "确认无误提交", "直接提交", ) +APPLICATION_SAVE_DRAFT_KEYWORDS = ( + "保存草稿", + "保存申请草稿", + "存草稿", + "先保存", +) APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "是", "好的", "可以", "没问题"} APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "无", "null", "none"} APPLICATION_DUPLICATE_IGNORED_STATUSES = { @@ -197,29 +203,45 @@ class UserAgentApplicationMixin: facts = self._resolve_expense_application_facts(payload) step = self._resolve_expense_application_step(payload, facts) application_claim = None - if step == "submitted": + if step in {"draft", "submitted"}: editable_claim = self._find_editable_expense_application_record(payload) if editable_claim is not None: - application_claim = self._update_expense_application_record(payload, facts, editable_claim) + application_claim = self._update_expense_application_record( + payload, + facts, + editable_claim, + submit=step == "submitted", + ) facts["application_edit_mode"] = "true" - else: + elif step == "submitted": application_claim = self._find_duplicate_expense_application_record(payload, facts) if application_claim is not None: step = "duplicate" facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip() else: - application_claim = self._create_expense_application_record(payload, facts) - facts["application_no"] = application_claim.claim_no - facts["application_claim_id"] = application_claim.id - facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim) + application_claim = self._create_expense_application_record( + payload, + facts, + submit=True, + ) + else: + application_claim = self._create_expense_application_record( + payload, + facts, + submit=False, + ) + if application_claim is not None: + facts["application_no"] = application_claim.claim_no + facts["application_claim_id"] = application_claim.id + facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim) return UserAgentResponse( answer=self._build_expense_application_answer(payload, facts=facts, step=step), citations=[], suggested_actions=self._build_expense_application_actions(step, facts), query_payload=None, draft_payload=( - self._build_submitted_application_payload(application_claim, facts) - if step == "submitted" + self._build_persisted_application_payload(application_claim, facts) + if step in {"draft", "submitted"} else None ), review_payload=None, @@ -251,6 +273,17 @@ class UserAgentApplicationMixin: ] ) + if step == "draft": + application_no = str(facts.get("application_no") or "").strip() + return "\n\n".join( + [ + "申请草稿已保存。", + f"草稿单号:{application_no}" if application_no else "草稿单号:待生成", + "当前节点:待提交。", + "后续可进入单据详情继续核对、补充或提交审批。", + ] + ) + if step == "submitted": application_no = str(facts.get("application_no") or "").strip() or self._build_application_claim_no(payload, facts) manager_name = str(facts.get("manager_name") or "").strip() or "直属领导" @@ -534,6 +567,8 @@ class UserAgentApplicationMixin: payload: UserAgentRequest, facts: dict[str, str], ) -> str: + if self._is_application_save_draft_action(payload): + return "draft" if self._resolve_application_missing_base_fields(facts): return "ask_missing" if self._resolve_application_missing_followup_fields(facts): @@ -1058,6 +1093,8 @@ class UserAgentApplicationMixin: payload: UserAgentRequest, facts: dict[str, str], claim: ExpenseClaim, + *, + submit: bool, ) -> ExpenseClaim: current_user = self._build_application_current_user(payload) flags = claim.risk_flags_json @@ -1080,6 +1117,14 @@ class UserAgentApplicationMixin: claim.occurred_at = self._parse_application_occurred_at(facts.get("time", "")) claim.risk_flags_json = [*preserved_flags, self._build_application_detail_flag(facts)] + if not submit: + claim.status = "draft" + claim.approval_stage = "待提交" + claim.submitted_at = None + self.db.commit() + self.db.refresh(claim) + return claim + from app.services.expense_claims import ExpenseClaimService submitted = ExpenseClaimService(self.db).submit_claim(claim.id, current_user) @@ -1091,6 +1136,8 @@ class UserAgentApplicationMixin: self, payload: UserAgentRequest, facts: dict[str, str], + *, + submit: bool, ) -> ExpenseClaim: claim_no = self._build_application_claim_no(payload, facts) existing = self.db.scalar( @@ -1130,22 +1177,23 @@ class UserAgentApplicationMixin: currency="CNY", invoice_count=0, occurred_at=self._parse_application_occurred_at(facts.get("time", "")), - submitted_at=datetime.now(UTC), - status="submitted", - approval_stage="直属领导审批", + submitted_at=datetime.now(UTC) if submit else None, + status="submitted" if submit else "draft", + approval_stage="直属领导审批" if submit else "待提交", risk_flags_json=[self._build_application_detail_flag(facts)], ) self.db.add(claim) self.db.flush() - from app.services.expense_claims import ExpenseClaimService + if submit: + from app.services.expense_claims import ExpenseClaimService - platform_review = ExpenseClaimService(self.db).evaluate_platform_risk_rules( - claim, - business_stage="expense_application", - ) - platform_flags = list(platform_review.get("flags") or []) - if platform_flags: - claim.risk_flags_json = [*list(claim.risk_flags_json or []), *platform_flags] + platform_review = ExpenseClaimService(self.db).evaluate_platform_risk_rules( + claim, + business_stage="expense_application", + ) + platform_flags = list(platform_review.get("flags") or []) + if platform_flags: + claim.risk_flags_json = [*list(claim.risk_flags_json or []), *platform_flags] self.db.commit() self.db.refresh(claim) return claim @@ -1382,7 +1430,7 @@ class UserAgentApplicationMixin: return datetime(year, month, day, tzinfo=UTC) return datetime.now(UTC) - def _build_submitted_application_payload( + def _build_persisted_application_payload( self, claim: ExpenseClaim | None, facts: dict[str, str], @@ -1400,6 +1448,21 @@ class UserAgentApplicationMixin: approval_stage=claim.approval_stage, ) + @staticmethod + def _is_application_save_draft_action(payload: UserAgentRequest) -> bool: + context_json = payload.context_json or {} + action = str( + context_json.get("application_action") + or context_json.get("applicationAction") + or "" + ).strip().lower() + if action in {"save_draft", "application_save_draft", "draft"}: + return True + if bool(context_json.get("application_save_mode") or context_json.get("applicationSaveMode")): + return True + compact_message = re.sub(r"\s+", "", str(payload.message or "")) + return any(keyword in compact_message for keyword in APPLICATION_SAVE_DRAFT_KEYWORDS) + def _is_application_submit_confirmation(self, payload: UserAgentRequest) -> bool: compact_message = re.sub(r"\s+", "", str(payload.message or "")) if any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS): diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index 4683e11..64c3bd3 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -20,7 +20,6 @@ from app.models.risk_observation import RiskObservation, RiskObservationFeedback from app.models.role import Role from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage -from app.services.expense_claims import ExpenseClaimService from app.services.ocr import OcrService @@ -814,6 +813,37 @@ def test_claim_delete_allows_admin_and_cleans_risk_observations(monkeypatch, tmp assert db.get(RiskObservationFeedback, "risk-observation-feedback-delete-1") is None +def test_claim_delete_allows_applicant_to_delete_own_draft(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path) + + client, session_factory = build_client() + with session_factory() as db: + claim, _ = seed_claim(db) + claim.claim_no = "AP-20260620-DRAFT" + claim.expense_type = "travel_application" + claim_id = claim.id + db.commit() + + response = client.delete( + f"/api/v1/reimbursements/claims/{claim_id}", + headers={ + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "张三", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["claim_id"] == claim_id + assert payload["status"] == "deleted" + assert "申请单已删除" in payload["message"] + + with session_factory() as db: + assert db.get(ExpenseClaim, claim_id) is None + + def test_claim_delete_allows_legacy_superadmin_without_is_admin_header(monkeypatch, tmp_path) -> None: monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path) @@ -859,7 +889,19 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch "source": "user_message", "user_id": "zhangsan@example.com", "conversation_id": "conversation-fast-submit", - "message": "差旅费用申请提交审批\n申请类型:差旅费用申请\n申请时间:2026-07-01 至 2026-07-03\n地点:北京\n事由:项目实施\n天数:3天\n出行方式:火车\n申请金额:1000元\n直接提交", + "message": "\n".join( + [ + "差旅费用申请提交审批", + "申请类型:差旅费用申请", + "申请时间:2026-07-01 至 2026-07-03", + "地点:北京", + "事由:项目实施", + "天数:3天", + "出行方式:火车", + "申请金额:1000元", + "直接提交", + ] + ), "context_json": { "session_type": "application", "entry_source": "workbench_ai_inline", @@ -899,3 +941,81 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch assert claim is not None assert claim.status == "submitted" assert claim.employee_name == "张三" + + +def test_application_preview_action_saves_draft_with_detail_reference(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path) + + client, session_factory = build_client() + with session_factory() as db: + seed_claim(db) + + response = client.post( + "/api/v1/reimbursements/application-preview-action", + headers={ + "x-auth-username": "zhangsan@example.com", + "x-auth-name": "Zhang San", + "x-auth-employee-no": "E10001", + "x-auth-role-codes": "user", + }, + json={ + "source": "user_message", + "user_id": "zhangsan@example.com", + "conversation_id": "conversation-fast-save", + "message": "\n".join( + [ + "费用申请保存草稿", + "申请类型:差旅费用申请", + "申请时间:2026-07-04 至 2026-07-05", + "地点:上海", + "事由:项目验收", + "天数:2天", + "出行方式:火车", + "申请金额:800元", + "保存草稿", + ] + ), + "context_json": { + "session_type": "application", + "entry_source": "workbench_ai_inline", + "document_type": "expense_application", + "application_stage": "expense_application", + "application_action": "save_draft", + "application_save_mode": True, + "application_preview": { + "fields": { + "applicationType": "差旅费用申请", + "time": "2026-07-04 至 2026-07-05", + "location": "上海", + "reason": "项目验收", + "days": "2天", + "transportMode": "火车", + "amount": "800元", + "applicant": "张三", + "department": "市场部", + "position": "招商主管", + "grade": "P4", + "managerName": "李总", + } + }, + }, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "succeeded" + draft_payload = payload["result"]["draft_payload"] + assert draft_payload["draft_type"] == "expense_application" + assert draft_payload["status"] == "draft" + assert draft_payload["approval_stage"] == "待提交" + assert draft_payload["claim_id"] + assert draft_payload["claim_no"].startswith("AP-") + + with session_factory() as db: + claim = db.get(ExpenseClaim, draft_payload["claim_id"]) + assert claim is not None + assert claim.status == "draft" + assert claim.approval_stage == "待提交" + assert claim.submitted_at is None + assert claim.employee_name == "张三"