feat: 新增预算费控模型与报销审批流引擎
后端新增预算费控服务和报销单审批流模块,引入申请人费用画像 算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常 量和明细同步,更新差旅报销规则电子表格,前端新增预算分析 组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧 边栏和顶栏样式,补充单元测试。
This commit is contained in:
@@ -87,7 +87,10 @@ class AgentFoundationService(
|
||||
|
||||
def _foundation_cache_key(self) -> str:
|
||||
bind = self.db.get_bind()
|
||||
return str(getattr(bind, "url", "") or id(bind))
|
||||
url = str(getattr(bind, "url", "") or "")
|
||||
if url.endswith("/:memory:"):
|
||||
return f"{url}:{id(bind)}"
|
||||
return url or str(id(bind))
|
||||
|
||||
def _ensure_financial_record_schema(self) -> None:
|
||||
bind = self.db.get_bind()
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.services.knowledge_ingest_log import enrich_knowledge_ingest_route_json
|
||||
logger = get_logger("app.services.agent_runs")
|
||||
|
||||
KNOWLEDGE_SYNC_HEARTBEAT_TIMEOUT = timedelta(minutes=30)
|
||||
KNOWLEDGE_SYNC_JOB_TYPES = {"knowledge_index_sync", "llm_wiki_sync"}
|
||||
|
||||
|
||||
class AgentRunService:
|
||||
@@ -262,7 +263,7 @@ class AgentRunService:
|
||||
continue
|
||||
|
||||
route_json = dict(run.route_json or {})
|
||||
if str(route_json.get("job_type") or "").strip() != "knowledge_index_sync":
|
||||
if str(route_json.get("job_type") or "").strip() not in KNOWLEDGE_SYNC_JOB_TYPES:
|
||||
continue
|
||||
|
||||
heartbeat_at = self._parse_heartbeat_time(
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.schemas.budget import (
|
||||
BudgetSummaryRead,
|
||||
BudgetTransactionRead,
|
||||
)
|
||||
from app.services.budget_expense_control import BudgetExpenseControlModel
|
||||
from app.services.budget_support import BudgetSupportMixin
|
||||
from app.services.budget_types import BudgetControlError, SUPPORTED_BUDGET_SUBJECT_CODES
|
||||
|
||||
@@ -112,6 +113,9 @@ class BudgetService(BudgetSupportMixin):
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
def analyze_claim_budget(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||
return BudgetExpenseControlModel().assess(self.build_claim_budget_context(claim), claim)
|
||||
|
||||
def create_or_update_allocation(
|
||||
self,
|
||||
payload: BudgetAllocationCreate,
|
||||
|
||||
194
server/src/app/services/budget_expense_control.py
Normal file
194
server/src/app/services/budget_expense_control.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
|
||||
|
||||
class BudgetExpenseControlModel:
|
||||
"""预算费用管控模型:用预算容量、单据影响、规则动作和信息完整度给出建议。"""
|
||||
|
||||
def assess(self, budget_context: dict[str, Any], claim: ExpenseClaim | None = None) -> dict[str, Any]:
|
||||
context = dict(budget_context or {})
|
||||
amount = self._money(context.get("claim_amount"))
|
||||
if not context.get("budget_applicable", True):
|
||||
return self._build_result(
|
||||
context=context,
|
||||
score=72,
|
||||
rating="reference",
|
||||
risk_level="low",
|
||||
summary="该费用类型暂未纳入预算强管控,本次仅作为申请合理性参考。",
|
||||
basis=["当前费用科目未启用预算控制。"],
|
||||
suggestions=["预算管理者可结合项目必要性和历史同类费用进行人工判断。"],
|
||||
)
|
||||
if not context.get("matched"):
|
||||
return self._build_result(
|
||||
context=context,
|
||||
score=38,
|
||||
rating="block",
|
||||
risk_level="high",
|
||||
summary="未匹配到可用预算池,建议先完成预算编制或调整预算维度后再审批。",
|
||||
basis=["系统按部门、成本中心、项目和费用科目未找到匹配预算额度。"],
|
||||
suggestions=["请预算编制者补充对应预算池,或核对申请部门、项目和费用类型是否填写正确。"],
|
||||
)
|
||||
|
||||
total = self._money(context.get("total_amount"))
|
||||
reserved = self._money(context.get("reserved_amount"))
|
||||
consumed = self._money(context.get("consumed_amount"))
|
||||
current_reserved = self._money(context.get("current_reserved_amount"))
|
||||
warning_threshold = self._money(context.get("warning_threshold") or "80")
|
||||
used_before = max(reserved + consumed - current_reserved, Decimal("0.00"))
|
||||
available_before = max(total - used_before, Decimal("0.00"))
|
||||
after_used = used_before + amount
|
||||
claim_ratio = self._percent(amount, total)
|
||||
after_usage_rate = self._percent(after_used, total)
|
||||
over_budget_amount = max(amount - available_before, Decimal("0.00"))
|
||||
|
||||
score = 100
|
||||
basis = [
|
||||
f"预算池 {context.get('budget_no') or '未命名'} 总额度 {self._fmt(total)} 元。",
|
||||
f"本次申请金额 {self._fmt(amount)} 元,占预算 {self._fmt(claim_ratio)}%。",
|
||||
f"审批后预算使用率预计 {self._fmt(after_usage_rate)}%,预警线 {self._fmt(warning_threshold)}%。",
|
||||
]
|
||||
suggestions: list[str] = []
|
||||
|
||||
if over_budget_amount > Decimal("0.00"):
|
||||
score -= 55
|
||||
basis.append(f"按当前预算余额测算,本次申请将超出预算 {self._fmt(over_budget_amount)} 元。")
|
||||
suggestions.append("建议先追加或调剂预算,再允许申请继续流转。")
|
||||
elif after_usage_rate >= Decimal("100.00"):
|
||||
score -= 38
|
||||
basis.append("审批后预算使用率将达到或超过 100%。")
|
||||
suggestions.append("建议预算管理者复核剩余额度,并确认是否需要预算调剂。")
|
||||
elif after_usage_rate >= warning_threshold:
|
||||
score -= 20
|
||||
basis.append("审批后预算使用率将触达预算预警线。")
|
||||
suggestions.append("建议关注后续同类费用,必要时提前调整预算节奏。")
|
||||
elif after_usage_rate >= Decimal("70.00"):
|
||||
score -= 8
|
||||
basis.append("审批后预算使用率较高,但尚未触达预警线。")
|
||||
|
||||
if claim_ratio >= Decimal("50.00"):
|
||||
score -= 20
|
||||
basis.append("单笔申请占预算比例超过 50%,对预算池影响较大。")
|
||||
suggestions.append("建议补充业务必要性、交付范围和费用拆分依据。")
|
||||
elif claim_ratio >= Decimal("30.00"):
|
||||
score -= 12
|
||||
basis.append("单笔申请占预算比例超过 30%,需要关注预算节奏。")
|
||||
elif claim_ratio >= Decimal("15.00"):
|
||||
score -= 5
|
||||
basis.append("单笔申请占预算比例超过 15%,属于中等预算影响。")
|
||||
|
||||
missing_fields = self._collect_context_gaps(claim)
|
||||
if missing_fields:
|
||||
score -= min(12, len(missing_fields) * 4)
|
||||
basis.append(f"申请信息仍缺少:{'、'.join(missing_fields)}。")
|
||||
suggestions.append("建议申请人补齐业务背景,便于预算管理者判断费用必要性。")
|
||||
|
||||
control_action = str(context.get("control_action") or "").strip().lower()
|
||||
if control_action == "block" and over_budget_amount > Decimal("0.00"):
|
||||
suggestions.append("该预算池为硬控制口径,超预算时不建议直接通过。")
|
||||
|
||||
score = max(0, min(100, int(round(score))))
|
||||
rating, risk_level = self._rate(score, over_budget_amount)
|
||||
if not suggestions:
|
||||
suggestions.append("预算额度与本次费用影响基本匹配,可以结合业务必要性继续审批。")
|
||||
|
||||
return self._build_result(
|
||||
context={
|
||||
**context,
|
||||
"claim_amount_ratio": str(claim_ratio),
|
||||
"after_usage_rate": str(after_usage_rate),
|
||||
"available_before_amount": str(available_before),
|
||||
"over_budget_amount": str(over_budget_amount),
|
||||
},
|
||||
score=score,
|
||||
rating=rating,
|
||||
risk_level=risk_level,
|
||||
summary=self._summary(rating),
|
||||
basis=basis,
|
||||
suggestions=suggestions,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _collect_context_gaps(claim: ExpenseClaim | None) -> list[str]:
|
||||
if claim is None:
|
||||
return []
|
||||
gaps = []
|
||||
if not str(claim.reason or "").strip():
|
||||
gaps.append("申请事由")
|
||||
if not str(claim.location or "").strip():
|
||||
gaps.append("地点")
|
||||
if not str(claim.project_code or "").strip():
|
||||
gaps.append("项目编号")
|
||||
return gaps
|
||||
|
||||
@staticmethod
|
||||
def _rate(score: int, over_budget_amount: Decimal) -> tuple[str, str]:
|
||||
if over_budget_amount > Decimal("0.00") or score < 50:
|
||||
return "block", "high"
|
||||
if score < 70:
|
||||
return "review", "medium"
|
||||
if score < 85:
|
||||
return "caution", "medium"
|
||||
return "recommended", "low"
|
||||
|
||||
@staticmethod
|
||||
def _summary(rating: str) -> str:
|
||||
summaries = {
|
||||
"recommended": "预算容量充足,单据费用与当前预算节奏基本匹配。",
|
||||
"caution": "预算整体可承接,但本次费用对预算池已有一定影响。",
|
||||
"review": "预算影响偏高,建议预算管理者结合业务必要性复核后再通过。",
|
||||
"block": "预算风险较高,不建议在未补充依据或调整预算前直接通过。",
|
||||
}
|
||||
return summaries.get(rating, "已完成预算费用合理性测算。")
|
||||
|
||||
@staticmethod
|
||||
def _build_result(
|
||||
*,
|
||||
context: dict[str, Any],
|
||||
score: int,
|
||||
rating: str,
|
||||
risk_level: str,
|
||||
summary: str,
|
||||
basis: list[str],
|
||||
suggestions: list[str],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"budget_context": context,
|
||||
"score": score,
|
||||
"rating": rating,
|
||||
"risk_level": risk_level,
|
||||
"summary": summary,
|
||||
"basis": basis,
|
||||
"suggestions": suggestions,
|
||||
"metrics": {
|
||||
"claim_amount": context.get("claim_amount"),
|
||||
"total_amount": context.get("total_amount"),
|
||||
"claim_amount_ratio": context.get("claim_amount_ratio", "0.00"),
|
||||
"usage_rate": context.get("usage_rate", "0.00"),
|
||||
"after_usage_rate": context.get("after_usage_rate", context.get("usage_rate", "0.00")),
|
||||
"available_amount": context.get("available_amount"),
|
||||
"available_before_amount": context.get("available_before_amount"),
|
||||
"over_budget_amount": context.get("over_budget_amount", "0.00"),
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _money(value: Any) -> Decimal:
|
||||
try:
|
||||
return Decimal(str(value if value is not None else "0")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal("0.00")
|
||||
|
||||
@staticmethod
|
||||
def _percent(numerator: Decimal, denominator: Decimal) -> Decimal:
|
||||
if denominator <= Decimal("0.00"):
|
||||
return Decimal("0.00")
|
||||
return ((numerator / denominator) * Decimal("100")).quantize(Decimal("0.01"))
|
||||
|
||||
@staticmethod
|
||||
def _fmt(value: Decimal) -> str:
|
||||
return f"{value.quantize(Decimal('0.01'))}"
|
||||
|
||||
@@ -310,6 +310,14 @@ class BudgetSupportMixin:
|
||||
}
|
||||
|
||||
balance = self.get_balance(allocation)
|
||||
reservation_source_type = self._reservation_source_type_from_claim(claim)
|
||||
current_reservation = self._find_active_reservation(
|
||||
source_type=reservation_source_type,
|
||||
source_id=claim.id,
|
||||
)
|
||||
current_reserved_amount = self._money(
|
||||
current_reservation.amount if current_reservation is not None else Decimal("0.00")
|
||||
)
|
||||
over_budget_amount = max(amount - balance.available_amount, Decimal("0.00"))
|
||||
return {
|
||||
"matched": True,
|
||||
@@ -319,6 +327,7 @@ class BudgetSupportMixin:
|
||||
"claim_amount": str(amount),
|
||||
"total_amount": str(balance.total_amount),
|
||||
"reserved_amount": str(balance.reserved_amount),
|
||||
"current_reserved_amount": str(current_reserved_amount),
|
||||
"consumed_amount": str(balance.consumed_amount),
|
||||
"available_amount": str(balance.available_amount),
|
||||
"usage_rate": str(balance.usage_rate),
|
||||
@@ -335,6 +344,14 @@ class BudgetSupportMixin:
|
||||
"project_code": allocation.project_code,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _reservation_source_type_from_claim(claim: ExpenseClaim) -> str:
|
||||
claim_no = str(claim.claim_no or "").strip().upper()
|
||||
expense_type = str(claim.expense_type or "").strip().lower()
|
||||
if claim_no.startswith(("AP-", "APP-")) or expense_type == "application" or expense_type.endswith("_application"):
|
||||
return "application"
|
||||
return "claim"
|
||||
|
||||
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(
|
||||
|
||||
@@ -10,14 +10,25 @@ from app.api.deps import CurrentUserContext
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.role import Role
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
)
|
||||
|
||||
|
||||
PRIVILEGED_CLAIM_ROLE_CODES = {"finance", "executive"}
|
||||
ARCHIVE_CENTER_ROLE_CODES = {"finance", "executive"}
|
||||
APPROVAL_VISIBLE_CLAIM_ROLE_CODES = {"manager", "approver"}
|
||||
BUDGET_APPROVAL_ROLE_CODES = {"budget_monitor", "executive"}
|
||||
BUDGET_MONITOR_ROLE_CODE = "budget_monitor"
|
||||
BUDGET_MONITOR_APPROVAL_GRADE = "P8"
|
||||
CLAIM_DELETE_ROLE_CODES = {"executive"}
|
||||
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
|
||||
APPLICATION_ARCHIVED_STAGES = ("审批完成", "申请归档", "completed")
|
||||
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
|
||||
|
||||
|
||||
class ExpenseClaimAccessPolicy:
|
||||
@@ -49,7 +60,7 @@ class ExpenseClaimAccessPolicy:
|
||||
normalized_type.like("%\\_application", escape="\\"),
|
||||
)
|
||||
return or_(
|
||||
stage == "归档入账",
|
||||
stage == ARCHIVE_ACCOUNTING_STAGE,
|
||||
stage == "completed",
|
||||
and_(
|
||||
application_condition,
|
||||
@@ -61,7 +72,7 @@ class ExpenseClaimAccessPolicy:
|
||||
or_(
|
||||
stage == "",
|
||||
stage.is_(None),
|
||||
stage == "归档入账",
|
||||
stage == ARCHIVE_ACCOUNTING_STAGE,
|
||||
stage == "completed",
|
||||
),
|
||||
),
|
||||
@@ -77,7 +88,7 @@ class ExpenseClaimAccessPolicy:
|
||||
def is_archived_claim(claim: ExpenseClaim) -> bool:
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
stage = str(claim.approval_stage or "").strip()
|
||||
if stage in {"归档入账", "completed"}:
|
||||
if stage in {ARCHIVE_ACCOUNTING_STAGE, "completed"}:
|
||||
return True
|
||||
normalized_type = str(claim.expense_type or "").strip().lower()
|
||||
claim_no = str(claim.claim_no or "").strip().upper()
|
||||
@@ -92,7 +103,7 @@ class ExpenseClaimAccessPolicy:
|
||||
and stage in APPLICATION_ARCHIVED_STAGES
|
||||
):
|
||||
return True
|
||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", "归档入账", "completed"}
|
||||
return normalized_status in ARCHIVED_CLAIM_STATUSES and stage in {"", ARCHIVE_ACCOUNTING_STAGE, "completed"}
|
||||
|
||||
def can_return_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
@@ -100,9 +111,11 @@ class ExpenseClaimAccessPolicy:
|
||||
return False
|
||||
|
||||
stage = str(claim.approval_stage or "").strip()
|
||||
if stage == "直属领导审批":
|
||||
if stage == DIRECT_MANAGER_APPROVAL_STAGE:
|
||||
return self.is_current_direct_manager_approver(current_user, claim)
|
||||
if stage == "财务审批":
|
||||
if stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
return self.is_budget_manager_approver(current_user, claim)
|
||||
if stage == FINANCE_APPROVAL_STAGE:
|
||||
return self.has_privileged_claim_access(current_user) and not self.is_claim_owned_by_current_user(
|
||||
claim,
|
||||
current_user,
|
||||
@@ -111,9 +124,11 @@ class ExpenseClaimAccessPolicy:
|
||||
|
||||
def can_approve_claim(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
stage = str(claim.approval_stage or "").strip()
|
||||
if stage == "直属领导审批":
|
||||
if stage == DIRECT_MANAGER_APPROVAL_STAGE:
|
||||
return self.is_current_direct_manager_approver(current_user, claim)
|
||||
if stage == "财务审批":
|
||||
if stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
return self.is_budget_manager_approver(current_user, claim)
|
||||
if stage == FINANCE_APPROVAL_STAGE:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
return (
|
||||
(current_user.is_admin or "finance" in role_codes)
|
||||
@@ -127,7 +142,7 @@ class ExpenseClaimAccessPolicy:
|
||||
return False
|
||||
if str(claim.status or "").strip().lower() != "submitted":
|
||||
return False
|
||||
if str(claim.approval_stage or "").strip() != "直属领导审批":
|
||||
if str(claim.approval_stage or "").strip() != DIRECT_MANAGER_APPROVAL_STAGE:
|
||||
return False
|
||||
|
||||
current_employee = self.resolve_current_employee(current_user)
|
||||
@@ -149,6 +164,65 @@ class ExpenseClaimAccessPolicy:
|
||||
|
||||
return self.resolve_claim_manager_name(claim) == approver_name
|
||||
|
||||
def is_budget_manager_approver(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
if str(claim.status or "").strip().lower() != "submitted":
|
||||
return False
|
||||
if str(claim.approval_stage or "").strip() != BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
return False
|
||||
if self.is_claim_owned_by_current_user(claim, current_user):
|
||||
return False
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if "executive" in role_codes:
|
||||
return True
|
||||
return self.is_department_p8_budget_monitor(current_user, claim)
|
||||
|
||||
def is_budget_manager_user(self, current_user: CurrentUserContext) -> bool:
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
return bool(role_codes & BUDGET_APPROVAL_ROLE_CODES)
|
||||
|
||||
def is_department_p8_budget_monitor(self, current_user: CurrentUserContext, claim: ExpenseClaim) -> bool:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
|
||||
return False
|
||||
|
||||
current_employee = self.resolve_current_employee(current_user)
|
||||
if current_employee is None:
|
||||
return False
|
||||
if not self._employee_has_budget_approval_grade(current_employee):
|
||||
return False
|
||||
|
||||
return self._employee_matches_claim_department(current_employee, current_user, claim)
|
||||
|
||||
def resolve_department_budget_manager(self, claim: ExpenseClaim) -> Employee | None:
|
||||
department_ids, department_names = self._collect_claim_department_identity(claim)
|
||||
department_conditions = []
|
||||
if department_ids:
|
||||
department_conditions.append(Employee.organization_unit_id.in_(department_ids))
|
||||
if department_names:
|
||||
department_conditions.append(Employee.organization_unit.has(OrganizationUnit.name.in_(department_names)))
|
||||
if not department_conditions:
|
||||
return None
|
||||
|
||||
stmt = (
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.organization_unit), selectinload(Employee.roles))
|
||||
.where(
|
||||
func.upper(func.coalesce(Employee.grade, "")) == BUDGET_MONITOR_APPROVAL_GRADE,
|
||||
Employee.roles.any(Role.role_code == BUDGET_MONITOR_ROLE_CODE),
|
||||
or_(*department_conditions),
|
||||
)
|
||||
.order_by(Employee.name.asc(), Employee.employee_no.asc())
|
||||
.limit(1)
|
||||
)
|
||||
claim_employee_id = str(claim.employee_id or "").strip()
|
||||
if claim_employee_id:
|
||||
stmt = stmt.where(Employee.id != claim_employee_id)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
@staticmethod
|
||||
def normalize_role_codes(current_user: CurrentUserContext) -> set[str]:
|
||||
return {
|
||||
@@ -157,6 +231,51 @@ class ExpenseClaimAccessPolicy:
|
||||
if str(item).strip()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _employee_has_budget_approval_grade(employee: Employee) -> bool:
|
||||
return str(employee.grade or "").strip().upper() == BUDGET_MONITOR_APPROVAL_GRADE
|
||||
|
||||
def _employee_matches_claim_department(
|
||||
self,
|
||||
employee: Employee,
|
||||
current_user: CurrentUserContext,
|
||||
claim: ExpenseClaim,
|
||||
) -> bool:
|
||||
claim_department_ids, claim_department_names = self._collect_claim_department_identity(claim)
|
||||
employee_department_ids = {
|
||||
str(employee.organization_unit_id or "").strip(),
|
||||
}
|
||||
employee_department_names = {
|
||||
str(current_user.department_name or "").strip(),
|
||||
}
|
||||
if employee.organization_unit is not None:
|
||||
employee_department_names.add(str(employee.organization_unit.name or "").strip())
|
||||
|
||||
employee_department_ids.discard("")
|
||||
employee_department_names.discard("")
|
||||
return bool(
|
||||
(claim_department_ids and employee_department_ids & claim_department_ids)
|
||||
or (claim_department_names and employee_department_names & claim_department_names)
|
||||
)
|
||||
|
||||
def _collect_claim_department_identity(self, claim: ExpenseClaim) -> tuple[set[str], set[str]]:
|
||||
department_ids = {
|
||||
str(claim.department_id or "").strip(),
|
||||
}
|
||||
department_names = {
|
||||
str(claim.department_name or "").strip(),
|
||||
}
|
||||
|
||||
claim_employee = self.resolve_claim_employee_for_backfill(claim)
|
||||
if claim_employee is not None:
|
||||
department_ids.add(str(claim_employee.organization_unit_id or "").strip())
|
||||
if claim_employee.organization_unit is not None:
|
||||
department_names.add(str(claim_employee.organization_unit.name or "").strip())
|
||||
|
||||
department_ids.discard("")
|
||||
department_names.discard("")
|
||||
return department_ids, department_names
|
||||
|
||||
def resolve_current_employee(self, current_user: CurrentUserContext) -> Employee | None:
|
||||
return self.resolve_employee_by_identity_candidates(
|
||||
[
|
||||
@@ -375,7 +494,7 @@ class ExpenseClaimAccessPolicy:
|
||||
).strip()
|
||||
pending_leader_approval_parts = [
|
||||
ExpenseClaim.status == "submitted",
|
||||
ExpenseClaim.approval_stage == "直属领导审批",
|
||||
ExpenseClaim.approval_stage == DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
]
|
||||
if employee is not None:
|
||||
pending_leader_approval_parts.append(
|
||||
@@ -399,17 +518,55 @@ class ExpenseClaimAccessPolicy:
|
||||
|
||||
return conditions
|
||||
|
||||
def build_budget_approval_claim_conditions(self, current_user: CurrentUserContext) -> list[Any]:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if BUDGET_MONITOR_ROLE_CODE not in role_codes:
|
||||
return []
|
||||
employee = self.resolve_current_employee(current_user)
|
||||
if employee is None or not self._employee_has_budget_approval_grade(employee):
|
||||
return []
|
||||
|
||||
pending_budget_approval_parts = [
|
||||
ExpenseClaim.status == "submitted",
|
||||
ExpenseClaim.approval_stage == BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
]
|
||||
pending_budget_approval_parts.append(
|
||||
or_(ExpenseClaim.employee_id.is_(None), ExpenseClaim.employee_id != employee.id)
|
||||
)
|
||||
if employee.name:
|
||||
pending_budget_approval_parts.append(ExpenseClaim.employee_name != employee.name)
|
||||
|
||||
department_conditions = []
|
||||
department_name = str(current_user.department_name or "").strip()
|
||||
if employee.organization_unit_id:
|
||||
department_conditions.append(ExpenseClaim.department_id == employee.organization_unit_id)
|
||||
subordinate_department_employee_ids = select(Employee.id).where(
|
||||
Employee.organization_unit_id == employee.organization_unit_id
|
||||
)
|
||||
department_conditions.append(ExpenseClaim.employee_id.in_(subordinate_department_employee_ids))
|
||||
if employee.organization_unit is not None and employee.organization_unit.name:
|
||||
department_conditions.append(ExpenseClaim.department_name == employee.organization_unit.name)
|
||||
if department_name:
|
||||
department_conditions.append(ExpenseClaim.department_name == department_name)
|
||||
if not department_conditions:
|
||||
return []
|
||||
|
||||
pending_budget_approval_parts.append(or_(*department_conditions))
|
||||
return [and_(*pending_budget_approval_parts)]
|
||||
|
||||
def apply_approval_claim_scope(self, stmt: Any, current_user: CurrentUserContext) -> Any:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if current_user.is_admin or "executive" in role_codes:
|
||||
return stmt.where(ExpenseClaim.status == "submitted")
|
||||
conditions = []
|
||||
if "finance" in role_codes:
|
||||
return stmt.where(
|
||||
conditions.append(and_(
|
||||
ExpenseClaim.status == "submitted",
|
||||
ExpenseClaim.approval_stage == "财务审批",
|
||||
)
|
||||
ExpenseClaim.approval_stage == FINANCE_APPROVAL_STAGE,
|
||||
))
|
||||
|
||||
conditions = self.build_approval_claim_conditions(current_user)
|
||||
conditions.extend(self.build_budget_approval_claim_conditions(current_user))
|
||||
conditions.extend(self.build_approval_claim_conditions(current_user))
|
||||
if not conditions:
|
||||
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
|
||||
|
||||
@@ -440,6 +597,7 @@ class ExpenseClaimAccessPolicy:
|
||||
return stmt.where(ExpenseClaim.id == "__no_visible_claim__")
|
||||
|
||||
if include_approval_scope:
|
||||
conditions.extend(self.build_budget_approval_claim_conditions(current_user))
|
||||
conditions.extend(self.build_approval_claim_conditions(current_user))
|
||||
|
||||
return stmt.where(or_(*conditions))
|
||||
|
||||
@@ -68,7 +68,10 @@ class ExpenseClaimApplicationHandoffMixin:
|
||||
"application_claim_no": application_claim.claim_no,
|
||||
"application_budget_amount": str(application_claim.amount or Decimal("0.00")),
|
||||
"application_approval_event_id": str(approval_flag.get("approval_event_id") or ""),
|
||||
"leader_opinion": str(approval_flag.get("opinion") or "").strip(),
|
||||
"leader_opinion": str(
|
||||
approval_flag.get("leader_opinion") or approval_flag.get("opinion") or ""
|
||||
).strip(),
|
||||
"budget_opinion": str(approval_flag.get("budget_opinion") or "").strip(),
|
||||
"created_at": created_at.isoformat(),
|
||||
}
|
||||
],
|
||||
|
||||
173
server/src/app/services/expense_claim_approval_flow.py
Normal file
173
server/src/app/services/expense_claim_approval_flow.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimApprovalFlowMixin:
|
||||
def approve_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
opinion: str | None = None,
|
||||
):
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status != "submitted":
|
||||
raise ValueError("只有审批中的单据可以审批通过。")
|
||||
|
||||
previous_stage = str(claim.approval_stage or "").strip()
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
next_budget_manager = None
|
||||
if previous_stage == DIRECT_MANAGER_APPROVAL_STAGE:
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
|
||||
approval_source = "manual_approval"
|
||||
event_type = "expense_application_approval" if is_application_claim else "expense_claim_approval"
|
||||
label = "领导审批通过"
|
||||
if is_application_claim:
|
||||
next_budget_manager = self._access_policy.resolve_department_budget_manager(claim)
|
||||
next_status = "submitted"
|
||||
next_stage = BUDGET_MANAGER_APPROVAL_STAGE
|
||||
default_message = "{operator} 已确认直属领导审核,流转至预算管理者审批。"
|
||||
else:
|
||||
next_status = "submitted"
|
||||
next_stage = FINANCE_APPROVAL_STAGE
|
||||
default_message = "{operator} 已审批通过,流转至{next_stage}。"
|
||||
elif previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
if not is_application_claim:
|
||||
raise ValueError("只有费用申请需要预算管理者审批。")
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前预算管理者可以审批通过该费用申请。")
|
||||
approval_source = "budget_approval"
|
||||
event_type = "expense_application_budget_approval"
|
||||
label = "预算管理者审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = APPROVAL_DONE_STAGE
|
||||
default_message = "{operator} 已完成预算审核,申请流程完成并生成报销草稿。"
|
||||
elif previous_stage == FINANCE_APPROVAL_STAGE:
|
||||
if is_application_claim:
|
||||
raise ValueError("费用申请需先完成预算管理者审批。")
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员可以完成财务终审。")
|
||||
approval_source = "finance_approval"
|
||||
event_type = "expense_claim_finance_approval"
|
||||
label = "财务审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = ARCHIVE_ACCOUNTING_STAGE
|
||||
default_message = "{operator} 已完成财务审核,进入归档入账。"
|
||||
else:
|
||||
raise ValueError("当前节点不支持审批通过。")
|
||||
|
||||
approval_opinion = str(opinion or "").strip()
|
||||
if previous_stage in {DIRECT_MANAGER_APPROVAL_STAGE, BUDGET_MANAGER_APPROVAL_STAGE} and not approval_opinion:
|
||||
approval_opinion = "同意"
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
budget_flags: list[dict[str, Any]] = []
|
||||
if approval_source == "finance_approval" and not is_application_claim:
|
||||
consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user)
|
||||
if consumed_budget_flag is not None:
|
||||
budget_flags.append(consumed_budget_flag)
|
||||
approval_flag = {
|
||||
"source": approval_source,
|
||||
"event_type": event_type,
|
||||
"approval_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": label,
|
||||
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"opinion": approval_opinion,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": next_status,
|
||||
"next_approval_stage": next_stage,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
if next_budget_manager is not None:
|
||||
approval_flag.update(
|
||||
{
|
||||
"next_approver_name": str(next_budget_manager.name or "").strip(),
|
||||
"next_approver_employee_id": next_budget_manager.id,
|
||||
"next_approver_grade": str(next_budget_manager.grade or "").strip(),
|
||||
"next_approver_role_code": "budget_monitor",
|
||||
}
|
||||
)
|
||||
|
||||
claim.status = next_status
|
||||
claim.approval_stage = next_stage
|
||||
if claim.submitted_at is None:
|
||||
claim.submitted_at = datetime.now(UTC)
|
||||
if is_application_claim and previous_stage == BUDGET_MANAGER_APPROVAL_STAGE:
|
||||
approval_flag["leader_opinion"] = self._resolve_latest_approval_opinion(
|
||||
claim,
|
||||
source="manual_approval",
|
||||
)
|
||||
approval_flag["budget_opinion"] = approval_opinion
|
||||
generated_draft = self._create_reimbursement_draft_from_application(
|
||||
application_claim=claim,
|
||||
approval_flag=approval_flag,
|
||||
operator=operator,
|
||||
)
|
||||
transferred_budget_flag = self._transfer_application_budget_to_reimbursement(
|
||||
application_claim=claim,
|
||||
draft_claim=generated_draft,
|
||||
current_user=current_user,
|
||||
)
|
||||
if transferred_budget_flag is not None:
|
||||
budget_flags.append(transferred_budget_flag)
|
||||
generated_draft.risk_flags_json = self._append_budget_flags(
|
||||
generated_draft.risk_flags_json,
|
||||
transferred_budget_flag,
|
||||
)
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*list(claim.risk_flags_json or []), approval_flag],
|
||||
budget_flags,
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.approve",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
@staticmethod
|
||||
def _resolve_latest_approval_opinion(claim, *, source: str) -> str:
|
||||
for flag in reversed(list(claim.risk_flags_json or [])):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
if str(flag.get("source") or "").strip() != source:
|
||||
continue
|
||||
opinion = str(flag.get("opinion") or flag.get("message") or "").strip()
|
||||
if opinion:
|
||||
return opinion
|
||||
return ""
|
||||
@@ -225,6 +225,7 @@ RETURN_REASON_OPTIONS = {
|
||||
"application_budget_basis_missing": "预算测算依据不足",
|
||||
"application_policy_mismatch": "制度口径不匹配",
|
||||
"application_attachment_needed": "前置材料需补充",
|
||||
"application_other": "其他",
|
||||
}
|
||||
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
|
||||
DOCUMENT_DATE_PATTERN = re.compile(
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
|
||||
from app.models.agent_asset import AgentAsset
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||
@@ -50,7 +51,7 @@ class ExpenseClaimItemSyncMixin:
|
||||
self._discard_claim_item(claim, item)
|
||||
return
|
||||
|
||||
grade = str(claim.employee_grade or "").strip()
|
||||
grade = self._resolve_claim_employee_grade(claim)
|
||||
if not grade:
|
||||
return
|
||||
|
||||
@@ -115,6 +116,16 @@ class ExpenseClaimItemSyncMixin:
|
||||
item.item_amount = allowance_amount
|
||||
item.invoice_id = None
|
||||
|
||||
def _resolve_claim_employee_grade(self, claim: ExpenseClaim) -> str:
|
||||
grade = str(claim.employee_grade or "").strip()
|
||||
if grade:
|
||||
return grade
|
||||
employee_id = str(claim.employee_id or "").strip()
|
||||
if not employee_id:
|
||||
return ""
|
||||
employee = self.db.get(Employee, employee_id)
|
||||
return str(employee.grade if employee is not None and employee.grade else "").strip()
|
||||
|
||||
def _discard_claim_item(self, claim: ExpenseClaim, item: ExpenseClaimItem) -> None:
|
||||
if item in claim.items:
|
||||
claim.items.remove(item)
|
||||
|
||||
@@ -204,6 +204,8 @@ class ExpenseClaimReadModelMixin:
|
||||
normalized = str(stage or "").strip()
|
||||
if "直属" in normalized or "领导" in normalized or "负责人" in normalized:
|
||||
return "direct_manager"
|
||||
if "预算" in normalized:
|
||||
return "budget"
|
||||
if "财务" in normalized:
|
||||
return "finance"
|
||||
if "AI" in normalized or "预审" in normalized:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
DIRECT_MANAGER_APPROVAL_STAGE = "直属领导审批"
|
||||
BUDGET_MANAGER_APPROVAL_STAGE = "预算管理者审批"
|
||||
FINANCE_APPROVAL_STAGE = "财务审批"
|
||||
APPROVAL_DONE_STAGE = "审批完成"
|
||||
ARCHIVE_ACCOUNTING_STAGE = "归档入账"
|
||||
|
||||
@@ -35,6 +35,7 @@ from app.services.audit import AuditLogService
|
||||
from app.services.document_intelligence import build_document_insight
|
||||
from app.services.document_numbering import is_application_claim_no
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_approval_flow import ExpenseClaimApprovalFlowMixin
|
||||
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
|
||||
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
|
||||
from app.services.expense_claim_application_handoff import ExpenseClaimApplicationHandoffMixin
|
||||
@@ -127,6 +128,7 @@ from app.services.ocr import OcrService
|
||||
|
||||
|
||||
class ExpenseClaimService(
|
||||
ExpenseClaimApprovalFlowMixin,
|
||||
ExpenseClaimApplicationHandoffMixin,
|
||||
ExpenseClaimBudgetFlowMixin,
|
||||
ExpenseClaimAttachmentOperationsMixin,
|
||||
@@ -234,6 +236,18 @@ class ExpenseClaimService(
|
||||
stmt = self._access_policy.apply_claim_scope(stmt, current_user, include_approval_scope=True)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def can_view_budget_analysis(self, current_user: CurrentUserContext, claim: ExpenseClaim | None = None) -> bool:
|
||||
if claim is None:
|
||||
return self._access_policy.is_budget_manager_user(current_user)
|
||||
if current_user.is_admin:
|
||||
return True
|
||||
role_codes = self._access_policy.normalize_role_codes(current_user)
|
||||
if "executive" in role_codes:
|
||||
return True
|
||||
if self._access_policy.is_claim_owned_by_current_user(claim, current_user):
|
||||
return False
|
||||
return self._access_policy.is_department_p8_budget_monitor(current_user, claim)
|
||||
|
||||
def update_claim(
|
||||
self,
|
||||
*,
|
||||
@@ -562,9 +576,6 @@ class ExpenseClaimService(
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
if not self._access_policy.can_return_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status == "draft":
|
||||
raise ValueError("草稿状态无需退回。")
|
||||
@@ -573,6 +584,9 @@ class ExpenseClaimService(
|
||||
if normalized_status in {"approved", "completed", "paid"}:
|
||||
raise ValueError("已完成单据不允许退回。")
|
||||
|
||||
if not self._access_policy.can_return_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员、高级财务人员或当前审批人可以退回报销单。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
previous_status = str(claim.status or "").strip()
|
||||
@@ -580,21 +594,25 @@ class ExpenseClaimService(
|
||||
previous_stage_key = self._normalize_return_stage_key(previous_stage)
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
is_direct_manager_return = previous_stage_key == "direct_manager"
|
||||
is_budget_return = previous_stage_key == "budget"
|
||||
is_application_return = is_application_claim and (is_direct_manager_return or is_budget_return)
|
||||
return_event_type = (
|
||||
"expense_application_return"
|
||||
if is_application_claim and is_direct_manager_return
|
||||
if is_application_return
|
||||
else "expense_claim_return"
|
||||
)
|
||||
return_label = (
|
||||
"领导退回"
|
||||
if is_application_claim and is_direct_manager_return
|
||||
else "预算退回"
|
||||
if is_application_claim and is_budget_return
|
||||
else "人工退回"
|
||||
)
|
||||
return_reason = str(reason or "").strip()
|
||||
reason_code_payload = self._normalize_return_reason_code_payload(reason_codes)
|
||||
normalized_reason_codes = reason_code_payload["reason_codes"]
|
||||
unknown_reason_codes = reason_code_payload["unknown_reason_codes"]
|
||||
if is_application_claim and is_direct_manager_return and not any(
|
||||
if is_application_return and not any(
|
||||
code.startswith("application_") for code in normalized_reason_codes
|
||||
):
|
||||
raise ValueError("申请单退回必须选择至少一个退单类型。")
|
||||
@@ -627,6 +645,7 @@ class ExpenseClaimService(
|
||||
"reason": return_reason,
|
||||
"opinion": message,
|
||||
"leader_opinion": message if is_application_claim and is_direct_manager_return else "",
|
||||
"budget_opinion": message if is_application_claim and is_budget_return else "",
|
||||
"reason_codes": normalized_reason_codes,
|
||||
"risk_points": risk_points,
|
||||
"operator": operator,
|
||||
@@ -676,204 +695,6 @@ class ExpenseClaimService(
|
||||
|
||||
return claim
|
||||
|
||||
def approve_claim(
|
||||
self,
|
||||
claim_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
opinion: str | None = None,
|
||||
) -> ExpenseClaim | None:
|
||||
claim = self.get_claim(claim_id, current_user)
|
||||
if claim is None:
|
||||
return None
|
||||
|
||||
normalized_status = str(claim.status or "").strip().lower()
|
||||
if normalized_status != "submitted":
|
||||
raise ValueError("只有审批中的单据可以审批通过。")
|
||||
|
||||
previous_stage = str(claim.approval_stage or "").strip()
|
||||
is_application_claim = self._is_expense_application_claim(claim)
|
||||
if previous_stage == "直属领导审批":
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有当前直属领导审批人可以审批通过该单据。")
|
||||
approval_source = "manual_approval"
|
||||
if is_application_claim:
|
||||
event_type = "expense_application_approval"
|
||||
label = "领导审批通过"
|
||||
next_status = "approved"
|
||||
next_stage = "审批完成"
|
||||
default_message = "{operator} 已确认审核,申请流程完成并生成报销草稿。"
|
||||
else:
|
||||
event_type = "expense_claim_approval"
|
||||
label = "领导审批通过"
|
||||
next_status = "submitted"
|
||||
next_stage = "财务审批"
|
||||
default_message = "{operator} 已审批通过,流转至{next_stage}。"
|
||||
elif previous_stage == "财务审批":
|
||||
if is_application_claim:
|
||||
raise ValueError("费用申请无需财务审批,直属领导审批通过后即完成。")
|
||||
if not self._access_policy.can_approve_claim(current_user, claim):
|
||||
raise ValueError("只有财务人员可以完成财务终审。")
|
||||
approval_source = "finance_approval"
|
||||
event_type = "expense_claim_finance_approval"
|
||||
label = "财务审核通过"
|
||||
next_status = "approved"
|
||||
next_stage = "归档入账"
|
||||
default_message = "{operator} 已完成财务审核,进入归档入账。"
|
||||
else:
|
||||
raise ValueError("当前节点不支持审批通过。")
|
||||
|
||||
approval_opinion = str(opinion or "").strip()
|
||||
if previous_stage == "直属领导审批" and not approval_opinion:
|
||||
raise ValueError("领导审核意见不能为空,请填写意见后再确认审核。")
|
||||
|
||||
before_json = self._serialize_claim(claim)
|
||||
operator = self._access_policy.resolve_current_user_display_name(current_user)
|
||||
budget_flags: list[dict[str, Any]] = []
|
||||
if approval_source == "finance_approval" and not is_application_claim:
|
||||
consumed_budget_flag = self._consume_budget_for_finance_approval(claim, current_user)
|
||||
if consumed_budget_flag is not None:
|
||||
budget_flags.append(consumed_budget_flag)
|
||||
approval_flag = {
|
||||
"source": approval_source,
|
||||
"event_type": event_type,
|
||||
"approval_event_id": str(uuid.uuid4()),
|
||||
"severity": "info",
|
||||
"label": label,
|
||||
"message": approval_opinion or default_message.format(operator=operator, next_stage=next_stage),
|
||||
"opinion": approval_opinion,
|
||||
"operator": operator,
|
||||
"operator_username": current_user.username,
|
||||
"operator_role_codes": [
|
||||
str(item).strip().lower()
|
||||
for item in current_user.role_codes
|
||||
if str(item).strip()
|
||||
],
|
||||
"previous_status": str(claim.status or "").strip(),
|
||||
"previous_approval_stage": previous_stage,
|
||||
"next_status": next_status,
|
||||
"next_approval_stage": next_stage,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
claim.status = next_status
|
||||
claim.approval_stage = next_stage
|
||||
if claim.submitted_at is None:
|
||||
claim.submitted_at = datetime.now(UTC)
|
||||
if is_application_claim and previous_stage == "直属领导审批":
|
||||
generated_draft = self._create_reimbursement_draft_from_application(
|
||||
application_claim=claim,
|
||||
approval_flag=approval_flag,
|
||||
operator=operator,
|
||||
)
|
||||
transferred_budget_flag = self._transfer_application_budget_to_reimbursement(
|
||||
application_claim=claim,
|
||||
draft_claim=generated_draft,
|
||||
current_user=current_user,
|
||||
)
|
||||
if transferred_budget_flag is not None:
|
||||
budget_flags.append(transferred_budget_flag)
|
||||
generated_draft.risk_flags_json = self._append_budget_flags(
|
||||
generated_draft.risk_flags_json,
|
||||
transferred_budget_flag,
|
||||
)
|
||||
claim.risk_flags_json = self._append_budget_flags(
|
||||
[*list(claim.risk_flags_json or []), approval_flag],
|
||||
budget_flags,
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(claim)
|
||||
|
||||
self.audit_service.log_action(
|
||||
actor=operator,
|
||||
action="expense_claim.approve",
|
||||
resource_type="expense_claim",
|
||||
resource_id=claim.id,
|
||||
before_json=before_json,
|
||||
after_json=self._serialize_claim(claim),
|
||||
)
|
||||
|
||||
return claim
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -602,7 +602,15 @@ class KnowledgeService:
|
||||
status_payload = status_map.get(document_id) or {}
|
||||
rag_status = str(status_payload.get("status") or "").strip().lower()
|
||||
linked_run_status = resolve_linked_ingest_run_status(entry, db=self.db)
|
||||
if linked_run_status == AgentRunStatus.FAILED.value and rag_status in {
|
||||
if not status_payload:
|
||||
if (
|
||||
current_status == KNOWLEDGE_INGEST_STATUS_SYNCING
|
||||
and linked_run_status == AgentRunStatus.FAILED.value
|
||||
):
|
||||
desired_status = KNOWLEDGE_INGEST_STATUS_FAILED
|
||||
else:
|
||||
continue
|
||||
elif linked_run_status == AgentRunStatus.FAILED.value and rag_status in {
|
||||
"pending",
|
||||
"processing",
|
||||
"preprocessed",
|
||||
|
||||
@@ -4,8 +4,9 @@ import os
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -89,8 +90,10 @@ STRUCTURED_APPENDIX_LEADING_MARKERS = (
|
||||
)
|
||||
STRUCTURED_APPENDIX_LEADING_WINDOW = 220
|
||||
_runtime_lock = threading.RLock()
|
||||
_runtime_instances: dict[int, _LightRagRuntime] = {}
|
||||
_runtime_signatures: dict[int, tuple[Any, ...]] = {}
|
||||
_runtime_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="knowledge-rag-runtime")
|
||||
_runtime_instances: dict[str, _LightRagRuntime] = {}
|
||||
_runtime_signatures: dict[str, tuple[Any, ...]] = {}
|
||||
_RUNTIME_CACHE_KEY = "lightrag"
|
||||
|
||||
|
||||
class KnowledgeRagService:
|
||||
@@ -133,21 +136,26 @@ class KnowledgeRagService:
|
||||
|
||||
runtime_hits: list[dict[str, Any]] = []
|
||||
runtime_references: list[str] = []
|
||||
try:
|
||||
runtime = self._get_runtime()
|
||||
raw = runtime.query_data(rewritten_query, conversation_history=conversation_history)
|
||||
data = raw.get("data") if isinstance(raw, dict) else {}
|
||||
chunks = list(data.get("chunks") or []) if isinstance(data, dict) else []
|
||||
entities = list(data.get("entities") or []) if isinstance(data, dict) else []
|
||||
runtime_references = list(data.get("references") or []) if isinstance(data, dict) else []
|
||||
runtime_hits = self._build_hits_from_query_data(
|
||||
query=rewritten_query,
|
||||
chunks=chunks,
|
||||
entities=entities,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Knowledge query failed: %s", exc)
|
||||
if not local_result.confident:
|
||||
try:
|
||||
raw = self._run_runtime_operation(
|
||||
lambda runtime: runtime.query_data(
|
||||
rewritten_query,
|
||||
conversation_history=conversation_history,
|
||||
)
|
||||
)
|
||||
data = raw.get("data") if isinstance(raw, dict) else {}
|
||||
chunks = list(data.get("chunks") or []) if isinstance(data, dict) else []
|
||||
entities = list(data.get("entities") or []) if isinstance(data, dict) else []
|
||||
runtime_references = list(data.get("references") or []) if isinstance(data, dict) else []
|
||||
runtime_hits = self._build_hits_from_query_data(
|
||||
query=rewritten_query,
|
||||
chunks=chunks,
|
||||
entities=entities,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Knowledge query failed: %s", exc)
|
||||
|
||||
all_hits: dict[str, dict[str, Any]] = {}
|
||||
for hit in local_result.hits:
|
||||
@@ -189,7 +197,7 @@ class KnowledgeRagService:
|
||||
],
|
||||
"raw_references": runtime_references,
|
||||
"metadata": {
|
||||
"retrieval_strategy": "fusion",
|
||||
"retrieval_strategy": "fusion" if runtime_hits else "local_text_chunks",
|
||||
"local_total_chunks": local_result.total_chunks,
|
||||
"local_best_score": local_result.best_score,
|
||||
},
|
||||
@@ -244,14 +252,17 @@ class KnowledgeRagService:
|
||||
file_paths: list[str] = []
|
||||
document_summaries: list[dict[str, Any]] = []
|
||||
|
||||
runtime = self._get_runtime()
|
||||
existing_statuses = runtime.get_document_statuses(normalized_ids)
|
||||
existing_statuses = self._run_runtime_operation(
|
||||
lambda runtime: runtime.get_document_statuses(normalized_ids)
|
||||
)
|
||||
|
||||
for document_id in normalized_ids:
|
||||
entry = knowledge_service.get_document_entry(document_id)
|
||||
if force and document_id in existing_statuses:
|
||||
try:
|
||||
runtime.delete_document(document_id)
|
||||
self._run_runtime_operation(
|
||||
lambda runtime, target_id=document_id: runtime.delete_document(target_id)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Delete existing LightRAG document failed doc_id=%s: %s", document_id, exc
|
||||
@@ -277,13 +288,17 @@ class KnowledgeRagService:
|
||||
)
|
||||
)
|
||||
|
||||
track_id = runtime.insert_documents(
|
||||
texts=texts,
|
||||
document_ids=normalized_ids,
|
||||
file_paths=file_paths,
|
||||
track_id = self._run_runtime_operation(
|
||||
lambda runtime: runtime.insert_documents(
|
||||
texts=texts,
|
||||
document_ids=normalized_ids,
|
||||
file_paths=file_paths,
|
||||
)
|
||||
)
|
||||
|
||||
statuses = runtime.get_document_statuses(normalized_ids)
|
||||
statuses = self._run_runtime_operation(
|
||||
lambda runtime: runtime.get_document_statuses(normalized_ids)
|
||||
)
|
||||
succeeded_document_ids: list[str] = []
|
||||
failed_documents: list[dict[str, str]] = []
|
||||
summary_by_id = {
|
||||
@@ -344,7 +359,9 @@ class KnowledgeRagService:
|
||||
if not target_ids:
|
||||
return {}
|
||||
try:
|
||||
statuses = self._get_runtime().get_document_statuses(target_ids)
|
||||
statuses = self._run_runtime_operation(
|
||||
lambda runtime: runtime.get_document_statuses(target_ids)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Load LightRAG document statuses failed: %s", exc)
|
||||
return {}
|
||||
@@ -358,16 +375,40 @@ class KnowledgeRagService:
|
||||
if not normalized_id:
|
||||
return
|
||||
try:
|
||||
self._get_runtime().delete_document(normalized_id)
|
||||
self._run_runtime_operation(
|
||||
lambda runtime: runtime.delete_document(normalized_id)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Delete LightRAG document ignored doc_id=%s: %s", normalized_id, exc)
|
||||
|
||||
def _get_runtime(self) -> _LightRagRuntime:
|
||||
def _run_runtime_operation(self, operation: Callable[[_LightRagRuntime], Any]) -> Any:
|
||||
signature, runtime_kwargs = self._build_runtime_signature()
|
||||
thread_id = threading.get_ident()
|
||||
return _runtime_executor.submit(
|
||||
self._execute_runtime_operation,
|
||||
signature,
|
||||
runtime_kwargs,
|
||||
operation,
|
||||
).result()
|
||||
|
||||
def _execute_runtime_operation(
|
||||
self,
|
||||
signature: tuple[Any, ...],
|
||||
runtime_kwargs: dict[str, Any],
|
||||
operation: Callable[[_LightRagRuntime], Any],
|
||||
) -> Any:
|
||||
return operation(self._get_runtime(signature=signature, runtime_kwargs=runtime_kwargs))
|
||||
|
||||
def _get_runtime(
|
||||
self,
|
||||
*,
|
||||
signature: tuple[Any, ...] | None = None,
|
||||
runtime_kwargs: dict[str, Any] | None = None,
|
||||
) -> _LightRagRuntime:
|
||||
if signature is None or runtime_kwargs is None:
|
||||
signature, runtime_kwargs = self._build_runtime_signature()
|
||||
with _runtime_lock:
|
||||
runtime = _runtime_instances.get(thread_id)
|
||||
if runtime is not None and _runtime_signatures.get(thread_id) == signature:
|
||||
runtime = _runtime_instances.get(_RUNTIME_CACHE_KEY)
|
||||
if runtime is not None and _runtime_signatures.get(_RUNTIME_CACHE_KEY) == signature:
|
||||
return runtime
|
||||
|
||||
if runtime is not None:
|
||||
@@ -377,8 +418,8 @@ class KnowledgeRagService:
|
||||
logger.warning("Finalize previous LightRAG runtime failed: %s", exc)
|
||||
|
||||
runtime = _LightRagRuntime(**runtime_kwargs)
|
||||
_runtime_instances[thread_id] = runtime
|
||||
_runtime_signatures[thread_id] = signature
|
||||
_runtime_instances[_RUNTIME_CACHE_KEY] = runtime
|
||||
_runtime_signatures[_RUNTIME_CACHE_KEY] = signature
|
||||
return runtime
|
||||
|
||||
def _build_runtime_signature(self) -> tuple[tuple[Any, ...], dict[str, Any]]:
|
||||
@@ -633,6 +674,10 @@ class KnowledgeRagService:
|
||||
|
||||
|
||||
def shutdown_knowledge_rag_runtime() -> None:
|
||||
_runtime_executor.submit(_shutdown_runtime_instances).result()
|
||||
|
||||
|
||||
def _shutdown_runtime_instances() -> None:
|
||||
with _runtime_lock:
|
||||
for runtime in list(_runtime_instances.values()):
|
||||
try:
|
||||
|
||||
@@ -229,7 +229,13 @@ class _LightRagRuntime:
|
||||
raise KnowledgeRagError(str(getattr(result, "message", "") or "LightRAG 删除文档失败。"))
|
||||
|
||||
def _probe_embedding_dimension(self, config: RuntimeModelConfig) -> int:
|
||||
vectors = self._request_embeddings(config, ["dimension probe"])
|
||||
try:
|
||||
vectors = self._request_embeddings(config, ["dimension probe"])
|
||||
except Exception as exc:
|
||||
raise KnowledgeRagError(
|
||||
"Embedding model probe failed "
|
||||
f"(slot={config.slot}, provider={config.provider}, model={config.model}): {exc}"
|
||||
) from exc
|
||||
if not vectors or not isinstance(vectors[0], list):
|
||||
raise KnowledgeRagError("无法从 embedding 模型返回结果中解析向量维度。")
|
||||
dimension = len(vectors[0])
|
||||
|
||||
@@ -335,7 +335,12 @@ class SettingsService:
|
||||
for model_row in model_rows.values():
|
||||
self.db.refresh(model_row)
|
||||
|
||||
return self._serialize(settings_row, secrets_row, model_rows)
|
||||
return self._serialize(
|
||||
settings_row,
|
||||
secrets_row,
|
||||
model_rows,
|
||||
self._build_hermes_form_snapshot(),
|
||||
)
|
||||
|
||||
def load_saved_model_api_key(self, slot: str | None) -> str:
|
||||
if not slot or slot not in MODEL_SLOT_CONFIGS:
|
||||
@@ -748,7 +753,11 @@ class SettingsService:
|
||||
hermesForm=hermes_form,
|
||||
llmForm={
|
||||
"mainProvider": main_model.provider,
|
||||
"backupProvider": backup_model.provider,
|
||||
"mainModel": main_model.model_name,
|
||||
"mainEndpoint": main_model.endpoint,
|
||||
"mainApiKey": "",
|
||||
"mainApiKeyConfigured": bool(main_model.api_key_encrypted),
|
||||
"backupProvider": backup_model.provider,
|
||||
"backupModel": backup_model.model_name,
|
||||
"backupEndpoint": backup_model.endpoint,
|
||||
"backupApiKey": "",
|
||||
|
||||
@@ -148,22 +148,7 @@ class UserAgentService(
|
||||
requires_confirmation=payload.requires_confirmation,
|
||||
)
|
||||
|
||||
fast_knowledge_answer = self._build_fast_knowledge_answer(
|
||||
payload,
|
||||
citations=citations,
|
||||
)
|
||||
if fast_knowledge_answer:
|
||||
return UserAgentResponse(
|
||||
answer=fast_knowledge_answer,
|
||||
citations=citations,
|
||||
suggested_actions=suggested_actions,
|
||||
query_payload=query_payload,
|
||||
draft_payload=draft_payload,
|
||||
review_payload=review_payload,
|
||||
risk_flags=risk_flags,
|
||||
requires_confirmation=payload.requires_confirmation,
|
||||
)
|
||||
|
||||
# 知识库问答必须优先让模型基于召回证据组织答案,避免片段渲染抢答导致答非所问。
|
||||
fallback_answer = self._build_fallback_answer(
|
||||
payload,
|
||||
citations=citations,
|
||||
|
||||
@@ -86,7 +86,6 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
|
||||
*,
|
||||
citations: list[UserAgentCitation],
|
||||
) -> str | None:
|
||||
return None
|
||||
if payload.ontology.scenario != "knowledge":
|
||||
return None
|
||||
if str(payload.tool_payload.get("result_type") or "").strip() != "knowledge_search":
|
||||
@@ -130,7 +129,10 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
|
||||
primary_heading = self._format_knowledge_heading_label(
|
||||
str(primary_item.get("heading") or "").strip()
|
||||
)
|
||||
primary_lines = self._collect_direct_knowledge_answer_lines(ordered_evidence_items)
|
||||
primary_lines = self._collect_direct_knowledge_answer_lines(
|
||||
ordered_evidence_items,
|
||||
query_terms=query_terms,
|
||||
)
|
||||
|
||||
lines: list[str] = []
|
||||
if user_name:
|
||||
@@ -139,20 +141,42 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
|
||||
if primary_heading:
|
||||
source_prefix = f"{source_prefix}({primary_heading})"
|
||||
|
||||
conclusion_lines: list[str] = []
|
||||
evidence_lines: list[str] = []
|
||||
if str(primary_item.get("kind") or "") == "table":
|
||||
lines.append(f"{source_prefix},当前能直接确认的是:")
|
||||
lines.append(self._extract_relevant_table_preview(str(primary_item.get("content") or ""), query_terms))
|
||||
table_content = str(primary_item.get("content") or "")
|
||||
if self._question_requests_broad_knowledge_table(question):
|
||||
table_preview = table_content.strip()
|
||||
else:
|
||||
table_preview = self._extract_relevant_table_preview(
|
||||
table_content,
|
||||
query_terms,
|
||||
preferred_terms=self._build_knowledge_table_preferred_terms(payload),
|
||||
)
|
||||
table_summary = self._summarize_knowledge_table_preview(table_preview)
|
||||
conclusion_lines.append(f"{source_prefix},{table_summary}")
|
||||
evidence_lines.append(table_preview)
|
||||
else:
|
||||
if not primary_lines:
|
||||
lines.append(
|
||||
summary = self._summarize_knowledge_evidence_content(primary_item, query_terms)
|
||||
conclusion_lines.append(
|
||||
f"{source_prefix},当前能直接确认的是:"
|
||||
f"{self._summarize_knowledge_evidence_content(primary_item, query_terms)}"
|
||||
f"{summary}"
|
||||
)
|
||||
elif len(primary_lines) == 1:
|
||||
lines.append(f"{source_prefix},当前能直接确认的是:{primary_lines[0].strip()}")
|
||||
conclusion_lines.append(f"{source_prefix},当前能直接确认的是:{primary_lines[0].strip()}")
|
||||
evidence_lines.extend(primary_lines)
|
||||
else:
|
||||
lines.append(f"{source_prefix},当前能直接确认的是:")
|
||||
lines.extend(primary_lines)
|
||||
subject = self._build_knowledge_answer_subject(question, primary_heading)
|
||||
summary = self._summarize_knowledge_lines_conclusion(
|
||||
primary_lines,
|
||||
heading=subject,
|
||||
)
|
||||
if summary:
|
||||
conclusion_lines.append(f"{source_prefix},{summary}")
|
||||
else:
|
||||
conclusion_lines.append(f"{source_prefix},当前能直接确认的是:")
|
||||
evidence_lines.extend(primary_lines)
|
||||
|
||||
notes: list[str] = []
|
||||
location_note = self._build_missing_location_grounding_note(question, evidence_items)
|
||||
@@ -161,14 +185,64 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
|
||||
if self._question_requires_explicit_condition(question) and not self._answer_evidence_has_numeric_or_condition(evidence_items):
|
||||
notes.append("当前命中的证据更偏规则说明或流程约束,还没有直接给出可立即套用的数值或完整条件。")
|
||||
|
||||
self._append_markdown_section(lines, "结论", conclusion_lines)
|
||||
self._append_markdown_section(lines, "依据", evidence_lines)
|
||||
if notes:
|
||||
lines.append("")
|
||||
lines.append("说明:")
|
||||
lines.extend(f"- {note}" for note in notes)
|
||||
self._append_markdown_section(lines, "说明", [f"- {note}" for note in notes])
|
||||
|
||||
return "\n".join(line for line in lines if line is not None).strip()
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _append_markdown_section(lines: list[str], title: str, body_lines: list[str]) -> None:
|
||||
cleaned = [str(line or "").rstrip() for line in body_lines if str(line or "").strip()]
|
||||
if not cleaned:
|
||||
return
|
||||
if lines and lines[-1] != "":
|
||||
lines.append("")
|
||||
lines.append(f"## {title}")
|
||||
lines.append("")
|
||||
lines.extend(cleaned)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _build_knowledge_answer_subject(question: str, heading: str = "") -> str:
|
||||
clean_heading = str(heading or "").strip()
|
||||
if clean_heading and not any(
|
||||
marker in clean_heading
|
||||
for marker in ("问答线索补充", "结构化表格补充", "重点章节摘录", "章节导航")
|
||||
):
|
||||
return clean_heading
|
||||
|
||||
normalized = re.sub(r"\s+", "", str(question or "").strip())
|
||||
normalized = re.sub(r"[??。.!!]+$", "", normalized)
|
||||
normalized = re.sub(r"(是什么|有哪些|是多少|如何|怎么|吗|呢)$", "", normalized)
|
||||
return normalized.strip("::,,。.")
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _build_knowledge_table_preferred_terms(payload: UserAgentRequest) -> list[str]:
|
||||
terms: list[str] = []
|
||||
context = payload.context_json or {}
|
||||
for key in ("grade", "position", "job_grade", "rank", "level"):
|
||||
value = str(context.get(key) or "").strip()
|
||||
if value and value not in terms:
|
||||
terms.append(value)
|
||||
|
||||
grade_match = re.fullmatch(r"[Pp](\d{1,2})", str(context.get("grade") or "").strip())
|
||||
if grade_match:
|
||||
grade = int(grade_match.group(1))
|
||||
for start in range(max(0, grade - 4), grade + 1):
|
||||
for end in range(grade, min(12, grade + 4) + 1):
|
||||
if start >= end:
|
||||
continue
|
||||
for separator in ("~", "~", "-", "至"):
|
||||
term = f"P{start}{separator}P{end}"
|
||||
if term not in terms:
|
||||
terms.append(term)
|
||||
return terms
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _resolve_knowledge_question(payload: UserAgentRequest) -> str:
|
||||
return str(payload.context_json.get("user_input_text") or payload.message or "").strip()
|
||||
@@ -484,6 +558,8 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
|
||||
def _collect_direct_knowledge_answer_lines(
|
||||
self,
|
||||
ordered_evidence_items: list[dict[str, Any]],
|
||||
*,
|
||||
query_terms: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
if not ordered_evidence_items:
|
||||
return []
|
||||
@@ -509,8 +585,18 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
|
||||
lines: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in related_items:
|
||||
rendered = self._render_knowledge_evidence_text(item)
|
||||
for line in rendered.splitlines():
|
||||
item_kind = str(item.get("kind") or "").strip()
|
||||
item_content = str(item.get("content") or "")
|
||||
if item_kind == "paragraph" or self._has_inline_numbered_knowledge_items(item_content):
|
||||
rendered = self._focus_knowledge_segment_content(
|
||||
item_content,
|
||||
query_terms or [],
|
||||
)
|
||||
rendered_lines = self._split_inline_numbered_knowledge_items(rendered)
|
||||
else:
|
||||
rendered = self._render_knowledge_evidence_text(item)
|
||||
rendered_lines = rendered.splitlines()
|
||||
for line in rendered_lines:
|
||||
normalized = str(line or "").strip()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
@@ -573,13 +659,21 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
|
||||
or "相关制度"
|
||||
).strip()
|
||||
user_name = str(payload.context_json.get("name") or "").strip()
|
||||
prefix = f"{user_name},您好。\n" if user_name else ""
|
||||
answer_lines: list[str] = []
|
||||
if user_name:
|
||||
answer_lines.append(f"{user_name},您好。")
|
||||
if not hits:
|
||||
return (
|
||||
f"{prefix}我已经从《{title}》中检索到与你这次问题相关的制度依据,"
|
||||
"但本次答案生成环节暂时没有成功返回。请稍后重试一次;如果仍然失败,"
|
||||
"建议先检查主对话模型的连通性。"
|
||||
self._append_markdown_section(
|
||||
answer_lines,
|
||||
"结论",
|
||||
[f"当前没有拿到可用于回答这个问题的《{title}》知识库命中。"],
|
||||
)
|
||||
self._append_markdown_section(
|
||||
answer_lines,
|
||||
"说明",
|
||||
["- 我不会用相似主题或外部常识硬凑答案;请补充更具体的关键词后再试一次。"],
|
||||
)
|
||||
return "\n".join(answer_lines).strip()
|
||||
|
||||
evidence_lines: list[str] = []
|
||||
for item in evidence_items[:3]:
|
||||
@@ -614,19 +708,28 @@ class UserAgentKnowledgeMixin(UserAgentKnowledgeHelpersMixin):
|
||||
evidence_lines.append(f"- **《{item_title}》**:{excerpt}")
|
||||
|
||||
if not evidence_lines:
|
||||
return (
|
||||
f"{prefix}当前《{title}》里可用于回答的关键条款还不够明确。"
|
||||
"请补充费用类型、适用地区、职级或具体业务场景,我再继续帮你缩小范围。"
|
||||
self._append_markdown_section(
|
||||
answer_lines,
|
||||
"结论",
|
||||
[f"当前《{title}》里可用于回答这个问题的关键条款还不够明确。"],
|
||||
)
|
||||
self._append_markdown_section(
|
||||
answer_lines,
|
||||
"说明",
|
||||
["- 请补充费用类型、适用地区、职级或具体业务场景,我再继续帮你缩小范围。"],
|
||||
)
|
||||
return "\n".join(answer_lines).strip()
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
f"{prefix}我先根据当前制度依据给出可以确认的部分。",
|
||||
"",
|
||||
"**依据**:",
|
||||
*evidence_lines,
|
||||
"",
|
||||
"**说明**:以上只使用当前命中的知识库证据;没有在证据中出现的适用条件或金额,我不会替你默认补齐。",
|
||||
]
|
||||
).strip()
|
||||
self._append_markdown_section(
|
||||
answer_lines,
|
||||
"结论",
|
||||
["我先根据当前制度依据给出可以确认的部分。"],
|
||||
)
|
||||
self._append_markdown_section(answer_lines, "依据", evidence_lines)
|
||||
self._append_markdown_section(
|
||||
answer_lines,
|
||||
"说明",
|
||||
["- 以上只使用当前命中的知识库证据;没有在证据中出现的适用条件或金额,我不会替你默认补齐。"],
|
||||
)
|
||||
return "\n".join(answer_lines).strip()
|
||||
|
||||
|
||||
@@ -280,10 +280,15 @@ class UserAgentResponseMixin:
|
||||
if payload.ontology.scenario == "knowledge":
|
||||
answer_style_instruction = (
|
||||
"你是财务制度知识问答助手。只能依据 facts.tool_payload.hits、facts.knowledge_answer_evidence、citations 与 conversation_history 回答,"
|
||||
"不要扩展成通用助手。优先直接回答,不要复述思考过程,不要输出 JSON、代码块或 <think>。"
|
||||
"不要扩展成通用助手。优先直接回答,不要复述思考过程,不要输出 JSON、代码块或可见思考过程。"
|
||||
"禁止使用“已命中”“答案整理阶段”“稍后重试”。"
|
||||
"最终答复必须使用 Markdown,优先包含“## 结论”“## 依据”“## 说明”这三个二级标题;"
|
||||
"如果某一部分没有内容,可以省略该标题。"
|
||||
"回答风格要像一位真正熟悉制度的财务伙伴:先直接回应用户的核心问题,再用一张简洁表格或短段落说明依据,"
|
||||
"最后补充最重要的注意事项。不要写成“已检索到内容”的系统回执,也不要把命中片段连缀成答案。"
|
||||
"必须优先回答用户当前这句话本身,不能把制度标题、制度全文或完整标准表当成主答案。"
|
||||
"回答前先判断召回内容是否真的能回答当前问题;如果不能,必须明确说当前知识库没有找到直接依据,"
|
||||
"不要改答相邻主题,也不要用相似条款硬凑答案。"
|
||||
"如果用户问的是某次具体行程“一共能报多少”,就先给“当前已能确认的金额”,再用一张很短的表说明项目、"
|
||||
"适用标准、计算式和结果;如果总额还缺少住宿晚数、实际票据或其他必要条件,就明确写出“暂不能确认的部分”。"
|
||||
"只有用户明确在问“标准有哪些”或“制度全文怎么规定”时,才展开完整标准表。"
|
||||
@@ -488,7 +493,7 @@ class UserAgentResponseMixin:
|
||||
citations: list[UserAgentCitation],
|
||||
) -> str:
|
||||
if str(payload.tool_payload.get("result_type") or "").strip() == "knowledge_search":
|
||||
if citations:
|
||||
if citations or list(payload.tool_payload.get("hits") or []):
|
||||
return self._build_knowledge_search_answer(payload, citations)
|
||||
|
||||
tool_message = str(payload.tool_payload.get("message") or "").strip()
|
||||
|
||||
Reference in New Issue
Block a user