Files
shanxia2.0/backend/app/services/bx/advance.py
T
2026-09-23 12:03:07 +08:00

705 lines
26 KiB
Python

from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Sequence, Tuple
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.core.bx_auth import BxUserContext
from app.core.tenant_context import get_tenant_no
from app.core.utils import generate_uuid
from app.models.bx_models import (
BxAdvanceApply,
BxAdvanceApproval,
BxReimburseOffset,
BxWorkflowDef,
BxWorkflowNode,
)
from app.services.bx.approval_cycle import filter_current_cycle_approvals
from app.services.bx.base import BxCrudService
from app.services.bx_data_scope import apply_bx_scope
from app.utils.bx_codegen import generate_apply_code
from app.utils.bx_date_range import append_create_time_filters, current_month_bounds, resolve_create_time_bounds
class AdvanceService:
@staticmethod
def _tenant_no(db: Session, user: BxUserContext) -> str:
return get_tenant_no(db, user.to_tenant_user_dict())
@staticmethod
def get_by_id(
db: Session,
advance_id: str,
tenant_no: str,
*,
user: Optional[BxUserContext] = None,
skip_scope: bool = False,
) -> Optional[BxAdvanceApply]:
return BxCrudService.get_by_id(
BxAdvanceApply, db, advance_id, tenant_no, user=user, skip_scope=skip_scope
)
@staticmethod
def get_paged(
db: Session,
tenant_no: str,
page: int,
page_size: int,
filters: Optional[List[Any]] = None,
*,
user: Optional[BxUserContext] = None,
order_by: Optional[Any] = None,
) -> Tuple[Sequence[BxAdvanceApply], int]:
return BxCrudService.get_paged(
BxAdvanceApply,
db,
tenant_no,
page,
page_size,
filters,
user=user,
order_by=order_by,
)
@staticmethod
def create(
db: Session,
obj_in: dict,
*,
user: BxUserContext,
) -> BxAdvanceApply:
from sqlalchemy.exc import IntegrityError
tenant_no = AdvanceService._tenant_no(db, user)
data = dict(obj_in)
data.pop("apply_code", None)
last_error: Exception | None = None
for attempt in range(5):
payload = {
**data,
"apply_code": generate_apply_code(db, "BYJ", tenant_no),
}
try:
return BxCrudService.create(
BxAdvanceApply,
db,
payload,
tenant_no=tenant_no,
user_id=user.id,
dept_id=user.dept_id,
)
except IntegrityError as exc:
db.rollback()
last_error = exc
if attempt >= 4:
raise
continue
if last_error:
raise last_error
raise RuntimeError("备用金申请创建失败")
@staticmethod
def update(
db: Session,
advance_id: str,
obj_in: dict,
*,
user: BxUserContext,
) -> Optional[BxAdvanceApply]:
tenant_no = AdvanceService._tenant_no(db, user)
return BxCrudService.update(
BxAdvanceApply,
db,
advance_id,
tenant_no,
obj_in,
user_id=user.id,
user=user,
)
@staticmethod
def soft_delete(db: Session, advance_id: str, *, user: BxUserContext) -> bool:
tenant_no = AdvanceService._tenant_no(db, user)
return BxCrudService.soft_delete(
BxAdvanceApply, db, advance_id, tenant_no, user_id=user.id, user=user
)
@staticmethod
def pending_approval(
db: Session,
user: BxUserContext,
tenant_no: str,
page: int,
page_size: int,
keyword: str = "",
applicant_id: Optional[str] = None,
apply_date: Optional[str] = None,
apply_date_from: Optional[str] = None,
apply_date_to: Optional[str] = None,
extra_filters: Optional[list] = None,
order_by: Optional[Any] = None,
):
subquery = (
db.query(BxAdvanceApply.id)
.join(
BxWorkflowNode,
and_(
BxWorkflowNode.wf_id == BxAdvanceApply.wf_id,
BxWorkflowNode.node_name == BxAdvanceApply.current_node,
BxWorkflowNode.tenant_no == tenant_no,
BxWorkflowNode.del_flag == 0,
),
)
.filter(
BxAdvanceApply.del_flag == 0,
BxAdvanceApply.tenant_no == tenant_no,
BxAdvanceApply.status == "pending",
BxWorkflowNode.approver_id == user.id,
)
)
if extra_filters:
subquery = subquery.filter(*extra_filters)
else:
if keyword:
subquery = subquery.filter(
or_(
BxAdvanceApply.apply_code.contains(keyword),
BxAdvanceApply.applicant_name.contains(keyword),
)
)
if applicant_id:
subquery = subquery.filter(BxAdvanceApply.applicant_id == applicant_id)
start, end = resolve_create_time_bounds(
apply_date=apply_date,
apply_date_from=apply_date_from,
apply_date_to=apply_date_to,
)
if start is not None:
subquery = subquery.filter(BxAdvanceApply.create_time >= start)
if end is not None:
subquery = subquery.filter(BxAdvanceApply.create_time < end)
subquery = subquery.subquery()
total = db.query(func.count()).select_from(subquery).scalar() or 0
order_expr = order_by if order_by is not None else BxAdvanceApply.create_time.desc()
rows = (
db.query(BxAdvanceApply)
.join(subquery, BxAdvanceApply.id == subquery.c.id)
.order_by(order_expr)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return rows, total
@staticmethod
def approved_by_me(
db: Session,
user: BxUserContext,
tenant_no: str,
page: int,
page_size: int,
keyword: str = "",
applicant_id: Optional[str] = None,
apply_date: Optional[str] = None,
apply_date_from: Optional[str] = None,
apply_date_to: Optional[str] = None,
extra_filters: Optional[list] = None,
order_by: Optional[Any] = None,
):
subquery = (
db.query(BxAdvanceApproval.apply_id)
.filter(
BxAdvanceApproval.approver_id == user.id,
BxAdvanceApproval.tenant_no == tenant_no,
BxAdvanceApproval.del_flag == 0,
)
.distinct()
.subquery()
)
query = (
db.query(BxAdvanceApply)
.join(subquery, BxAdvanceApply.id == subquery.c.apply_id)
.filter(BxAdvanceApply.del_flag == 0, BxAdvanceApply.tenant_no == tenant_no)
)
if extra_filters:
query = query.filter(*extra_filters)
else:
if keyword:
query = query.filter(
or_(
BxAdvanceApply.apply_code.contains(keyword),
BxAdvanceApply.applicant_name.contains(keyword),
)
)
if applicant_id:
query = query.filter(BxAdvanceApply.applicant_id == applicant_id)
start, end = resolve_create_time_bounds(
apply_date=apply_date,
apply_date_from=apply_date_from,
apply_date_to=apply_date_to,
)
if start is not None:
query = query.filter(BxAdvanceApply.create_time >= start)
if end is not None:
query = query.filter(BxAdvanceApply.create_time < end)
query = apply_bx_scope(query, BxAdvanceApply, user, db, tenant_no)
total = query.count()
order_expr = order_by if order_by is not None else BxAdvanceApply.create_time.desc()
rows = (
query.order_by(order_expr)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return rows, total
@staticmethod
def _workflow_nodes(db: Session, wf_id: str, tenant_no: str) -> List[BxWorkflowNode]:
return (
db.query(BxWorkflowNode)
.filter(
BxWorkflowNode.wf_id == wf_id,
BxWorkflowNode.tenant_no == tenant_no,
BxWorkflowNode.del_flag == 0,
)
.order_by(BxWorkflowNode.node_order)
.all()
)
@staticmethod
def submit(db: Session, apply_id: str, user: BxUserContext) -> Optional[BxAdvanceApply]:
tenant_no = AdvanceService._tenant_no(db, user)
apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no, user=user)
if not apply_obj:
return None
apply_obj.status = "pending"
if not apply_obj.apply_code:
apply_obj.apply_code = generate_apply_code(db, "BYJ", tenant_no)
apply_obj.update_by = user.id
if apply_obj.wf_id:
nodes = AdvanceService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
if nodes:
apply_obj.current_node = nodes[0].node_name
db.flush()
return apply_obj
@staticmethod
def approve(
db: Session, apply_id: str, approver: BxUserContext, opinion: str
) -> Optional[BxAdvanceApply]:
tenant_no = AdvanceService._tenant_no(db, approver)
apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no, skip_scope=True)
if not apply_obj or not apply_obj.wf_id:
return None
nodes = AdvanceService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
if not nodes:
return None
current_idx = None
for i, node in enumerate(nodes):
if node.node_name == apply_obj.current_node:
current_idx = i
break
if current_idx is None:
return None
current_node = nodes[current_idx]
if current_node.approver_id != approver.id:
return None
approval = BxAdvanceApproval(
id=generate_uuid(),
apply_id=apply_id,
approver_id=approver.id,
approver_name=approver.name,
approval_opinion=opinion,
node_name=current_node.node_name,
node_order=current_node.node_order,
approval_status="approved",
approval_time=datetime.now(),
tenant_no=tenant_no,
create_by=approver.id,
create_dept_id=approver.dept_id or "",
)
db.add(approval)
if current_idx == len(nodes) - 1:
apply_obj.status = "approved"
apply_obj.current_node = None
else:
apply_obj.current_node = nodes[current_idx + 1].node_name
apply_obj.update_by = approver.id
db.flush()
return apply_obj
@staticmethod
def reject(
db: Session, apply_id: str, approver: BxUserContext, opinion: str
) -> Optional[BxAdvanceApply]:
tenant_no = AdvanceService._tenant_no(db, approver)
apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no, skip_scope=True)
if not apply_obj or not apply_obj.wf_id:
return None
nodes = AdvanceService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
current_node = None
for node in nodes:
if node.node_name == apply_obj.current_node:
current_node = node
break
if current_node is None or current_node.approver_id != approver.id:
return None
approval = BxAdvanceApproval(
id=generate_uuid(),
apply_id=apply_id,
approver_id=approver.id,
approver_name=approver.name,
approval_opinion=opinion,
node_name=current_node.node_name,
node_order=current_node.node_order,
approval_status="rejected",
approval_time=datetime.now(),
tenant_no=tenant_no,
create_by=approver.id,
create_dept_id=approver.dept_id or "",
)
db.add(approval)
apply_obj.status = "rejected"
apply_obj.current_node = None
apply_obj.update_by = approver.id
db.flush()
return apply_obj
@staticmethod
def withdraw(db: Session, apply_id: str, user: BxUserContext) -> Optional[BxAdvanceApply]:
tenant_no = AdvanceService._tenant_no(db, user)
apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no, user=user)
if not apply_obj:
return None
if apply_obj.applicant_id != user.id:
return None
if apply_obj.status != "pending":
return None
apply_obj.status = "draft"
apply_obj.current_node = None
apply_obj.update_by = user.id
db.flush()
return apply_obj
@staticmethod
def _approval_record_dict(approval: BxAdvanceApproval, apply_id: str) -> Dict[str, Any]:
return {
"id": approval.id,
"advance_id": apply_id,
"node_name": approval.node_name,
"node_order": approval.node_order,
"approval_status": approval.approval_status,
"approval_opinion": approval.approval_opinion,
"approver_name": approval.approver_name,
"approval_time": approval.approval_time,
}
@staticmethod
def _latest_approval_per_node(
approvals: Sequence[BxAdvanceApproval],
) -> Dict[int, BxAdvanceApproval]:
"""同一节点多次审批时,取 approval_time 最新的一条作为当前节点状态。"""
approval_map: Dict[int, BxAdvanceApproval] = {}
for approval in approvals:
existing = approval_map.get(approval.node_order)
if existing is None:
approval_map[approval.node_order] = approval
continue
approval_time = approval.approval_time or datetime.min
existing_time = existing.approval_time or datetime.min
if approval_time >= existing_time:
approval_map[approval.node_order] = approval
return approval_map
@staticmethod
def _query_approvals(db: Session, apply_id: str, tenant_no: str) -> List[BxAdvanceApproval]:
return (
db.query(BxAdvanceApproval)
.filter(
BxAdvanceApproval.apply_id == apply_id,
BxAdvanceApproval.tenant_no == tenant_no,
BxAdvanceApproval.del_flag == 0,
)
.all()
)
@staticmethod
def get_approvals(
db: Session, apply_id: str, tenant_no: str, *, user: Optional[BxUserContext] = None
) -> List[Dict[str, Any]]:
apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no, user=user)
if not apply_obj or not apply_obj.wf_id:
return []
wf = BxCrudService.get_by_id(BxWorkflowDef, db, apply_obj.wf_id, tenant_no)
if not wf:
return []
nodes = AdvanceService._workflow_nodes(db, wf.id, tenant_no)
if not nodes:
return []
approvals = AdvanceService._query_approvals(db, apply_id, tenant_no)
cycle_approvals = filter_current_cycle_approvals(approvals, apply_obj.status)
approval_map = AdvanceService._latest_approval_per_node(cycle_approvals)
result: List[Dict[str, Any]] = []
for node in nodes:
approval = approval_map.get(node.node_order)
if approval:
result.append(AdvanceService._approval_record_dict(approval, apply_id))
else:
result.append(
{
"id": "0",
"advance_id": apply_id,
"node_name": node.node_name,
"node_order": node.node_order,
"approval_status": "pending",
"approval_opinion": None,
"approver_name": node.approver_name or "",
"approval_time": None,
}
)
return result
@staticmethod
def get_approval_history(
db: Session, apply_id: str, tenant_no: str, *, user: Optional[BxUserContext] = None
) -> List[Dict[str, Any]]:
"""返回全部审批动作,按时间正序排列,用于展示完整审批历史。"""
if not AdvanceService.get_by_id(db, apply_id, tenant_no, user=user):
return []
approvals = (
db.query(BxAdvanceApproval)
.filter(
BxAdvanceApproval.apply_id == apply_id,
BxAdvanceApproval.tenant_no == tenant_no,
BxAdvanceApproval.del_flag == 0,
)
.order_by(BxAdvanceApproval.approval_time.asc(), BxAdvanceApproval.create_time.asc())
.all()
)
return [AdvanceService._approval_record_dict(a, apply_id) for a in approvals]
@staticmethod
def global_status_counts(db: Session, tenant_no: str, *, user: BxUserContext) -> Dict[str, int]:
query = db.query(BxAdvanceApply.status, func.count()).filter(
BxAdvanceApply.del_flag == 0, BxAdvanceApply.tenant_no == tenant_no
)
query = apply_bx_scope(query, BxAdvanceApply, user, db, tenant_no)
rows = query.group_by(BxAdvanceApply.status).all()
return {str(s or ""): int(c) for s, c in rows}
@staticmethod
def _my_apply_base(db: Session, user: BxUserContext, tenant_no: str):
query = db.query(BxAdvanceApply).filter(
BxAdvanceApply.del_flag == 0,
BxAdvanceApply.tenant_no == tenant_no,
or_(
BxAdvanceApply.applicant_id == user.id,
BxAdvanceApply.create_by == user.id,
),
)
return apply_bx_scope(query, BxAdvanceApply, user, db, tenant_no)
@staticmethod
def my_apply_stats(db: Session, user: BxUserContext, tenant_no: str) -> dict:
base_q = AdvanceService._my_apply_base(db, user, tenant_no).subquery()
base = BxAdvanceApply.id.in_(db.query(base_q.c.id))
total_count = (
db.query(func.count()).select_from(BxAdvanceApply).filter(base).scalar() or 0
)
pending = (
db.query(func.count())
.select_from(BxAdvanceApply)
.filter(base, BxAdvanceApply.status == "pending")
.scalar()
or 0
)
approved_pass = (
db.query(func.count())
.select_from(BxAdvanceApply)
.filter(base, BxAdvanceApply.status == "approved")
.scalar()
or 0
)
rejected_count = (
db.query(func.count())
.select_from(BxAdvanceApply)
.filter(base, BxAdvanceApply.status == "rejected")
.scalar()
or 0
)
pending_issue = approved_pass
issued = (
db.query(func.count())
.select_from(BxAdvanceApply)
.filter(base, BxAdvanceApply.status == "issued")
.scalar()
or 0
)
issued_amount = (
db.query(func.coalesce(func.sum(BxAdvanceApply.amount), 0))
.filter(base, BxAdvanceApply.status == "issued")
.scalar()
or 0
)
m0, m1 = current_month_bounds()
month_apply_amount = (
db.query(func.coalesce(func.sum(BxAdvanceApply.amount), 0))
.filter(base, BxAdvanceApply.create_time >= m0, BxAdvanceApply.create_time < m1)
.scalar()
or 0
)
month_apply_count = (
db.query(func.count())
.select_from(BxAdvanceApply)
.filter(base, BxAdvanceApply.create_time >= m0, BxAdvanceApply.create_time < m1)
.scalar()
or 0
)
offset_total = (
db.query(func.coalesce(func.sum(BxReimburseOffset.offset_amount), 0))
.join(BxAdvanceApply, BxReimburseOffset.advance_id == BxAdvanceApply.id)
.filter(
BxAdvanceApply.id.in_(db.query(base_q.c.id)),
BxReimburseOffset.tenant_no == tenant_no,
BxReimburseOffset.del_flag == 0,
)
.scalar()
or 0
)
return {
"total_count": int(total_count),
"pending_count": int(pending),
"approved_pass_count": int(approved_pass),
"rejected_count": int(rejected_count),
"pending_issue_count": int(pending_issue),
"issued_count": int(issued),
"issued_amount": float(issued_amount),
"month_apply_amount": float(month_apply_amount),
"month_apply_count": int(month_apply_count),
"offset_total": float(offset_total),
}
@staticmethod
def pending_approval_stats(db: Session, user: BxUserContext, tenant_no: str) -> dict:
subquery = (
db.query(BxAdvanceApply.id)
.join(
BxWorkflowNode,
and_(
BxWorkflowNode.wf_id == BxAdvanceApply.wf_id,
BxWorkflowNode.node_name == BxAdvanceApply.current_node,
BxWorkflowNode.tenant_no == tenant_no,
BxWorkflowNode.del_flag == 0,
),
)
.filter(
BxAdvanceApply.del_flag == 0,
BxAdvanceApply.tenant_no == tenant_no,
BxAdvanceApply.status == "pending",
BxWorkflowNode.approver_id == user.id,
)
).subquery()
total = db.query(func.count()).select_from(subquery).scalar() or 0
m0, m1 = current_month_bounds()
month_sum = (
db.query(func.coalesce(func.sum(BxAdvanceApply.amount), 0))
.join(subquery, BxAdvanceApply.id == subquery.c.id)
.filter(BxAdvanceApply.create_time >= m0, BxAdvanceApply.create_time < m1)
.scalar()
or 0
)
return {"pending_total": int(total), "month_pending_amount": float(month_sum)}
@staticmethod
def offset_amount_map(db: Session, tenant_no: str, advance_ids: List[str]) -> Dict[str, float]:
if not advance_ids:
return {}
rows = (
db.query(
BxReimburseOffset.advance_id,
func.coalesce(func.sum(BxReimburseOffset.offset_amount), 0),
)
.filter(
BxReimburseOffset.advance_id.in_(advance_ids),
BxReimburseOffset.tenant_no == tenant_no,
BxReimburseOffset.del_flag == 0,
)
.group_by(BxReimburseOffset.advance_id)
.all()
)
return {str(advance_id): float(amount) for advance_id, amount in rows}
@staticmethod
def issue(
db: Session,
advance_id: str,
user: BxUserContext,
remark: str = "",
actual_amount: Optional[float] = None,
) -> Optional[BxAdvanceApply]:
tenant_no = AdvanceService._tenant_no(db, user)
apply_obj = AdvanceService.get_by_id(db, advance_id, tenant_no, user=user)
if not apply_obj:
return None
apply_obj.status = "issued"
apply_obj.update_by = user.id
apply_obj.issue_status = "issued"
apply_obj.issue_time = datetime.now()
apply_obj.issue_user_id = user.id
apply_obj.issue_user_name = user.name
if remark:
apply_obj.issue_remark = remark
if actual_amount is not None:
apply_obj.actual_amount = actual_amount
db.flush()
return apply_obj
@staticmethod
def finance_issue_stats(db: Session, tenant_no: str, *, user: BxUserContext) -> dict:
base_q = (
db.query(BxAdvanceApply)
.filter(BxAdvanceApply.del_flag == 0, BxAdvanceApply.tenant_no == tenant_no)
)
base_q = apply_bx_scope(base_q, BxAdvanceApply, user, db, tenant_no).subquery()
base = BxAdvanceApply.id.in_(db.query(base_q.c.id))
pending_f = and_(
BxAdvanceApply.status == "approved",
or_(BxAdvanceApply.issue_status.is_(None), BxAdvanceApply.issue_status != "issued"),
)
pending_n = db.query(func.count()).select_from(BxAdvanceApply).filter(base, pending_f).scalar() or 0
issued_n = (
db.query(func.count())
.select_from(BxAdvanceApply)
.filter(base, BxAdvanceApply.status == "issued")
.scalar()
or 0
)
pending_amount = (
db.query(func.coalesce(func.sum(BxAdvanceApply.amount), 0))
.filter(base, pending_f)
.scalar()
or 0
)
m0, m1 = current_month_bounds()
amt_expr = func.coalesce(BxAdvanceApply.actual_amount, BxAdvanceApply.amount)
issued_month = (
db.query(func.coalesce(func.sum(amt_expr), 0))
.filter(
base,
BxAdvanceApply.status == "issued",
BxAdvanceApply.issue_time.isnot(None),
BxAdvanceApply.issue_time >= m0,
BxAdvanceApply.issue_time < m1,
)
.scalar()
or 0
)
return {
"pending_count": int(pending_n),
"issued_count": int(issued_n),
"pending_amount": float(pending_amount),
"issued_month_amount": float(issued_month),
}