From 729d833edb2b3c695e6ba6214b6f69a5ef653929 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Sat, 20 Jun 2026 14:41:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E6=96=B0=E5=A2=9E=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E6=A0=B8=E5=AF=B9=E9=A2=84=E8=A7=88=E5=BF=AB=E9=80=9F?= =?UTF-8?q?=E5=BB=BA=E5=8D=95=E6=8E=A5=E5=8F=A3=E4=B8=8E=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E5=88=A4=E5=AE=9A=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reimbursements 新增 POST /application-preview-action,AI 工作台表格核对后直接走 UserAgentService 建单/提交,免去通用 Orchestrator 编排 - 平台管理员判定统一抽取 PLATFORM_ADMIN_IDENTITIES 常量,identity 与 role_codes 均支持 admin/superadmin,含 header 开关 - docker-compose 镜像补装 openssh-server - 同步更新差旅/交通/通信等财务规则表与 reimbursements 端点测试 --- docker-compose.yml | 2 +- .../rules/finance-rules/交通工具等级标准.xlsx | Bin 6070 -> 6071 bytes .../rules/finance-rules/交通费用预估表.xlsx | Bin 7195 -> 7196 bytes .../finance-rules/公司通信费报销规则.xlsx | Bin 5933 -> 5933 bytes server/rules/finance-rules/出差补助标准.xlsx | Bin 5929 -> 5930 bytes .../rules/finance-rules/地区淡旺季映射表.xlsx | Bin 11425 -> 11426 bytes .../rules/finance-rules/差旅职级映射表.xlsx | Bin 5781 -> 5782 bytes server/src/app/api/deps.py | 9 +- .../app/api/v1/endpoints/reimbursements.py | 90 +++++++++++++++++ server/src/app/schemas/reimbursement.py | 23 +++++ server/tests/test_reimbursement_endpoints.py | 91 +++++++++++++++++- 11 files changed, 210 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ae513f5..70e7a52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: - > apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends - python3 python3-pip python3-venv fontconfig && + python3 python3-pip python3-venv fontconfig openssh-server && if ! fc-match 'Noto Sans CJK SC' | grep -qi 'Noto'; then if ! timeout "${CJK_FONT_INSTALL_TIMEOUT_SECONDS:-45}" sh -lc 'DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk fonts-noto-cjk-extra'; then printf '%s\n' '[WARN] CJK font installation timed out or failed; continuing startup without blocking the app.'; fi; fi && printf '%s\n' '' diff --git a/server/rules/finance-rules/交通工具等级标准.xlsx b/server/rules/finance-rules/交通工具等级标准.xlsx index d2ecf62f888a847bdef8120cc60cb9228aba3b04..e65800cf03d3341faa7cf70a3c162e9ddafd490c 100644 GIT binary patch delta 535 zcmdm{zg?dvz?+#xgn@y9gTdT%BF{libJHs^UauV^UroFwU+;I=fTt}!_0-kQnzeYtKldz19>AOHW{3^QSCja{<*25VPLa1`&p%*lr~ zh^jwZz#ug5#O*2lF4k@BdR3<%8_Ow1)NFTe3OdDQv$1rVuFoMI9k;?jtC%SdmCZIE zPz-RM7v&f5g!N9uvKRH2=kpeCn&7-jeAWBkd*aXE*%8RNzMud6=TDJ$)wbzQGPt$y zkjJ#$8Oyp4Rc4sfXumrBWB(mWo`bmC1X~$FTwCGCAg+>#513mZYJ&*I$y-IuKx*EK$}rxU%q6A-;^>LV zFv?E$7E_l81@{y82rW)#28Kpn1_mI4g9gUF$rHt7KpNHqH5{6JMobFCc_Aiazgm!i zp(01WJijPADL+43uOc@mz?+dtgc%l>S7MA`Ki;(#XxMK?1_mj(p$rTSj7GwfeT8Kv Y$BXlTjLQ|5VHBU-C9cMnEDBNv0Qkwk{Qv*} delta 533 zcmdn4zfGSfz?+#xgn@y9gW)sNM4p43pP8=2OyOGg?B&F3^7URv3O3$fWkr@+PYm+i?zigr z-r1xaIECS+%*EF+el9Z()z06u?Bz^8fvuI-*;x)&GRt{J`%0xH3Wh6NYp;!*`cb*G z`+(E|K3ld^0aHa|A`d;O|2&`9d())kGHp{HXUi+x=D8sUUq3$lc~i|pCpB4~O<|GB zzD~ZiSwM%exDqonW{$D|8w0}}_03-xjoFw!Gi}!7$Y%x7 zvv`e|f%N2a0?$F*?Sie0Ag-P8V-Qza#0ShR6tzJF8`-rLAf`a>rdxRDzGXp~-F9QP*!9fFKFOXZ2qhDN7nUh+qSCN|&;LXS+!VHVA z$s2&?9GrYsObTS^OCV>}WHxa*1(2b}uOIJP3pDCCBLjmJ+(-t721Y~S$$r8zlM}>w VK)Uk8Wf;XKcZ;jBC5eKR0RZ^F$;SWy diff --git a/server/rules/finance-rules/交通费用预估表.xlsx b/server/rules/finance-rules/交通费用预估表.xlsx index 49756425ca48b2e74fb06b9c652dbf2125aec36d..701ba4b99b4847c754a157510bad4a636110ce85 100644 GIT binary patch delta 582 zcmbPjF~@=@z?+#xgn@y9gTdT%BF{lVbJHs^UauV^UjfBVGcqs;PBxGdsb4zrpxN(GGa7q&Dh<;KL?Rwd*GjR9z<+{=AP147I{Qq+^%!I8qcFFP^ ztX(m|QM~&yCm-4%s{U*NgV4Max2N>GShuz7Rh@opET7*0#U+(Fsl|E~xj6yej7%cTu;{!Jv)%o5*8!kDF(w8EZe(*9H76TNOM%Ssm6l-? snw%vq2jX-AInyUEm(~QkVs@KTi!?9^TxUjiY0c#K(rRocBtb?30OhU6WB>pF delta 604 zcmbPZG24PCz?+#xgn@y9gW)sNM4p3!pP8=2OyOGg>?Kg_G$R9p;ABH7k@}?*4|*Lj z5NL}}J-K(TNvo3L?e0gOjs=b%#U|ann=rX8Wy#&`+t==nbG01nj->Tz#XOnW^6o#8J7hlKtxy(3JJAcozmoxbUwpLzeXE|8OEaw^RE0vZg z7_Mxsy*6^{N9EG)15yY0Y}rl)Ocjl(k396`^L$?KO_P$#v`u-OEw6B!=Y|}7{rK?b zO*Idl)MR-!g+(g+I{DUSJ@0;$`9`Ehd)4PR>zQtyHF3P8`Dj|~r0KuD<&~e0I_Mhw zVVOY9@tq=D*W}+zyWanA^@Z=(-rlwE5qDhu;QIcp^O3{jO3cidIm!ZT44XePnz1o` zX4R zIqD1y!oX05fdCwOFqrHz&ZGkx7IZ7N1vQw!6RXIsnus#>BwDjcg91hA0C=d{JsnvA!ON zMAbOiPg;giaB{Y^9LT0_AZOa-71Ekur_FA2YLNyejO)zk&aa;QL0XONxFpCP0KJUW AjQ{`u diff --git a/server/rules/finance-rules/公司通信费报销规则.xlsx b/server/rules/finance-rules/公司通信费报销规则.xlsx index 386437e23f196e6e37b53f9187caf4337c10bfff..769b594fc13990026c7b3643c813b5b9ed5abf10 100644 GIT binary patch delta 408 zcmZ3hw^olQz?+#xgn@y9gTdT%BF{k%bJHs^UauV^Ctg*k_d9IB(-xn4YH#T+Ut5<* zJ?A+NPDw%?(N8L(T`#+J2JYUzTsNA%N&5JY|9@_VnXt9SE?ItqwJRn#ig#b;$Y~is?(2+izFM@#pXC2xMH}&wu{&r^vf%+jJ)x+*)|Z zW7_VFW!;A=GfZlAT?kBzc2LN4H4DKA(Ic z@vxtZBZEq<)AFF1obwMm%gQUdgG=%R4&_m zAmad^Y?W8QRM9&ThhEfQn$KIjX+rWQZc`p-%QfkG`5^~i=l!!4TSj+h&WdLrft0C54B9smFU diff --git a/server/rules/finance-rules/出差补助标准.xlsx b/server/rules/finance-rules/出差补助标准.xlsx index 50c1b4c27f1a201a65e3f6dc1416caaeb2f0cf55..fbf9bec855ed63df873a62c8a4f27620b99561eb 100644 GIT binary patch delta 541 zcmZ3fw@QyEz?+#xgn@y9gTdT%BF{libJHs^UauV^UroFwU+;I=fTt}!_0-kQnzeYtKldz19>AOHW{3^QSCja{<*25VPLa1`&p%*lr~ zh^jwZz#ug5#O*2lF4k@BdR3<%8_Ow1)NFTe3OdDQv$1rVuFoMI9k;?jtC%SdmCZIE zPz-RM7v&f5g!N9uvKRH2=kpeCn&7-jeAWBkd*aXE*%8RNzMud6=TDJ$)wbzQGPt$y zkjJ#$8Oyp4Rc4sfXumrBWB(LK&p}*c!D>bjcZ1Ml5O=Du519K{#0C+JlQl%mKxz_1Wf<>Ft`$`RaTWqO zvXl3Tsz-r>`-yvm7AG?ULnAK(0}#PM10y$(Talw*TvC~nTC7)*n-k#8$RxrHi?1s& z+udJx9RO+&V`5<7hHGSCXkfITY%eASvMX9lhEZd3g_sO3$fWkr@+PYm+i?zigr z-r1xaIECS+%*EF+el9Z()z06u?Bz^8fvuI-*;x)&GRt{J`%0xH3Wh6NYp;!*`cb*G z`+(E|K3ld^0aHa|A`d;O|2&`9d())kGHp{HXUi+x=D8sUUq3$lc~i|pCpB4~O<|GB zzD~ZiSwM%exDqonW{$D|8w0}}_03-xjoFw!Gi}!7c*6ps z#d(dGf%If=f#)EuiC{G&h`UkfF^D@&*aysgB4UFG#>twZW*{|*qB4xPC)bH8$%8_8 z$(v2=9sKkN}203^X*#Ox`c54$|;KRK|vjmw}-oN58nFGAFfIuOc@mz?+dt zgc%lJS7NrizwSB!)GWrtz`zYN1V}e9+DlLDC&BPPSBKDkm%4#b%$Cd0HuX!1cJ X89r2_CO-r!>zw>gOpWa-$an?-h8oN) diff --git a/server/rules/finance-rules/地区淡旺季映射表.xlsx b/server/rules/finance-rules/地区淡旺季映射表.xlsx index b1645a0cf2bbdf752a6fe7e1721dcf37f60d4312..f48c015d190287578ad9acba5981fae139530591 100644 GIT binary patch delta 503 zcmZ1&xhRq+z?+#xgn@y9gTdT%BF{libJHs^UauV^UroFwU+;I=fTt}!_0-kQnzeYtKldz19>AOHW{3^QSCja{<*25VPLa1`&p%*lr~ zh^jwZz#ug5#O*2lF4k@BdR3<%8_Ow1)NFTe3OdDQv$1rVuFoMI9k;?jtC%SdmCZIE zPz-RM7v&f5g!N9uvKRH2=kpeCn&7-jeAWBkd*aXE*%8RNzMud6=TDJ$)wbzQGPt$y zkjJ#$8Oyp4Rc4sfXumrBW9%WEk&E{;H(};z(-C zFv?Cg(^dy@;(?s1$u-(CAkHjp8LM@w3=Hu_sX4{^dLS~un~_O`85T!ZVwNRno{eN= uV6bLlVBiPF91JutPM`ciTMlH3fDXufT^&sjCj!V}np~=*#^$F5QVRg*thtW> delta 503 zcmZ1!xiFF^z?+#xgn@y9gW)sNM4p43pP8=2OyOGg?B&F3^7URv3O3$fWkr@+PYm+i?zigr z-r1xaIECS+%*EF+el9Z()z06u?Bz^8fvuI-*;x)&GRt{J`%0xH3Wh6NYp;!*`cb*G z`+(E|K3ld^0aHa|A`d;O|2&`9d())kGHp{HXUi+x=D8sUUq3$lc~i|pCpB4~O<|GB zzD~ZiSwM%exDqonW{$D|8w0}}_03-xjoFw!Gi}!7h?WA; zZAwPWKzi~n)#o7YVzm}V5LZ*z7*XxSiwaq>bfGmx5_S~85cCx6pY0&%3Y zWf)~9n`^6sI0-;b<>Xpz84zc-wv5$URR)ImqSTyXeLWBv;LXS+!VHU}D>2IwG|xsd vGB8*(F);80V-5xy7^hACs4WLFMNkK1zMhUIh!Y9qFitMhQDgJf0;vT6^f<$^ diff --git a/server/rules/finance-rules/差旅职级映射表.xlsx b/server/rules/finance-rules/差旅职级映射表.xlsx index 43d0326b6ff92e6156087e6083eaa00ec522a36d..b3a737e964bc037444845c3f2f7b64cce65bf733 100644 GIT binary patch delta 558 zcmbQLJ584-z?+#xgn@y9gTdT%BF{libJHs^UauV^UroFwU+;I=fTt}!_0-kQnzeYtKldz19>AOHW{3^QSCja{<*25VPLa1`&p%*lr~ zh^jwZz#ug5#O*2lF4k@BdR3<%8_Ow1)NFTe3OdDQv$1rVuFoMI9k;?jtC%SdmCZIE zPz-RM7v&f5g!N9uvKRH2=kpeCn&7-jeAWBkd*aXE*%8RNzMud6=TDJ$)wbzQGPt$y zkjJ#$8Oyp4Rc4sfXumrBW=N61@{y82rW)#28Kpn1_mI4g9gUJ$pxY^APs#$4NE7l7L|$tX}Dz4P!h(- zz+l0|z`zez&cM*X*eAfiP?4iwo?n!ml%JoiSCN|&;LXS+!VHVzD>25eAMaWV)btx@ jfE0>m!O4nZnjqVJ#AKMh2~8Fh7M)x#Ccx$d(#!w=CR4%~ delta 535 zcmbQHJ5`q_z?+#xgn@y9gW)sNM4p43pP8=2OyOGg?B&F3^7URv3O3$fWkr@+PYm+i?zigr z-r1xaIECS+%*EF+el9Z()z06u?Bz^8fvuI-*;x)&GRt{J`%0xH3Wh6NYp;!*`cb*G z`+(E|K3ld^0aHa|A`d;O|2&`9d())kGHp{HXUi+x=D8sUUq3$lc~i|pCpB4~O<|GB zzD~ZiSwM%exDqonW{$D|8w0}}_03-xjoFw!Gi}!72x0-z zwLC`5Kzi~<{^ua>EP+Nw5LZ^{F^J0~>;vWoh}a;4aq?6VGmx6IA~KA(C%+O=0&)06 zWf)~9Ym2JOgM#~sdxRDzGXp~-F9QP*!9fFK!Q?_w8IXp4poS%r*N94iI44A9?0W?m z7%Fn~%kzt}lk)Sk^(u060=yZSM3`Z5c_qg9_2XS@frkBNWMGhj8_K}Yz$hRzSyxDA Yvb`7&$T(jy8OE=Zv&7Wc96_2H0I|BsfB*mh diff --git a/server/src/app/api/deps.py b/server/src/app/api/deps.py index 40976b3..af1cb1c 100644 --- a/server/src/app/api/deps.py +++ b/server/src/app/api/deps.py @@ -8,6 +8,10 @@ from sqlalchemy.orm import Session from app.db.session import get_session_factory +PLATFORM_ADMIN_IDENTITIES = {"admin", "superadmin"} +ADMIN_HEADER_TRUE_VALUES = {"1", "true", "yes", "on"} + + def get_db() -> Generator[Session, None, None]: db = get_session_factory()() try: @@ -124,14 +128,15 @@ def _resolve_platform_admin_flag( role_codes: list[str], header_value: str | None, ) -> bool: - if str(header_value or "").strip().lower() in {"1", "true", "yes", "on"}: + if str(header_value or "").strip().lower() in ADMIN_HEADER_TRUE_VALUES: return True identities = { str(username or "").strip().lower(), str(name or "").strip().lower(), } - return "admin" in identities or "admin" in {_normalize_role_code(item) for item in role_codes} + normalized_role_codes = {_normalize_role_code(item) for item in role_codes} + return bool(identities & PLATFORM_ADMIN_IDENTITIES) or bool(normalized_role_codes & PLATFORM_ADMIN_IDENTITIES) def require_admin_user( diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index 371f098..c6571bb 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -11,6 +11,9 @@ 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.reimbursement import ( + ExpenseApplicationPreviewActionPayload, + ExpenseApplicationPreviewActionResponse, + ExpenseApplicationPreviewActionResult, ExpenseClaimAttachmentActionResponse, ExpenseClaimActionResponse, ExpenseClaimAttachmentRead, @@ -27,10 +30,13 @@ 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.expense_claims import ExpenseClaimService from app.services.reimbursement import ReimbursementService from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService +from app.services.user_agent import UserAgentService router = APIRouter() DbSession = Annotated[Session, Depends(get_db)] @@ -88,6 +94,90 @@ def calculate_travel_reimbursement( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error +def _build_application_preview_action_context( + payload: ExpenseApplicationPreviewActionPayload, + current_user: CurrentUserContext, +) -> dict[str, object]: + context_json = dict(payload.context_json or {}) + context_json.setdefault("session_type", "application") + context_json.setdefault("entry_source", "workbench_ai_inline") + context_json.setdefault("document_type", "expense_application") + context_json.setdefault("application_stage", "expense_application") + context_json.setdefault("role_codes", current_user.role_codes) + context_json.setdefault("is_admin", current_user.is_admin) + context_json.setdefault("username", current_user.username) + context_json.setdefault("name", current_user.name) + context_json.setdefault("department_name", current_user.department_name) + context_json.setdefault("position", current_user.position) + context_json.setdefault("grade", current_user.grade) + context_json.setdefault("employee_no", current_user.employee_no) + context_json.setdefault("manager_name", current_user.manager_name) + return context_json + + +@router.post( + "/application-preview-action", + response_model=ExpenseApplicationPreviewActionResponse, + summary="按申请核对预览快速保存或提交申请单", + description="用于 AI 工作台已完成表格核对后的轻量建单/提交流程,避免重复进入通用 Orchestrator 编排。", +) +def run_application_preview_action( + payload: ExpenseApplicationPreviewActionPayload, + db: DbSession, + current_user: CurrentUser, +) -> ExpenseApplicationPreviewActionResponse: + context_json = _build_application_preview_action_context(payload, current_user) + run_id = f"application-preview-action:{payload.conversation_id or current_user.username}" + request = UserAgentRequest( + run_id=run_id, + user_id=payload.user_id or current_user.username or current_user.name, + message=payload.message, + ontology=OntologyParseResult( + scenario="expense", + intent="operate", + permission=OntologyPermission( + level="approval_required", + allowed=True, + reason="application preview fast action", + ), + confidence=1.0, + run_id=run_id, + ), + context_json=context_json, + tool_payload={}, + selected_capability_codes=[], + degraded=False, + requires_confirmation=False, + ) + try: + user_agent_response = UserAgentService(db)._build_expense_application_response( + request, + risk_flags=[], + ) + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + + return ExpenseApplicationPreviewActionResponse( + status="succeeded", + conversation_id=payload.conversation_id, + result=ExpenseApplicationPreviewActionResult( + message=user_agent_response.answer, + answer=user_agent_response.answer, + suggested_actions=[ + action.model_dump(mode="json") + for action in user_agent_response.suggested_actions + ], + risk_flags=user_agent_response.risk_flags, + requires_confirmation=user_agent_response.requires_confirmation, + draft_payload=( + user_agent_response.draft_payload.model_dump(mode="json") + if user_agent_response.draft_payload is not None + else None + ), + ), + ) + + @router.get( "/claims", response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead], diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index a87889c..fe60739 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -185,6 +185,29 @@ class ExpenseClaimActionResponse(BaseModel): status: str | None = None +class ExpenseApplicationPreviewActionPayload(BaseModel): + source: str = Field(default="user_message", max_length=80) + user_id: str | None = Field(default=None, max_length=120) + conversation_id: str | None = Field(default=None, max_length=120) + message: str = Field(min_length=1, max_length=4000) + context_json: dict[str, Any] = Field(default_factory=dict) + + +class ExpenseApplicationPreviewActionResult(BaseModel): + message: str + answer: str + suggested_actions: list[dict[str, Any]] = Field(default_factory=list) + risk_flags: list[str] = Field(default_factory=list) + requires_confirmation: bool = False + draft_payload: dict[str, Any] | None = None + + +class ExpenseApplicationPreviewActionResponse(BaseModel): + status: str = "succeeded" + conversation_id: str | None = None + result: ExpenseApplicationPreviewActionResult + + class ExpenseClaimReturnPayload(BaseModel): reason: str | None = Field(default=None, max_length=500) reason_codes: list[str] = Field(default_factory=list, max_length=10) diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index 05e3ad2..4683e11 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -765,7 +765,7 @@ def test_claim_item_delete_removes_item_and_attachment(monkeypatch, tmp_path) -> assert deleted_meta_response.status_code == 404 -def test_claim_delete_allows_draft_owner_by_employee_id_without_employee_no_header(monkeypatch, tmp_path) -> None: +def test_claim_delete_allows_admin_and_cleans_risk_observations(monkeypatch, tmp_path) -> None: monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path) client, session_factory = build_client() @@ -800,7 +800,7 @@ def test_claim_delete_allows_draft_owner_by_employee_id_without_employee_no_head response = client.delete( f"/api/v1/reimbursements/claims/{claim_id}", - headers={"x-auth-username": "emp-1", "x-auth-name": "Browser Session User"}, + headers={"x-auth-username": "admin", "x-auth-name": "Admin User"}, ) assert response.status_code == 200 @@ -812,3 +812,90 @@ def test_claim_delete_allows_draft_owner_by_employee_id_without_employee_no_head assert db.get(ExpenseClaim, claim_id) is None assert db.get(RiskObservation, "risk-observation-delete-1") is None assert db.get(RiskObservationFeedback, "risk-observation-feedback-delete-1") 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) + + client, session_factory = build_client() + with session_factory() as db: + claim, _ = seed_claim(db) + claim_id = claim.id + + response = client.delete( + f"/api/v1/reimbursements/claims/{claim_id}", + headers={ + "x-auth-username": "superadmin", + "x-auth-name": "superadmin", + "x-auth-role-codes": "manager", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["claim_id"] == claim_id + assert payload["status"] == "deleted" + + with session_factory() as db: + assert db.get(ExpenseClaim, claim_id) is None + + +def test_application_preview_action_submits_without_orchestrator_run(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-submit", + "message": "差旅费用申请提交审批\n申请类型:差旅费用申请\n申请时间:2026-07-01 至 2026-07-03\n地点:北京\n事由:项目实施\n天数:3天\n出行方式:火车\n申请金额:1000元\n直接提交", + "context_json": { + "session_type": "application", + "entry_source": "workbench_ai_inline", + "document_type": "expense_application", + "application_stage": "expense_application", + "application_preview": { + "fields": { + "applicationType": "差旅费用申请", + "time": "2026-07-01 至 2026-07-03", + "location": "北京", + "reason": "项目实施", + "days": "3天", + "transportMode": "火车", + "amount": "1000元", + "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"] == "submitted" + assert draft_payload["approval_stage"] == "直属领导审批" + 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 == "submitted" + assert claim.employee_name == "张三"