| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648 |
- 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,
- ) -> Tuple[Sequence[BxAdvanceApply], int]:
- return BxCrudService.get_paged(
- BxAdvanceApply, db, tenant_no, page, page_size, filters, user=user
- )
- @staticmethod
- def create(
- db: Session,
- obj_in: dict,
- *,
- user: BxUserContext,
- ) -> BxAdvanceApply:
- tenant_no = AdvanceService._tenant_no(db, user)
- return BxCrudService.create(
- BxAdvanceApply,
- db,
- obj_in,
- tenant_no=tenant_no,
- user_id=user.id,
- dept_id=user.dept_id,
- )
- @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,
- ):
- 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 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
- rows = (
- db.query(BxAdvanceApply)
- .join(subquery, BxAdvanceApply.id == subquery.c.id)
- .order_by(BxAdvanceApply.create_time.desc())
- .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,
- ):
- 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 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()
- rows = (
- query.order_by(BxAdvanceApply.create_time.desc())
- .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 _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),
- }
|