Files
X-Financial/server/src/app/services/orchestrator_expense_query.py

536 lines
21 KiB
Python
Raw Normal View History

from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from app.models.employee import Employee
from app.models.financial_record import (
AccountsPayableRecord,
AccountsReceivableRecord,
ExpenseClaim,
)
from app.schemas.ontology import OntologyParseResult
PRIVILEGED_EXPENSE_QUERY_ROLE_CODES = {"finance"}
SELF_REFERENCE_KEYWORDS = ("我的", "我自己", "本人", "我名下", "给我查", "我提交", "我申请")
EXPENSE_QUERY_RECENT_WINDOW_DAYS = 10
EXPENSE_QUERY_PREVIEW_LIMIT = 20
EXPENSE_STATUS_LABELS = {
"draft": "草稿",
"submitted": "已提交",
"review": "审核中",
"approved": "已通过",
"rejected": "已驳回",
"paid": "已付款",
}
EXPENSE_STATUS_GROUP_LABELS = {
"draft": "草稿",
"in_progress": "审批中",
"completed": "审批完成",
"other": "其他状态",
}
EXPENSE_STATUS_GROUP_ORDER = ("draft", "in_progress", "completed", "other")
EXPENSE_TYPE_LABELS = {
"travel": "差旅费",
"hotel": "住宿费",
"transport": "交通费",
"meal": "餐费",
"meeting": "会务费",
"entertainment": "业务招待费",
"office": "办公费",
"training": "培训费",
"communication": "通讯费",
"welfare": "福利费",
"other": "其他费用",
}
class OrchestratorDatabaseQueryBuilder:
def __init__(self, db: Session) -> None:
self.db = db
def build_database_answer(
self,
ontology: OntologyParseResult,
*,
user_id: str | None,
context_json: dict[str, Any],
message: str,
) -> dict[str, Any]:
if ontology.scenario == "expense":
return self._build_expense_database_answer(
ontology=ontology,
user_id=user_id,
context_json=context_json,
message=message,
)
if ontology.scenario == "accounts_receivable":
return self._build_accounts_receivable_answer()
return self._build_accounts_payable_answer()
def _build_expense_database_answer(
self,
*,
ontology: OntologyParseResult,
user_id: str | None,
context_json: dict[str, Any],
message: str,
) -> dict[str, Any]:
conditions, scope_label, scoped_to_current_user = self._build_expense_query_scope(
ontology=ontology,
user_id=user_id,
context_json=context_json,
message=message,
)
count_stmt = select(func.count()).select_from(ExpenseClaim)
amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim)
for condition in conditions:
count_stmt = count_stmt.where(condition)
amount_stmt = amount_stmt.where(condition)
total_count = int(self.db.scalar(count_stmt) or 0)
total_amount = float(self.db.scalar(amount_stmt) or 0)
recent_window_applied = self._should_limit_expense_query_to_recent_window(ontology)
display_count = total_count
display_amount = total_amount
older_record_count = 0
display_conditions = list(conditions)
window_start_date: str | None = None
window_end_date: str | None = None
if recent_window_applied:
reference_now = self._resolve_reference_now(context_json)
recent_window_start, recent_window_end = self._resolve_expense_recent_window_bounds(reference_now)
recent_condition = self._build_expense_recent_window_condition(
recent_window_start,
recent_window_end,
)
display_conditions.append(recent_condition)
window_start_date = recent_window_start.date().isoformat()
window_end_date = (recent_window_end - timedelta(microseconds=1)).date().isoformat()
recent_count_stmt = select(func.count()).select_from(ExpenseClaim).where(recent_condition)
recent_amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim).where(
recent_condition
)
for condition in conditions:
recent_count_stmt = recent_count_stmt.where(condition)
recent_amount_stmt = recent_amount_stmt.where(condition)
display_count = int(self.db.scalar(recent_count_stmt) or 0)
display_amount = float(self.db.scalar(recent_amount_stmt) or 0)
older_record_count = max(0, total_count - display_count)
preview_stmt = (
select(ExpenseClaim)
.order_by(
func.coalesce(
ExpenseClaim.submitted_at,
ExpenseClaim.created_at,
ExpenseClaim.occurred_at,
).desc(),
ExpenseClaim.occurred_at.desc(),
)
.limit(EXPENSE_QUERY_PREVIEW_LIMIT)
)
for condition in display_conditions:
preview_stmt = preview_stmt.where(condition)
preview_claims = list(self.db.scalars(preview_stmt).all())
status_groups = self._build_expense_status_groups(display_conditions)
return {
"result_type": "expense_claim_list",
"record_count": display_count,
"total_amount": round(display_amount, 2),
"scope_label": scope_label,
"scoped_to_current_user": scoped_to_current_user,
"recent_window_applied": recent_window_applied,
"window_days": EXPENSE_QUERY_RECENT_WINDOW_DAYS if recent_window_applied else None,
"window_start_date": window_start_date,
"window_end_date": window_end_date,
"preview_count": len(preview_claims),
"older_record_count": older_record_count,
"records": [
self._build_expense_query_record(claim)
for claim in preview_claims
],
"status_groups": status_groups,
"has_more_in_window": display_count > len(preview_claims),
"total_matched_count": total_count,
}
def _build_accounts_receivable_answer(self) -> dict[str, Any]:
total_count = int(
self.db.scalar(
select(func.count()).select_from(AccountsReceivableRecord)
)
or 0
)
total_amount = float(
self.db.scalar(
select(func.coalesce(func.sum(AccountsReceivableRecord.amount_outstanding), 0))
)
or 0
)
return {
"record_count": total_count,
"outstanding_amount": round(total_amount, 2),
}
def _build_accounts_payable_answer(self) -> dict[str, Any]:
total_count = int(
self.db.scalar(select(func.count()).select_from(AccountsPayableRecord))
or 0
)
total_amount = float(
self.db.scalar(
select(func.coalesce(func.sum(AccountsPayableRecord.amount_outstanding), 0))
)
or 0
)
return {
"record_count": total_count,
"outstanding_amount": round(total_amount, 2),
}
@staticmethod
def _should_limit_expense_query_to_recent_window(
ontology: OntologyParseResult,
) -> bool:
has_explicit_claim_no = any(
item.type == "expense_claim"
and str(item.normalized_value or item.value or "").strip()
for item in ontology.entities
)
has_explicit_time_range = bool(
ontology.time_range.start_date or ontology.time_range.end_date
)
return not has_explicit_claim_no and not has_explicit_time_range
@staticmethod
def _resolve_reference_now(context_json: dict[str, Any]) -> datetime:
raw_value = str(context_json.get("client_now_iso") or "").strip()
if raw_value:
normalized = raw_value.replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
except ValueError:
pass
return datetime.now(UTC)
@staticmethod
def _resolve_expense_recent_window_bounds(
reference_now: datetime,
) -> tuple[datetime, datetime]:
normalized_now = reference_now.astimezone(UTC)
window_end = normalized_now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
window_start = window_end - timedelta(days=EXPENSE_QUERY_RECENT_WINDOW_DAYS)
return window_start, window_end
@staticmethod
def _build_expense_recent_window_condition(
window_start: datetime,
window_end: datetime,
) -> Any:
document_datetime = func.coalesce(
ExpenseClaim.submitted_at,
ExpenseClaim.created_at,
ExpenseClaim.occurred_at,
)
return and_(document_datetime >= window_start, document_datetime < window_end)
def _build_expense_status_groups(
self,
conditions: list[Any],
) -> list[dict[str, Any]]:
stmt = select(ExpenseClaim.status, func.count()).select_from(ExpenseClaim).group_by(ExpenseClaim.status)
for condition in conditions:
stmt = stmt.where(condition)
grouped_counts = {
key: 0
for key in EXPENSE_STATUS_GROUP_ORDER
}
for status, count in self.db.execute(stmt).all():
group_key, _ = self._resolve_expense_status_group(str(status or "").strip())
grouped_counts[group_key] = grouped_counts.get(group_key, 0) + int(count or 0)
return [
{
"key": key,
"label": EXPENSE_STATUS_GROUP_LABELS[key],
"count": grouped_counts.get(key, 0),
}
for key in EXPENSE_STATUS_GROUP_ORDER
if grouped_counts.get(key, 0) > 0
]
@staticmethod
def _resolve_expense_status_group(status: str) -> tuple[str, str]:
normalized = str(status or "").strip().lower()
if normalized == "draft":
return "draft", EXPENSE_STATUS_GROUP_LABELS["draft"]
if normalized in {"submitted", "review"}:
return "in_progress", EXPENSE_STATUS_GROUP_LABELS["in_progress"]
if normalized in {"approved", "paid"}:
return "completed", EXPENSE_STATUS_GROUP_LABELS["completed"]
return "other", EXPENSE_STATUS_GROUP_LABELS["other"]
@staticmethod
def _resolve_expense_query_document_datetime(
claim: ExpenseClaim,
) -> datetime | None:
return claim.submitted_at or claim.created_at or claim.occurred_at
def _build_expense_query_record(
self,
claim: ExpenseClaim,
) -> dict[str, Any]:
status_group, status_group_label = self._resolve_expense_status_group(claim.status)
document_datetime = self._resolve_expense_query_document_datetime(claim)
return {
"claim_id": claim.id,
"claim_no": claim.claim_no,
"employee_name": claim.employee_name,
"expense_type": claim.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(claim.expense_type, claim.expense_type or "报销"),
"amount": round(float(claim.amount), 2),
"status": claim.status,
"status_label": EXPENSE_STATUS_LABELS.get(claim.status, claim.status or "处理中"),
"status_group": status_group,
"status_group_label": status_group_label,
"approval_stage": claim.approval_stage,
"document_date": document_datetime.date().isoformat() if document_datetime else "",
"occurred_at": claim.occurred_at.date().isoformat() if claim.occurred_at else "",
"reason": claim.reason,
"location": claim.location,
}
def _build_expense_query_scope(
self,
*,
ontology: OntologyParseResult,
user_id: str | None,
context_json: dict[str, Any],
message: str,
) -> tuple[list[Any], str, bool]:
conditions: list[Any] = []
explicit_employee_names = list(
dict.fromkeys(
str(item.value or "").strip()
for item in ontology.entities
if item.type == "employee" and str(item.value or "").strip()
)
)
expense_claim_nos = list(
dict.fromkeys(
str(item.normalized_value or item.value or "").strip().upper()
for item in ontology.entities
if item.type == "expense_claim" and str(item.normalized_value or item.value or "").strip()
)
)
expense_types = list(
dict.fromkeys(
str(item.normalized_value or item.value or "").strip()
for item in ontology.entities
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
)
)
project_values = self._collect_expense_query_filter_values(ontology, "project")
location_values = self._collect_expense_query_filter_values(ontology, "location")
status_values = list(
dict.fromkeys(
str(item.value).strip()
for item in ontology.constraints
if item.field == "status" and item.operator == "=" and str(item.value).strip()
)
)
amount_constraints = [
item
for item in ontology.constraints
if item.field == "amount" and item.operator in {">", ">=", "<", "<=", "="}
]
scope_label = "报销单"
scoped_to_current_user = False
if expense_claim_nos:
conditions.append(ExpenseClaim.claim_no.in_(expense_claim_nos))
if expense_types:
conditions.append(ExpenseClaim.expense_type.in_(expense_types))
if status_values:
conditions.append(ExpenseClaim.status.in_(status_values))
if project_values:
project_conditions = []
for value in project_values:
pattern = f"%{value}%"
project_conditions.append(ExpenseClaim.project_code.ilike(pattern))
project_conditions.append(ExpenseClaim.reason.ilike(pattern))
conditions.append(or_(*project_conditions))
if location_values:
location_conditions = []
for value in location_values:
pattern = f"%{value}%"
location_conditions.append(ExpenseClaim.location.ilike(pattern))
location_conditions.append(ExpenseClaim.reason.ilike(pattern))
conditions.append(or_(*location_conditions))
for item in amount_constraints:
amount_value = float(item.value)
if item.operator == ">":
conditions.append(ExpenseClaim.amount > amount_value)
elif item.operator == ">=":
conditions.append(ExpenseClaim.amount >= amount_value)
elif item.operator == "<":
conditions.append(ExpenseClaim.amount < amount_value)
elif item.operator == "<=":
conditions.append(ExpenseClaim.amount <= amount_value)
else:
conditions.append(ExpenseClaim.amount == amount_value)
if ontology.time_range.start_date:
conditions.append(
ExpenseClaim.occurred_at
>= datetime.fromisoformat(f"{ontology.time_range.start_date}T00:00:00+00:00")
)
if ontology.time_range.end_date:
conditions.append(
ExpenseClaim.occurred_at
<= datetime.fromisoformat(f"{ontology.time_range.end_date}T23:59:59.999999+00:00")
)
has_privileged_access = self._has_privileged_expense_query_access(context_json)
refers_to_self = self._is_self_expense_query(message)
if not has_privileged_access:
owner_conditions, owner_label = self._build_current_user_claim_conditions(
user_id=user_id,
context_json=context_json,
)
if owner_conditions:
conditions.append(or_(*owner_conditions))
scope_label = owner_label
scoped_to_current_user = True
else:
conditions.append(ExpenseClaim.id == "__no_visible_claim__")
scope_label = "你的报销单"
scoped_to_current_user = True
elif explicit_employee_names:
conditions.append(ExpenseClaim.employee_name.in_(explicit_employee_names))
scope_label = f"{''.join(explicit_employee_names)}的报销单"
elif refers_to_self:
owner_conditions, owner_label = self._build_current_user_claim_conditions(
user_id=user_id,
context_json=context_json,
)
if owner_conditions:
conditions.append(or_(*owner_conditions))
scope_label = owner_label
scoped_to_current_user = True
else:
conditions.append(ExpenseClaim.id == "__no_visible_claim__")
scope_label = "你的报销单"
scoped_to_current_user = True
else:
scope_label = "全部报销单"
return conditions, scope_label, scoped_to_current_user
@staticmethod
def _collect_expense_query_filter_values(
ontology: OntologyParseResult,
field_name: str,
) -> list[str]:
values: list[str] = []
for entity in ontology.entities:
if entity.type != field_name:
continue
value = str(entity.normalized_value or entity.value or "").strip()
if value:
values.append(value)
for constraint in ontology.constraints:
if constraint.field != field_name or constraint.operator != "=":
continue
value = str(constraint.value or "").strip()
if value:
values.append(value)
return list(dict.fromkeys(values))
def _build_current_user_claim_conditions(
self,
*,
user_id: str | None,
context_json: dict[str, Any],
) -> tuple[list[Any], str]:
normalized_user_id = str(user_id or "").strip()
employee = None
if normalized_user_id:
employee = self.db.scalar(
select(Employee)
.where(func.lower(Employee.email) == normalized_user_id.lower())
.limit(1)
)
conditions: list[Any] = []
seen: set[tuple[str, str]] = set()
def add_condition(field_name: str, value: str | None) -> None:
normalized = str(value or "").strip()
if not normalized:
return
marker = (field_name, normalized.lower())
if marker in seen:
return
seen.add(marker)
if field_name == "employee_id":
conditions.append(ExpenseClaim.employee_id == normalized)
return
conditions.append(ExpenseClaim.employee_name == normalized)
if employee is not None:
add_condition("employee_id", employee.id)
add_condition("employee_name", employee.email)
if self._employee_name_is_unique(employee):
add_condition("employee_name", employee.name)
else:
add_condition("employee_id", normalized_user_id)
add_condition("employee_name", normalized_user_id)
subject_name = (employee.name if employee is not None else "") or normalized_user_id
if subject_name:
return conditions, "你的报销单"
return conditions, "当前用户的报销单"
def _employee_name_is_unique(self, employee: Employee) -> bool:
normalized_name = str(employee.name or "").strip()
if not normalized_name:
return False
same_name_count = int(
self.db.scalar(
select(func.count()).select_from(Employee).where(Employee.name == normalized_name)
)
or 0
)
return same_name_count == 1
@staticmethod
def _has_privileged_expense_query_access(context_json: dict[str, Any]) -> bool:
role_codes = {
str(item).strip().lower()
for item in context_json.get("role_codes", [])
if str(item).strip()
}
return bool(role_codes & PRIVILEGED_EXPENSE_QUERY_ROLE_CODES)
@staticmethod
def _is_self_expense_query(message: str) -> bool:
compact_message = "".join(str(message or "").split())
return any(keyword in compact_message for keyword in SELF_REFERENCE_KEYWORDS)