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 current_month_bounds, parse_create_time_day_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, ): 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) bounds = parse_create_time_day_bounds(apply_date) if bounds: d0, d1 = bounds subquery = subquery.filter( BxAdvanceApply.create_time >= d0, BxAdvanceApply.create_time < d1 ) 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, ): 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) bounds = parse_create_time_day_bounds(apply_date) if bounds: d0, d1 = bounds query = query.filter(BxAdvanceApply.create_time >= d0, BxAdvanceApply.create_time < d1) 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), }