Files
shanxia2.0/backend/app/api/bx/reimburse.py
T

893 lines
32 KiB
Python

import io
from datetime import date as date_cls
from typing import Any, List, Optional
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
from pydantic import BaseModel, ConfigDict, Field
from fastapi.responses import StreamingResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.api.bx.export_common import (
EXPORT_MAX_ROWS,
ExportColumn,
ExportValidationError,
BxApplyListExportBody,
dump_export_row,
respond_xlsx,
)
from app.core.bx_auth import BxUserContext, get_bx_current_user
from app.core.database import get_db
from app.core.tenant_context import get_tenant_no
from app.models.bx_models import BxReimburseApply, BxWorkflowDef
from app.schemas.bx.common import ApiResponse, PagedData
from app.schemas.bx.reimburse import (
ReimburseApplyCreate,
ReimburseApplyOut,
ReimburseApplyUpdate,
ReimburseApprovalCreate,
ReimburseApprovalOut,
ReimburseApproverPendingStatsOut,
ReimburseMyStatsOut,
ReimburseSubmitBody,
)
from app.services.bx.expense_item import ExpenseItemService
from app.services.bx.list_sort import order_reimburse, parse_sort_params
from app.services.bx.payment_list_filters import payment_filters_from_params
from app.services.bx.reimburse import ReimburseService
from app.services.bx.reimburse_import import (
build_applicant_label,
build_import_template_bytes,
import_reimburse_workbook,
load_applicant_options,
load_expense_nodes_flat,
)
from app.services.bx.reimburse_list_filters import build_reimburse_column_filters
from app.services.bx_data_scope import apply_bx_scope
from app.utils.bx_codegen import generate_title, parse_optional_date
router = APIRouter(prefix="/reimburses", tags=["报销管理"])
def _tenant(db: Session, user: BxUserContext) -> str:
return get_tenant_no(db, user.to_tenant_user_dict())
def _reimburse_order(params: dict):
sf, so = parse_sort_params(params)
return order_reimburse(sf, so)
REIMBURSE_EXPORT_COLUMNS = [
ExportColumn(title="申请单号", field="applyCode"),
ExportColumn(title="申请时间", field="applyTime"),
ExportColumn(title="申请人", field="applicantName"),
ExportColumn(title="报销主题", field="title"),
ExportColumn(title="费用科目", field="expenseName"),
ExportColumn(title="金额(元)", field="amount"),
ExportColumn(title="状态", field="status"),
ExportColumn(title="当前节点", field="currentNode"),
]
def _merge_query(request: Request, **known) -> dict:
out = {k: v for k, v in known.items() if v is not None and v != ""}
for k, v in request.query_params.multi_items():
if k in ("page", "pageSize"):
continue
if k not in out and v not in (None, ""):
out[k] = v
return out
def _param_get(params: dict, *keys: str) -> Any:
for key in keys:
if key in params and params[key] not in (None, ""):
return params[key]
return None
def _filters_from_params(params: dict, *, db: Session, tenant_no: str = "") -> list:
ids = _param_get(params, "ids")
if isinstance(ids, str):
ids = [s.strip() for s in ids.split(",") if s.strip()]
return build_reimburse_column_filters(
db=db,
tenant_no=tenant_no,
keyword=str(_param_get(params, "keyword") or ""),
status=_param_get(params, "status"),
status_in=_param_get(params, "statusIn", "status_in"),
apply_code=_param_get(params, "applyCode", "apply_code"),
apply_code_in=_param_get(params, "applyCodeIn", "apply_code_in"),
title=_param_get(params, "title"),
title_in=_param_get(params, "titleIn", "title_in"),
expense_name=_param_get(params, "expenseName", "expense_name"),
expense_id=_param_get(params, "expenseId", "expense_id"),
applicant_id=_param_get(params, "applicantId", "applicant_id"),
applicant_name=_param_get(params, "applicantName", "applicant_name"),
applicant_name_in=_param_get(params, "applicantNameIn", "applicant_name_in"),
current_node=_param_get(params, "currentNode", "current_node"),
current_node_in=_param_get(params, "currentNodeIn", "current_node_in"),
amount_op=_param_get(params, "amountOp", "amount_op"),
amount=_param_get(params, "amount"),
amount_min=_param_get(params, "amountMin", "amount_min"),
amount_max=_param_get(params, "amountMax", "amount_max"),
apply_date=_param_get(params, "applyDate", "apply_date"),
apply_date_from=_param_get(params, "applyDateFrom", "apply_date_from"),
apply_date_to=_param_get(params, "applyDateTo", "apply_date_to"),
occur_date_from=_param_get(params, "occurDateFrom", "occur_date_from"),
occur_date_to=_param_get(params, "occurDateTo", "occur_date_to"),
ids=ids if isinstance(ids, list) else None,
)
def _body_to_params(body: BxApplyListExportBody) -> dict:
data = body.model_dump(by_alias=True, exclude_none=True)
extra = getattr(body, "model_extra", None) or {}
for key, value in extra.items():
if key not in data and value not in (None, ""):
data[key] = value
return data
def _reimburse_list_filters(body: BxApplyListExportBody, db: Session, tenant_no: str) -> list:
return _filters_from_params(_body_to_params(body), db=db, tenant_no=tenant_no)
def _export_reimburse_rows(db: Session, tenant_no: str, rows) -> list:
out_rows = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
return [dump_export_row(r) for r in out_rows]
def _build_details_data(
db: Session,
tenant_no: str,
body_details,
*,
user: BxUserContext,
) -> List[dict]:
details_data: List[dict] = []
for d in body_details:
item = ExpenseItemService.get_by_id(
db, d.expense_item_id, tenant_no, user=user, skip_scope=True
)
if not item:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"费用项目 {d.expense_item_id} 不存在",
)
try:
occur_date = parse_optional_date(d.occur_date)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="发生时间格式无效,请使用 YYYY-MM-DD",
)
values = {
"id": d.id,
"expense_id": d.expense_item_id,
"expense_name": item.item_name,
"amount": d.amount,
}
if "remark" in d.model_fields_set:
values["remark"] = d.remark
if "occur_date" in d.model_fields_set:
values["occur_date"] = occur_date
details_data.append(values)
return details_data
@router.get("/generate-title", response_model=ApiResponse[str])
def get_generated_title(
reimburseDate: Optional[str] = None,
excludeApplyId: Optional[str] = None,
applicantId: Optional[str] = None,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
exclude_id = str(excludeApplyId).strip() if excludeApplyId else None
_, applicant_name = ReimburseService.resolve_applicant(
db, user, applicantId, tenant_no=tenant_no
)
title = generate_title(db, applicant_name, tenant_no, reimburseDate, exclude_id)
return ApiResponse(data=title)
@router.get("/import-template")
def download_reimburse_import_template(
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
nodes = load_expense_nodes_flat(db, tenant_no)
applicants = load_applicant_options(db, user=user, tenant_no=tenant_no)
me = next((a for a in applicants if a["id"] == user.id), None)
if me:
cur_label = build_applicant_label(me["nick_name"], me["user_no"], me["id"])
else:
cur_label = build_applicant_label(user.name or "", "", user.id)
applicants = [
{"id": user.id, "nick_name": user.name or "", "user_no": ""}
] + applicants
raw = build_import_template_bytes(
expense_nodes=nodes,
applicants=applicants,
current_applicant_label=cur_label,
today=date_cls.today(),
)
filename = quote("报销申请导入模板.xlsx")
return StreamingResponse(
io.BytesIO(raw),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename={filename}; filename*=UTF-8''{filename}"
},
)
@router.post("/import", response_model=ApiResponse[dict])
def import_reimburses(
file: UploadFile = File(...),
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
content = file.file.read()
if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件为空")
try:
result = import_reimburse_workbook(db, file_bytes=content, user=user)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return ApiResponse(data=result)
class ReimburseColumnFacetBody(BaseModel):
"""报销申请列头可选值(去重;可附带列表上下文)"""
model_config = ConfigDict(populate_by_name=True, extra="allow")
field: str = Field(..., description="applyCode | applicantName | title | currentNode")
scope: Optional[str] = Field(
default=None,
description=(
"列表视图:pending/approved=审批;mine=我的申请;"
"payment_pending/payment_processed=付款;省略则按通用数据权限"
),
)
keyword: Optional[str] = Field(default=None, description="列表快速搜索关键字")
limit: Optional[int] = Field(default=500, ge=1, le=500, description="返回条数上限")
_REIMBURSE_FACET_SCOPES = frozenset(
{"pending", "approved", "mine", "payment_pending", "payment_processed"}
)
_REIMBURSE_FACET_COLUMNS = {
"applyCode": BxReimburseApply.apply_code,
"applicantName": BxReimburseApply.applicant_name,
"title": BxReimburseApply.title,
"currentNode": BxReimburseApply.current_node,
}
@router.post("/column-facet-values", response_model=ApiResponse[dict])
def reimburse_column_facet_values(
body: ReimburseColumnFacetBody,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
"""报销申请列头可选值:在列表上下文(排除本列)下去重;可按列表视图 scope 收窄。"""
from app.services.column_facet import list_column_facet_values
from app.services.pl.facet_context import strip_simple_facet_self_params
column = _REIMBURSE_FACET_COLUMNS.get(body.field)
if column is None:
raise HTTPException(status_code=400, detail="不支持的列")
tenant_no = _tenant(db, user)
scope = (body.scope or "").strip() or None
if scope and scope not in _REIMBURSE_FACET_SCOPES:
raise HTTPException(status_code=400, detail="不支持的 scope")
# body.keyword 与列表快速搜索同名:先取出作列表上下文,再剥离本列键
list_keyword = (body.keyword or "").strip() or None
params = strip_simple_facet_self_params(_body_to_params(body), body.field)
params.pop("scope", None)
params.pop("field", None)
params.pop("limit", None)
if list_keyword:
params["keyword"] = list_keyword
# Tab/视图已固定 status 语义时,不吃 status 列参
if scope in ("pending", "payment_pending"):
params.pop("status", None)
params.pop("statusIn", None)
params.pop("status_in", None)
if scope in ("payment_pending", "payment_processed"):
extra_filters = payment_filters_from_params(params, db=db, tenant_no=tenant_no)
else:
extra_filters = _filters_from_params(params, db=db, tenant_no=tenant_no)
if scope:
try:
extra_filters = list(extra_filters) + [
ReimburseService.list_scope_filter(db, user, tenant_no, scope)
]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
rows = list_column_facet_values(
db,
BxReimburseApply,
column,
tenant_no=tenant_no,
keyword=None,
limit=body.limit if body.limit is not None else 500,
apply_scope=(
None
if scope == "pending"
else lambda query: apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
),
extra_filters=extra_filters or None,
)
return ApiResponse(data={"list": rows})
@router.get("/", response_model=ApiResponse[PagedData[ReimburseApplyOut]])
def list_reimburses(
request: Request,
page: int = 1,
pageSize: int = Query(10, ge=1, le=500),
status: Optional[str] = None,
keyword: str = "",
expense_id: Optional[str] = None,
expenseName: Optional[str] = None,
applicantId: Optional[str] = None,
applyDate: Optional[str] = None,
applyDateFrom: Optional[str] = None,
applyDateTo: Optional[str] = None,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
params = _merge_query(
request,
status=status,
keyword=keyword or None,
expenseId=expense_id,
expenseName=expenseName,
applicantId=applicantId,
applyDate=applyDate,
applyDateFrom=applyDateFrom,
applyDateTo=applyDateTo,
)
filters = _filters_from_params(params, db=db, tenant_no=tenant_no)
rows, total = ReimburseService.get_paged(
db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user, order_by=_reimburse_order(params)
)
out_rows = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
@router.get("/my", response_model=ApiResponse[PagedData[ReimburseApplyOut]])
def my_reimburses(
request: Request,
page: int = 1,
pageSize: int = Query(10, ge=1, le=500),
status: Optional[str] = None,
keyword: str = "",
expenseName: Optional[str] = None,
applyCode: Optional[str] = None,
title: Optional[str] = None,
currentNode: Optional[str] = None,
statusIn: Optional[str] = None,
amountOp: Optional[str] = None,
amount: Optional[str] = None,
amountMin: Optional[str] = None,
amountMax: Optional[str] = None,
applyDate: Optional[str] = None,
applyDateFrom: Optional[str] = None,
applyDateTo: Optional[str] = None,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
params = _merge_query(
request,
status=status,
keyword=keyword or None,
expenseName=expenseName,
applyCode=applyCode,
title=title,
currentNode=currentNode,
statusIn=statusIn,
amountOp=amountOp,
amount=amount,
amountMin=amountMin,
amountMax=amountMax,
applyDate=applyDate,
applyDateFrom=applyDateFrom,
applyDateTo=applyDateTo,
)
filters = [
or_(
BxReimburseApply.applicant_id == user.id,
BxReimburseApply.create_by == user.id,
)
]
filters.extend(_filters_from_params(params, db=db, tenant_no=tenant_no))
rows, total = ReimburseService.get_paged(
db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user, order_by=_reimburse_order(params)
)
out_rows = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
@router.get("/my/stats", response_model=ApiResponse[ReimburseMyStatsOut])
def my_reimburse_stats(
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
data = ReimburseService.my_apply_stats(db, user, tenant_no)
return ApiResponse(data=ReimburseMyStatsOut.model_validate(data))
@router.get("/stats/status-counts", response_model=ApiResponse[dict])
def reimburse_status_counts(
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
return ApiResponse(data=ReimburseService.global_status_counts(db, tenant_no, user=user))
@router.get("/pending-approval", response_model=ApiResponse[PagedData[ReimburseApplyOut]])
def pending_approval(
request: Request,
page: int = 1,
pageSize: int = Query(10, ge=1, le=500),
keyword: str = "",
applicantId: Optional[str] = None,
expenseName: Optional[str] = None,
applyDate: Optional[str] = None,
applyDateFrom: Optional[str] = None,
applyDateTo: Optional[str] = None,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
params = _merge_query(
request,
keyword=keyword or None,
applicantId=applicantId,
expenseName=expenseName,
applyDate=applyDate,
applyDateFrom=applyDateFrom,
applyDateTo=applyDateTo,
)
# Tab 语义固定 pending;不把 status 列参传入 builder
params.pop("status", None)
params.pop("statusIn", None)
params.pop("status_in", None)
extra_filters = _filters_from_params(params, db=db, tenant_no=tenant_no)
rows, total = ReimburseService.pending_approval(
db,
user,
tenant_no,
page=page,
page_size=pageSize,
extra_filters=extra_filters,
order_by=_reimburse_order(params),
)
out_rows = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
@router.get("/pending-approval/stats", response_model=ApiResponse[ReimburseApproverPendingStatsOut])
def pending_approval_stats(
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
data = ReimburseService.pending_approval_stats(db, user, tenant_no)
return ApiResponse(data=ReimburseApproverPendingStatsOut.model_validate(data))
@router.get("/approved-by-me", response_model=ApiResponse[PagedData[ReimburseApplyOut]])
def approved_by_me(
request: Request,
page: int = 1,
pageSize: int = Query(10, ge=1, le=500),
keyword: str = "",
applicantId: Optional[str] = None,
expenseName: Optional[str] = None,
applyDate: Optional[str] = None,
applyDateFrom: Optional[str] = None,
applyDateTo: Optional[str] = None,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
params = _merge_query(
request,
keyword=keyword or None,
applicantId=applicantId,
expenseName=expenseName,
applyDate=applyDate,
applyDateFrom=applyDateFrom,
applyDateTo=applyDateTo,
)
extra_filters = _filters_from_params(params, db=db, tenant_no=tenant_no)
rows, total = ReimburseService.approved_by_me(
db,
user,
tenant_no,
page=page,
page_size=pageSize,
extra_filters=extra_filters,
order_by=_reimburse_order(params),
)
out_rows = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
@router.post("/export")
def export_reimburses(
body: BxApplyListExportBody,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
try:
tenant_no = _tenant(db, user)
params = _body_to_params(body)
filters = _filters_from_params(params, db=db, tenant_no=tenant_no)
rows, _ = ReimburseService.get_paged(
db,
tenant_no,
page=1,
page_size=EXPORT_MAX_ROWS,
filters=filters,
user=user,
order_by=_reimburse_order(params),
)
return respond_xlsx(
db,
tenant_no=tenant_no,
sheet_title="报销申请",
columns=REIMBURSE_EXPORT_COLUMNS,
rows=_export_reimburse_rows(db, tenant_no, rows),
filename_stem="报销申请",
requested_columns=body.columns,
)
except ExportValidationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@router.post("/my/export")
def export_my_reimburses(
body: BxApplyListExportBody,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
try:
tenant_no = _tenant(db, user)
params = _body_to_params(body)
filters = [
or_(
BxReimburseApply.applicant_id == user.id,
BxReimburseApply.create_by == user.id,
)
]
filters.extend(_filters_from_params(params, db=db, tenant_no=tenant_no))
rows, _ = ReimburseService.get_paged(
db,
tenant_no,
page=1,
page_size=EXPORT_MAX_ROWS,
filters=filters,
user=user,
order_by=_reimburse_order(params),
)
return respond_xlsx(
db,
tenant_no=tenant_no,
sheet_title="我的报销",
columns=REIMBURSE_EXPORT_COLUMNS,
rows=_export_reimburse_rows(db, tenant_no, rows),
filename_stem="我的报销申请",
requested_columns=body.columns,
)
except ExportValidationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@router.post("/pending-approval/export")
def export_pending_approval_reimburses(
body: BxApplyListExportBody,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
try:
tenant_no = _tenant(db, user)
params = _body_to_params(body)
params.pop("status", None)
params.pop("statusIn", None)
params.pop("status_in", None)
extra_filters = _filters_from_params(params, db=db, tenant_no=tenant_no)
rows, _ = ReimburseService.pending_approval(
db,
user,
tenant_no,
page=1,
page_size=EXPORT_MAX_ROWS,
extra_filters=extra_filters,
order_by=_reimburse_order(params),
)
return respond_xlsx(
db,
tenant_no=tenant_no,
sheet_title="报销待审批",
columns=REIMBURSE_EXPORT_COLUMNS,
rows=_export_reimburse_rows(db, tenant_no, rows),
filename_stem="报销待审批",
requested_columns=body.columns,
)
except ExportValidationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@router.post("/approved-by-me/export")
def export_approved_by_me_reimburses(
body: BxApplyListExportBody,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
try:
tenant_no = _tenant(db, user)
params = _body_to_params(body)
extra_filters = _filters_from_params(params, db=db, tenant_no=tenant_no)
rows, _ = ReimburseService.approved_by_me(
db,
user,
tenant_no,
page=1,
page_size=EXPORT_MAX_ROWS,
extra_filters=extra_filters,
order_by=_reimburse_order(params),
)
return respond_xlsx(
db,
tenant_no=tenant_no,
sheet_title="报销已审批",
columns=REIMBURSE_EXPORT_COLUMNS,
rows=_export_reimburse_rows(db, tenant_no, rows),
filename_stem="报销已审批",
requested_columns=body.columns,
)
except ExportValidationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@router.get("/{reimburse_id}", response_model=ApiResponse[ReimburseApplyOut])
def get_reimburse(
reimburse_id: str,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
reimburse = ReimburseService.get_by_id(db, reimburse_id, tenant_no, user=user)
if not reimburse:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
out = ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse)
return ApiResponse(data=out)
@router.get("/{reimburse_id}/approvals", response_model=ApiResponse[List[ReimburseApprovalOut]])
def get_approvals(
reimburse_id: str,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
if not ReimburseService.get_by_id(db, reimburse_id, tenant_no, user=user):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
approvals = ReimburseService.get_approvals(db, reimburse_id, tenant_no, user=user)
return ApiResponse(data=[ReimburseApprovalOut.model_validate(a) for a in approvals])
@router.get("/{reimburse_id}/approval-history", response_model=ApiResponse[List[ReimburseApprovalOut]])
def get_approval_history(
reimburse_id: str,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
if not ReimburseService.get_by_id(db, reimburse_id, tenant_no, user=user):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
history = ReimburseService.get_approval_history(db, reimburse_id, tenant_no, user=user)
return ApiResponse(data=[ReimburseApprovalOut.model_validate(a) for a in history])
@router.post("/", response_model=ApiResponse[ReimburseApplyOut])
def create_reimburse(
body: ReimburseApplyCreate,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
details_data = _build_details_data(db, tenant_no, body.details, user=user)
data = {
"status": "draft",
"remark": body.remark,
"reimburse_date": body.reimburse_date,
}
if body.title:
data["title"] = body.title
wf = (
db.query(BxWorkflowDef)
.filter(
BxWorkflowDef.biz_type == "reimburse",
BxWorkflowDef.tenant_no == tenant_no,
BxWorkflowDef.del_flag == 0,
)
.first()
)
if wf:
data["wf_id"] = wf.id
applicant_id, applicant_name = ReimburseService.resolve_applicant(
db, user, body.applicant_id, tenant_no=tenant_no
)
reimburse = ReimburseService.create_with_details(
db,
data,
details_data,
user=user,
applicant_id=applicant_id,
applicant_name=applicant_name,
)
if body.invoice_links is not None or body.invoice_ids is not None:
ReimburseService.sync_reimburse_invoices(
db,
reimburse.id,
body.invoice_ids,
tenant_no,
user,
applicant_id=applicant_id,
invoice_links=body.invoice_links,
)
db.commit()
db.refresh(reimburse)
out = ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse)
return ApiResponse(data=out)
@router.put("/{reimburse_id}", response_model=ApiResponse[ReimburseApplyOut])
def update_reimburse(
reimburse_id: str,
body: ReimburseApplyUpdate,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
details_data = None
if body.details is not None:
details_data = _build_details_data(db, tenant_no, body.details, user=user)
main_data = {}
if body.remark is not None:
main_data["remark"] = body.remark
if body.reimburse_date is not None:
main_data["reimburse_date"] = body.reimburse_date
if body.title is not None:
main_data["title"] = body.title
if body.applicant_id is not None:
applicant_id, applicant_name = ReimburseService.resolve_applicant(
db, user, body.applicant_id, tenant_no=tenant_no
)
main_data["applicant_id"] = applicant_id
main_data["applicant_name"] = applicant_name
reimburse = ReimburseService.update_with_details(
db, reimburse_id, main_data, details_data, user=user
)
if not reimburse:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
if body.invoice_links is not None or body.invoice_ids is not None:
ReimburseService.sync_reimburse_invoices(
db,
reimburse_id,
body.invoice_ids,
tenant_no,
user,
applicant_id=reimburse.applicant_id,
invoice_links=body.invoice_links,
)
db.commit()
db.refresh(reimburse)
out = ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse)
return ApiResponse(data=out)
@router.post("/{reimburse_id}/submit", response_model=ApiResponse[ReimburseApplyOut])
def submit_reimburse(
reimburse_id: str,
body: ReimburseSubmitBody = ReimburseSubmitBody(),
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
reimburse = ReimburseService.submit(
db,
reimburse_id,
user,
invoice_ids=body.invoice_ids,
invoice_links=body.invoice_links,
)
if not reimburse:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
db.commit()
db.refresh(reimburse)
return ApiResponse(data=ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse))
@router.post("/{reimburse_id}/approve", response_model=ApiResponse[ReimburseApplyOut])
def approve_reimburse(
reimburse_id: str,
body: ReimburseApprovalCreate,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
reimburse = ReimburseService.approve(db, reimburse_id, user, body.approval_opinion or "")
if not reimburse:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在或无权审批")
db.commit()
db.refresh(reimburse)
return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
@router.post("/{reimburse_id}/reject", response_model=ApiResponse[ReimburseApplyOut])
def reject_reimburse(
reimburse_id: str,
body: ReimburseApprovalCreate,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
reimburse = ReimburseService.reject(db, reimburse_id, user, body.approval_opinion or "")
if not reimburse:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在或无权审批")
db.commit()
db.refresh(reimburse)
return ApiResponse(data=ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse))
@router.post("/{reimburse_id}/withdraw", response_model=ApiResponse[ReimburseApplyOut])
def withdraw_reimburse(
reimburse_id: str,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
reimburse = ReimburseService.withdraw(db, reimburse_id, user)
if not reimburse:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="报销单不存在、非本人申请或当前状态不可撤回",
)
db.commit()
db.refresh(reimburse)
return ApiResponse(data=ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse))
@router.delete("/{reimburse_id}", response_model=ApiResponse[None])
def delete_reimburse(
reimburse_id: str,
db: Session = Depends(get_db),
user: BxUserContext = Depends(get_bx_current_user),
):
tenant_no = _tenant(db, user)
if not ReimburseService.get_by_id(db, reimburse_id, tenant_no, user=user):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
ReimburseService.soft_delete(db, reimburse_id, user=user)
db.commit()
return ApiResponse(data=None)