From 81e990ab72e336065c1c58b7465866c3d83ccb7c Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Sat, 20 Jun 2026 21:44:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E7=94=B3=E8=AF=B7=E5=8D=95?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=8D=89=E7=A8=BF=E4=BF=9D=E5=AD=98=E5=B9=B6?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=88=A0=E9=99=A4=E6=9D=83=E9=99=90=E5=8F=A3?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - user_agent_application 新增草稿分支:识别'保存草稿/存草稿/先保存'等意图,复用可编辑记录更新或建草稿,提交前单据重叠仍拦截 - 草稿态返回单号与待提交提示,submit 仅在确认提交分支触发,避免草稿进入审批流 - reimbursements 删除接口文案与判定统一为系统管理员可删、申请人删自有草稿/退回单,申请单判定改用 is_application_claim_no - 更新财务规则表与 reimbursement 端点测试 --- .../rules/finance-rules/交通工具等级标准.xlsx | Bin 6071 -> 6071 bytes .../rules/finance-rules/交通费用预估表.xlsx | Bin 7196 -> 7196 bytes .../finance-rules/公司通信费报销规则.xlsx | Bin 5933 -> 5933 bytes server/rules/finance-rules/出差补助标准.xlsx | Bin 5930 -> 5930 bytes .../rules/finance-rules/地区淡旺季映射表.xlsx | Bin 11426 -> 11426 bytes .../rules/finance-rules/差旅职级映射表.xlsx | Bin 5782 -> 5782 bytes .../app/api/v1/endpoints/reimbursements.py | 22 +++- .../app/services/user_agent_application.py | 105 ++++++++++++--- server/tests/test_reimbursement_endpoints.py | 124 +++++++++++++++++- 9 files changed, 221 insertions(+), 30 deletions(-) diff --git a/server/rules/finance-rules/交通工具等级标准.xlsx b/server/rules/finance-rules/交通工具等级标准.xlsx index e65800cf03d3341faa7cf70a3c162e9ddafd490c..2b094adf28985260d3baea395c27416d8fcc19ac 100644 GIT binary patch delta 393 zcmdn4zg?d@z?+#xgn@y9gCS}n_aTlb(wnmQr|ti#Db=sHw8|}7 zH{ExRgHw`FNA#46=q=BjI|Fyu`$uo%V=?dh`1{AkwI*5|PnGT5B=M(pF=u2Ze@X4TQv^5ze#Hh zYmkj9nX=#n_l}J%^%s}fOFv6dY)=te_5Sys^=Wx`Ry1s1etdf6&oet*E@`IZE}j}7 zsQUAUpLpx%3#K2|-iS%77Y$f{jI-$+t`Ts{ZSq>yanR_lT7*-Ld|j z`t|;QvoBQTetRZ=Z1sZN2iNy+osSd-K=03tnWHSg#=x-o6Qd~`W7K91jsjL7HIvtv znK5eeS%DWo&Njg|Mj*#l_z94sB;pI=6o}fwgKzRyQFEZsTTurfsVC+NBum7sfaH2H T3n2MI%mzrRin{~JTyZY|MbM>O delta 393 zcmdn4zg?d@z?+#xgn@y9gTZ_v_aP2*(lwPv2>cQ&mkQhx57ZHm?;mH%{CuU z3~-(oNK;JiwF)%)Lj;?LjN5y-f{pa1;lPmy=kw&_kXxV7+* z$F$uU%eoI$W|-7yzdHS9J=4ys7LFe+FNDQRp8oY+UcJ7L=4q|Tp<$=?uDP--VD_ur zqQ5WpbJjk8<@}fbacF|cpI_#mIg!Eu=>3^7bCdCz?+#xgn@y9gCS}n_aVV3()$14BTDsAH9u_#k}w1?;ji2nrLx6Rkm}N zH<)yAi&W0@Ndbvx`&=9uR7zd6gYAU!c)r_3c|JClQ}8IA#NIOLvy{%HE0a`w4(aH) zl?7&P)i~__Cap27K{l#n%7PQzJL)&KTwG=^{VYYXJw%kr{&#Q(Xf5_@#&R6 z&+Kryq?wYtcxr&4>dzZ~;;o-An0{D$BPOk0G+_NP&ZcvW*Wb+A6E^$pyT+}m`mcMg zN1iO-BUZk2$NGEf*Zcp?zEG9>?V0?s)eCYTT;IQSK2mr9gJNdP9AyEv%^w)e*%+fX zYj9lWWQ^J*WX#MMHQ7Y;1yD#H>IqAq=>Wyko1+d0g_$P?m+UAv=;y)9j4v@ delta 402 zcmbPZF~@>Cz?+#xgn@y9gTZ_v_aQ-Z(poPOVN#?0>hzoSOgpbyIDWLe5Ee6e`qy`P_4+=Vr?n=B zhMn5G=E|~w*{^bo{=V4HS^NBz^I!hQp$R5`ewlyfL<$dJP|S>(qb$I-`2(Xl8>9JV z4UX%ajOLq!jF}nDC!2`A01C;9wJ-uXi^QJ*Iqed@AkI}uTMk$_PL`4~2MPsAIRMGY eQm#Pqrj!+s6p^+7lD^V5K(b5P9Y|i1_5uJdsG~dp diff --git a/server/rules/finance-rules/公司通信费报销规则.xlsx b/server/rules/finance-rules/公司通信费报销规则.xlsx index 769b594fc13990026c7b3643c813b5b9ed5abf10..3e0d9e55abbaedadb975172ac8fd4d41838aa06e 100644 GIT binary patch delta 408 zcmZ3hw^olQz?+#xgn@y9gMle`BF{k%rrawr&!3jdPrRyL|7vgPEhqlK>8I8F6ci0P zkLVWd(T%$7))}~a`*PiA_9p4$KmPx@8J6`$Xt%@pg`Y)k1jX>K%UtZT->>gX9FvR9 zq|1iVon<`VV+&>-Pq%2eUU7-N$*F@&W~13GQ=dau^p8DRWU@o!uX{#XqvL`$nXNL4 z=Zki1^f`FcUiw*zVtajt_^S86*XXC`-O*^@zWn&~%AZ%ZO)Tx6vf|c)BM!56FId+7 zr!ph!N7x$u&HK4h!XC4nO!t5NWY(T(v){f`3~bd{6%vxESXQa|zD4Bw`^m4}|NFkM zO8)*${@7}Vs~=w9zoL%p`71FqW9BFeurV;qQDb&J^|mbDxXYz(aAewx~HsNs6dFh*~P@0-|1vT7oDw UF|b^`m^FwuN6ZaGJrVN+0OuN~E&u=k delta 408 zcmZ3hw^olQz?+#xgn@y9gTdT%BF{k%bJHs^UauV^Ctg*rKee~?manZ#q@ME}2d5;V zj_4;9(XN->IsvKp)$E`5XDrU++Mfn9h zVZ9Tv?8W8zyv3U)IM=TdU-ka?p7`^3b_6o6@8>`N`BUUwwQahS3~ntvXx9IPS{hYPW zUpfEfe;k@%^5>WNXHI0#Ux}F+Ge=o~je%i~Is=0+Fi1B4V$@}0GB@3<$?=>8L<{j6 zF$3wzZUWCiTz$c2Mi6(4&|?sHrmzo~`&`5Z9*UE-Ma@AwnmQr|ti#Db=sHw8|}7 zH{ExRgHw`FNA#46=q=BjI|Fyu`$uo%V=?dh`1{AkwI*5|PnGT5B=M(pF=u2Ze@X4TQv^5ze#Hh zYmkj9nX=#n_l}J%^%s}fOFv6dY)=te_5Sys^=Wx`Ry1s1etdf6&oet*E@`IZE}j}7 zsQUAUpLpx%3#K2|-iS%77Y$f{jI-$+t`Ts{ZSq>yanR_lT7*-Ld|j z`t|;QvoBQTetRZ=Z1sZN2iNy+osSd-K=03tnWHSg#=x-o6Qd~`W7On@qQaZ4Ic~8q zMr{`3HD(5icnQ1!a*PFQ7=fG(LQjC4slvV>&SMcK=Qe$ Y6_8XCvjCFOVm3f>hL}5$d?4lp0Gnf`ZvX%Q delta 397 zcmZ3bw@QyYz?+#xgn@y9gTZ_v_aP2*(lwPv2>cQ&mkQhx57ZHm?;mH%{CuU z3~-(oNK;JiwF)%)Lj;?LjN5y-f{pa1;lPmy=kw&_kXxV7+* z$F$uU%eoI$W|-7yzdHS9J=4ys7LFe+FNDQRp8oY+UcJ7L=4q|Tp<$=?uDP--VD_ur zqQ5WpbJjk8<@}fbacF|cpI_#mIg!Eu=>3^7bCd_wwFM$_8jwnmQr|ti#Db=sHw8|}7 zH{ExRgHw`FNA#46=q=BjI|Fyu`$uo%V=?dh`1{AkwI*5|PnGT5B=M(pF=u2Ze@X4TQv^5ze#Hh zYmkj9nX=#n_l}J%^%s}fOFv6dY)=te_5Sys^=Wx`Ry1s1etdf6&oet*E@`IZE}j}7 zsQUAUpLpx%3#K2|-iS%77Y$f{jI-$+t`Ts{ZSq>yanR_lT7*-Ld|j z`t|;QvoBQTetRZ=Z1sZN2iNy+osSd-K=03tnWHSg#=x-o6Qd~`W7K91ju)$|2%qP1+{!8dtlwPv2>cQ&mkQhx57ZHm?;mH%{CuU z3~-(oNK;JiwF)%)Lj;?LjN5y-f{pa1;lPmy=kw&_kXxV7+* z$F$uU%eoI$W|-7yzdHS9J=4ys7LFe+FNDQRp8oY+UcJ7L=4q|Tp<$=?uDP--VD_ur zqQ5WpbJjk8<@}fbacF|cpI_#mIg!Eu=>3^7bCd T0FsxrZGfbJjysTy(D4EQkiVhl diff --git a/server/rules/finance-rules/差旅职级映射表.xlsx b/server/rules/finance-rules/差旅职级映射表.xlsx index b3a737e964bc037444845c3f2f7b64cce65bf733..ec76db76a69ab4475e1a9726c07f7de5515b5a77 100644 GIT binary patch delta 397 zcmbQHJ5856z?+#xgn@y9gCS}n_aTlb(wnmQr|ti#Db=sHw8|}7 zH{ExRgHw`FNA#46=q=BjI|Fyu`$uo%V=?dh`1{AkwI*5|PnGT5B=M(pF=u2Ze@X4TQv^5ze#Hh zYmkj9nX=#n_l}J%^%s}fOFv6dY)=te_5Sys^=Wx`Ry1s1etdf6&oet*E@`IZE}j}7 zsQUAUpLpx%3#K2|-iS%77Y$f{jI-$+t`Ts{ZSq>yanR_lT7*-Ld|j z`t|;QvoBQTetRZ=Z1sZN2iNy+osSd-K=03tnWHSg#=x-o6Qd~`W7K91j$jrbRl{S< z%osI!1OE#kXQn_CBakB_^aRLZ6!ryi{6%cx!8dt|h&fQ`jEDn}bWV(vhaQCN6#yqGxv3ZbLk delta 397 zcmbQHJ5856z?+#xgn@y9gTZ_v_aP2*(lwPv2>cQ&mkQhx57ZHm?;mH%{CuU z3~-(oNK;JiwF)%)Lj;?LjN5y-f{pa1;lPmy=kw&_kXxV7+* z$F$uU%eoI$W|-7yzdHS9J=4ys7LFe+FNDQRp8oY+UcJ7L=4q|Tp<$=?uDP--VD_ur zqQ5WpbJjk8<@}fbacF|cpI_#mIg!Eu=>3^7bCd 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 == "张三"