feat(server): 新增申请核对预览快速建单接口与平台管理员判定统一
- reimbursements 新增 POST /application-preview-action,AI 工作台表格核对后直接走 UserAgentService 建单/提交,免去通用 Orchestrator 编排 - 平台管理员判定统一抽取 PLATFORM_ADMIN_IDENTITIES 常量,identity 与 role_codes 均支持 admin/superadmin,含 header 开关 - docker-compose 镜像补装 openssh-server - 同步更新差旅/交通/通信等财务规则表与 reimbursements 端点测试
This commit is contained in:
@@ -32,7 +32,7 @@ services:
|
|||||||
- >
|
- >
|
||||||
apt-get update &&
|
apt-get update &&
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
|
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 &&
|
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'
|
printf '%s\n'
|
||||||
'<?xml version="1.0"?>'
|
'<?xml version="1.0"?>'
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,10 @@ from sqlalchemy.orm import Session
|
|||||||
from app.db.session import get_session_factory
|
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]:
|
def get_db() -> Generator[Session, None, None]:
|
||||||
db = get_session_factory()()
|
db = get_session_factory()()
|
||||||
try:
|
try:
|
||||||
@@ -124,14 +128,15 @@ def _resolve_platform_admin_flag(
|
|||||||
role_codes: list[str],
|
role_codes: list[str],
|
||||||
header_value: str | None,
|
header_value: str | None,
|
||||||
) -> bool:
|
) -> 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
|
return True
|
||||||
|
|
||||||
identities = {
|
identities = {
|
||||||
str(username or "").strip().lower(),
|
str(username or "").strip().lower(),
|
||||||
str(name 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(
|
def require_admin_user(
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
|
|||||||
from app.schemas.budget import BudgetClaimAnalysisRead
|
from app.schemas.budget import BudgetClaimAnalysisRead
|
||||||
from app.schemas.common import ErrorResponse, PaginatedResponse
|
from app.schemas.common import ErrorResponse, PaginatedResponse
|
||||||
from app.schemas.reimbursement import (
|
from app.schemas.reimbursement import (
|
||||||
|
ExpenseApplicationPreviewActionPayload,
|
||||||
|
ExpenseApplicationPreviewActionResponse,
|
||||||
|
ExpenseApplicationPreviewActionResult,
|
||||||
ExpenseClaimAttachmentActionResponse,
|
ExpenseClaimAttachmentActionResponse,
|
||||||
ExpenseClaimActionResponse,
|
ExpenseClaimActionResponse,
|
||||||
ExpenseClaimAttachmentRead,
|
ExpenseClaimAttachmentRead,
|
||||||
@@ -27,10 +30,13 @@ from app.schemas.reimbursement import (
|
|||||||
TravelReimbursementCalculatorRequest,
|
TravelReimbursementCalculatorRequest,
|
||||||
TravelReimbursementCalculatorResponse,
|
TravelReimbursementCalculatorResponse,
|
||||||
)
|
)
|
||||||
|
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||||
|
from app.schemas.user_agent import UserAgentRequest
|
||||||
from app.services.budget import BudgetService
|
from app.services.budget import BudgetService
|
||||||
from app.services.expense_claims import ExpenseClaimService
|
from app.services.expense_claims import ExpenseClaimService
|
||||||
from app.services.reimbursement import ReimbursementService
|
from app.services.reimbursement import ReimbursementService
|
||||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||||
|
from app.services.user_agent import UserAgentService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
DbSession = Annotated[Session, Depends(get_db)]
|
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
|
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(
|
@router.get(
|
||||||
"/claims",
|
"/claims",
|
||||||
response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
|
response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],
|
||||||
|
|||||||
@@ -185,6 +185,29 @@ class ExpenseClaimActionResponse(BaseModel):
|
|||||||
status: str | None = None
|
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):
|
class ExpenseClaimReturnPayload(BaseModel):
|
||||||
reason: str | None = Field(default=None, max_length=500)
|
reason: str | None = Field(default=None, max_length=500)
|
||||||
reason_codes: list[str] = Field(default_factory=list, max_length=10)
|
reason_codes: list[str] = Field(default_factory=list, max_length=10)
|
||||||
|
|||||||
@@ -765,7 +765,7 @@ def test_claim_item_delete_removes_item_and_attachment(monkeypatch, tmp_path) ->
|
|||||||
assert deleted_meta_response.status_code == 404
|
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)
|
monkeypatch.setattr(ExpenseClaimAttachmentStorage, "root", lambda self: tmp_path)
|
||||||
|
|
||||||
client, session_factory = build_client()
|
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(
|
response = client.delete(
|
||||||
f"/api/v1/reimbursements/claims/{claim_id}",
|
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
|
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(ExpenseClaim, claim_id) is None
|
||||||
assert db.get(RiskObservation, "risk-observation-delete-1") is None
|
assert db.get(RiskObservation, "risk-observation-delete-1") is None
|
||||||
assert db.get(RiskObservationFeedback, "risk-observation-feedback-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 == "张三"
|
||||||
|
|||||||
Reference in New Issue
Block a user