feat: 新增预算后端服务与差旅风险规则库
后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额 查询,清理旧生成规则文件并替换为按严重等级分类的差旅风 险规则库,优化认证权限和报销单访问策略,新增财务规则目 录和演示数据构建脚本,前端预算中心增加对话框交互,完善 审计页面运行时模型和元数据展示,补充单元测试。
This commit is contained in:
623
server/src/app/services/budget_support.py
Normal file
623
server/src/app/services/budget_support.py
Normal file
@@ -0,0 +1,623 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.schemas.budget import BudgetAllocationRead, BudgetTransactionRead
|
||||
from app.services.budget_types import (
|
||||
BUDGET_SUBJECT_LABELS,
|
||||
BudgetBalance,
|
||||
DEFAULT_SUBJECT_AMOUNTS,
|
||||
SUBJECT_CODE_ALIASES,
|
||||
SUPPORTED_BUDGET_SUBJECT_CODES,
|
||||
)
|
||||
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
||||
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||
|
||||
|
||||
class BudgetSupportMixin:
|
||||
def serialize_allocation(self, allocation: BudgetAllocation) -> BudgetAllocationRead:
|
||||
return BudgetAllocationRead(
|
||||
id=allocation.id,
|
||||
budget_no=allocation.budget_no,
|
||||
fiscal_year=allocation.fiscal_year,
|
||||
period_type=allocation.period_type,
|
||||
period_key=allocation.period_key,
|
||||
department_id=allocation.department_id,
|
||||
department_name=allocation.department_name,
|
||||
cost_center=allocation.cost_center,
|
||||
project_code=allocation.project_code,
|
||||
subject_code=allocation.subject_code,
|
||||
subject_name=allocation.subject_name,
|
||||
original_amount=self._money(allocation.original_amount),
|
||||
adjusted_amount=self._money(allocation.adjusted_amount),
|
||||
status=allocation.status,
|
||||
warning_threshold=self._percent(allocation.warning_threshold),
|
||||
control_action=allocation.control_action,
|
||||
description=allocation.description,
|
||||
balance=self.get_balance(allocation).to_read(),
|
||||
created_at=allocation.created_at,
|
||||
updated_at=allocation.updated_at,
|
||||
)
|
||||
|
||||
def get_balance(self, allocation: BudgetAllocation) -> BudgetBalance:
|
||||
reservations = self.db.scalars(
|
||||
select(BudgetReservation).where(
|
||||
BudgetReservation.allocation_id == allocation.id,
|
||||
BudgetReservation.source_status == "active",
|
||||
)
|
||||
).all()
|
||||
transactions = self.db.scalars(
|
||||
select(BudgetTransaction).where(BudgetTransaction.allocation_id == allocation.id)
|
||||
).all()
|
||||
reserved_amount = sum((self._money(item.amount) for item in reservations), Decimal("0.00"))
|
||||
consumed_amount = Decimal("0.00")
|
||||
for transaction in transactions:
|
||||
transaction_type = str(transaction.transaction_type or "").strip().lower()
|
||||
amount = self._money(transaction.amount)
|
||||
if transaction_type == "consume":
|
||||
consumed_amount += amount
|
||||
elif transaction_type == "rollback":
|
||||
consumed_amount -= amount
|
||||
total_amount = self._money(allocation.original_amount) + self._money(allocation.adjusted_amount)
|
||||
available_amount = total_amount - reserved_amount - consumed_amount
|
||||
usage_amount = reserved_amount + consumed_amount
|
||||
usage_rate = Decimal("0.00")
|
||||
if total_amount > Decimal("0.00"):
|
||||
usage_rate = ((usage_amount / total_amount) * Decimal("100")).quantize(Decimal("0.01"))
|
||||
return BudgetBalance(
|
||||
total_amount=total_amount,
|
||||
reserved_amount=reserved_amount,
|
||||
consumed_amount=consumed_amount,
|
||||
available_amount=available_amount,
|
||||
usage_rate=usage_rate,
|
||||
)
|
||||
|
||||
def list_transactions(self, allocation_id: str) -> list[BudgetTransactionRead]:
|
||||
self.ensure_budget_ready()
|
||||
rows = self.db.scalars(
|
||||
select(BudgetTransaction)
|
||||
.where(BudgetTransaction.allocation_id == allocation_id)
|
||||
.order_by(BudgetTransaction.created_at.desc())
|
||||
).all()
|
||||
return [BudgetTransactionRead.model_validate(row) for row in rows]
|
||||
|
||||
def get_allocation_row(self, allocation_id: str) -> BudgetAllocation | None:
|
||||
self.ensure_budget_ready()
|
||||
return self.db.get(BudgetAllocation, allocation_id)
|
||||
|
||||
def _review_allocation_amount(
|
||||
self,
|
||||
allocation: BudgetAllocation,
|
||||
amount: Decimal,
|
||||
) -> dict[str, list[Any]]:
|
||||
balance = self.get_balance(allocation)
|
||||
flags: list[dict[str, Any]] = []
|
||||
blocking_reasons: list[str] = []
|
||||
if str(allocation.status or "").strip().lower() == "frozen":
|
||||
message = f"预算 {allocation.budget_no} 已冻结,不能继续占用。"
|
||||
flags.append(
|
||||
self._build_operation_flag(
|
||||
allocation,
|
||||
event_type="budget_frozen",
|
||||
label="预算已冻结",
|
||||
message=message,
|
||||
severity="high",
|
||||
amount=amount,
|
||||
)
|
||||
)
|
||||
blocking_reasons.append(message)
|
||||
return {"flags": flags, "blocking_reasons": blocking_reasons}
|
||||
|
||||
if amount > balance.available_amount:
|
||||
over_amount = amount - balance.available_amount
|
||||
message = (
|
||||
f"预算 {allocation.budget_no} 可用余额 {balance.available_amount} 元,"
|
||||
f"当前单据金额 {amount} 元,超出 {over_amount} 元。"
|
||||
)
|
||||
flags.append(
|
||||
self._build_operation_flag(
|
||||
allocation,
|
||||
event_type="budget_insufficient",
|
||||
label="预算余额不足",
|
||||
message=message,
|
||||
severity="high",
|
||||
amount=amount,
|
||||
extra={"available_amount": str(balance.available_amount), "over_budget_amount": str(over_amount)},
|
||||
)
|
||||
)
|
||||
blocking_reasons.append(message)
|
||||
return {"flags": flags, "blocking_reasons": blocking_reasons}
|
||||
|
||||
after_usage = balance.reserved_amount + balance.consumed_amount + amount
|
||||
usage_rate = Decimal("0.00")
|
||||
if balance.total_amount > Decimal("0.00"):
|
||||
usage_rate = ((after_usage / balance.total_amount) * Decimal("100")).quantize(Decimal("0.01"))
|
||||
if usage_rate >= self._percent(allocation.warning_threshold):
|
||||
flags.append(
|
||||
self._build_operation_flag(
|
||||
allocation,
|
||||
event_type="budget_warning",
|
||||
label="预算接近预警线",
|
||||
message=(
|
||||
f"预算 {allocation.budget_no} 本次占用后使用率预计达到 {usage_rate}%,"
|
||||
f"已达到预警线 {allocation.warning_threshold}%。"
|
||||
),
|
||||
severity="medium",
|
||||
amount=amount,
|
||||
extra={"usage_rate": str(usage_rate)},
|
||||
)
|
||||
)
|
||||
return {"flags": flags, "blocking_reasons": blocking_reasons}
|
||||
|
||||
def build_claim_budget_context(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||
self.ensure_budget_ready()
|
||||
amount = self._money(claim.amount or Decimal("0.00"))
|
||||
fiscal_year, period_key = self._period_from_claim(claim)
|
||||
subject_code = self._subject_code_from_claim(claim)
|
||||
if not self._is_supported_budget_subject(subject_code):
|
||||
return {
|
||||
"matched": False,
|
||||
"budget_applicable": False,
|
||||
"skip_reason": "demo_budget_subject_not_enabled",
|
||||
"claim_amount": str(amount),
|
||||
"fiscal_year": fiscal_year,
|
||||
"period_key": period_key,
|
||||
"subject_code": subject_code,
|
||||
"department_id": claim.department_id,
|
||||
"department_name": claim.department_name,
|
||||
"cost_center": self._resolve_claim_cost_center(claim),
|
||||
}
|
||||
|
||||
allocation = self._find_allocation_for_claim(claim)
|
||||
if allocation is None:
|
||||
return {
|
||||
"matched": False,
|
||||
"budget_applicable": True,
|
||||
"claim_amount": str(amount),
|
||||
"fiscal_year": fiscal_year,
|
||||
"period_key": period_key,
|
||||
"subject_code": subject_code,
|
||||
"department_id": claim.department_id,
|
||||
"department_name": claim.department_name,
|
||||
"cost_center": self._resolve_claim_cost_center(claim),
|
||||
}
|
||||
|
||||
balance = self.get_balance(allocation)
|
||||
over_budget_amount = max(amount - balance.available_amount, Decimal("0.00"))
|
||||
return {
|
||||
"matched": True,
|
||||
"budget_applicable": True,
|
||||
"allocation_id": allocation.id,
|
||||
"budget_no": allocation.budget_no,
|
||||
"claim_amount": str(amount),
|
||||
"total_amount": str(balance.total_amount),
|
||||
"reserved_amount": str(balance.reserved_amount),
|
||||
"consumed_amount": str(balance.consumed_amount),
|
||||
"available_amount": str(balance.available_amount),
|
||||
"usage_rate": str(balance.usage_rate),
|
||||
"over_budget_amount": str(over_budget_amount),
|
||||
"warning_threshold": str(allocation.warning_threshold),
|
||||
"control_action": allocation.control_action,
|
||||
"fiscal_year": allocation.fiscal_year,
|
||||
"period_key": allocation.period_key,
|
||||
"subject_code": allocation.subject_code,
|
||||
"subject_name": allocation.subject_name,
|
||||
"department_id": allocation.department_id,
|
||||
"department_name": allocation.department_name,
|
||||
"cost_center": allocation.cost_center,
|
||||
"project_code": allocation.project_code,
|
||||
}
|
||||
|
||||
def _find_allocation_for_claim(self, claim: ExpenseClaim) -> BudgetAllocation | None:
|
||||
fiscal_year, period_key = self._period_from_claim(claim)
|
||||
return self._find_allocation_for_dimension(
|
||||
fiscal_year=fiscal_year,
|
||||
period_key=period_key,
|
||||
department_id=claim.department_id,
|
||||
department_name=claim.department_name,
|
||||
cost_center=self._resolve_claim_cost_center(claim),
|
||||
project_code=claim.project_code,
|
||||
subject_code=self._subject_code_from_claim(claim),
|
||||
)
|
||||
|
||||
def _find_allocation_for_dimension(
|
||||
self,
|
||||
*,
|
||||
fiscal_year: int | None,
|
||||
period_key: str | None,
|
||||
department_id: str | None,
|
||||
department_name: str | None,
|
||||
cost_center: str | None,
|
||||
project_code: str | None,
|
||||
subject_code: str,
|
||||
) -> BudgetAllocation | None:
|
||||
now = datetime.now(UTC)
|
||||
year = fiscal_year or now.year
|
||||
key = self._normalize_period_key(year, period_key or self._quarter_key(year, now.month))
|
||||
normalized_subject = self._normalize_subject_code(subject_code)
|
||||
candidates = list(
|
||||
self.db.scalars(
|
||||
select(BudgetAllocation)
|
||||
.where(BudgetAllocation.fiscal_year == year)
|
||||
.where(BudgetAllocation.period_key == key)
|
||||
.where(BudgetAllocation.subject_code == normalized_subject)
|
||||
.where(BudgetAllocation.status.in_(["active", "published"]))
|
||||
.order_by(BudgetAllocation.project_code.desc().nullslast())
|
||||
).all()
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
normalized_department_id = self._blank_to_none(department_id)
|
||||
normalized_department_name = str(department_name or "").strip()
|
||||
normalized_cost_center = self._blank_to_none(cost_center)
|
||||
normalized_project_code = self._blank_to_none(project_code)
|
||||
for item in candidates:
|
||||
if normalized_project_code and item.project_code and item.project_code != normalized_project_code:
|
||||
continue
|
||||
if normalized_department_id and item.department_id == normalized_department_id:
|
||||
return item
|
||||
if normalized_cost_center and item.cost_center == normalized_cost_center:
|
||||
return item
|
||||
if normalized_department_name and item.department_name == normalized_department_name:
|
||||
return item
|
||||
return None
|
||||
|
||||
def _find_exact_allocation(
|
||||
self,
|
||||
*,
|
||||
fiscal_year: int,
|
||||
period_key: str,
|
||||
department_id: str | None,
|
||||
department_name: str,
|
||||
cost_center: str | None,
|
||||
project_code: str | None,
|
||||
subject_code: str,
|
||||
) -> BudgetAllocation | None:
|
||||
rows = self.db.scalars(
|
||||
select(BudgetAllocation)
|
||||
.where(BudgetAllocation.fiscal_year == fiscal_year)
|
||||
.where(BudgetAllocation.period_key == period_key)
|
||||
.where(BudgetAllocation.subject_code == subject_code)
|
||||
).all()
|
||||
normalized_department_id = self._blank_to_none(department_id)
|
||||
normalized_department_name = department_name.strip()
|
||||
normalized_cost_center = self._blank_to_none(cost_center)
|
||||
normalized_project_code = self._blank_to_none(project_code)
|
||||
for row in rows:
|
||||
if row.project_code != normalized_project_code:
|
||||
continue
|
||||
if normalized_department_id and row.department_id == normalized_department_id:
|
||||
return row
|
||||
if normalized_cost_center and row.cost_center == normalized_cost_center:
|
||||
return row
|
||||
if row.department_name == normalized_department_name:
|
||||
return row
|
||||
return None
|
||||
|
||||
def _find_active_reservation(self, *, source_type: str, source_id: str) -> BudgetReservation | None:
|
||||
return self.db.scalar(
|
||||
select(BudgetReservation)
|
||||
.where(BudgetReservation.source_type == source_type)
|
||||
.where(BudgetReservation.source_id == source_id)
|
||||
.where(BudgetReservation.source_status == "active")
|
||||
.order_by(BudgetReservation.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
def _find_active_reservations(self, *, source_type: str, source_id: str) -> list[BudgetReservation]:
|
||||
return list(
|
||||
self.db.scalars(
|
||||
select(BudgetReservation)
|
||||
.where(BudgetReservation.source_type == source_type)
|
||||
.where(BudgetReservation.source_id == source_id)
|
||||
.where(BudgetReservation.source_status == "active")
|
||||
).all()
|
||||
)
|
||||
|
||||
def _seed_default_allocations(self) -> None:
|
||||
units = list(
|
||||
self.db.scalars(
|
||||
select(OrganizationUnit).where(OrganizationUnit.unit_type == "department")
|
||||
).all()
|
||||
)
|
||||
if not units:
|
||||
return
|
||||
year = datetime.now(UTC).year
|
||||
for unit in units:
|
||||
for quarter in range(1, 5):
|
||||
period_key = f"{year}Q{quarter}"
|
||||
for subject_code, amount in DEFAULT_SUBJECT_AMOUNTS.items():
|
||||
allocation = BudgetAllocation(
|
||||
budget_no=self._make_no("BUD"),
|
||||
fiscal_year=year,
|
||||
period_type="quarter",
|
||||
period_key=period_key,
|
||||
department_id=unit.id,
|
||||
department_name=unit.name,
|
||||
cost_center=unit.cost_center,
|
||||
project_code=None,
|
||||
subject_code=subject_code,
|
||||
subject_name=self._subject_label(subject_code),
|
||||
original_amount=amount,
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="block",
|
||||
description="系统初始化预算池额度",
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
self.db.add(allocation)
|
||||
self.db.flush()
|
||||
self._record_transaction(
|
||||
allocation=allocation,
|
||||
transaction_type="init",
|
||||
amount=amount,
|
||||
before_available=Decimal("0.00"),
|
||||
after_available=amount,
|
||||
source_type="budget_seed",
|
||||
source_id=allocation.id,
|
||||
source_no=allocation.budget_no,
|
||||
operator="system",
|
||||
reason="系统初始化预算池额度",
|
||||
)
|
||||
self.db.flush()
|
||||
|
||||
def _create_fallback_allocation_for_claim(self, claim: ExpenseClaim) -> BudgetAllocation:
|
||||
fiscal_year, period_key = self._period_from_claim(claim)
|
||||
subject_code = self._subject_code_from_claim(claim)
|
||||
allocation = BudgetAllocation(
|
||||
budget_no=self._make_no("BUD"),
|
||||
fiscal_year=fiscal_year,
|
||||
period_type="quarter",
|
||||
period_key=period_key,
|
||||
department_id=claim.department_id,
|
||||
department_name=str(claim.department_name or "未归属部门").strip() or "未归属部门",
|
||||
cost_center=self._resolve_claim_cost_center(claim),
|
||||
project_code=claim.project_code,
|
||||
subject_code=subject_code,
|
||||
subject_name=self._subject_label(subject_code),
|
||||
original_amount=DEFAULT_SUBJECT_AMOUNTS.get(subject_code, Decimal("100000.00")),
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="block",
|
||||
description="测试或演示环境自动补齐预算池额度",
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
self.db.add(allocation)
|
||||
self.db.flush()
|
||||
self._record_transaction(
|
||||
allocation=allocation,
|
||||
transaction_type="init",
|
||||
amount=allocation.original_amount,
|
||||
before_available=Decimal("0.00"),
|
||||
after_available=allocation.original_amount,
|
||||
source_type="budget_seed",
|
||||
source_id=allocation.id,
|
||||
source_no=allocation.budget_no,
|
||||
operator="system",
|
||||
reason="自动补齐预算池额度",
|
||||
)
|
||||
self.db.flush()
|
||||
return allocation
|
||||
|
||||
def _budget_table_empty(self) -> bool:
|
||||
return self.db.scalar(select(BudgetAllocation.id).limit(1)) is None
|
||||
|
||||
def _record_transaction(
|
||||
self,
|
||||
*,
|
||||
allocation: BudgetAllocation,
|
||||
transaction_type: str,
|
||||
amount: Decimal,
|
||||
before_available: Decimal,
|
||||
after_available: Decimal,
|
||||
source_type: str,
|
||||
source_id: str,
|
||||
source_no: str,
|
||||
operator: str | None,
|
||||
reason: str | None,
|
||||
reservation: BudgetReservation | None = None,
|
||||
context_json: dict[str, Any] | None = None,
|
||||
) -> BudgetTransaction:
|
||||
transaction = BudgetTransaction(
|
||||
transaction_no=self._make_no("BTX"),
|
||||
allocation_id=allocation.id,
|
||||
reservation_id=reservation.id if reservation is not None else None,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
source_no=source_no,
|
||||
transaction_type=transaction_type,
|
||||
amount=self._money(amount),
|
||||
before_available_amount=self._money(before_available),
|
||||
after_available_amount=self._money(after_available),
|
||||
operator=operator,
|
||||
reason=reason,
|
||||
context_json=context_json or {},
|
||||
)
|
||||
self.db.add(transaction)
|
||||
return transaction
|
||||
|
||||
@staticmethod
|
||||
def _build_budget_flag(
|
||||
*,
|
||||
event_type: str,
|
||||
severity: str,
|
||||
label: str,
|
||||
message: str,
|
||||
amount: Decimal,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"source": "budget_control",
|
||||
"event_type": event_type,
|
||||
"severity": severity,
|
||||
"label": label,
|
||||
"message": message,
|
||||
"amount": str(amount),
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
payload.update(extra or {})
|
||||
return payload
|
||||
|
||||
def _build_operation_flag(
|
||||
self,
|
||||
allocation: BudgetAllocation,
|
||||
*,
|
||||
event_type: str,
|
||||
label: str,
|
||||
message: str,
|
||||
amount: Decimal,
|
||||
severity: str = "info",
|
||||
reservation_id: str | None = None,
|
||||
transaction_id: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
balance = self.get_balance(allocation)
|
||||
payload = self._build_budget_flag(
|
||||
event_type=event_type,
|
||||
severity=severity,
|
||||
label=label,
|
||||
message=message,
|
||||
amount=amount,
|
||||
extra={
|
||||
"allocation_id": allocation.id,
|
||||
"budget_no": allocation.budget_no,
|
||||
"subject_code": allocation.subject_code,
|
||||
"subject_name": allocation.subject_name,
|
||||
"available_amount": str(balance.available_amount),
|
||||
"reserved_amount": str(balance.reserved_amount),
|
||||
"consumed_amount": str(balance.consumed_amount),
|
||||
**(extra or {}),
|
||||
},
|
||||
)
|
||||
if reservation_id:
|
||||
payload["reservation_id"] = reservation_id
|
||||
if transaction_id:
|
||||
payload["transaction_id"] = transaction_id
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _money(value: Any) -> Decimal:
|
||||
return Decimal(str(value or "0")).quantize(Decimal("0.01"))
|
||||
|
||||
@staticmethod
|
||||
def _percent(value: Any) -> Decimal:
|
||||
return Decimal(str(value or "0")).quantize(Decimal("0.01"))
|
||||
|
||||
@staticmethod
|
||||
def _blank_to_none(value: str | None) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
@staticmethod
|
||||
def _make_no(prefix: str) -> str:
|
||||
return f"{prefix}-{datetime.now(UTC).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_period_type(value: str | None) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
return text if text in {"month", "quarter", "year"} else "quarter"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_period_key(year: int, value: str | None) -> str:
|
||||
text = str(value or "").strip().upper().replace("年", "").replace("第", "").replace("季度", "")
|
||||
if text.startswith(str(year)) and "Q" in text:
|
||||
return text
|
||||
if text in {"Q1", "Q2", "Q3", "Q4"}:
|
||||
return f"{year}{text}"
|
||||
return text or f"{year}Q1"
|
||||
|
||||
@staticmethod
|
||||
def _quarter_key(year: int, month: int) -> str:
|
||||
quarter = ((max(1, min(month, 12)) - 1) // 3) + 1
|
||||
return f"{year}Q{quarter}"
|
||||
|
||||
def _period_from_claim(self, claim: ExpenseClaim) -> tuple[int, str]:
|
||||
occurred_at = claim.occurred_at or claim.submitted_at or datetime.now(UTC)
|
||||
return occurred_at.year, self._quarter_key(occurred_at.year, occurred_at.month)
|
||||
|
||||
def _subject_code_from_claim(self, claim: ExpenseClaim) -> str:
|
||||
expense_type = str(claim.expense_type or "").strip().lower()
|
||||
if expense_type.endswith("_application"):
|
||||
expense_type = expense_type.removesuffix("_application")
|
||||
expense_type = SUBJECT_CODE_ALIASES.get(expense_type, expense_type)
|
||||
if expense_type in DEFAULT_SUBJECT_AMOUNTS or expense_type in EXPENSE_TYPE_LABELS:
|
||||
return expense_type
|
||||
resolved = resolve_expense_type_code_from_text(expense_type)
|
||||
if resolved:
|
||||
return SUBJECT_CODE_ALIASES.get(resolved, resolved)
|
||||
return resolved or expense_type or "other"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_subject_code(value: str | None) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if text.endswith("_application"):
|
||||
text = text.removesuffix("_application")
|
||||
text = SUBJECT_CODE_ALIASES.get(text, text)
|
||||
resolved = resolve_expense_type_code_from_text(text)
|
||||
if resolved:
|
||||
return SUBJECT_CODE_ALIASES.get(resolved, resolved)
|
||||
return text or "other"
|
||||
|
||||
@staticmethod
|
||||
def _is_supported_budget_subject(subject_code: str | None) -> bool:
|
||||
return str(subject_code or "").strip().lower() in SUPPORTED_BUDGET_SUBJECT_CODES
|
||||
|
||||
def _claim_uses_budget_control(self, claim: ExpenseClaim) -> bool:
|
||||
return self._is_supported_budget_subject(self._subject_code_from_claim(claim))
|
||||
|
||||
@staticmethod
|
||||
def _subject_label(code: str) -> str:
|
||||
return BUDGET_SUBJECT_LABELS.get(code, EXPENSE_TYPE_LABELS.get(code, code))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_control_action(value: str | None) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if text in {"block", "control", "管控", "强控"}:
|
||||
return "block"
|
||||
if text in {"warn", "warning", "提醒", "预警"}:
|
||||
return "warn"
|
||||
if text in {"allow", "normal", "正常", "放行"}:
|
||||
return "allow"
|
||||
return "block"
|
||||
|
||||
def _resolve_claim_cost_center(self, claim: ExpenseClaim) -> str | None:
|
||||
employee = getattr(claim, "employee", None)
|
||||
if employee is not None:
|
||||
cost_center = self._blank_to_none(getattr(employee, "cost_center", None))
|
||||
if cost_center:
|
||||
return cost_center
|
||||
organization_unit = getattr(employee, "organization_unit", None)
|
||||
if organization_unit is not None:
|
||||
cost_center = self._blank_to_none(getattr(organization_unit, "cost_center", None))
|
||||
if cost_center:
|
||||
return cost_center
|
||||
return None
|
||||
|
||||
def _claim_context(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||
fiscal_year, period_key = self._period_from_claim(claim)
|
||||
return {
|
||||
"claim_id": claim.id,
|
||||
"claim_no": claim.claim_no,
|
||||
"employee_id": claim.employee_id,
|
||||
"employee_name": claim.employee_name,
|
||||
"department_id": claim.department_id,
|
||||
"department_name": claim.department_name,
|
||||
"cost_center": self._resolve_claim_cost_center(claim),
|
||||
"project_code": claim.project_code,
|
||||
"expense_type": claim.expense_type,
|
||||
"subject_code": self._subject_code_from_claim(claim),
|
||||
"fiscal_year": fiscal_year,
|
||||
"period_key": period_key,
|
||||
}
|
||||
Reference in New Issue
Block a user