| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321 |
- from __future__ import annotations
- from datetime import datetime
- from decimal import Decimal
- from typing import Any, Dict, List, Optional, Sequence, Tuple
- from fastapi import HTTPException, status
- from sqlalchemy import and_, func, or_
- from sqlalchemy.exc import IntegrityError
- from sqlalchemy.orm import Session
- from app.core.bx_auth import BxUserContext
- from app.core.menu_guard import SUPER_ADMIN_ROLE
- from app.core.tenant_context import get_tenant_no
- from app.core.utils import generate_uuid
- from app.models.bx_models import (
- BxAttachment,
- BxInvoice,
- BxInvoiceLedger,
- BxReimburseApply,
- BxReimburseApproval,
- BxReimburseDetail,
- BxReimburseInvoice,
- BxReimburseOffset,
- BxWorkflowDef,
- BxWorkflowNode,
- )
- from app.models.models import SysUser
- from app.schemas.bx.reimburse import (
- ReimburseApplyOut,
- ReimburseDetailOut,
- ReimburseInvoiceBriefOut,
- ReimburseInvoiceLinkIn,
- )
- from app.services.bx.approval_cycle import filter_current_cycle_approvals
- from app.services.bx.attachment import AttachmentService
- from app.services.bx.base import BxCrudService
- from app.services.bx.invoice_ledger import ENTRY_DEBIT_REIMBURSE, InvoiceLedgerService
- from app.services.bx_data_scope import apply_bx_scope
- from app.services.web_auth_roles import iter_user_menu_perm_codes
- from app.utils.bx_codegen import (
- allocate_reimburse_title_seq,
- generate_apply_code,
- format_reimburse_title,
- is_auto_reimburse_title,
- parse_reimburse_date,
- resolve_reimburse_title,
- )
- from app.utils.bx_date_range import current_month_bounds, resolve_create_time_bounds
- REIMBURSE_CHANGE_APPLICANT_PERM = "bx:reimburseApply:changeApplicant"
- _REIMBURSE_MY_STATS_PASSED_STATUSES = ("approved", "processed", "paid", "offset")
- class ReimburseService:
- @staticmethod
- def _tenant_no(db: Session, user: BxUserContext) -> str:
- return get_tenant_no(db, user.to_tenant_user_dict())
- @staticmethod
- def can_change_applicant(db: Session, user: BxUserContext) -> bool:
- """是否可选择他人作为报销申请人(超管或具备按钮权限)。"""
- if user.role == SUPER_ADMIN_ROLE:
- return True
- return REIMBURSE_CHANGE_APPLICANT_PERM in set(
- iter_user_menu_perm_codes(db, user.id, user.tenant_no)
- )
- @staticmethod
- def resolve_applicant(
- db: Session,
- user: BxUserContext,
- applicant_id: Optional[str],
- *,
- tenant_no: str,
- ) -> tuple[str, str]:
- """
- 解析报销申请人。
- 无 changeApplicant 权限时忽略客户端指定,强制本人;
- 有权限时可指定同租户有效用户。
- 返回 (applicant_id, applicant_name)。
- """
- requested = (applicant_id or "").strip() or None
- if not requested or requested == user.id:
- return user.id, (user.name or user.id)
- if not ReimburseService.can_change_applicant(db, user):
- return user.id, (user.name or user.id)
- target = (
- db.query(SysUser)
- .filter(
- SysUser.id == requested,
- SysUser.tenant_no == tenant_no,
- SysUser.del_flag == 0,
- SysUser.status == "1",
- )
- .first()
- )
- if not target:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="申请人不存在或已停用",
- )
- return target.id, (target.nick_name or target.user_no or target.id)
- @staticmethod
- def get_by_id(
- db: Session,
- reimburse_id: str,
- tenant_no: str,
- *,
- user: Optional[BxUserContext] = None,
- skip_scope: bool = False,
- ) -> Optional[BxReimburseApply]:
- return BxCrudService.get_by_id(
- BxReimburseApply, db, reimburse_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[BxReimburseApply], int]:
- return BxCrudService.get_paged(
- BxReimburseApply, db, tenant_no, page, page_size, filters, user=user
- )
- @staticmethod
- def soft_delete(db: Session, reimburse_id: str, *, user: BxUserContext) -> bool:
- tenant_no = ReimburseService._tenant_no(db, user)
- return BxCrudService.soft_delete(
- BxReimburseApply, db, reimburse_id, tenant_no, user_id=user.id, user=user
- )
- @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 pending_approval(
- db: Session,
- user: BxUserContext,
- tenant_no: str,
- page: int,
- page_size: int,
- keyword: str = "",
- applicant_id: Optional[str] = None,
- expense_name: Optional[str] = None,
- apply_date: Optional[str] = None,
- apply_date_from: Optional[str] = None,
- apply_date_to: Optional[str] = None,
- ):
- subquery = (
- db.query(BxReimburseApply.id)
- .join(
- BxWorkflowNode,
- and_(
- BxWorkflowNode.wf_id == BxReimburseApply.wf_id,
- BxWorkflowNode.node_name == BxReimburseApply.current_node,
- BxWorkflowNode.tenant_no == tenant_no,
- BxWorkflowNode.del_flag == 0,
- ),
- )
- .filter(
- BxReimburseApply.del_flag == 0,
- BxReimburseApply.tenant_no == tenant_no,
- BxReimburseApply.status == "pending",
- BxWorkflowNode.approver_id == user.id,
- )
- )
- if keyword:
- subquery = subquery.filter(
- or_(
- BxReimburseApply.apply_code.contains(keyword),
- BxReimburseApply.applicant_name.contains(keyword),
- BxReimburseApply.title.contains(keyword),
- )
- )
- if applicant_id:
- subquery = subquery.filter(BxReimburseApply.applicant_id == applicant_id)
- expense_filter = ReimburseService.build_expense_name_condition(db, tenant_no, expense_name)
- if expense_filter is not None:
- subquery = subquery.filter(expense_filter)
- 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(BxReimburseApply.create_time >= start)
- if end is not None:
- subquery = subquery.filter(BxReimburseApply.create_time < end)
- subquery = subquery.subquery()
- total = db.query(func.count()).select_from(subquery).scalar() or 0
- rows = (
- db.query(BxReimburseApply)
- .join(subquery, BxReimburseApply.id == subquery.c.id)
- .order_by(BxReimburseApply.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,
- expense_name: Optional[str] = None,
- apply_date: Optional[str] = None,
- apply_date_from: Optional[str] = None,
- apply_date_to: Optional[str] = None,
- ):
- subquery = (
- db.query(BxReimburseApproval.apply_id)
- .filter(
- BxReimburseApproval.approver_id == user.id,
- BxReimburseApproval.tenant_no == tenant_no,
- BxReimburseApproval.del_flag == 0,
- )
- .distinct()
- .subquery()
- )
- query = (
- db.query(BxReimburseApply)
- .join(subquery, BxReimburseApply.id == subquery.c.apply_id)
- .filter(BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no)
- )
- if keyword:
- query = query.filter(
- or_(
- BxReimburseApply.apply_code.contains(keyword),
- BxReimburseApply.applicant_name.contains(keyword),
- BxReimburseApply.title.contains(keyword),
- )
- )
- if applicant_id:
- query = query.filter(BxReimburseApply.applicant_id == applicant_id)
- expense_filter = ReimburseService.build_expense_name_condition(db, tenant_no, expense_name)
- if expense_filter is not None:
- query = query.filter(expense_filter)
- 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(BxReimburseApply.create_time >= start)
- if end is not None:
- query = query.filter(BxReimburseApply.create_time < end)
- query = apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
- total = query.count()
- rows = (
- query.order_by(BxReimburseApply.create_time.desc())
- .offset((page - 1) * page_size)
- .limit(page_size)
- .all()
- )
- return rows, total
- @staticmethod
- def _has_debit_reimburse(db: Session, tenant_no: str, reimburse_id: str) -> bool:
- return (
- db.query(BxInvoiceLedger.id)
- .filter(
- BxInvoiceLedger.tenant_no == tenant_no,
- BxInvoiceLedger.reimburse_id == reimburse_id,
- BxInvoiceLedger.entry_type == ENTRY_DEBIT_REIMBURSE,
- BxInvoiceLedger.del_flag == 0,
- )
- .first()
- is not None
- )
- @staticmethod
- def normalize_invoice_links(
- invoice_links: Optional[Sequence[ReimburseInvoiceLinkIn]] = None,
- invoice_ids: Optional[Sequence[str]] = None,
- ) -> Optional[List[Tuple[str, Optional[str]]]]:
- """归一化勾选链接;返回 None 表示未传(不改关联),[] 表示清空。"""
- if invoice_links is not None:
- normalized: List[Tuple[str, Optional[str]]] = []
- seen: set[Tuple[str, str]] = set()
- for link in invoice_links:
- invoice_id = str(link.invoice_id or "").strip()
- if not invoice_id:
- continue
- attachment_id = str(link.attachment_id or "").strip() or None
- key = (invoice_id, attachment_id or "")
- if key in seen:
- continue
- seen.add(key)
- normalized.append((invoice_id, attachment_id))
- return normalized
- if invoice_ids is not None:
- normalized_ids: List[Tuple[str, Optional[str]]] = []
- seen_ids: set[str] = set()
- for raw_id in invoice_ids:
- invoice_id = str(raw_id or "").strip()
- if not invoice_id or invoice_id in seen_ids:
- continue
- seen_ids.add(invoice_id)
- normalized_ids.append((invoice_id, None))
- return normalized_ids
- return None
- @staticmethod
- def sync_reimburse_invoices(
- db: Session,
- reimburse_id: str,
- invoice_ids: Optional[List[str]],
- tenant_no: str,
- user: BxUserContext,
- *,
- applicant_id: str,
- invoice_links: Optional[Sequence[ReimburseInvoiceLinkIn]] = None,
- ) -> None:
- links = ReimburseService.normalize_invoice_links(invoice_links, invoice_ids)
- if links is None:
- return
- invoice_id_list = list({invoice_id for invoice_id, _ in links})
- if invoice_id_list:
- invoices = (
- db.query(BxInvoice)
- .filter(
- BxInvoice.tenant_no == tenant_no,
- BxInvoice.id.in_(invoice_id_list),
- BxInvoice.del_flag == 0,
- )
- .all()
- )
- invoice_map = {inv.id: inv for inv in invoices}
- for invoice_id in invoice_id_list:
- invoice = invoice_map.get(invoice_id)
- if not invoice:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"发票 {invoice_id} 不存在",
- )
- if invoice.owner_user_id != applicant_id:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"发票 {invoice_id} 不属于申请人",
- )
- attachment_ids = [aid for _, aid in links if aid]
- attachment_map: Dict[str, BxAttachment] = {}
- if attachment_ids:
- attachments = (
- db.query(BxAttachment)
- .filter(
- BxAttachment.id.in_(attachment_ids),
- BxAttachment.biz_type == "invoice",
- BxAttachment.tenant_no == tenant_no,
- BxAttachment.del_flag == 0,
- )
- .all()
- )
- attachment_map = {att.id: att for att in attachments}
- for invoice_id, attachment_id in links:
- if not attachment_id:
- continue
- att = attachment_map.get(attachment_id)
- if not att:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"发票附件 {attachment_id} 不存在",
- )
- if att.biz_id != invoice_id:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail=f"附件 {attachment_id} 不属于发票 {invoice_id}",
- )
- db.query(BxReimburseInvoice).filter(
- BxReimburseInvoice.reimburse_id == reimburse_id,
- BxReimburseInvoice.tenant_no == tenant_no,
- BxReimburseInvoice.del_flag == 0,
- ).update({"del_flag": 1, "update_by": user.id}, synchronize_session=False)
- for invoice_id, attachment_id in links:
- db.add(
- BxReimburseInvoice(
- id=generate_uuid(),
- reimburse_id=reimburse_id,
- invoice_id=invoice_id,
- attachment_id=attachment_id,
- tenant_no=tenant_no,
- create_by=user.id,
- create_dept_id=user.dept_id or "",
- )
- )
- db.flush()
- @staticmethod
- def get_linked_invoice_ids(db: Session, reimburse_id: str, tenant_no: str) -> List[str]:
- rows = (
- db.query(BxReimburseInvoice.invoice_id)
- .filter(
- BxReimburseInvoice.reimburse_id == reimburse_id,
- BxReimburseInvoice.tenant_no == tenant_no,
- BxReimburseInvoice.del_flag == 0,
- )
- .order_by(BxReimburseInvoice.create_time.asc())
- .all()
- )
- # 同一发票多附件时去重,保持首次出现顺序
- seen: set[str] = set()
- ordered: List[str] = []
- for (invoice_id,) in rows:
- if invoice_id in seen:
- continue
- seen.add(invoice_id)
- ordered.append(invoice_id)
- return ordered
- @staticmethod
- def get_linked_invoices_brief(
- db: Session, reimburse_id: str, tenant_no: str
- ) -> List[ReimburseInvoiceBriefOut]:
- rows = (
- db.query(BxReimburseInvoice, BxInvoice)
- .join(
- BxInvoice,
- and_(
- BxInvoice.id == BxReimburseInvoice.invoice_id,
- BxInvoice.tenant_no == tenant_no,
- BxInvoice.del_flag == 0,
- ),
- )
- .filter(
- BxReimburseInvoice.reimburse_id == reimburse_id,
- BxReimburseInvoice.tenant_no == tenant_no,
- BxReimburseInvoice.del_flag == 0,
- )
- .order_by(BxReimburseInvoice.create_time.asc())
- .all()
- )
- if not rows:
- return []
- explicit_att_ids = [
- link.attachment_id for link, _ in rows if (link.attachment_id or "").strip()
- ]
- invoice_ids_need_fallback = [
- invoice.id
- for link, invoice in rows
- if not (link.attachment_id or "").strip()
- ]
- att_by_id: Dict[str, BxAttachment] = {}
- if explicit_att_ids:
- for att in (
- db.query(BxAttachment)
- .filter(
- BxAttachment.id.in_(explicit_att_ids),
- BxAttachment.tenant_no == tenant_no,
- BxAttachment.del_flag == 0,
- )
- .all()
- ):
- att_by_id[att.id] = att
- first_att_by_invoice: Dict[str, BxAttachment] = {}
- if invoice_ids_need_fallback:
- fallback_atts = (
- db.query(BxAttachment)
- .filter(
- BxAttachment.biz_type == "invoice",
- BxAttachment.biz_id.in_(invoice_ids_need_fallback),
- BxAttachment.tenant_no == tenant_no,
- BxAttachment.del_flag == 0,
- )
- .order_by(BxAttachment.sort_order.asc(), BxAttachment.create_time.asc())
- .all()
- )
- for att in fallback_atts:
- first_att_by_invoice.setdefault(att.biz_id, att)
- result: List[ReimburseInvoiceBriefOut] = []
- sort_keys: List[Tuple[datetime, str]] = []
- for link, invoice in rows:
- att: Optional[BxAttachment] = None
- aid = (link.attachment_id or "").strip()
- if aid:
- att = att_by_id.get(aid)
- if att is None:
- att = first_att_by_invoice.get(invoice.id)
- # 固定顺序:附件上传时间优先,否则发票创建时间;同时间按附件/发票 ID
- ts = att.create_time if att and att.create_time else invoice.create_time
- sort_keys.append((ts or datetime.min, str(att.id if att else invoice.id)))
- result.append(
- ReimburseInvoiceBriefOut(
- id=invoice.id,
- amount=float(invoice.amount),
- owner_user_name=invoice.owner_user_name,
- remark=invoice.remark,
- attachment_id=att.id if att else (aid or None),
- file_name=(att.file_name if att else None),
- file_type=(att.file_type if att else None),
- create_time=ts,
- )
- )
- # 与发票夹选择弹窗一致:按时间从新到旧
- ordered = sorted(
- range(len(result)),
- key=lambda i: (sort_keys[i][0], sort_keys[i][1]),
- reverse=True,
- )
- return [result[i] for i in ordered]
- @staticmethod
- def get_linked_invoice_ids_map(
- db: Session, reimburse_ids: List[str], tenant_no: str
- ) -> Dict[str, List[str]]:
- if not reimburse_ids:
- return {}
- rows = (
- db.query(BxReimburseInvoice.reimburse_id, BxReimburseInvoice.invoice_id)
- .filter(
- BxReimburseInvoice.reimburse_id.in_(reimburse_ids),
- BxReimburseInvoice.tenant_no == tenant_no,
- BxReimburseInvoice.del_flag == 0,
- )
- .order_by(BxReimburseInvoice.create_time.asc())
- .all()
- )
- grouped: Dict[str, List[str]] = {}
- for reimburse_id, invoice_id in rows:
- bucket = grouped.setdefault(reimburse_id, [])
- if invoice_id not in bucket:
- bucket.append(invoice_id)
- return grouped
- @staticmethod
- def submit(
- db: Session,
- apply_id: str,
- user: BxUserContext,
- *,
- invoice_ids: Optional[List[str]] = None,
- invoice_links: Optional[Sequence[ReimburseInvoiceLinkIn]] = None,
- ) -> Optional[BxReimburseApply]:
- tenant_no = ReimburseService._tenant_no(db, user)
- apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
- if not apply_obj:
- return None
- if invoice_links is not None or invoice_ids is not None:
- ReimburseService.sync_reimburse_invoices(
- db,
- apply_id,
- invoice_ids,
- tenant_no,
- user,
- applicant_id=apply_obj.applicant_id,
- invoice_links=invoice_links,
- )
- if not ReimburseService._has_debit_reimburse(db, tenant_no, apply_id):
- amount = Decimal(str(apply_obj.amount or 0))
- InvoiceLedgerService.debit_reimburse(
- db,
- tenant_no=tenant_no,
- owner_user_id=apply_obj.applicant_id,
- amount=amount,
- reimburse_id=apply_id,
- user=user,
- )
- apply_obj.status = "pending"
- if not apply_obj.apply_code:
- apply_obj.apply_code = generate_apply_code(db, "BX", tenant_no)
- apply_obj.update_by = user.id
- if apply_obj.wf_id:
- nodes = ReimburseService._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[BxReimburseApply]:
- tenant_no = ReimburseService._tenant_no(db, approver)
- apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, skip_scope=True)
- if not apply_obj or not apply_obj.wf_id:
- return None
- nodes = ReimburseService._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 = BxReimburseApproval(
- 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[BxReimburseApply]:
- tenant_no = ReimburseService._tenant_no(db, approver)
- apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, skip_scope=True)
- if not apply_obj or not apply_obj.wf_id:
- return None
- nodes = ReimburseService._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 = BxReimburseApproval(
- 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
- InvoiceLedgerService.release_reimburse(
- db, tenant_no=tenant_no, reimburse_id=apply_id, user=approver
- )
- db.flush()
- return apply_obj
- @staticmethod
- def withdraw(db: Session, apply_id: str, user: BxUserContext) -> Optional[BxReimburseApply]:
- tenant_no = ReimburseService._tenant_no(db, user)
- apply_obj = ReimburseService.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
- InvoiceLedgerService.release_reimburse(
- db, tenant_no=tenant_no, reimburse_id=apply_id, user=user
- )
- db.flush()
- return apply_obj
- @staticmethod
- def _approval_record_dict(approval: BxReimburseApproval, apply_id: str) -> Dict[str, Any]:
- return {
- "id": approval.id,
- "reimburse_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[BxReimburseApproval],
- ) -> Dict[int, BxReimburseApproval]:
- """同一节点多次审批时,取 approval_time 最新的一条作为当前节点状态。"""
- approval_map: Dict[int, BxReimburseApproval] = {}
- 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[BxReimburseApproval]:
- return (
- db.query(BxReimburseApproval)
- .filter(
- BxReimburseApproval.apply_id == apply_id,
- BxReimburseApproval.tenant_no == tenant_no,
- BxReimburseApproval.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 = ReimburseService.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 = ReimburseService._workflow_nodes(db, wf.id, tenant_no)
- if not nodes:
- return []
- approvals = ReimburseService._query_approvals(db, apply_id, tenant_no)
- cycle_approvals = filter_current_cycle_approvals(approvals, apply_obj.status)
- approval_map = ReimburseService._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(ReimburseService._approval_record_dict(approval, apply_id))
- else:
- result.append(
- {
- "id": "0",
- "reimburse_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 ReimburseService.get_by_id(db, apply_id, tenant_no, user=user):
- return []
- approvals = (
- db.query(BxReimburseApproval)
- .filter(
- BxReimburseApproval.apply_id == apply_id,
- BxReimburseApproval.tenant_no == tenant_no,
- BxReimburseApproval.del_flag == 0,
- )
- .order_by(BxReimburseApproval.approval_time.asc(), BxReimburseApproval.create_time.asc())
- .all()
- )
- return [ReimburseService._approval_record_dict(a, apply_id) for a in approvals]
- @staticmethod
- def create_with_details(
- db: Session,
- obj_in: dict,
- details: List[dict],
- *,
- user: BxUserContext,
- applicant_id: Optional[str] = None,
- applicant_name: Optional[str] = None,
- ) -> BxReimburseApply:
- tenant_no = ReimburseService._tenant_no(db, user)
- data = dict(obj_in)
- total_amount = sum(float(d.get("amount", 0) or 0) for d in details)
- reimburse_date = parse_reimburse_date(data.pop("reimburse_date", None))
- requested_title = data.pop("title", None)
- first_detail = details[0] if details else {}
- resolved_id = applicant_id or user.id
- resolved_name = applicant_name or user.name
- apply_obj: BxReimburseApply | None = None
- for attempt in range(5):
- title, title_seq = resolve_reimburse_title(
- db, resolved_name, tenant_no, reimburse_date, requested_title
- )
- apply_obj = BxReimburseApply(
- id=generate_uuid(),
- title=title,
- reimburse_date=reimburse_date,
- title_seq=title_seq,
- amount=total_amount,
- expense_id=str(first_detail.get("expense_id") or ""),
- expense_name=first_detail.get("expense_name") or "",
- applicant_id=resolved_id,
- applicant_name=resolved_name,
- tenant_no=tenant_no,
- create_by=user.id,
- create_dept_id=user.dept_id or "",
- **data,
- )
- db.add(apply_obj)
- try:
- db.flush()
- break
- except IntegrityError:
- db.rollback()
- if attempt >= 4:
- raise
- continue
- if apply_obj is None:
- raise RuntimeError("报销申请创建失败")
- for d in details:
- detail = BxReimburseDetail(
- id=generate_uuid(),
- reimburse_id=apply_obj.id,
- expense_id=str(d["expense_id"]),
- expense_name=d["expense_name"],
- amount=d["amount"],
- remark=d.get("remark"),
- tenant_no=tenant_no,
- create_by=user.id,
- create_dept_id=user.dept_id or "",
- )
- db.add(detail)
- db.flush()
- return apply_obj
- @staticmethod
- def update_with_details(
- db: Session,
- apply_id: str,
- obj_in: dict,
- details: Optional[List[dict]],
- *,
- user: BxUserContext,
- ) -> Optional[BxReimburseApply]:
- tenant_no = ReimburseService._tenant_no(db, user)
- apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
- if not apply_obj:
- return None
- reimburse_date = None
- date_changed = False
- if "reimburse_date" in obj_in:
- reimburse_date = parse_reimburse_date(obj_in.pop("reimburse_date"))
- date_changed = reimburse_date != apply_obj.reimburse_date
- requested_title = obj_in.pop("title", None)
- prev_applicant_id = apply_obj.applicant_id
- for key, value in obj_in.items():
- setattr(apply_obj, key, value)
- if reimburse_date is not None:
- apply_obj.reimburse_date = reimburse_date
- if requested_title is not None:
- apply_obj.title = str(requested_title).strip()
- applicant_changed = apply_obj.applicant_id != prev_applicant_id
- should_regen_title = (date_changed or applicant_changed) and is_auto_reimburse_title(
- apply_obj.title
- )
- if should_regen_title:
- seq = allocate_reimburse_title_seq(
- db, tenant_no, apply_obj.reimburse_date, exclude_apply_id=apply_id
- )
- apply_obj.title_seq = seq
- apply_obj.title = format_reimburse_title(
- apply_obj.applicant_name, apply_obj.reimburse_date, seq
- )
- elif requested_title is not None and not is_auto_reimburse_title(apply_obj.title):
- apply_obj.title_seq = None
- if details is not None:
- db.query(BxReimburseDetail).filter(
- BxReimburseDetail.reimburse_id == apply_id,
- BxReimburseDetail.tenant_no == tenant_no,
- BxReimburseDetail.del_flag == 0,
- ).update({"del_flag": 1}, synchronize_session=False)
- total_amount = Decimal("0")
- first_expense_name = ""
- first_expense_id = ""
- for i, d in enumerate(details):
- detail = BxReimburseDetail(
- id=generate_uuid(),
- reimburse_id=apply_id,
- expense_id=str(d["expense_id"]),
- expense_name=d["expense_name"],
- amount=d["amount"],
- remark=d.get("remark"),
- tenant_no=tenant_no,
- create_by=user.id,
- create_dept_id=user.dept_id or "",
- )
- db.add(detail)
- total_amount += Decimal(str(d["amount"]))
- if i == 0:
- first_expense_name = d["expense_name"]
- first_expense_id = str(d["expense_id"])
- apply_obj.amount = total_amount
- apply_obj.expense_name = first_expense_name
- apply_obj.expense_id = first_expense_id
- apply_obj.update_by = user.id
- db.flush()
- return apply_obj
- @staticmethod
- def get_details(db: Session, reimburse_id: str, tenant_no: str) -> List[BxReimburseDetail]:
- return (
- db.query(BxReimburseDetail)
- .filter(
- BxReimburseDetail.reimburse_id == reimburse_id,
- BxReimburseDetail.tenant_no == tenant_no,
- BxReimburseDetail.del_flag == 0,
- )
- .order_by(BxReimburseDetail.create_time)
- .all()
- )
- @staticmethod
- def build_expense_name_condition(
- db: Session, tenant_no: str, expense_name: Optional[str]
- ):
- name_kw = str(expense_name or "").strip()
- if not name_kw:
- return None
- detail_reimburse_ids = (
- db.query(BxReimburseDetail.reimburse_id)
- .filter(
- BxReimburseDetail.tenant_no == tenant_no,
- BxReimburseDetail.del_flag == 0,
- BxReimburseDetail.expense_name.contains(name_kw),
- )
- )
- return or_(
- BxReimburseApply.expense_name.contains(name_kw),
- BxReimburseApply.id.in_(detail_reimburse_ids),
- )
- @staticmethod
- def get_details_map_by_reimburse_ids(
- db: Session, reimburse_ids: List[str], tenant_no: str
- ) -> Dict[str, List[BxReimburseDetail]]:
- if not reimburse_ids:
- return {}
- detail_rows = (
- db.query(BxReimburseDetail)
- .filter(
- BxReimburseDetail.reimburse_id.in_(reimburse_ids),
- BxReimburseDetail.tenant_no == tenant_no,
- BxReimburseDetail.del_flag == 0,
- )
- .order_by(BxReimburseDetail.create_time)
- .all()
- )
- grouped: Dict[str, List[BxReimburseDetail]] = {}
- for detail in detail_rows:
- grouped.setdefault(detail.reimburse_id, []).append(detail)
- return grouped
- @staticmethod
- def out_rows_with_attachment_counts(
- db: Session, tenant_no: str, rows: List[BxReimburseApply]
- ) -> List[ReimburseApplyOut]:
- if not rows:
- return []
- ids = [r.id for r in rows]
- counts = AttachmentService.count_invoice_attach_by_biz_ids(
- db, "reimburse_apply", ids, tenant_no
- )
- details_map = ReimburseService.get_details_map_by_reimburse_ids(db, ids, tenant_no)
- invoice_ids_map = ReimburseService.get_linked_invoice_ids_map(db, ids, tenant_no)
- out_list: List[ReimburseApplyOut] = []
- for row in rows:
- out = ReimburseApplyOut.model_validate(row)
- inv, att = counts.get(row.id, (0, 0))
- detail_rows = details_map.get(row.id, [])
- linked_ids = invoice_ids_map.get(row.id, [])
- out_list.append(
- out.model_copy(
- update={
- "invoice_count": inv,
- "attach_count": att,
- "details": [ReimburseDetailOut.model_validate(d) for d in detail_rows],
- "invoice_ids": linked_ids,
- }
- )
- )
- return out_list
- @staticmethod
- def out_one_with_attachment_counts(
- db: Session, tenant_no: str, row: BxReimburseApply
- ) -> ReimburseApplyOut:
- return ReimburseService.out_rows_with_attachment_counts(db, tenant_no, [row])[0]
- @staticmethod
- def build_apply_out_with_details(
- db: Session, tenant_no: str, reimburse: BxReimburseApply
- ) -> ReimburseApplyOut:
- out = ReimburseService.out_one_with_attachment_counts(db, tenant_no, reimburse)
- detail_rows = ReimburseService.get_details(db, reimburse.id, tenant_no)
- out.details = [ReimburseDetailOut.model_validate(d) for d in detail_rows]
- out.invoice_ids = ReimburseService.get_linked_invoice_ids(db, reimburse.id, tenant_no)
- out.invoices = ReimburseService.get_linked_invoices_brief(db, reimburse.id, tenant_no)
- return out
- @staticmethod
- def global_status_counts(db: Session, tenant_no: str, *, user: BxUserContext) -> Dict[str, int]:
- query = db.query(BxReimburseApply.status, func.count()).filter(
- BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no
- )
- query = apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
- rows = query.group_by(BxReimburseApply.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(BxReimburseApply).filter(
- BxReimburseApply.del_flag == 0,
- BxReimburseApply.tenant_no == tenant_no,
- or_(
- BxReimburseApply.applicant_id == user.id,
- BxReimburseApply.create_by == user.id,
- ),
- )
- return apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
- @staticmethod
- def my_apply_stats(db: Session, user: BxUserContext, tenant_no: str) -> dict:
- base_q = ReimburseService._my_apply_base(db, user, tenant_no).subquery()
- base = BxReimburseApply.id.in_(db.query(base_q.c.id))
- total_count = (
- db.query(func.count()).select_from(BxReimburseApply).filter(base).scalar() or 0
- )
- pending = (
- db.query(func.count())
- .select_from(BxReimburseApply)
- .filter(base, BxReimburseApply.status == "pending")
- .scalar()
- or 0
- )
- approved_count = (
- db.query(func.count())
- .select_from(BxReimburseApply)
- .filter(base, BxReimburseApply.status.in_(_REIMBURSE_MY_STATS_PASSED_STATUSES))
- .scalar()
- or 0
- )
- rejected_count = (
- db.query(func.count())
- .select_from(BxReimburseApply)
- .filter(base, BxReimburseApply.status == "rejected")
- .scalar()
- or 0
- )
- approved_amount = (
- db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
- .filter(base, BxReimburseApply.status.in_(_REIMBURSE_MY_STATS_PASSED_STATUSES))
- .scalar()
- or 0
- )
- m0, m1 = current_month_bounds()
- month_apply_amount = (
- db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
- .filter(base, BxReimburseApply.create_time >= m0, BxReimburseApply.create_time < m1)
- .scalar()
- or 0
- )
- month_apply_count = (
- db.query(func.count())
- .select_from(BxReimburseApply)
- .filter(base, BxReimburseApply.create_time >= m0, BxReimburseApply.create_time < m1)
- .scalar()
- or 0
- )
- return {
- "total_count": int(total_count),
- "pending_count": int(pending),
- "approved_count": int(approved_count),
- "rejected_count": int(rejected_count),
- "approved_amount": float(approved_amount),
- "month_apply_amount": float(month_apply_amount),
- "month_apply_count": int(month_apply_count),
- }
- @staticmethod
- def pending_approval_stats(db: Session, user: BxUserContext, tenant_no: str) -> dict:
- subquery = (
- db.query(BxReimburseApply.id)
- .join(
- BxWorkflowNode,
- and_(
- BxWorkflowNode.wf_id == BxReimburseApply.wf_id,
- BxWorkflowNode.node_name == BxReimburseApply.current_node,
- BxWorkflowNode.tenant_no == tenant_no,
- BxWorkflowNode.del_flag == 0,
- ),
- )
- .filter(
- BxReimburseApply.del_flag == 0,
- BxReimburseApply.tenant_no == tenant_no,
- BxReimburseApply.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(BxReimburseApply.amount), 0))
- .join(subquery, BxReimburseApply.id == subquery.c.id)
- .filter(BxReimburseApply.create_time >= m0, BxReimburseApply.create_time < m1)
- .scalar()
- or 0
- )
- return {"pending_total": int(total), "month_pending_amount": float(month_sum)}
- @staticmethod
- def pay(
- db: Session,
- apply_id: str,
- user: BxUserContext,
- pay_remark: str = "",
- actual_amount: Optional[float] = None,
- ) -> Optional[BxReimburseApply]:
- tenant_no = ReimburseService._tenant_no(db, user)
- apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
- if not apply_obj:
- return None
- apply_obj.status = "processed"
- apply_obj.update_by = user.id
- apply_obj.pay_status = "paid"
- apply_obj.pay_time = datetime.now()
- apply_obj.pay_user_id = user.id
- apply_obj.pay_user_name = user.name
- if actual_amount is not None:
- apply_obj.actual_amount = actual_amount
- db.flush()
- return apply_obj
- @staticmethod
- def offset(
- db: Session,
- apply_id: str,
- advance_id: str,
- amount: float,
- user: BxUserContext,
- remark: str = "",
- ) -> Optional[BxReimburseApply]:
- tenant_no = ReimburseService._tenant_no(db, user)
- apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
- if not apply_obj:
- return None
- apply_obj.status = "offset"
- apply_obj.offset_advance_id = advance_id
- apply_obj.offset_amount = amount
- apply_obj.update_by = user.id
- offset_record = BxReimburseOffset(
- id=generate_uuid(),
- reimburse_id=apply_id,
- advance_id=advance_id,
- offset_amount=amount,
- remark=remark or None,
- tenant_no=tenant_no,
- create_by=user.name,
- create_dept_id=user.dept_id or "",
- )
- db.add(offset_record)
- db.flush()
- return apply_obj
- @staticmethod
- def batch_offset(
- db: Session,
- apply_id: str,
- items: List[dict],
- pay_amount: float,
- user: BxUserContext,
- remark: str = "",
- ) -> Optional[BxReimburseApply]:
- tenant_no = ReimburseService._tenant_no(db, user)
- apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
- if not apply_obj:
- return None
- has_offsets = bool(items)
- total_offset = 0.0
- first_advance_id = None
- for item in items:
- advance_id = str(item["advance_id"])
- offset_amount = float(item["offset_amount"])
- if first_advance_id is None:
- first_advance_id = advance_id
- total_offset += offset_amount
- offset_record = BxReimburseOffset(
- id=generate_uuid(),
- reimburse_id=apply_id,
- advance_id=advance_id,
- offset_amount=offset_amount,
- remark=remark or None,
- tenant_no=tenant_no,
- create_by=user.name,
- create_dept_id=user.dept_id or "",
- )
- db.add(offset_record)
- if first_advance_id is not None:
- apply_obj.offset_advance_id = first_advance_id
- apply_obj.offset_amount = total_offset
- if pay_amount > 0:
- apply_obj.pay_status = "paid"
- apply_obj.pay_time = datetime.now()
- apply_obj.pay_user_id = user.id
- apply_obj.pay_user_name = user.name
- apply_obj.actual_amount = pay_amount
- apply_obj.status = "processed"
- elif has_offsets:
- apply_obj.actual_amount = 0
- apply_obj.status = "processed"
- apply_obj.update_by = user.id
- db.flush()
- return apply_obj
- @staticmethod
- def finance_payment_stats(db: Session, tenant_no: str, *, user: BxUserContext) -> dict:
- base_q = (
- db.query(BxReimburseApply)
- .filter(BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no)
- )
- base_q = apply_bx_scope(base_q, BxReimburseApply, user, db, tenant_no).subquery()
- base = BxReimburseApply.id.in_(db.query(base_q.c.id))
- pending = (
- db.query(func.count())
- .select_from(BxReimburseApply)
- .filter(base, BxReimburseApply.status == "approved")
- .scalar()
- or 0
- )
- bank_paid = BxReimburseApply.pay_time.isnot(None)
- paid = (
- db.query(func.count()).select_from(BxReimburseApply).filter(base, bank_paid).scalar() or 0
- )
- offset_settled = or_(
- BxReimburseApply.status == "offset",
- and_(
- BxReimburseApply.status == "processed",
- BxReimburseApply.pay_time.is_(None),
- func.coalesce(BxReimburseApply.offset_amount, 0) > 0,
- ),
- )
- offset = (
- db.query(func.count()).select_from(BxReimburseApply).filter(base, offset_settled).scalar() or 0
- )
- m0, m1 = current_month_bounds()
- month_paid = (
- db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
- .filter(base, bank_paid, BxReimburseApply.pay_time >= m0, BxReimburseApply.pay_time < m1)
- .scalar()
- or 0
- )
- month_offset = (
- db.query(func.coalesce(func.sum(BxReimburseApply.amount), 0))
- .filter(base, offset_settled, BxReimburseApply.update_time >= m0, BxReimburseApply.update_time < m1)
- .scalar()
- or 0
- )
- return {
- "pending_count": int(pending),
- "paid_count": int(paid),
- "offset_count": int(offset),
- "month_processed_amount": float(month_paid) + float(month_offset),
- }
|