feat: 新增预算中心本体与风险规则评分回填
后端新增预算本体解析模块和风险规则评分回填服务,优化规则 生成本体对齐和提示词构建,增强费用类型关键词和本体验证, 完善报销查询和审计接口,前端预算中心页面增加对话框和本 体工具函数,重构审计页面元数据和视图模型,补充单元测试。
This commit is contained in:
@@ -32,6 +32,46 @@ over_budget
|
|||||||
budget_warning
|
budget_warning
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 预算字段设计
|
||||||
|
|
||||||
|
预算中心字段分为四层,前端弹窗、预算台账、后端本体解析都必须使用同一套语义键。
|
||||||
|
|
||||||
|
### 预算主信息
|
||||||
|
|
||||||
|
- `budget_period`:预算周期,支持年度、季度、月份。
|
||||||
|
- `department`:所属部门,来自真实组织/部门数据。
|
||||||
|
- `cost_center`:成本中心,跟随部门归属。
|
||||||
|
- `budget_owner`:预算负责人。
|
||||||
|
- `budget_version`:预算版本,例如 `V1.0(初始版本)`。
|
||||||
|
- `budget_status`:预算状态,第一版限定为 `编制中 / 已发布 / 已冻结`。
|
||||||
|
- `budget_description`:预算说明。
|
||||||
|
|
||||||
|
### 预算明细
|
||||||
|
|
||||||
|
- `budget_subject`:预算科目,对应页面费用类型。
|
||||||
|
- `budget_subject_code`:预算科目编码,例如 `travel / office / training`。
|
||||||
|
- `budget_amount`:预算金额。
|
||||||
|
- `warning_threshold`:预警线,例如 `70% / 80%`。
|
||||||
|
- `control_action`:控制动作,第一版限定为 `正常 / 提醒 / 管控`。
|
||||||
|
- `budget_remark`:明细备注。
|
||||||
|
|
||||||
|
### 预算执行
|
||||||
|
|
||||||
|
- `reserved_amount`:已占用/已预占金额。
|
||||||
|
- `consumed_amount`:已发生/已核销金额。
|
||||||
|
- `available_amount`:剩余可用金额。
|
||||||
|
- `budget_usage_rate`:预算执行率。
|
||||||
|
- `over_budget`:是否超预算。
|
||||||
|
- `budget_warning`:是否触发预算预警。
|
||||||
|
|
||||||
|
### 本体映射规则
|
||||||
|
|
||||||
|
- 页面字段使用驼峰变量,但提交/上下文统一映射为 snake_case 本体字段。
|
||||||
|
- 本体 `scenario=budget` 负责预算编制、预算查询、预算预警、预算占用、预算不足解释。
|
||||||
|
- 费用申请/报销仍使用 `scenario=expense`,但预算占用字段必须引用 `budget_subject / budget_period / cost_center`。
|
||||||
|
- 问句中出现“预算金额、可用预算、剩余预算、预算占用、成本中心、预警线、超预算、预算不足”等词,应优先识别为 `budget` 场景。
|
||||||
|
- 本体输出中,预算字段优先进入 `entities`;金额类查询同步进入 `metrics`;筛选口径进入 `constraints`。
|
||||||
|
|
||||||
## AI解释能力
|
## AI解释能力
|
||||||
|
|
||||||
需要支持的问题:
|
需要支持的问题:
|
||||||
@@ -48,4 +88,3 @@ budget_warning
|
|||||||
- [ ] AI能解释预算不足原因。
|
- [ ] AI能解释预算不足原因。
|
||||||
- [ ] 首页预算看板来自后端真实汇总。
|
- [ ] 首页预算看板来自后端真实汇总。
|
||||||
- [ ] 预算中心和AI回答的金额一致。
|
- [ ] 预算中心和AI回答的金额一致。
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -80,6 +80,18 @@ def require_admin_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_platform_admin_user(
|
||||||
|
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
|
) -> CurrentUserContext:
|
||||||
|
if current_user.is_admin:
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="只有 admin 管理员可以执行该操作。",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def require_rule_editor_user(
|
def require_rule_editor_user(
|
||||||
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
|
||||||
) -> CurrentUserContext:
|
) -> CurrentUserContext:
|
||||||
@@ -102,5 +114,5 @@ def require_rule_reviewer_user(
|
|||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="只有高级管理人员可以审核、发布或恢复正式规则。",
|
detail="只有高级管理人员或 admin 管理员可以执行该操作。",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from app.api.deps import (
|
|||||||
CurrentUserContext,
|
CurrentUserContext,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
get_db,
|
get_db,
|
||||||
require_admin_user,
|
require_platform_admin_user,
|
||||||
require_rule_editor_user,
|
require_rule_editor_user,
|
||||||
require_rule_reviewer_user,
|
require_rule_reviewer_user,
|
||||||
)
|
)
|
||||||
@@ -58,7 +58,7 @@ RequestIdHeader = Annotated[
|
|||||||
Header(description="外部请求 ID,用于串联审计日志和上游调用链。"),
|
Header(description="外部请求 ID,用于串联审计日志和上游调用链。"),
|
||||||
]
|
]
|
||||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||||
AdminUser = Annotated[CurrentUserContext, Depends(require_admin_user)]
|
PlatformAdminUser = Annotated[CurrentUserContext, Depends(require_platform_admin_user)]
|
||||||
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
|
RuleEditorUser = Annotated[CurrentUserContext, Depends(require_rule_editor_user)]
|
||||||
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
|
RuleReviewerUser = Annotated[CurrentUserContext, Depends(require_rule_reviewer_user)]
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ def get_agent_asset_risk_rule_latest_test(
|
|||||||
def simulate_agent_asset_risk_rule_test(
|
def simulate_agent_asset_risk_rule_test(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
payload: AgentAssetRiskRuleSimulationRequest,
|
payload: AgentAssetRiskRuleSimulationRequest,
|
||||||
_: RuleEditorUser,
|
_: PlatformAdminUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
) -> AgentAssetRiskRuleSimulationRead:
|
) -> AgentAssetRiskRuleSimulationRead:
|
||||||
try:
|
try:
|
||||||
@@ -205,7 +205,7 @@ def simulate_agent_asset_risk_rule_test(
|
|||||||
def run_agent_asset_risk_rule_sample_test(
|
def run_agent_asset_risk_rule_sample_test(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
payload: AgentAssetRiskRuleSampleTestRequest,
|
payload: AgentAssetRiskRuleSampleTestRequest,
|
||||||
current_user: RuleEditorUser,
|
current_user: PlatformAdminUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
x_actor: ActorHeader = None,
|
x_actor: ActorHeader = None,
|
||||||
x_request_id: RequestIdHeader = None,
|
x_request_id: RequestIdHeader = None,
|
||||||
@@ -230,7 +230,7 @@ def run_agent_asset_risk_rule_sample_test(
|
|||||||
def run_agent_asset_risk_rule_scenario_test(
|
def run_agent_asset_risk_rule_scenario_test(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
payload: AgentAssetRiskRuleScenarioTestRequest,
|
payload: AgentAssetRiskRuleScenarioTestRequest,
|
||||||
current_user: RuleEditorUser,
|
current_user: PlatformAdminUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
x_actor: ActorHeader = None,
|
x_actor: ActorHeader = None,
|
||||||
x_request_id: RequestIdHeader = None,
|
x_request_id: RequestIdHeader = None,
|
||||||
@@ -255,7 +255,7 @@ def run_agent_asset_risk_rule_scenario_test(
|
|||||||
def confirm_agent_asset_risk_rule_test_report(
|
def confirm_agent_asset_risk_rule_test_report(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
payload: AgentAssetRiskRuleReportRequest,
|
payload: AgentAssetRiskRuleReportRequest,
|
||||||
current_user: RuleEditorUser,
|
current_user: PlatformAdminUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
x_actor: ActorHeader = None,
|
x_actor: ActorHeader = None,
|
||||||
x_request_id: RequestIdHeader = None,
|
x_request_id: RequestIdHeader = None,
|
||||||
@@ -301,12 +301,12 @@ def save_agent_asset_rule_json(
|
|||||||
response_model=AgentAssetRead,
|
response_model=AgentAssetRead,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
summary="根据自然语言新建风险规则草稿",
|
summary="根据自然语言新建风险规则草稿",
|
||||||
description="根据业务域、风险等级和自然语言描述生成 JSON 风险规则,并保存为待审核草稿资产。",
|
description="根据业务域、自然语言描述和风险评分模型生成 JSON 风险规则,并保存为待上线草稿资产。",
|
||||||
)
|
)
|
||||||
def generate_agent_asset_risk_rule(
|
def generate_agent_asset_risk_rule(
|
||||||
payload: AgentAssetRiskRuleGenerateRequest,
|
payload: AgentAssetRiskRuleGenerateRequest,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
current_user: RuleEditorUser,
|
current_user: RuleReviewerUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
x_actor: ActorHeader = None,
|
x_actor: ActorHeader = None,
|
||||||
x_request_id: RequestIdHeader = None,
|
x_request_id: RequestIdHeader = None,
|
||||||
@@ -550,6 +550,7 @@ def list_agent_asset_spreadsheet_change_records(
|
|||||||
)
|
)
|
||||||
def create_agent_asset(
|
def create_agent_asset(
|
||||||
payload: AgentAssetCreate,
|
payload: AgentAssetCreate,
|
||||||
|
current_user: RuleReviewerUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
x_actor: ActorHeader = None,
|
x_actor: ActorHeader = None,
|
||||||
x_request_id: RequestIdHeader = None,
|
x_request_id: RequestIdHeader = None,
|
||||||
@@ -557,7 +558,7 @@ def create_agent_asset(
|
|||||||
try:
|
try:
|
||||||
return AgentAssetService(db).create_asset(
|
return AgentAssetService(db).create_asset(
|
||||||
payload,
|
payload,
|
||||||
actor=(x_actor or payload.owner).strip() or "system",
|
actor=(x_actor or current_user.name or payload.owner).strip() or "system",
|
||||||
request_id=x_request_id,
|
request_id=x_request_id,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -583,15 +584,21 @@ def create_agent_asset(
|
|||||||
def update_agent_asset(
|
def update_agent_asset(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
payload: AgentAssetUpdate,
|
payload: AgentAssetUpdate,
|
||||||
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
x_actor: ActorHeader = None,
|
x_actor: ActorHeader = None,
|
||||||
x_request_id: RequestIdHeader = None,
|
x_request_id: RequestIdHeader = None,
|
||||||
) -> AgentAssetRead:
|
) -> AgentAssetRead:
|
||||||
try:
|
try:
|
||||||
|
role_codes = {item.strip() for item in current_user.role_codes}
|
||||||
|
if (payload.status is not None or payload.published_version is not None) and not (
|
||||||
|
current_user.is_admin or "manager" in role_codes
|
||||||
|
):
|
||||||
|
raise PermissionError("只有高级管理员或 admin 管理员可以更改规则上线状态。")
|
||||||
return AgentAssetService(db).update_asset(
|
return AgentAssetService(db).update_asset(
|
||||||
asset_id,
|
asset_id,
|
||||||
payload,
|
payload,
|
||||||
actor=(x_actor or "system").strip() or "system",
|
actor=(x_actor or current_user.name or "system").strip() or "system",
|
||||||
request_id=x_request_id,
|
request_id=x_request_id,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -846,7 +853,7 @@ def publish_agent_asset_risk_rule(
|
|||||||
)
|
)
|
||||||
def delete_agent_asset(
|
def delete_agent_asset(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
current_user: RuleEditorUser,
|
current_user: PlatformAdminUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
x_actor: ActorHeader = None,
|
x_actor: ActorHeader = None,
|
||||||
x_request_id: RequestIdHeader = None,
|
x_request_id: RequestIdHeader = None,
|
||||||
|
|||||||
@@ -112,6 +112,11 @@ class AgentAssetRuleJsonRead(BaseModel):
|
|||||||
|
|
||||||
class AgentAssetRiskRuleGenerateRequest(BaseModel):
|
class AgentAssetRiskRuleGenerateRequest(BaseModel):
|
||||||
business_domain: AgentAssetDomain = AgentAssetDomain.EXPENSE
|
business_domain: AgentAssetDomain = AgentAssetDomain.EXPENSE
|
||||||
|
business_stage: str | None = Field(
|
||||||
|
default="reimbursement",
|
||||||
|
pattern="^(expense_application|reimbursement)$",
|
||||||
|
max_length=40,
|
||||||
|
)
|
||||||
expense_category: str | None = Field(default=None, max_length=40)
|
expense_category: str | None = Field(default=None, max_length=40)
|
||||||
rule_title: str | None = Field(default=None, max_length=80)
|
rule_title: str | None = Field(default=None, max_length=80)
|
||||||
risk_level: str | None = Field(default=None, pattern="^(low|medium|high|critical)$")
|
risk_level: str | None = Field(default=None, pattern="^(low|medium|high|critical)$")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ OntologyScenario = Literal[
|
|||||||
"expense",
|
"expense",
|
||||||
"accounts_receivable",
|
"accounts_receivable",
|
||||||
"accounts_payable",
|
"accounts_payable",
|
||||||
|
"budget",
|
||||||
"knowledge",
|
"knowledge",
|
||||||
"unknown",
|
"unknown",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class AgentAssetRiskRuleLevelMixin:
|
|||||||
actor: str,
|
actor: str,
|
||||||
request_id: str | None = None,
|
request_id: str | None = None,
|
||||||
) -> AgentAsset:
|
) -> AgentAsset:
|
||||||
|
del asset_id, risk_level, actor, request_id
|
||||||
|
raise ValueError("风险等级和分数由评分模型自动计算,不能手动修改。")
|
||||||
asset = self._resolve_asset(asset_id)
|
asset = self._resolve_asset(asset_id)
|
||||||
self._require_json_risk_asset(asset)
|
self._require_json_risk_asset(asset)
|
||||||
normalized_level = self._normalize_risk_rule_level(risk_level)
|
normalized_level = self._normalize_risk_rule_level(risk_level)
|
||||||
|
|||||||
@@ -148,11 +148,12 @@ class AgentAssetRiskRuleTestingMixin:
|
|||||||
if not body.confirm_passed:
|
if not body.confirm_passed:
|
||||||
raise ValueError("请确认测试通过后再保存测试报告。")
|
raise ValueError("请确认测试通过后再保存测试报告。")
|
||||||
|
|
||||||
summary = "测试报告已确认,当前版本可提交审核。"
|
summary = "测试报告已确认,当前版本可上线。"
|
||||||
if scenario is None:
|
if scenario is None:
|
||||||
summary = "快速样例测试已确认通过,真实场景试运行未执行。"
|
summary = "快速样例测试已确认通过,真实场景试运行未执行。"
|
||||||
elif not scenario.passed:
|
elif not scenario.passed:
|
||||||
summary = "快速样例测试已确认通过,真实场景试运行未找到可测样本。"
|
summary = "快速样例测试已确认通过,真实场景试运行未找到可测样本。"
|
||||||
|
self._mark_risk_rule_operation(asset, action="test", actor=actor)
|
||||||
return self._create_test_run(
|
return self._create_test_run(
|
||||||
asset,
|
asset,
|
||||||
version=version,
|
version=version,
|
||||||
@@ -162,9 +163,9 @@ class AgentAssetRiskRuleTestingMixin:
|
|||||||
input_json={"confirm_passed": True, "note": body.note or ""},
|
input_json={"confirm_passed": True, "note": body.note or ""},
|
||||||
result_json={
|
result_json={
|
||||||
"sample_test_run_id": sample.id,
|
"sample_test_run_id": sample.id,
|
||||||
"scenario_test_run_id": scenario.id,
|
"scenario_test_run_id": scenario.id if scenario else "",
|
||||||
"sample_summary": sample.summary,
|
"sample_summary": sample.summary,
|
||||||
"scenario_summary": scenario.summary,
|
"scenario_summary": scenario.summary if scenario else "",
|
||||||
},
|
},
|
||||||
actor=actor,
|
actor=actor,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
@@ -308,6 +309,11 @@ class AgentAssetRiskRuleTestingMixin:
|
|||||||
|
|
||||||
config_json = dict(asset.config_json or {})
|
config_json = dict(asset.config_json or {})
|
||||||
config_json["enabled"] = bool(enabled)
|
config_json["enabled"] = bool(enabled)
|
||||||
|
self._set_risk_rule_status_for_online_toggle(asset, enabled=enabled, actor=actor)
|
||||||
|
config_json["last_operation"] = self._build_last_operation(
|
||||||
|
action="online" if enabled else "offline",
|
||||||
|
actor=actor,
|
||||||
|
)
|
||||||
asset.config_json = config_json
|
asset.config_json = config_json
|
||||||
updated = self.repository.save_asset(asset)
|
updated = self.repository.save_asset(asset)
|
||||||
self.audit_service.log_action(
|
self.audit_service.log_action(
|
||||||
@@ -321,6 +327,50 @@ class AgentAssetRiskRuleTestingMixin:
|
|||||||
)
|
)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
def _set_risk_rule_status_for_online_toggle(
|
||||||
|
self,
|
||||||
|
asset: AgentAsset,
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
actor: str,
|
||||||
|
) -> None:
|
||||||
|
if enabled:
|
||||||
|
version = self._resolve_target_version(asset, None)
|
||||||
|
approved_review = self.repository.get_review(
|
||||||
|
asset.id, version, AgentReviewStatus.APPROVED.value
|
||||||
|
)
|
||||||
|
if approved_review is None:
|
||||||
|
self.db.add(
|
||||||
|
AgentAssetReview(
|
||||||
|
asset_id=asset.id,
|
||||||
|
version=version,
|
||||||
|
reviewer=actor,
|
||||||
|
review_status=AgentReviewStatus.APPROVED.value,
|
||||||
|
review_note="直接上线风险规则。",
|
||||||
|
reviewed_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
asset.published_version = version
|
||||||
|
asset.reviewer = actor
|
||||||
|
asset.status = AgentAssetStatus.ACTIVE.value
|
||||||
|
return
|
||||||
|
|
||||||
|
asset.status = AgentAssetStatus.DISABLED.value
|
||||||
|
|
||||||
|
def _mark_risk_rule_operation(self, asset: AgentAsset, *, action: str, actor: str) -> None:
|
||||||
|
config_json = dict(asset.config_json or {})
|
||||||
|
config_json["last_operation"] = self._build_last_operation(action=action, actor=actor)
|
||||||
|
asset.config_json = config_json
|
||||||
|
self.db.add(asset)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_last_operation(*, action: str, actor: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"action": action,
|
||||||
|
"actor": str(actor or "system").strip() or "system",
|
||||||
|
"at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
def _load_risk_rule_for_test(
|
def _load_risk_rule_for_test(
|
||||||
self, asset_id: str, version: str | None
|
self, asset_id: str, version: str | None
|
||||||
) -> tuple[AgentAsset, str, dict[str, Any]]:
|
) -> tuple[AgentAsset, str, dict[str, Any]]:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from app.services.agent_asset_spreadsheet_helpers import AgentAssetSpreadsheetHe
|
|||||||
from app.services.agent_asset_timeline import AgentAssetTimelineMixin
|
from app.services.agent_asset_timeline import AgentAssetTimelineMixin
|
||||||
from app.services.agent_foundation import AgentFoundationService
|
from app.services.agent_foundation import AgentFoundationService
|
||||||
from app.services.audit import AuditLogService
|
from app.services.audit import AuditLogService
|
||||||
|
from app.services.risk_rule_score_backfill import backfill_missing_risk_rule_score
|
||||||
|
|
||||||
logger = get_logger("app.services.agent_assets")
|
logger = get_logger("app.services.agent_assets")
|
||||||
|
|
||||||
@@ -79,6 +80,11 @@ class AgentAssetService(
|
|||||||
asset = self.repository.get(asset_id)
|
asset = self.repository.get(asset_id)
|
||||||
if asset is None:
|
if asset is None:
|
||||||
return None
|
return None
|
||||||
|
try:
|
||||||
|
if backfill_missing_risk_rule_score(asset):
|
||||||
|
asset = self.repository.save_asset(asset)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to backfill risk rule score asset_id=%s", asset_id, exc_info=True)
|
||||||
|
|
||||||
working_version = self._resolve_working_version(asset)
|
working_version = self._resolve_working_version(asset)
|
||||||
recent_versions = self._sort_versions(
|
recent_versions = self._sort_versions(
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ EXPENSE_TYPE_LABELS = {
|
|||||||
"meal": "业务招待",
|
"meal": "业务招待",
|
||||||
"meeting": "会务",
|
"meeting": "会务",
|
||||||
"entertainment": "招待",
|
"entertainment": "招待",
|
||||||
|
"marketing": "市场推广",
|
||||||
"office": "办公用品",
|
"office": "办公用品",
|
||||||
"training": "培训",
|
"training": "培训",
|
||||||
|
"software": "软件服务",
|
||||||
"communication": "通讯",
|
"communication": "通讯",
|
||||||
"welfare": "福利",
|
"welfare": "福利",
|
||||||
}
|
}
|
||||||
@@ -52,8 +54,21 @@ DOCUMENT_TYPE_SCENE_MAP = {
|
|||||||
"meeting_invoice": "meeting",
|
"meeting_invoice": "meeting",
|
||||||
"training_invoice": "training",
|
"training_invoice": "training",
|
||||||
}
|
}
|
||||||
DOCUMENT_FACT_ITEM_TYPES = {"train_ticket", "flight_ticket", "hotel_ticket", "ride_ticket", "ship_ticket", "ferry_ticket"}
|
DOCUMENT_FACT_ITEM_TYPES = {
|
||||||
ROUTE_DESCRIPTION_ITEM_TYPES = {"train_ticket", "flight_ticket", "ship_ticket", "ferry_ticket", "ride_ticket"}
|
"train_ticket",
|
||||||
|
"flight_ticket",
|
||||||
|
"hotel_ticket",
|
||||||
|
"ride_ticket",
|
||||||
|
"ship_ticket",
|
||||||
|
"ferry_ticket",
|
||||||
|
}
|
||||||
|
ROUTE_DESCRIPTION_ITEM_TYPES = {
|
||||||
|
"train_ticket",
|
||||||
|
"flight_ticket",
|
||||||
|
"ship_ticket",
|
||||||
|
"ferry_ticket",
|
||||||
|
"ride_ticket",
|
||||||
|
}
|
||||||
DOCUMENT_TRIP_DATE_LABELS = {
|
DOCUMENT_TRIP_DATE_LABELS = {
|
||||||
"train_ticket": "列车出发时间",
|
"train_ticket": "列车出发时间",
|
||||||
"flight_itinerary": "起飞日期",
|
"flight_itinerary": "起飞日期",
|
||||||
@@ -118,7 +133,17 @@ DOCUMENT_ROUTE_TEXT_PATTERN = re.compile(
|
|||||||
r"([A-Za-z0-9\u4e00-\u9fa5()()·]{2,40})\s*(?:至|到|→|->|—|–|-)\s*"
|
r"([A-Za-z0-9\u4e00-\u9fa5()()·]{2,40})\s*(?:至|到|→|->|—|–|-)\s*"
|
||||||
r"([A-Za-z0-9\u4e00-\u9fa5()()·]{2,40})"
|
r"([A-Za-z0-9\u4e00-\u9fa5()()·]{2,40})"
|
||||||
)
|
)
|
||||||
DOCUMENT_ROUTE_ORIGIN_LABELS = {"起点", "上车", "上车地点", "上车地址", "出发", "出发地", "出发站", "始发站", "乘车起点"}
|
DOCUMENT_ROUTE_ORIGIN_LABELS = {
|
||||||
|
"起点",
|
||||||
|
"上车",
|
||||||
|
"上车地点",
|
||||||
|
"上车地址",
|
||||||
|
"出发",
|
||||||
|
"出发地",
|
||||||
|
"出发站",
|
||||||
|
"始发站",
|
||||||
|
"乘车起点",
|
||||||
|
}
|
||||||
DOCUMENT_ROUTE_DESTINATION_LABELS = {
|
DOCUMENT_ROUTE_DESTINATION_LABELS = {
|
||||||
"终点",
|
"终点",
|
||||||
"下车",
|
"下车",
|
||||||
@@ -140,9 +165,11 @@ EXPENSE_SCENE_KEYWORDS = {
|
|||||||
"transport",
|
"transport",
|
||||||
"meal",
|
"meal",
|
||||||
"entertainment",
|
"entertainment",
|
||||||
|
"marketing",
|
||||||
"office",
|
"office",
|
||||||
"meeting",
|
"meeting",
|
||||||
"training",
|
"training",
|
||||||
|
"software",
|
||||||
"communication",
|
"communication",
|
||||||
"welfare",
|
"welfare",
|
||||||
)
|
)
|
||||||
@@ -158,9 +185,11 @@ EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = {
|
|||||||
"transport": {"transport", "travel"},
|
"transport": {"transport", "travel"},
|
||||||
"meal": {"meal", "entertainment"},
|
"meal": {"meal", "entertainment"},
|
||||||
"entertainment": {"entertainment", "meal"},
|
"entertainment": {"entertainment", "meal"},
|
||||||
|
"marketing": {"marketing"},
|
||||||
"office": {"office"},
|
"office": {"office"},
|
||||||
"meeting": {"meeting"},
|
"meeting": {"meeting"},
|
||||||
"training": {"training"},
|
"training": {"training"},
|
||||||
|
"software": {"software"},
|
||||||
}
|
}
|
||||||
DOCUMENT_SCENE_LABELS = {
|
DOCUMENT_SCENE_LABELS = {
|
||||||
"travel": "差旅",
|
"travel": "差旅",
|
||||||
@@ -168,9 +197,11 @@ DOCUMENT_SCENE_LABELS = {
|
|||||||
"transport": "交通",
|
"transport": "交通",
|
||||||
"meal": "业务招待",
|
"meal": "业务招待",
|
||||||
"entertainment": "业务招待",
|
"entertainment": "业务招待",
|
||||||
|
"marketing": "市场推广",
|
||||||
"office": "办公用品",
|
"office": "办公用品",
|
||||||
"meeting": "会务",
|
"meeting": "会务",
|
||||||
"training": "培训",
|
"training": "培训",
|
||||||
|
"software": "软件服务",
|
||||||
"other": "其他票据",
|
"other": "其他票据",
|
||||||
}
|
}
|
||||||
DOCUMENT_ASSOCIATION_REVIEW_ACTIONS = {
|
DOCUMENT_ASSOCIATION_REVIEW_ACTIONS = {
|
||||||
@@ -191,7 +222,10 @@ RETURN_REASON_OPTIONS = {
|
|||||||
"approval_question": "审批人需要补充说明",
|
"approval_question": "审批人需要补充说明",
|
||||||
}
|
}
|
||||||
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
|
MAX_CLAIM_NO_RETRY_ATTEMPTS = 3
|
||||||
DOCUMENT_DATE_PATTERN = re.compile(r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.](?:3[01]|[12]\d|0?[1-9])日?)")
|
DOCUMENT_DATE_PATTERN = re.compile(
|
||||||
|
r"((?:20\d{2}|19\d{2})[-/年.](?:1[0-2]|0?[1-9])[-/月.]"
|
||||||
|
r"(?:3[01]|[12]\d|0?[1-9])日?)"
|
||||||
|
)
|
||||||
SYSTEM_GENERATED_REASON_PREFIXES = (
|
SYSTEM_GENERATED_REASON_PREFIXES = (
|
||||||
"我上传了",
|
"我上传了",
|
||||||
"请按当前已识别信息",
|
"请按当前已识别信息",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
|
||||||
EXPENSE_TYPE_KEYWORD_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
EXPENSE_TYPE_KEYWORD_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
||||||
(
|
(
|
||||||
@@ -132,6 +131,22 @@ EXPENSE_TYPE_KEYWORD_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
|||||||
"布展",
|
"布展",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"marketing",
|
||||||
|
"市场推广费",
|
||||||
|
(
|
||||||
|
"市场推广费",
|
||||||
|
"市场推广",
|
||||||
|
"推广费",
|
||||||
|
"广告费",
|
||||||
|
"广告投放",
|
||||||
|
"投放费",
|
||||||
|
"品牌宣传",
|
||||||
|
"宣传费",
|
||||||
|
"营销物料",
|
||||||
|
"推广物料",
|
||||||
|
),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"office",
|
"office",
|
||||||
"办公用品费",
|
"办公用品费",
|
||||||
@@ -177,6 +192,24 @@ EXPENSE_TYPE_KEYWORD_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
|||||||
"认证",
|
"认证",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"software",
|
||||||
|
"软件服务费",
|
||||||
|
(
|
||||||
|
"软件服务费",
|
||||||
|
"软件费",
|
||||||
|
"软件订阅",
|
||||||
|
"SaaS",
|
||||||
|
"SAAS",
|
||||||
|
"saas",
|
||||||
|
"SaaS订阅",
|
||||||
|
"系统服务费",
|
||||||
|
"云服务费",
|
||||||
|
"云资源",
|
||||||
|
"平台服务费",
|
||||||
|
"技术服务费",
|
||||||
|
),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"communication",
|
"communication",
|
||||||
"通讯费",
|
"通讯费",
|
||||||
|
|||||||
269
server/src/app/services/ontology_budget.py
Normal file
269
server/src/app/services/ontology_budget.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.schemas.ontology import OntologyEntity, OntologyMetric
|
||||||
|
from app.services.ontology_rules import (
|
||||||
|
BUDGET_CONTEXT_TYPES,
|
||||||
|
BUDGET_CONTROL_ACTION_KEYWORDS,
|
||||||
|
BUDGET_KEYWORDS,
|
||||||
|
BUDGET_REQUIRED_SLOT_KEYS,
|
||||||
|
BUDGET_STATUS_KEYWORDS,
|
||||||
|
BUDGET_SUBJECT_KEYWORDS,
|
||||||
|
BUDGET_SUBJECT_LABEL_BY_CODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetOntologyMixin:
|
||||||
|
@staticmethod
|
||||||
|
def _is_budget_context_value(context_json: dict[str, Any]) -> bool:
|
||||||
|
document_type = str(context_json.get("document_type") or "").strip()
|
||||||
|
entry_source = str(context_json.get("entry_source") or "").strip()
|
||||||
|
session_type = str(context_json.get("session_type") or "").strip()
|
||||||
|
conversation_scenario = str(context_json.get("conversation_scenario") or "").strip()
|
||||||
|
return (
|
||||||
|
document_type in BUDGET_CONTEXT_TYPES
|
||||||
|
or entry_source in BUDGET_CONTEXT_TYPES
|
||||||
|
or session_type in BUDGET_CONTEXT_TYPES
|
||||||
|
or conversation_scenario == "budget"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_budget_signal(compact_query: str) -> bool:
|
||||||
|
return any(keyword in compact_query for keyword in BUDGET_KEYWORDS)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _infer_budget_missing_slots(
|
||||||
|
entities: list[OntologyEntity],
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
entity_types = {item.type for item in entities}
|
||||||
|
budget_values = context_json.get("budget_header")
|
||||||
|
if not isinstance(budget_values, dict):
|
||||||
|
budget_values = {}
|
||||||
|
detail_values = context_json.get("budget_details")
|
||||||
|
if not isinstance(detail_values, list):
|
||||||
|
detail_values = []
|
||||||
|
|
||||||
|
missing_slots: list[str] = []
|
||||||
|
has_budget_period = str(budget_values.get("budget_period") or "").strip()
|
||||||
|
has_department = str(budget_values.get("department") or "").strip()
|
||||||
|
if "budget_period" not in entity_types and not has_budget_period:
|
||||||
|
missing_slots.append("budget_period")
|
||||||
|
if "department" not in entity_types and not has_department:
|
||||||
|
missing_slots.append("department")
|
||||||
|
has_subject = "budget_subject" in entity_types or any(
|
||||||
|
str(item.get("budget_subject") or "").strip()
|
||||||
|
for item in detail_values
|
||||||
|
if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
if not has_subject:
|
||||||
|
missing_slots.append("budget_subject")
|
||||||
|
has_amount = "budget_amount" in entity_types or any(
|
||||||
|
str(item.get("budget_amount") or "").strip()
|
||||||
|
for item in detail_values
|
||||||
|
if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
if not has_amount:
|
||||||
|
missing_slots.append("budget_amount")
|
||||||
|
return [item for item in BUDGET_REQUIRED_SLOT_KEYS if item in missing_slots]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_budget_metrics(compact_query: str) -> list[OntologyMetric]:
|
||||||
|
metrics: list[OntologyMetric] = []
|
||||||
|
if any(keyword in compact_query for keyword in ("预算金额", "预算总额", "预算额度")):
|
||||||
|
metrics.append(OntologyMetric(name="budget_amount", aggregation="sum", unit="CNY"))
|
||||||
|
if any(
|
||||||
|
keyword in compact_query
|
||||||
|
for keyword in ("可用预算", "剩余预算", "可用余额", "剩余可用")
|
||||||
|
):
|
||||||
|
metrics.append(OntologyMetric(name="available_amount", aggregation="sum", unit="CNY"))
|
||||||
|
if any(
|
||||||
|
keyword in compact_query
|
||||||
|
for keyword in ("已占用", "已预占", "预算占用", "占用金额")
|
||||||
|
):
|
||||||
|
metrics.append(OntologyMetric(name="reserved_amount", aggregation="sum", unit="CNY"))
|
||||||
|
if any(keyword in compact_query for keyword in ("已发生", "已核销", "已消耗", "已使用")):
|
||||||
|
metrics.append(OntologyMetric(name="consumed_amount", aggregation="sum", unit="CNY"))
|
||||||
|
if any(keyword in compact_query for keyword in ("执行率", "使用率")):
|
||||||
|
metrics.append(
|
||||||
|
OntologyMetric(name="budget_usage_rate", aggregation="ratio", unit="percent")
|
||||||
|
)
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
def _extract_budget_entities(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
compact_query: str,
|
||||||
|
context_json: dict[str, Any],
|
||||||
|
) -> list[OntologyEntity]:
|
||||||
|
entities: list[OntologyEntity] = []
|
||||||
|
|
||||||
|
if self._is_budget_context_value(context_json) or self._has_budget_signal(compact_query):
|
||||||
|
entities.append(
|
||||||
|
self._make_entity(
|
||||||
|
"document_type",
|
||||||
|
"预算",
|
||||||
|
"budget_plan",
|
||||||
|
role="target",
|
||||||
|
confidence=0.94,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entities.append(
|
||||||
|
self._make_entity(
|
||||||
|
"workflow_stage",
|
||||||
|
"预算控制",
|
||||||
|
"budget_control",
|
||||||
|
role="target",
|
||||||
|
confidence=0.9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
period_pattern = (
|
||||||
|
r"(?P<year>20\d{2})\s*年\s*"
|
||||||
|
r"(?:(?P<quarter>Q[1-4]|[一二三四]季度)|(?P<month>\d{1,2})\s*月|度)?"
|
||||||
|
)
|
||||||
|
for match in re.finditer(period_pattern, query, flags=re.IGNORECASE):
|
||||||
|
year = match.group("year")
|
||||||
|
quarter = match.group("quarter")
|
||||||
|
month = match.group("month")
|
||||||
|
if quarter:
|
||||||
|
quarter_text = quarter.upper() if quarter.upper().startswith("Q") else quarter
|
||||||
|
normalized = f"{year}年{quarter_text}"
|
||||||
|
elif month:
|
||||||
|
normalized = f"{year}年{int(month)}月"
|
||||||
|
else:
|
||||||
|
normalized = f"{year}年度"
|
||||||
|
entities.append(
|
||||||
|
self._make_entity(
|
||||||
|
"budget_period",
|
||||||
|
match.group(0).strip(),
|
||||||
|
normalized,
|
||||||
|
role="filter",
|
||||||
|
confidence=0.88,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for code in re.findall(r"CC-\d+", query, flags=re.IGNORECASE):
|
||||||
|
entities.append(
|
||||||
|
self._make_entity(
|
||||||
|
"cost_center",
|
||||||
|
code,
|
||||||
|
code.upper(),
|
||||||
|
role="filter",
|
||||||
|
confidence=0.92,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for label, normalized in BUDGET_SUBJECT_KEYWORDS.items():
|
||||||
|
if label in query:
|
||||||
|
subject_label = BUDGET_SUBJECT_LABEL_BY_CODE.get(normalized, label)
|
||||||
|
entities.append(
|
||||||
|
self._make_entity(
|
||||||
|
"budget_subject",
|
||||||
|
label,
|
||||||
|
normalized,
|
||||||
|
role="filter",
|
||||||
|
confidence=0.9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entities.append(
|
||||||
|
self._make_entity(
|
||||||
|
"expense_type",
|
||||||
|
subject_label,
|
||||||
|
normalized,
|
||||||
|
role="filter",
|
||||||
|
confidence=0.9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for label, normalized in BUDGET_STATUS_KEYWORDS.items():
|
||||||
|
if label in query:
|
||||||
|
entities.append(
|
||||||
|
self._make_entity(
|
||||||
|
"budget_status",
|
||||||
|
label,
|
||||||
|
normalized,
|
||||||
|
role="filter",
|
||||||
|
confidence=0.86,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for label, normalized in BUDGET_CONTROL_ACTION_KEYWORDS.items():
|
||||||
|
if label in query:
|
||||||
|
entities.append(
|
||||||
|
self._make_entity(
|
||||||
|
"control_action",
|
||||||
|
label,
|
||||||
|
normalized,
|
||||||
|
role="target",
|
||||||
|
confidence=0.84,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
version_match = re.search(r"V\d+(?:\.\d+){0,2}", query, flags=re.IGNORECASE)
|
||||||
|
if version_match:
|
||||||
|
version = version_match.group(0).upper()
|
||||||
|
entities.append(
|
||||||
|
self._make_entity(
|
||||||
|
"budget_version",
|
||||||
|
version,
|
||||||
|
version,
|
||||||
|
role="filter",
|
||||||
|
confidence=0.86,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
warning_match = re.search(r"(?:预警线|预警阈值|预算预警)\s*(?P<value>\d{1,3})\s*%", query)
|
||||||
|
if warning_match:
|
||||||
|
value = f"{warning_match.group('value')}%"
|
||||||
|
entities.append(
|
||||||
|
self._make_entity(
|
||||||
|
"warning_threshold",
|
||||||
|
value,
|
||||||
|
value,
|
||||||
|
role="threshold",
|
||||||
|
confidence=0.9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
entities.extend(self._extract_budget_amount_entities(query))
|
||||||
|
return entities
|
||||||
|
|
||||||
|
def _extract_budget_amount_entities(self, query: str) -> list[OntologyEntity]:
|
||||||
|
entities: list[OntologyEntity] = []
|
||||||
|
patterns = (
|
||||||
|
(
|
||||||
|
"budget_amount",
|
||||||
|
r"(?:预算金额|预算额度|预算总额)\s*(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>万元|万|元)?",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"available_amount",
|
||||||
|
r"(?:可用预算|剩余预算|可用余额|剩余可用)\s*(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>万元|万|元)?",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"reserved_amount",
|
||||||
|
r"(?:已占用|已预占|占用金额|预算占用)\s*(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>万元|万|元)?",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"consumed_amount",
|
||||||
|
r"(?:已发生|已核销|已消耗|已使用)\s*(?P<value>\d+(?:\.\d+)?)\s*(?P<unit>万元|万|元)?",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for entity_type, pattern in patterns:
|
||||||
|
for match in re.finditer(pattern, query):
|
||||||
|
raw_value = match.group("value")
|
||||||
|
unit = match.group("unit")
|
||||||
|
amount_value = self._normalize_amount(raw_value, unit)
|
||||||
|
display_value = f"{raw_value}{unit or ''}"
|
||||||
|
entities.append(
|
||||||
|
self._make_entity(
|
||||||
|
entity_type,
|
||||||
|
display_value,
|
||||||
|
str(amount_value),
|
||||||
|
role="target",
|
||||||
|
confidence=0.9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return entities
|
||||||
@@ -15,8 +15,10 @@ from app.schemas.ontology import (
|
|||||||
OntologyTimeRange,
|
OntologyTimeRange,
|
||||||
)
|
)
|
||||||
from app.services.ontology_rules import (
|
from app.services.ontology_rules import (
|
||||||
AR_CORE_KEYWORDS,
|
|
||||||
AP_CORE_KEYWORDS,
|
AP_CORE_KEYWORDS,
|
||||||
|
AR_CORE_KEYWORDS,
|
||||||
|
BUDGET_DRAFT_KEYWORDS,
|
||||||
|
BUDGET_OPERATE_KEYWORDS,
|
||||||
COMPARE_KEYWORDS,
|
COMPARE_KEYWORDS,
|
||||||
DRAFT_FOLLOW_UP_KEYWORDS,
|
DRAFT_FOLLOW_UP_KEYWORDS,
|
||||||
DRAFT_KEYWORDS,
|
DRAFT_KEYWORDS,
|
||||||
@@ -27,13 +29,13 @@ from app.services.ontology_rules import (
|
|||||||
EXPLAIN_KEYWORDS,
|
EXPLAIN_KEYWORDS,
|
||||||
GENERIC_EXPENSE_PROMPTS,
|
GENERIC_EXPENSE_PROMPTS,
|
||||||
KNOWLEDGE_INTENTS,
|
KNOWLEDGE_INTENTS,
|
||||||
LlmOntologyEntityHint,
|
|
||||||
LlmOntologyParseResult,
|
|
||||||
OPERATE_KEYWORDS,
|
OPERATE_KEYWORDS,
|
||||||
QUERY_KEYWORDS,
|
QUERY_KEYWORDS,
|
||||||
RISK_KEYWORDS,
|
RISK_KEYWORDS,
|
||||||
SCENARIO_KEYWORDS,
|
SCENARIO_KEYWORDS,
|
||||||
STATUS_KEYWORDS,
|
STATUS_KEYWORDS,
|
||||||
|
LlmOntologyEntityHint,
|
||||||
|
LlmOntologyParseResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger("app.services.ontology")
|
logger = get_logger("app.services.ontology")
|
||||||
@@ -99,6 +101,9 @@ class OntologyDetectionMixin:
|
|||||||
|
|
||||||
best_scenario = max(scores, key=scores.get)
|
best_scenario = max(scores, key=scores.get)
|
||||||
best_score = scores[best_scenario]
|
best_score = scores[best_scenario]
|
||||||
|
if scores.get("budget", 0.0) > 0 and scores["budget"] >= best_score:
|
||||||
|
best_scenario = "budget"
|
||||||
|
best_score = scores["budget"]
|
||||||
if best_score <= 0:
|
if best_score <= 0:
|
||||||
if "单据" in compact_query and any(
|
if "单据" in compact_query and any(
|
||||||
keyword in compact_query for keyword in STATUS_KEYWORDS
|
keyword in compact_query for keyword in STATUS_KEYWORDS
|
||||||
@@ -111,9 +116,10 @@ class OntologyDetectionMixin:
|
|||||||
scores["expense"],
|
scores["expense"],
|
||||||
scores["accounts_receivable"],
|
scores["accounts_receivable"],
|
||||||
scores["accounts_payable"],
|
scores["accounts_payable"],
|
||||||
|
scores["budget"],
|
||||||
]
|
]
|
||||||
if max(business_scores) > 0:
|
if max(business_scores) > 0:
|
||||||
best_scenario = ("expense", "accounts_receivable", "accounts_payable")[
|
best_scenario = ("expense", "accounts_receivable", "accounts_payable", "budget")[
|
||||||
business_scores.index(max(business_scores))
|
business_scores.index(max(business_scores))
|
||||||
]
|
]
|
||||||
best_score = max(business_scores)
|
best_score = max(business_scores)
|
||||||
@@ -130,6 +136,14 @@ class OntologyDetectionMixin:
|
|||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
|
if any(keyword in compact_query for keyword in OPERATE_KEYWORDS):
|
||||||
return "operate", 0.30
|
return "operate", 0.30
|
||||||
|
if scenario == "budget" and any(
|
||||||
|
keyword in compact_query for keyword in BUDGET_OPERATE_KEYWORDS
|
||||||
|
):
|
||||||
|
return "operate", 0.30
|
||||||
|
if scenario == "budget" and any(
|
||||||
|
keyword in compact_query for keyword in BUDGET_DRAFT_KEYWORDS
|
||||||
|
):
|
||||||
|
return "draft", 0.28
|
||||||
status_document_query = (
|
status_document_query = (
|
||||||
"单据" in compact_query
|
"单据" in compact_query
|
||||||
and any(keyword in compact_query for keyword in STATUS_KEYWORDS)
|
and any(keyword in compact_query for keyword in STATUS_KEYWORDS)
|
||||||
@@ -383,13 +397,15 @@ class OntologyDetectionMixin:
|
|||||||
"你的任务是把用户输入解析为固定 JSON,用于后续路由、追问和权限判断。"
|
"你的任务是把用户输入解析为固定 JSON,用于后续路由、追问和权限判断。"
|
||||||
"只输出 JSON 对象,不要输出 Markdown、代码块、解释、标题或 <think>。"
|
"只输出 JSON 对象,不要输出 Markdown、代码块、解释、标题或 <think>。"
|
||||||
"场景 scenario 只能是:expense, accounts_receivable, "
|
"场景 scenario 只能是:expense, accounts_receivable, "
|
||||||
"accounts_payable, knowledge, unknown。"
|
"accounts_payable, budget, knowledge, unknown。"
|
||||||
"意图 intent 只能是:query, explain, compare, risk_check, draft, operate。"
|
"意图 intent 只能是:query, explain, compare, risk_check, draft, operate。"
|
||||||
"如果用户是在描述一笔待处理费用、待报销事项、上传票据或希望整理报销,"
|
"如果用户是在描述一笔待处理费用、待报销事项、上传票据或希望整理报销,"
|
||||||
"即使没有明确说“生成草稿”,也优先使用 expense + draft。"
|
"即使没有明确说“生成草稿”,也优先使用 expense + draft。"
|
||||||
"如果提供了 conversation_history,必须把最近轮次作为当前追问的上下文,"
|
"如果提供了 conversation_history,必须把最近轮次作为当前追问的上下文,"
|
||||||
"正确理解“这个”“那笔”“改成 800”“继续补充”这类省略表达。"
|
"正确理解“这个”“那笔”“改成 800”“继续补充”这类省略表达。"
|
||||||
"出现“客户”不等于应收,出现“供应商”不等于应付,必须结合动作词和业务目标判断。"
|
"出现“客户”不等于应收,出现“供应商”不等于应付,必须结合动作词和业务目标判断。"
|
||||||
|
"预算编制、预算金额、成本中心、预算科目、预算预警、预算占用、"
|
||||||
|
"剩余预算、可用预算、超预算、预算不足等问题必须使用 budget 场景。"
|
||||||
"只有明确查询、统计、列出、多少、明细、对比时才优先使用 query 或 compare。"
|
"只有明确查询、统计、列出、多少、明细、对比时才优先使用 query 或 compare。"
|
||||||
"附件名称和 OCR 摘要只作为辅助证据,不能编造未出现的事实。"
|
"附件名称和 OCR 摘要只作为辅助证据,不能编造未出现的事实。"
|
||||||
"如果用户明确提到打车、的士票、出租车票、网约车、乘车费、车费等交通票据,"
|
"如果用户明确提到打车、的士票、出租车票、网约车、乘车费、车费等交通票据,"
|
||||||
@@ -397,7 +413,8 @@ class OntologyDetectionMixin:
|
|||||||
"不要输出用户原文未出现、且与规则候选冲突的费用类型。"
|
"不要输出用户原文未出现、且与规则候选冲突的费用类型。"
|
||||||
"信息不足时 clarification_required=true,并给出一句简短中文追问。"
|
"信息不足时 clarification_required=true,并给出一句简短中文追问。"
|
||||||
"missing_slots 使用简短 snake_case,例如 expense_type, amount, "
|
"missing_slots 使用简短 snake_case,例如 expense_type, amount, "
|
||||||
"customer_name, participants, attachments。"
|
"customer_name, participants, attachments, budget_period, "
|
||||||
|
"budget_subject, budget_amount。"
|
||||||
"entity_hints 只填写你比较确定的业务对象;如果不确定,可以返回空数组。"
|
"entity_hints 只填写你比较确定的业务对象;如果不确定,可以返回空数组。"
|
||||||
"费用申请场景下,建议把干净的申请事由放入 type=reason,"
|
"费用申请场景下,建议把干净的申请事由放入 type=reason,"
|
||||||
"把出行方式放入 type=transport_mode,取值优先为飞机、火车、轮船。"
|
"把出行方式放入 type=transport_mode,取值优先为飞机、火车、轮船。"
|
||||||
@@ -422,6 +439,9 @@ class OntologyDetectionMixin:
|
|||||||
'"confidence": 0.86},\n'
|
'"confidence": 0.86},\n'
|
||||||
' {"type": "reason", "value": "服务客户业务部署", '
|
' {"type": "reason", "value": "服务客户业务部署", '
|
||||||
'"normalized_value": "服务客户业务部署", "role": "target", '
|
'"normalized_value": "服务客户业务部署", "role": "target", '
|
||||||
|
'"confidence": 0.86},\n'
|
||||||
|
' {"type": "budget_subject", "value": "差旅费", '
|
||||||
|
'"normalized_value": "travel", "role": "filter", '
|
||||||
'"confidence": 0.86}\n'
|
'"confidence": 0.86}\n'
|
||||||
" ]\n"
|
" ]\n"
|
||||||
"}"
|
"}"
|
||||||
|
|||||||
@@ -14,28 +14,28 @@ from app.schemas.ontology import (
|
|||||||
OntologyTimeRange,
|
OntologyTimeRange,
|
||||||
)
|
)
|
||||||
from app.services.document_numbering import DOCUMENT_NUMBER_EXTRACT_PATTERN
|
from app.services.document_numbering import DOCUMENT_NUMBER_EXTRACT_PATTERN
|
||||||
|
from app.services.ontology_budget import BudgetOntologyMixin
|
||||||
from app.services.ontology_rules import (
|
from app.services.ontology_rules import (
|
||||||
AMOUNT_PATTERN,
|
AMOUNT_PATTERN,
|
||||||
DATE_RANGE_PATTERN,
|
DATE_RANGE_PATTERN,
|
||||||
EXPLICIT_DATE_PATTERN,
|
|
||||||
EXPLICIT_MONTH_PATTERN,
|
|
||||||
EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES,
|
EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES,
|
||||||
EXPENSE_APPLICATION_CONTEXT_TYPES,
|
EXPENSE_APPLICATION_CONTEXT_TYPES,
|
||||||
EXPENSE_APPLICATION_KEYWORDS,
|
EXPENSE_APPLICATION_KEYWORDS,
|
||||||
EXPENSE_APPLICATION_REQUIRED_SLOT_KEYS,
|
EXPENSE_APPLICATION_REQUIRED_SLOT_KEYS,
|
||||||
EXPENSE_TYPE_KEYWORDS,
|
EXPENSE_TYPE_KEYWORDS,
|
||||||
|
EXPLICIT_DATE_PATTERN,
|
||||||
|
EXPLICIT_MONTH_PATTERN,
|
||||||
GENERIC_EXPENSE_APPLICATION_PROMPTS,
|
GENERIC_EXPENSE_APPLICATION_PROMPTS,
|
||||||
GENERIC_EXPENSE_PROMPTS,
|
|
||||||
LOCATION_KEYWORDS,
|
LOCATION_KEYWORDS,
|
||||||
MONTH_DAY_PATTERN,
|
MONTH_DAY_PATTERN,
|
||||||
MONTH_DAY_RANGE_PATTERN,
|
MONTH_DAY_RANGE_PATTERN,
|
||||||
ReferenceCatalog,
|
|
||||||
STATUS_KEYWORDS,
|
STATUS_KEYWORDS,
|
||||||
TOP_N_PATTERN,
|
TOP_N_PATTERN,
|
||||||
|
ReferenceCatalog,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class OntologyExtractionMixin:
|
class OntologyExtractionMixin(BudgetOntologyMixin):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_expense_application_context_value(context_json: dict[str, Any]) -> bool:
|
def _is_expense_application_context_value(context_json: dict[str, Any]) -> bool:
|
||||||
document_type = str(context_json.get("document_type") or "").strip()
|
document_type = str(context_json.get("document_type") or "").strip()
|
||||||
@@ -63,6 +63,9 @@ class OntologyExtractionMixin:
|
|||||||
time_range: OntologyTimeRange,
|
time_range: OntologyTimeRange,
|
||||||
context_json: dict[str, Any],
|
context_json: dict[str, Any],
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
|
if scenario == "budget" and intent == "draft":
|
||||||
|
return self._infer_budget_missing_slots(entities, context_json)
|
||||||
|
|
||||||
if scenario != "expense" or intent != "draft":
|
if scenario != "expense" or intent != "draft":
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -87,7 +90,8 @@ class OntologyExtractionMixin:
|
|||||||
for item in entities
|
for item in entities
|
||||||
if item.type == "expense_type"
|
if item.type == "expense_type"
|
||||||
}
|
}
|
||||||
if "expense_type" not in entity_types and not str(form_values.get("expense_type") or "").strip():
|
form_expense_type = str(form_values.get("expense_type") or "").strip()
|
||||||
|
if "expense_type" not in entity_types and not form_expense_type:
|
||||||
missing_slots.append("expense_type")
|
missing_slots.append("expense_type")
|
||||||
if "amount" not in entity_types and not str(form_values.get("amount") or "").strip():
|
if "amount" not in entity_types and not str(form_values.get("amount") or "").strip():
|
||||||
missing_slots.append("amount")
|
missing_slots.append("amount")
|
||||||
@@ -103,7 +107,10 @@ class OntologyExtractionMixin:
|
|||||||
).strip()
|
).strip()
|
||||||
if not reason_value and compact_query in GENERIC_EXPENSE_APPLICATION_PROMPTS:
|
if not reason_value and compact_query in GENERIC_EXPENSE_APPLICATION_PROMPTS:
|
||||||
missing_slots.append("reason")
|
missing_slots.append("reason")
|
||||||
if attachment_count <= 0 and expense_type_codes & EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES:
|
if (
|
||||||
|
attachment_count <= 0
|
||||||
|
and expense_type_codes & EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES
|
||||||
|
):
|
||||||
missing_slots.append("attachments")
|
missing_slots.append("attachments")
|
||||||
ordered_keys = [*EXPENSE_APPLICATION_REQUIRED_SLOT_KEYS, "attachments"]
|
ordered_keys = [*EXPENSE_APPLICATION_REQUIRED_SLOT_KEYS, "attachments"]
|
||||||
return [item for item in ordered_keys if item in missing_slots]
|
return [item for item in ordered_keys if item in missing_slots]
|
||||||
@@ -193,6 +200,9 @@ class OntologyExtractionMixin:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for entity in self._extract_budget_entities(query, compact_query, context_json):
|
||||||
|
upsert(entity)
|
||||||
|
|
||||||
for match in re.finditer(r"客户\s*([A-Za-z0-9一二三四五六七八九十]+)", query):
|
for match in re.finditer(r"客户\s*([A-Za-z0-9一二三四五六七八九十]+)", query):
|
||||||
suffix = match.group(1).strip()
|
suffix = match.group(1).strip()
|
||||||
normalized = f"客户{suffix}".replace(" ", "")
|
normalized = f"客户{suffix}".replace(" ", "")
|
||||||
@@ -257,7 +267,15 @@ class OntologyExtractionMixin:
|
|||||||
upsert(self._make_entity("contract", code, code.upper()))
|
upsert(self._make_entity("contract", code, code.upper()))
|
||||||
for location in LOCATION_KEYWORDS:
|
for location in LOCATION_KEYWORDS:
|
||||||
if location in query:
|
if location in query:
|
||||||
upsert(self._make_entity("location", location, location, role="filter", confidence=0.86))
|
upsert(
|
||||||
|
self._make_entity(
|
||||||
|
"location",
|
||||||
|
location,
|
||||||
|
location,
|
||||||
|
role="filter",
|
||||||
|
confidence=0.86,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for label, normalized in EXPENSE_TYPE_KEYWORDS.items():
|
for label, normalized in EXPENSE_TYPE_KEYWORDS.items():
|
||||||
if label in query:
|
if label in query:
|
||||||
@@ -301,34 +319,139 @@ class OntologyExtractionMixin:
|
|||||||
"高速费",
|
"高速费",
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))
|
upsert(
|
||||||
|
self._make_entity(
|
||||||
if any(keyword in query for keyword in ("出差", "机票", "飞机票", "航班", "火车票", "火车", "高铁票", "高铁", "动车", "行程单")):
|
"expense_type",
|
||||||
upsert(self._make_entity("expense_type", "差旅", "travel", role="filter", confidence=0.88))
|
"交通",
|
||||||
|
"transport",
|
||||||
if any(keyword in query for keyword in ("酒店", "酒店发票", "住宿", "住宿费", "宾馆", "民宿", "房费", "客房")):
|
role="filter",
|
||||||
upsert(self._make_entity("expense_type", "住宿", "hotel", role="filter", confidence=0.86))
|
confidence=0.9,
|
||||||
|
)
|
||||||
if (
|
)
|
||||||
not has_customer_entertainment_signal
|
|
||||||
and any(keyword in query for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "餐饮"))
|
|
||||||
):
|
|
||||||
upsert(self._make_entity("expense_type", "业务招待费", "meal", role="filter", confidence=0.84))
|
|
||||||
|
|
||||||
if any(
|
if any(
|
||||||
keyword in query
|
keyword in query
|
||||||
for keyword in ("办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板", "硒鼓", "墨盒")
|
for keyword in (
|
||||||
|
"出差",
|
||||||
|
"机票",
|
||||||
|
"飞机票",
|
||||||
|
"航班",
|
||||||
|
"火车票",
|
||||||
|
"火车",
|
||||||
|
"高铁票",
|
||||||
|
"高铁",
|
||||||
|
"动车",
|
||||||
|
"行程单",
|
||||||
|
)
|
||||||
):
|
):
|
||||||
upsert(self._make_entity("expense_type", "办公用品费", "office", role="filter", confidence=0.87))
|
upsert(
|
||||||
|
self._make_entity(
|
||||||
|
"expense_type",
|
||||||
|
"差旅",
|
||||||
|
"travel",
|
||||||
|
role="filter",
|
||||||
|
confidence=0.88,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if any(keyword in query for keyword in ("培训", "讲师费", "课时费", "课程费", "教材", "认证费", "考试费")):
|
if any(
|
||||||
upsert(self._make_entity("expense_type", "培训费", "training", role="filter", confidence=0.84))
|
keyword in query
|
||||||
|
for keyword in ("酒店", "酒店发票", "住宿", "住宿费", "宾馆", "民宿", "房费", "客房")
|
||||||
|
):
|
||||||
|
upsert(
|
||||||
|
self._make_entity(
|
||||||
|
"expense_type",
|
||||||
|
"住宿",
|
||||||
|
"hotel",
|
||||||
|
role="filter",
|
||||||
|
confidence=0.86,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if any(keyword in query for keyword in ("通讯费", "话费", "电话费", "手机费", "流量费", "宽带费", "网络费")):
|
if (
|
||||||
upsert(self._make_entity("expense_type", "通讯费", "communication", role="filter", confidence=0.84))
|
not has_customer_entertainment_signal
|
||||||
|
and any(
|
||||||
|
keyword in query
|
||||||
|
for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "餐饮")
|
||||||
|
)
|
||||||
|
):
|
||||||
|
upsert(
|
||||||
|
self._make_entity(
|
||||||
|
"expense_type",
|
||||||
|
"业务招待费",
|
||||||
|
"meal",
|
||||||
|
role="filter",
|
||||||
|
confidence=0.84,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if any(keyword in query for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费", "员工关怀")):
|
if any(
|
||||||
upsert(self._make_entity("expense_type", "福利费", "welfare", role="filter", confidence=0.84))
|
keyword in query
|
||||||
|
for keyword in (
|
||||||
|
"办公用品",
|
||||||
|
"文具",
|
||||||
|
"耗材",
|
||||||
|
"办公耗材",
|
||||||
|
"打印纸",
|
||||||
|
"办公设备",
|
||||||
|
"键盘",
|
||||||
|
"鼠标",
|
||||||
|
"白板",
|
||||||
|
"硒鼓",
|
||||||
|
"墨盒",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
upsert(
|
||||||
|
self._make_entity(
|
||||||
|
"expense_type",
|
||||||
|
"办公用品费",
|
||||||
|
"office",
|
||||||
|
role="filter",
|
||||||
|
confidence=0.87,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(
|
||||||
|
keyword in query
|
||||||
|
for keyword in ("培训", "讲师费", "课时费", "课程费", "教材", "认证费", "考试费")
|
||||||
|
):
|
||||||
|
upsert(
|
||||||
|
self._make_entity(
|
||||||
|
"expense_type",
|
||||||
|
"培训费",
|
||||||
|
"training",
|
||||||
|
role="filter",
|
||||||
|
confidence=0.84,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(
|
||||||
|
keyword in query
|
||||||
|
for keyword in ("通讯费", "话费", "电话费", "手机费", "流量费", "宽带费", "网络费")
|
||||||
|
):
|
||||||
|
upsert(
|
||||||
|
self._make_entity(
|
||||||
|
"expense_type",
|
||||||
|
"通讯费",
|
||||||
|
"communication",
|
||||||
|
role="filter",
|
||||||
|
confidence=0.84,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(
|
||||||
|
keyword in query
|
||||||
|
for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费", "员工关怀")
|
||||||
|
):
|
||||||
|
upsert(
|
||||||
|
self._make_entity(
|
||||||
|
"expense_type",
|
||||||
|
"福利费",
|
||||||
|
"welfare",
|
||||||
|
role="filter",
|
||||||
|
confidence=0.84,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for amount in self._extract_amount_entities(query):
|
for amount in self._extract_amount_entities(query):
|
||||||
upsert(amount)
|
upsert(amount)
|
||||||
@@ -380,6 +503,20 @@ class OntologyExtractionMixin:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _infer_scenario_from_entities(entities: list[OntologyEntity]) -> str | None:
|
def _infer_scenario_from_entities(entities: list[OntologyEntity]) -> str | None:
|
||||||
entity_types = {item.type for item in entities}
|
entity_types = {item.type for item in entities}
|
||||||
|
if entity_types & {
|
||||||
|
"budget_period",
|
||||||
|
"budget_subject",
|
||||||
|
"budget_status",
|
||||||
|
"budget_version",
|
||||||
|
"budget_amount",
|
||||||
|
"available_amount",
|
||||||
|
"reserved_amount",
|
||||||
|
"consumed_amount",
|
||||||
|
"cost_center",
|
||||||
|
"warning_threshold",
|
||||||
|
"control_action",
|
||||||
|
}:
|
||||||
|
return "budget"
|
||||||
if entity_types & {"vendor", "payable"}:
|
if entity_types & {"vendor", "payable"}:
|
||||||
return "accounts_payable"
|
return "accounts_payable"
|
||||||
if entity_types & {"customer", "receivable", "contract"}:
|
if entity_types & {"customer", "receivable", "contract"}:
|
||||||
@@ -548,9 +685,11 @@ class OntologyExtractionMixin:
|
|||||||
|
|
||||||
if any(
|
if any(
|
||||||
keyword in compact_query
|
keyword in compact_query
|
||||||
for keyword in ("多少钱", "金额", "总额", "支出", "回款", "应收", "应付")
|
for keyword in ("多少钱", "金额", "总额", "支出", "回款", "应收", "应付", "预算")
|
||||||
):
|
):
|
||||||
upsert(OntologyMetric(name="amount", aggregation="sum", unit="CNY"))
|
upsert(OntologyMetric(name="amount", aggregation="sum", unit="CNY"))
|
||||||
|
for metric in self._extract_budget_metrics(compact_query):
|
||||||
|
upsert(metric)
|
||||||
if any(keyword in compact_query for keyword in ("多少笔", "几笔", "数量", "条数", "单数")):
|
if any(keyword in compact_query for keyword in ("多少笔", "几笔", "数量", "条数", "单数")):
|
||||||
upsert(OntologyMetric(name="count", aggregation="count", unit="records"))
|
upsert(OntologyMetric(name="count", aggregation="count", unit="records"))
|
||||||
if "超标" in compact_query or "超预算" in compact_query:
|
if "超标" in compact_query or "超预算" in compact_query:
|
||||||
@@ -600,6 +739,17 @@ class OntologyExtractionMixin:
|
|||||||
"expense_type",
|
"expense_type",
|
||||||
"document_type",
|
"document_type",
|
||||||
"workflow_stage",
|
"workflow_stage",
|
||||||
|
"budget_period",
|
||||||
|
"budget_subject",
|
||||||
|
"budget_status",
|
||||||
|
"budget_version",
|
||||||
|
"budget_amount",
|
||||||
|
"available_amount",
|
||||||
|
"reserved_amount",
|
||||||
|
"consumed_amount",
|
||||||
|
"cost_center",
|
||||||
|
"warning_threshold",
|
||||||
|
"control_action",
|
||||||
}:
|
}:
|
||||||
upsert(
|
upsert(
|
||||||
OntologyConstraint(
|
OntologyConstraint(
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ from dataclasses import dataclass
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from app.schemas.ontology import OntologyIntent, OntologyScenario
|
from app.schemas.ontology import OntologyIntent, OntologyScenario
|
||||||
from app.services.expense_type_keywords import build_expense_type_keyword_map
|
from app.services.expense_type_keywords import (
|
||||||
|
EXPENSE_TYPE_LABEL_BY_CODE,
|
||||||
|
build_expense_type_keyword_map,
|
||||||
|
)
|
||||||
|
|
||||||
DATE_RANGE_PATTERN = re.compile(
|
DATE_RANGE_PATTERN = re.compile(
|
||||||
r"(?P<start>\d{4}-\d{1,2}-\d{1,2})\s*(?:到|至|~|-)\s*(?P<end>\d{4}-\d{1,2}-\d{1,2})"
|
r"(?P<start>\d{4}-\d{1,2}-\d{1,2})\s*(?:到|至|~|-)\s*(?P<end>\d{4}-\d{1,2}-\d{1,2})"
|
||||||
@@ -61,6 +64,27 @@ SCENARIO_KEYWORDS = {
|
|||||||
("待付", 0.16),
|
("待付", 0.16),
|
||||||
("打款", 0.18),
|
("打款", 0.18),
|
||||||
),
|
),
|
||||||
|
"budget": (
|
||||||
|
("预算中心", 0.28),
|
||||||
|
("预算管理", 0.26),
|
||||||
|
("预算编制", 0.24),
|
||||||
|
("预算", 0.20),
|
||||||
|
("预算额度", 0.22),
|
||||||
|
("预算金额", 0.22),
|
||||||
|
("可用预算", 0.22),
|
||||||
|
("剩余预算", 0.22),
|
||||||
|
("预算余额", 0.20),
|
||||||
|
("预算占用", 0.22),
|
||||||
|
("预算预占", 0.22),
|
||||||
|
("预占", 0.16),
|
||||||
|
("核销", 0.16),
|
||||||
|
("成本中心", 0.22),
|
||||||
|
("预算科目", 0.22),
|
||||||
|
("预算预警", 0.22),
|
||||||
|
("预警线", 0.18),
|
||||||
|
("超预算", 0.24),
|
||||||
|
("预算不足", 0.24),
|
||||||
|
),
|
||||||
"knowledge": (
|
"knowledge": (
|
||||||
("制度", 0.20),
|
("制度", 0.20),
|
||||||
("规则", 0.20),
|
("规则", 0.20),
|
||||||
@@ -216,6 +240,56 @@ EXPENSE_APPLICATION_ATTACHMENT_REQUIRED_TYPES = {
|
|||||||
"office",
|
"office",
|
||||||
"training",
|
"training",
|
||||||
}
|
}
|
||||||
|
BUDGET_CONTEXT_TYPES = {
|
||||||
|
"budget",
|
||||||
|
"budget_plan",
|
||||||
|
"budget_center",
|
||||||
|
"budget_management",
|
||||||
|
}
|
||||||
|
BUDGET_KEYWORDS = tuple(keyword for keyword, _weight in SCENARIO_KEYWORDS["budget"])
|
||||||
|
BUDGET_DRAFT_KEYWORDS = (
|
||||||
|
"新建预算",
|
||||||
|
"创建预算",
|
||||||
|
"编制预算",
|
||||||
|
"编辑预算",
|
||||||
|
"调整预算",
|
||||||
|
"保存预算",
|
||||||
|
"预算草稿",
|
||||||
|
)
|
||||||
|
BUDGET_OPERATE_KEYWORDS = (
|
||||||
|
"发布预算",
|
||||||
|
"冻结预算",
|
||||||
|
"解冻预算",
|
||||||
|
"启用预算",
|
||||||
|
"停用预算",
|
||||||
|
)
|
||||||
|
BUDGET_REQUIRED_SLOT_KEYS = (
|
||||||
|
"budget_period",
|
||||||
|
"department",
|
||||||
|
"budget_subject",
|
||||||
|
"budget_amount",
|
||||||
|
)
|
||||||
|
BUDGET_SUBJECT_KEYWORDS = EXPENSE_TYPE_KEYWORDS
|
||||||
|
BUDGET_SUBJECT_LABEL_BY_CODE = EXPENSE_TYPE_LABEL_BY_CODE
|
||||||
|
BUDGET_STATUS_KEYWORDS = {
|
||||||
|
"编制中": "drafting",
|
||||||
|
"草稿": "draft",
|
||||||
|
"已发布": "published",
|
||||||
|
"发布": "published",
|
||||||
|
"已冻结": "frozen",
|
||||||
|
"冻结": "frozen",
|
||||||
|
"已关闭": "closed",
|
||||||
|
"关闭": "closed",
|
||||||
|
}
|
||||||
|
BUDGET_CONTROL_ACTION_KEYWORDS = {
|
||||||
|
"提醒": "remind",
|
||||||
|
"预警": "remind",
|
||||||
|
"正常": "allow",
|
||||||
|
"允许": "allow",
|
||||||
|
"管控": "control",
|
||||||
|
"阻断": "block",
|
||||||
|
"禁止": "block",
|
||||||
|
}
|
||||||
MISSING_SLOT_LABELS = {
|
MISSING_SLOT_LABELS = {
|
||||||
"expense_type": "费用类型",
|
"expense_type": "费用类型",
|
||||||
"amount": "金额",
|
"amount": "金额",
|
||||||
@@ -226,6 +300,13 @@ MISSING_SLOT_LABELS = {
|
|||||||
"time_range": "发生时间",
|
"time_range": "发生时间",
|
||||||
"reason": "事由说明",
|
"reason": "事由说明",
|
||||||
"document_id": "单据号",
|
"document_id": "单据号",
|
||||||
|
"department": "所属部门",
|
||||||
|
"budget_period": "预算周期",
|
||||||
|
"budget_subject": "预算科目",
|
||||||
|
"budget_amount": "预算金额",
|
||||||
|
"cost_center": "成本中心",
|
||||||
|
"warning_threshold": "预警线",
|
||||||
|
"control_action": "控制动作",
|
||||||
}
|
}
|
||||||
|
|
||||||
STATUS_KEYWORDS = {
|
STATUS_KEYWORDS = {
|
||||||
@@ -278,7 +359,7 @@ LOCATION_KEYWORDS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"}
|
PRIVILEGED_ROLE_CODES = {"manager", "finance", "approver", "executive"}
|
||||||
CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "knowledge"}
|
CONTEXTUAL_SCENARIOS = {"expense", "accounts_receivable", "accounts_payable", "budget", "knowledge"}
|
||||||
KNOWLEDGE_INTENTS = {"query", "explain", "compare"}
|
KNOWLEDGE_INTENTS = {"query", "explain", "compare"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from app.schemas.ontology import (
|
|||||||
OntologyTimeRange,
|
OntologyTimeRange,
|
||||||
)
|
)
|
||||||
from app.services.ontology_rules import (
|
from app.services.ontology_rules import (
|
||||||
AMOUNT_PATTERN,
|
|
||||||
EXPENSE_REVIEW_ACTIONS,
|
EXPENSE_REVIEW_ACTIONS,
|
||||||
MISSING_SLOT_LABELS,
|
MISSING_SLOT_LABELS,
|
||||||
OPERATE_KEYWORDS,
|
OPERATE_KEYWORDS,
|
||||||
@@ -37,6 +36,14 @@ class OntologyValidationMixin:
|
|||||||
append("invoice_anomaly")
|
append("invoice_anomaly")
|
||||||
if any(keyword in compact_query for keyword in ("超标", "超预算", "超限")):
|
if any(keyword in compact_query for keyword in ("超标", "超预算", "超限")):
|
||||||
append("amount_over_limit")
|
append("amount_over_limit")
|
||||||
|
if scenario == "budget" and any(
|
||||||
|
keyword in compact_query for keyword in ("预算不足", "超预算", "超支")
|
||||||
|
):
|
||||||
|
append("budget_over_limit")
|
||||||
|
if scenario == "budget" and any(
|
||||||
|
keyword in compact_query for keyword in ("预算预警", "触发预警", "接近预算")
|
||||||
|
):
|
||||||
|
append("budget_warning")
|
||||||
if scenario == "accounts_receivable" and any(
|
if scenario == "accounts_receivable" and any(
|
||||||
keyword in compact_query for keyword in ("逾期", "账龄", "欠款", "未回款")
|
keyword in compact_query for keyword in ("逾期", "账龄", "欠款", "未回款")
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -83,8 +83,10 @@ EXPENSE_TYPE_LABELS = {
|
|||||||
"meal": "业务招待费",
|
"meal": "业务招待费",
|
||||||
"meeting": "会务费",
|
"meeting": "会务费",
|
||||||
"entertainment": "业务招待费",
|
"entertainment": "业务招待费",
|
||||||
|
"marketing": "市场推广费",
|
||||||
"office": "办公用品费",
|
"office": "办公用品费",
|
||||||
"training": "培训费",
|
"training": "培训费",
|
||||||
|
"software": "软件服务费",
|
||||||
"communication": "通讯费",
|
"communication": "通讯费",
|
||||||
"welfare": "福利费",
|
"welfare": "福利费",
|
||||||
"other": "其他费用",
|
"other": "其他费用",
|
||||||
@@ -131,7 +133,9 @@ class OrchestratorDatabaseQueryBuilder:
|
|||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
count_stmt = select(func.count()).select_from(ExpenseClaim)
|
count_stmt = select(func.count()).select_from(ExpenseClaim)
|
||||||
amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim)
|
amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(
|
||||||
|
ExpenseClaim
|
||||||
|
)
|
||||||
for condition in conditions:
|
for condition in conditions:
|
||||||
count_stmt = count_stmt.where(condition)
|
count_stmt = count_stmt.where(condition)
|
||||||
amount_stmt = amount_stmt.where(condition)
|
amount_stmt = amount_stmt.where(condition)
|
||||||
@@ -148,7 +152,9 @@ class OrchestratorDatabaseQueryBuilder:
|
|||||||
|
|
||||||
if recent_window_applied:
|
if recent_window_applied:
|
||||||
reference_now = self._resolve_reference_now(context_json)
|
reference_now = self._resolve_reference_now(context_json)
|
||||||
recent_window_start, recent_window_end = self._resolve_expense_recent_window_bounds(reference_now)
|
recent_window_start, recent_window_end = self._resolve_expense_recent_window_bounds(
|
||||||
|
reference_now
|
||||||
|
)
|
||||||
recent_condition = self._build_expense_recent_window_condition(
|
recent_condition = self._build_expense_recent_window_condition(
|
||||||
recent_window_start,
|
recent_window_start,
|
||||||
recent_window_end,
|
recent_window_end,
|
||||||
@@ -157,9 +163,13 @@ class OrchestratorDatabaseQueryBuilder:
|
|||||||
window_start_date = recent_window_start.date().isoformat()
|
window_start_date = recent_window_start.date().isoformat()
|
||||||
window_end_date = (recent_window_end - timedelta(microseconds=1)).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_count_stmt = (
|
||||||
recent_amount_stmt = select(func.coalesce(func.sum(ExpenseClaim.amount), 0)).select_from(ExpenseClaim).where(
|
select(func.count()).select_from(ExpenseClaim).where(recent_condition)
|
||||||
recent_condition
|
)
|
||||||
|
recent_amount_stmt = (
|
||||||
|
select(func.coalesce(func.sum(ExpenseClaim.amount), 0))
|
||||||
|
.select_from(ExpenseClaim)
|
||||||
|
.where(recent_condition)
|
||||||
)
|
)
|
||||||
for condition in conditions:
|
for condition in conditions:
|
||||||
recent_count_stmt = recent_count_stmt.where(condition)
|
recent_count_stmt = recent_count_stmt.where(condition)
|
||||||
@@ -189,7 +199,11 @@ class OrchestratorDatabaseQueryBuilder:
|
|||||||
"record_count": display_count,
|
"record_count": display_count,
|
||||||
"total_amount": round(display_amount, 2),
|
"total_amount": round(display_amount, 2),
|
||||||
"scope_label": scope_label,
|
"scope_label": scope_label,
|
||||||
"title": f"最近 {len(preview_claims)} 条{scope_label}" if preview_claims else f"{scope_label}筛选结果",
|
"title": (
|
||||||
|
f"最近 {len(preview_claims)} 条{scope_label}"
|
||||||
|
if preview_claims
|
||||||
|
else f"{scope_label}筛选结果"
|
||||||
|
),
|
||||||
"scoped_to_current_user": scoped_to_current_user,
|
"scoped_to_current_user": scoped_to_current_user,
|
||||||
"recent_window_applied": recent_window_applied,
|
"recent_window_applied": recent_window_applied,
|
||||||
"window_days": EXPENSE_QUERY_RECENT_WINDOW_DAYS if recent_window_applied else None,
|
"window_days": EXPENSE_QUERY_RECENT_WINDOW_DAYS if recent_window_applied else None,
|
||||||
@@ -280,7 +294,8 @@ class OrchestratorDatabaseQueryBuilder:
|
|||||||
reference_now: datetime,
|
reference_now: datetime,
|
||||||
) -> tuple[datetime, datetime]:
|
) -> tuple[datetime, datetime]:
|
||||||
normalized_now = reference_now.astimezone(UTC)
|
normalized_now = reference_now.astimezone(UTC)
|
||||||
window_end = normalized_now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
window_end = normalized_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
window_end += timedelta(days=1)
|
||||||
window_start = window_end - timedelta(days=EXPENSE_QUERY_RECENT_WINDOW_DAYS)
|
window_start = window_end - timedelta(days=EXPENSE_QUERY_RECENT_WINDOW_DAYS)
|
||||||
return window_start, window_end
|
return window_start, window_end
|
||||||
|
|
||||||
@@ -300,7 +315,11 @@ class OrchestratorDatabaseQueryBuilder:
|
|||||||
self,
|
self,
|
||||||
conditions: list[Any],
|
conditions: list[Any],
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
stmt = select(ExpenseClaim.status, func.count()).select_from(ExpenseClaim).group_by(ExpenseClaim.status)
|
stmt = (
|
||||||
|
select(ExpenseClaim.status, func.count())
|
||||||
|
.select_from(ExpenseClaim)
|
||||||
|
.group_by(ExpenseClaim.status)
|
||||||
|
)
|
||||||
for condition in conditions:
|
for condition in conditions:
|
||||||
stmt = stmt.where(condition)
|
stmt = stmt.where(condition)
|
||||||
|
|
||||||
@@ -356,7 +375,10 @@ class OrchestratorDatabaseQueryBuilder:
|
|||||||
"claim_no": claim.claim_no,
|
"claim_no": claim.claim_no,
|
||||||
"employee_name": claim.employee_name,
|
"employee_name": claim.employee_name,
|
||||||
"expense_type": claim.expense_type,
|
"expense_type": claim.expense_type,
|
||||||
"expense_type_label": EXPENSE_TYPE_LABELS.get(claim.expense_type, claim.expense_type or "报销"),
|
"expense_type_label": EXPENSE_TYPE_LABELS.get(
|
||||||
|
claim.expense_type,
|
||||||
|
claim.expense_type or "报销",
|
||||||
|
),
|
||||||
"amount": round(float(claim.amount), 2),
|
"amount": round(float(claim.amount), 2),
|
||||||
"status": claim.status,
|
"status": claim.status,
|
||||||
"status_label": status_label,
|
"status_label": status_label,
|
||||||
@@ -378,7 +400,11 @@ class OrchestratorDatabaseQueryBuilder:
|
|||||||
normalized_flags: list[dict[str, str]] = []
|
normalized_flags: list[dict[str, str]] = []
|
||||||
for index, raw_flag in enumerate(raw_flags, start=1):
|
for index, raw_flag in enumerate(raw_flags, start=1):
|
||||||
if isinstance(raw_flag, dict):
|
if isinstance(raw_flag, dict):
|
||||||
raw_level = str(raw_flag.get("severity") or raw_flag.get("level") or "").strip().lower()
|
raw_level = (
|
||||||
|
str(raw_flag.get("severity") or raw_flag.get("level") or "")
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
level = raw_level if raw_level in EXPENSE_RISK_LEVEL_LABELS else "medium"
|
level = raw_level if raw_level in EXPENSE_RISK_LEVEL_LABELS else "medium"
|
||||||
summary = str(
|
summary = str(
|
||||||
raw_flag.get("message")
|
raw_flag.get("message")
|
||||||
@@ -397,7 +423,11 @@ class OrchestratorDatabaseQueryBuilder:
|
|||||||
raw_text = str(raw_flag or "").strip()
|
raw_text = str(raw_flag or "").strip()
|
||||||
if not raw_text:
|
if not raw_text:
|
||||||
continue
|
continue
|
||||||
level = "high" if any(keyword in raw_text for keyword in ("高风险", "超标", "重复", "异常")) else "medium"
|
level = (
|
||||||
|
"high"
|
||||||
|
if any(keyword in raw_text for keyword in ("高风险", "超标", "重复", "异常"))
|
||||||
|
else "medium"
|
||||||
|
)
|
||||||
summary = raw_text
|
summary = raw_text
|
||||||
detail = raw_text
|
detail = raw_text
|
||||||
title = EXPENSE_RISK_LEVEL_LABELS[level]
|
title = EXPENSE_RISK_LEVEL_LABELS[level]
|
||||||
@@ -436,14 +466,16 @@ class OrchestratorDatabaseQueryBuilder:
|
|||||||
dict.fromkeys(
|
dict.fromkeys(
|
||||||
str(item.normalized_value or item.value or "").strip().upper()
|
str(item.normalized_value or item.value or "").strip().upper()
|
||||||
for item in ontology.entities
|
for item in ontology.entities
|
||||||
if item.type == "expense_claim" and str(item.normalized_value or item.value or "").strip()
|
if item.type == "expense_claim"
|
||||||
|
and str(item.normalized_value or item.value or "").strip()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
expense_types = list(
|
expense_types = list(
|
||||||
dict.fromkeys(
|
dict.fromkeys(
|
||||||
str(item.normalized_value or item.value or "").strip()
|
str(item.normalized_value or item.value or "").strip()
|
||||||
for item in ontology.entities
|
for item in ontology.entities
|
||||||
if item.type == "expense_type" and str(item.normalized_value or item.value or "").strip()
|
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")
|
project_values = self._collect_expense_query_filter_values(ontology, "project")
|
||||||
@@ -551,7 +583,11 @@ class OrchestratorDatabaseQueryBuilder:
|
|||||||
else:
|
else:
|
||||||
scope_label = "全部报销单"
|
scope_label = "全部报销单"
|
||||||
|
|
||||||
return conditions, self._compose_expense_scope_label(scope_label, status_values), scoped_to_current_user
|
return (
|
||||||
|
conditions,
|
||||||
|
self._compose_expense_scope_label(scope_label, status_values),
|
||||||
|
scoped_to_current_user,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_expense_query_status_values(
|
def _resolve_expense_query_status_values(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app.services.risk_rule_flow_diagram import (
|
|||||||
from app.services.risk_rule_generation_ontology import (
|
from app.services.risk_rule_generation_ontology import (
|
||||||
BUSINESS_DOMAIN_LABELS,
|
BUSINESS_DOMAIN_LABELS,
|
||||||
DOMAIN_FIELD_PREFIXES,
|
DOMAIN_FIELD_PREFIXES,
|
||||||
|
EXPENSE_BUSINESS_STAGE_LABELS,
|
||||||
EXPENSE_RISK_CATEGORY_ALIASES,
|
EXPENSE_RISK_CATEGORY_ALIASES,
|
||||||
EXPENSE_RISK_CATEGORY_LABELS,
|
EXPENSE_RISK_CATEGORY_LABELS,
|
||||||
FIELD_ONTOLOGY,
|
FIELD_ONTOLOGY,
|
||||||
@@ -75,6 +76,8 @@ class RiskRuleGenerationService:
|
|||||||
raise ValueError("规则标题至少需要 2 个字。")
|
raise ValueError("规则标题至少需要 2 个字。")
|
||||||
|
|
||||||
requires_attachment = bool(body.requires_attachment)
|
requires_attachment = bool(body.requires_attachment)
|
||||||
|
business_stage = self._normalize_business_stage(body.business_stage, domain)
|
||||||
|
business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销")
|
||||||
expense_category = self._normalize_expense_category(body.expense_category, domain)
|
expense_category = self._normalize_expense_category(body.expense_category, domain)
|
||||||
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
||||||
|
|
||||||
@@ -83,6 +86,8 @@ class RiskRuleGenerationService:
|
|||||||
draft = self._compile_with_model(
|
draft = self._compile_with_model(
|
||||||
natural_language=natural_language,
|
natural_language=natural_language,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
business_stage=business_stage,
|
||||||
|
business_stage_label=business_stage_label,
|
||||||
expense_category=expense_category,
|
expense_category=expense_category,
|
||||||
expense_category_label=expense_category_label,
|
expense_category_label=expense_category_label,
|
||||||
fields=fields,
|
fields=fields,
|
||||||
@@ -113,6 +118,8 @@ class RiskRuleGenerationService:
|
|||||||
draft,
|
draft,
|
||||||
natural_language=natural_language,
|
natural_language=natural_language,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
business_stage=business_stage,
|
||||||
|
business_stage_label=business_stage_label,
|
||||||
expense_category=expense_category,
|
expense_category=expense_category,
|
||||||
expense_category_label=expense_category_label,
|
expense_category_label=expense_category_label,
|
||||||
risk_level=risk_level,
|
risk_level=risk_level,
|
||||||
@@ -155,6 +162,8 @@ class RiskRuleGenerationService:
|
|||||||
"requires_attachment": requires_attachment,
|
"requires_attachment": requires_attachment,
|
||||||
"tag": "风险规则",
|
"tag": "风险规则",
|
||||||
"detail_mode": "json_risk",
|
"detail_mode": "json_risk",
|
||||||
|
"business_stage": business_stage,
|
||||||
|
"business_stage_label": business_stage_label,
|
||||||
"expense_category": expense_category,
|
"expense_category": expense_category,
|
||||||
"expense_category_label": expense_category_label,
|
"expense_category_label": expense_category_label,
|
||||||
"risk_category": payload.get("risk_category"),
|
"risk_category": payload.get("risk_category"),
|
||||||
@@ -167,6 +176,11 @@ class RiskRuleGenerationService:
|
|||||||
"evaluator": payload.get("evaluator"),
|
"evaluator": payload.get("evaluator"),
|
||||||
"generated_by": "natural_language",
|
"generated_by": "natural_language",
|
||||||
"source_ref": "自然语言风险规则",
|
"source_ref": "自然语言风险规则",
|
||||||
|
"last_operation": {
|
||||||
|
"action": "create",
|
||||||
|
"actor": actor,
|
||||||
|
"at": datetime.now(UTC).isoformat(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.db.add(asset)
|
self.db.add(asset)
|
||||||
@@ -192,6 +206,7 @@ class RiskRuleGenerationService:
|
|||||||
"risk_level": risk_level,
|
"risk_level": risk_level,
|
||||||
"risk_score": risk_score["score"],
|
"risk_score": risk_score["score"],
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
|
"business_stage": business_stage,
|
||||||
"expense_category": expense_category,
|
"expense_category": expense_category,
|
||||||
"requires_attachment": requires_attachment,
|
"requires_attachment": requires_attachment,
|
||||||
},
|
},
|
||||||
@@ -205,6 +220,8 @@ class RiskRuleGenerationService:
|
|||||||
*,
|
*,
|
||||||
natural_language: str,
|
natural_language: str,
|
||||||
domain: str,
|
domain: str,
|
||||||
|
business_stage: str,
|
||||||
|
business_stage_label: str,
|
||||||
expense_category: str | None,
|
expense_category: str | None,
|
||||||
expense_category_label: str,
|
expense_category_label: str,
|
||||||
fields: list[RiskRuleField],
|
fields: list[RiskRuleField],
|
||||||
@@ -221,6 +238,8 @@ class RiskRuleGenerationService:
|
|||||||
messages = build_risk_rule_compiler_messages(
|
messages = build_risk_rule_compiler_messages(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
domain_label=BUSINESS_DOMAIN_LABELS[domain],
|
domain_label=BUSINESS_DOMAIN_LABELS[domain],
|
||||||
|
business_stage=business_stage,
|
||||||
|
business_stage_label=business_stage_label,
|
||||||
expense_category=expense_category,
|
expense_category=expense_category,
|
||||||
expense_category_label=expense_category_label,
|
expense_category_label=expense_category_label,
|
||||||
natural_language=natural_language,
|
natural_language=natural_language,
|
||||||
@@ -372,6 +391,8 @@ class RiskRuleGenerationService:
|
|||||||
*,
|
*,
|
||||||
natural_language: str,
|
natural_language: str,
|
||||||
domain: str,
|
domain: str,
|
||||||
|
business_stage: str,
|
||||||
|
business_stage_label: str,
|
||||||
expense_category: str | None,
|
expense_category: str | None,
|
||||||
expense_category_label: str,
|
expense_category_label: str,
|
||||||
risk_level: str,
|
risk_level: str,
|
||||||
@@ -408,6 +429,8 @@ class RiskRuleGenerationService:
|
|||||||
"field_keys": field_keys,
|
"field_keys": field_keys,
|
||||||
"condition_summary": condition_summary,
|
"condition_summary": condition_summary,
|
||||||
"natural_language": natural_language,
|
"natural_language": natural_language,
|
||||||
|
"business_stage": business_stage,
|
||||||
|
"business_stage_label": business_stage_label,
|
||||||
}
|
}
|
||||||
semantic_type = str(draft.get("semantic_type") or "").strip()
|
semantic_type = str(draft.get("semantic_type") or "").strip()
|
||||||
if semantic_type:
|
if semantic_type:
|
||||||
@@ -431,6 +454,8 @@ class RiskRuleGenerationService:
|
|||||||
params["keywords"] = keywords
|
params["keywords"] = keywords
|
||||||
params["search_fields"] = field_keys
|
params["search_fields"] = field_keys
|
||||||
applies_to: dict[str, Any] = {"domains": [domain]}
|
applies_to: dict[str, Any] = {"domains": [domain]}
|
||||||
|
if business_stage:
|
||||||
|
applies_to["business_stages"] = [business_stage]
|
||||||
if expense_category:
|
if expense_category:
|
||||||
applies_to["expense_categories"] = [expense_category]
|
applies_to["expense_categories"] = [expense_category]
|
||||||
|
|
||||||
@@ -485,6 +510,8 @@ class RiskRuleGenerationService:
|
|||||||
"rule_title": rule_title,
|
"rule_title": rule_title,
|
||||||
"expense_category": expense_category,
|
"expense_category": expense_category,
|
||||||
"expense_category_label": expense_category_label,
|
"expense_category_label": expense_category_label,
|
||||||
|
"business_stage": business_stage,
|
||||||
|
"business_stage_label": business_stage_label,
|
||||||
"natural_language": natural_language,
|
"natural_language": natural_language,
|
||||||
"business_explanation": self._clean_text(draft.get("description")),
|
"business_explanation": self._clean_text(draft.get("description")),
|
||||||
"condition_summary": condition_summary,
|
"condition_summary": condition_summary,
|
||||||
@@ -558,6 +585,19 @@ class RiskRuleGenerationService:
|
|||||||
raise ValueError(f"费用领域仅支持:{allowed}。")
|
raise ValueError(f"费用领域仅支持:{allowed}。")
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_business_stage(value: str | None, domain: str) -> str:
|
||||||
|
if domain != AgentAssetDomain.EXPENSE.value:
|
||||||
|
return "reimbursement"
|
||||||
|
|
||||||
|
normalized = str(value or "reimbursement").strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
normalized = "reimbursement"
|
||||||
|
if normalized not in EXPENSE_BUSINESS_STAGE_LABELS:
|
||||||
|
allowed = "、".join(EXPENSE_BUSINESS_STAGE_LABELS.values())
|
||||||
|
raise ValueError(f"业务环节仅支持:{allowed}。")
|
||||||
|
return normalized
|
||||||
|
|
||||||
def _resolve_fields(self, text: str, *, domain: str) -> list[RiskRuleField]:
|
def _resolve_fields(self, text: str, *, domain: str) -> list[RiskRuleField]:
|
||||||
prefixes = DOMAIN_FIELD_PREFIXES.get(domain, ())
|
prefixes = DOMAIN_FIELD_PREFIXES.get(domain, ())
|
||||||
candidates = [field for field in FIELD_ONTOLOGY if field.key.startswith(prefixes)]
|
candidates = [field for field in FIELD_ONTOLOGY if field.key.startswith(prefixes)]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
|||||||
from app.services.audit import AuditLogService
|
from app.services.audit import AuditLogService
|
||||||
from app.services.risk_rule_generation import (
|
from app.services.risk_rule_generation import (
|
||||||
BUSINESS_DOMAIN_LABELS,
|
BUSINESS_DOMAIN_LABELS,
|
||||||
|
EXPENSE_BUSINESS_STAGE_LABELS,
|
||||||
EXPENSE_RISK_CATEGORY_LABELS,
|
EXPENSE_RISK_CATEGORY_LABELS,
|
||||||
RiskRuleGenerationService,
|
RiskRuleGenerationService,
|
||||||
)
|
)
|
||||||
@@ -49,6 +50,8 @@ class RiskRuleGenerationJobService:
|
|||||||
natural_language = self._validate_natural_language(body)
|
natural_language = self._validate_natural_language(body)
|
||||||
rule_title = self._validate_rule_title(body)
|
rule_title = self._validate_rule_title(body)
|
||||||
requires_attachment = bool(body.requires_attachment)
|
requires_attachment = bool(body.requires_attachment)
|
||||||
|
business_stage = self.generator._normalize_business_stage(body.business_stage, domain)
|
||||||
|
business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销")
|
||||||
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
|
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
|
||||||
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
||||||
|
|
||||||
@@ -82,6 +85,8 @@ class RiskRuleGenerationJobService:
|
|||||||
"requires_attachment": requires_attachment,
|
"requires_attachment": requires_attachment,
|
||||||
"tag": "风险规则",
|
"tag": "风险规则",
|
||||||
"detail_mode": "json_risk",
|
"detail_mode": "json_risk",
|
||||||
|
"business_stage": business_stage,
|
||||||
|
"business_stage_label": business_stage_label,
|
||||||
"expense_category": expense_category,
|
"expense_category": expense_category,
|
||||||
"expense_category_label": expense_category_label,
|
"expense_category_label": expense_category_label,
|
||||||
"risk_category": category_label,
|
"risk_category": category_label,
|
||||||
@@ -94,6 +99,11 @@ class RiskRuleGenerationJobService:
|
|||||||
"generation_status": AgentAssetStatus.GENERATING.value,
|
"generation_status": AgentAssetStatus.GENERATING.value,
|
||||||
"generation_started_at": created_at.isoformat(),
|
"generation_started_at": created_at.isoformat(),
|
||||||
"generation_request": self._dump_generation_request(body),
|
"generation_request": self._dump_generation_request(body),
|
||||||
|
"last_operation": {
|
||||||
|
"action": "generate",
|
||||||
|
"actor": actor,
|
||||||
|
"at": created_at.isoformat(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.db.add(asset)
|
self.db.add(asset)
|
||||||
@@ -107,6 +117,7 @@ class RiskRuleGenerationJobService:
|
|||||||
after_json={
|
after_json={
|
||||||
"rule_code": rule_code,
|
"rule_code": rule_code,
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
|
"business_stage": business_stage,
|
||||||
"expense_category": expense_category,
|
"expense_category": expense_category,
|
||||||
},
|
},
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
@@ -181,6 +192,8 @@ class RiskRuleGenerationJobService:
|
|||||||
natural_language = self._validate_natural_language(body)
|
natural_language = self._validate_natural_language(body)
|
||||||
rule_title = self._validate_rule_title(body)
|
rule_title = self._validate_rule_title(body)
|
||||||
requires_attachment = bool(body.requires_attachment)
|
requires_attachment = bool(body.requires_attachment)
|
||||||
|
business_stage = self.generator._normalize_business_stage(body.business_stage, domain)
|
||||||
|
business_stage_label = EXPENSE_BUSINESS_STAGE_LABELS.get(business_stage, "费用报销")
|
||||||
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
|
expense_category = self.generator._normalize_expense_category(body.expense_category, domain)
|
||||||
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
expense_category_label = EXPENSE_RISK_CATEGORY_LABELS.get(expense_category or "", "")
|
||||||
created_at = asset.created_at or datetime.now(UTC)
|
created_at = asset.created_at or datetime.now(UTC)
|
||||||
@@ -189,6 +202,8 @@ class RiskRuleGenerationJobService:
|
|||||||
draft = self.generator._compile_with_model(
|
draft = self.generator._compile_with_model(
|
||||||
natural_language=natural_language,
|
natural_language=natural_language,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
business_stage=business_stage,
|
||||||
|
business_stage_label=business_stage_label,
|
||||||
expense_category=expense_category,
|
expense_category=expense_category,
|
||||||
expense_category_label=expense_category_label,
|
expense_category_label=expense_category_label,
|
||||||
fields=fields,
|
fields=fields,
|
||||||
@@ -219,6 +234,8 @@ class RiskRuleGenerationJobService:
|
|||||||
draft,
|
draft,
|
||||||
natural_language=natural_language,
|
natural_language=natural_language,
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
business_stage=business_stage,
|
||||||
|
business_stage_label=business_stage_label,
|
||||||
expense_category=expense_category,
|
expense_category=expense_category,
|
||||||
expense_category_label=expense_category_label,
|
expense_category_label=expense_category_label,
|
||||||
risk_level=risk_level,
|
risk_level=risk_level,
|
||||||
@@ -247,6 +264,8 @@ class RiskRuleGenerationJobService:
|
|||||||
"requires_attachment": requires_attachment,
|
"requires_attachment": requires_attachment,
|
||||||
"tag": "风险规则",
|
"tag": "风险规则",
|
||||||
"detail_mode": "json_risk",
|
"detail_mode": "json_risk",
|
||||||
|
"business_stage": business_stage,
|
||||||
|
"business_stage_label": business_stage_label,
|
||||||
"expense_category": expense_category,
|
"expense_category": expense_category,
|
||||||
"expense_category_label": expense_category_label,
|
"expense_category_label": expense_category_label,
|
||||||
"risk_category": payload.get("risk_category"),
|
"risk_category": payload.get("risk_category"),
|
||||||
@@ -261,6 +280,11 @@ class RiskRuleGenerationJobService:
|
|||||||
"source_ref": "自然语言风险规则",
|
"source_ref": "自然语言风险规则",
|
||||||
"generation_status": "completed",
|
"generation_status": "completed",
|
||||||
"generation_completed_at": datetime.now(UTC).isoformat(),
|
"generation_completed_at": datetime.now(UTC).isoformat(),
|
||||||
|
"last_operation": {
|
||||||
|
"action": "create",
|
||||||
|
"actor": actor,
|
||||||
|
"at": datetime.now(UTC).isoformat(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
asset.code = rule_code
|
asset.code = rule_code
|
||||||
@@ -296,6 +320,7 @@ class RiskRuleGenerationJobService:
|
|||||||
"risk_level": risk_level,
|
"risk_level": risk_level,
|
||||||
"risk_score": risk_score["score"],
|
"risk_score": risk_score["score"],
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
|
"business_stage": business_stage,
|
||||||
"expense_category": expense_category,
|
"expense_category": expense_category,
|
||||||
"requires_attachment": requires_attachment,
|
"requires_attachment": requires_attachment,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ EXPENSE_RISK_CATEGORY_ALIASES = {
|
|||||||
"entertainment": "meal",
|
"entertainment": "meal",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EXPENSE_BUSINESS_STAGE_LABELS: dict[str, str] = {
|
||||||
|
"expense_application": "费用申请",
|
||||||
|
"reimbursement": "费用报销",
|
||||||
|
}
|
||||||
|
|
||||||
FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
|
FIELD_ONTOLOGY: tuple[RiskRuleField, ...] = (
|
||||||
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")),
|
RiskRuleField("claim.reason", "报销事由", "text", "claim", ("事由", "说明", "理由", "用途")),
|
||||||
RiskRuleField(
|
RiskRuleField(
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ def build_risk_rule_compiler_messages(
|
|||||||
*,
|
*,
|
||||||
domain: str,
|
domain: str,
|
||||||
domain_label: str,
|
domain_label: str,
|
||||||
|
business_stage: str,
|
||||||
|
business_stage_label: str,
|
||||||
expense_category: str | None,
|
expense_category: str | None,
|
||||||
expense_category_label: str,
|
expense_category_label: str,
|
||||||
natural_language: str,
|
natural_language: str,
|
||||||
@@ -74,6 +76,9 @@ def build_risk_rule_compiler_messages(
|
|||||||
}
|
}
|
||||||
guardrails = [
|
guardrails = [
|
||||||
"只能输出 JSON 对象,不能输出 Markdown 或解释。",
|
"只能输出 JSON 对象,不能输出 Markdown 或解释。",
|
||||||
|
"必须区分业务环节:费用申请是事前风控,费用报销是事后核验;不要把二者的字段和流程语义混用。",
|
||||||
|
"费用申请阶段更关注预算余额、申请金额、申请事由、预计行程、预计费用科目、是否超预算或缺少前置审批。",
|
||||||
|
"费用报销阶段更关注真实票据、报销明细、发生日期、附件识别结果和申请/行程/票据一致性。",
|
||||||
"字段必须来自 available_fields,不能编造字段。",
|
"字段必须来自 available_fields,不能编造字段。",
|
||||||
"多步骤规则要使用 composite_rule_v1:先抽取事实变量,再写 conditions 和 hit_logic,不要压扁成单个关键词判断。",
|
"多步骤规则要使用 composite_rule_v1:先抽取事实变量,再写 conditions 和 hit_logic,不要压扁成单个关键词判断。",
|
||||||
"城市/地点/路线一致性必须用 field_compare_v1 或 semantic_type=travel_route_city_consistency。",
|
"城市/地点/路线一致性必须用 field_compare_v1 或 semantic_type=travel_route_city_consistency。",
|
||||||
@@ -88,6 +93,8 @@ def build_risk_rule_compiler_messages(
|
|||||||
"keyword_match_v1 只用于品名、摘要、票据全文中出现明确风险词的规则。",
|
"keyword_match_v1 只用于品名、摘要、票据全文中出现明确风险词的规则。",
|
||||||
"不要直接指定 risk_level 或 risk_score;只输出 risk_scoring_evidence,后端会按固定评分模型计算 0-100 分和风险等级。",
|
"不要直接指定 risk_level 或 risk_score;只输出 risk_scoring_evidence,后端会按固定评分模型计算 0-100 分和风险等级。",
|
||||||
"评分证据必须围绕六个指标:业务影响、违规确定性、证据强度、例外/规避空间、处置强度、场景敏感度。",
|
"评分证据必须围绕六个指标:业务影响、违规确定性、证据强度、例外/规避空间、处置强度、场景敏感度。",
|
||||||
|
"若规则语义是可修复的低风险提醒,例如资料要素缺失但归属清晰、仅提醒/提示/补齐且不退回不阻断,则 impact_level 和 control_action 应保持低强度。",
|
||||||
|
"只有涉及造假、重复报销、金额超标、城市/日期不一致、禁止提交、退回修改、阻断或审计复核时,才应给 high 或 critical 的评分证据。",
|
||||||
]
|
]
|
||||||
examples = [
|
examples = [
|
||||||
{
|
{
|
||||||
@@ -114,6 +121,26 @@ def build_risk_rule_compiler_messages(
|
|||||||
"keywords": [],
|
"keywords": [],
|
||||||
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
|
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user_rule": (
|
||||||
|
"差旅报销时,票据已上传但发票号码或商品服务名称缺失,且报销事由、人员和部门"
|
||||||
|
"能够说明费用归属,则标记为低风险,仅提醒补齐票据要素。"
|
||||||
|
),
|
||||||
|
"expected": {
|
||||||
|
"template_key": "field_required_v1",
|
||||||
|
"field_keys": ["attachment.invoice_no", "attachment.goods_name", "claim.reason"],
|
||||||
|
"condition_summary": "票据要素缺失但费用归属清晰时,仅提示补齐。",
|
||||||
|
"risk_scoring_evidence": {
|
||||||
|
"impact_level": "low",
|
||||||
|
"violation_certainty": "medium",
|
||||||
|
"evidence_strength": "medium",
|
||||||
|
"exception_dependence": "low",
|
||||||
|
"control_action": "remind",
|
||||||
|
"business_sensitivity": "medium",
|
||||||
|
"reason": "命中后只做补齐提醒,不阻断、不退回,也不涉及舞弊或金额越权。",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
return [
|
return [
|
||||||
@@ -134,6 +161,8 @@ def build_risk_rule_compiler_messages(
|
|||||||
{
|
{
|
||||||
"business_domain": domain,
|
"business_domain": domain,
|
||||||
"business_domain_label": domain_label,
|
"business_domain_label": domain_label,
|
||||||
|
"business_stage": business_stage,
|
||||||
|
"business_stage_label": business_stage_label,
|
||||||
"expense_category": expense_category,
|
"expense_category": expense_category,
|
||||||
"expense_category_label": expense_category_label,
|
"expense_category_label": expense_category_label,
|
||||||
"natural_language": natural_language,
|
"natural_language": natural_language,
|
||||||
|
|||||||
227
server/src/app/services/risk_rule_score_backfill.py
Normal file
227
server/src/app/services/risk_rule_score_backfill.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.models.agent_asset import AgentAsset
|
||||||
|
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
|
||||||
|
from app.services.agent_asset_spreadsheet import RISK_RULES_LIBRARY
|
||||||
|
from app.services.risk_rule_scoring import RISK_SCORE_MODEL_VERSION, calculate_risk_rule_score
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_missing_risk_rule_score(
|
||||||
|
asset: AgentAsset,
|
||||||
|
*,
|
||||||
|
rule_library_manager: AgentAssetRuleLibraryManager | None = None,
|
||||||
|
) -> bool:
|
||||||
|
config_json = dict(asset.config_json or {})
|
||||||
|
if str(config_json.get("detail_mode") or "").strip().lower() != "json_risk":
|
||||||
|
return False
|
||||||
|
if _has_current_score(config_json):
|
||||||
|
return False
|
||||||
|
|
||||||
|
manager = rule_library_manager or AgentAssetRuleLibraryManager()
|
||||||
|
library = str(config_json.get("rule_library") or RISK_RULES_LIBRARY).strip() or RISK_RULES_LIBRARY
|
||||||
|
file_name = _resolve_rule_file_name(asset, config_json)
|
||||||
|
if not file_name:
|
||||||
|
return False
|
||||||
|
|
||||||
|
manifest = manager.read_rule_library_json(library=library, file_name=file_name)
|
||||||
|
if _has_current_score(manifest) or _has_current_score(manifest.get("metadata")):
|
||||||
|
score = _read_existing_score(manifest)
|
||||||
|
else:
|
||||||
|
score = _calculate_score(asset, manifest, config_json)
|
||||||
|
_apply_score_to_manifest(manifest, score)
|
||||||
|
manager.write_rule_library_json(library=library, file_name=file_name, payload=manifest)
|
||||||
|
|
||||||
|
_apply_score_to_config(config_json, manifest, score)
|
||||||
|
asset.config_json = config_json
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_rule_file_name(asset: AgentAsset, config_json: dict[str, Any]) -> str:
|
||||||
|
rule_document = config_json.get("rule_document")
|
||||||
|
if isinstance(rule_document, dict):
|
||||||
|
file_name = str(rule_document.get("file_name") or "").strip()
|
||||||
|
if file_name:
|
||||||
|
return file_name
|
||||||
|
code = str(asset.code or "").strip()
|
||||||
|
return f"{code}.json" if code else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_score(
|
||||||
|
asset: AgentAsset,
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
config_json: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||||
|
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||||
|
fields = _read_fields(manifest)
|
||||||
|
field_keys = _read_field_keys(manifest, fields)
|
||||||
|
draft = {
|
||||||
|
"template_key": manifest.get("template_key") or params.get("template_key"),
|
||||||
|
"field_keys": field_keys,
|
||||||
|
"description": manifest.get("description") or asset.description,
|
||||||
|
"condition_summary": metadata.get("condition_summary") or params.get("condition_summary"),
|
||||||
|
"formula": params.get("formula"),
|
||||||
|
"message_template": params.get("message_template"),
|
||||||
|
"conditions": params.get("conditions") if isinstance(params.get("conditions"), list) else [],
|
||||||
|
"keywords": params.get("keywords") if isinstance(params.get("keywords"), list) else [],
|
||||||
|
"exception_keywords": params.get("exception_keywords")
|
||||||
|
if isinstance(params.get("exception_keywords"), list)
|
||||||
|
else [],
|
||||||
|
"flow": metadata.get("flow") if isinstance(metadata.get("flow"), dict) else {},
|
||||||
|
}
|
||||||
|
if isinstance(params.get("rule_ir"), dict):
|
||||||
|
draft["rule_ir"] = params["rule_ir"]
|
||||||
|
|
||||||
|
generation_request = (
|
||||||
|
config_json.get("generation_request")
|
||||||
|
if isinstance(config_json.get("generation_request"), dict)
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
natural_language = str(
|
||||||
|
metadata.get("natural_language")
|
||||||
|
or params.get("natural_language")
|
||||||
|
or generation_request.get("natural_language")
|
||||||
|
or manifest.get("description")
|
||||||
|
or asset.description
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
expense_category = str(
|
||||||
|
metadata.get("expense_category") or config_json.get("expense_category") or ""
|
||||||
|
).strip() or None
|
||||||
|
expense_category_label = str(
|
||||||
|
metadata.get("expense_category_label")
|
||||||
|
or config_json.get("expense_category_label")
|
||||||
|
or manifest.get("risk_category")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
requires_attachment = bool(
|
||||||
|
manifest.get("requires_attachment") or config_json.get("requires_attachment")
|
||||||
|
)
|
||||||
|
return calculate_risk_rule_score(
|
||||||
|
natural_language=natural_language,
|
||||||
|
draft=draft,
|
||||||
|
fields=fields,
|
||||||
|
expense_category=expense_category,
|
||||||
|
expense_category_label=expense_category_label,
|
||||||
|
requires_attachment=requires_attachment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_fields(manifest: dict[str, Any]) -> list[Any]:
|
||||||
|
inputs = manifest.get("inputs") if isinstance(manifest.get("inputs"), dict) else {}
|
||||||
|
rows = inputs.get("fields") if isinstance(inputs.get("fields"), list) else []
|
||||||
|
return [
|
||||||
|
SimpleNamespace(
|
||||||
|
key=str(row.get("key") or "").strip(),
|
||||||
|
label=str(row.get("label") or "").strip(),
|
||||||
|
field_type=str(row.get("type") or "").strip(),
|
||||||
|
source=str(row.get("source") or "").strip(),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
if isinstance(row, dict) and str(row.get("key") or "").strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_field_keys(manifest: dict[str, Any], fields: list[Any]) -> list[str]:
|
||||||
|
params = manifest.get("params") if isinstance(manifest.get("params"), dict) else {}
|
||||||
|
raw_keys = params.get("field_keys") or params.get("required_fields")
|
||||||
|
if isinstance(raw_keys, list):
|
||||||
|
keys = [str(item or "").strip() for item in raw_keys if str(item or "").strip()]
|
||||||
|
if keys:
|
||||||
|
return keys
|
||||||
|
return [str(getattr(field, "key", "") or "").strip() for field in fields]
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_score_to_manifest(manifest: dict[str, Any], score: dict[str, Any]) -> None:
|
||||||
|
level = str(score.get("level") or "medium")
|
||||||
|
manifest["severity"] = level
|
||||||
|
manifest["risk_score"] = int(score.get("score") or 0)
|
||||||
|
manifest["risk_level"] = level
|
||||||
|
manifest["risk_level_label"] = str(score.get("level_label") or "")
|
||||||
|
manifest["risk_score_detail"] = score
|
||||||
|
|
||||||
|
outcomes = manifest.setdefault("outcomes", {})
|
||||||
|
if isinstance(outcomes, dict):
|
||||||
|
fail = outcomes.setdefault("fail", {})
|
||||||
|
if isinstance(fail, dict):
|
||||||
|
fail["severity"] = level
|
||||||
|
fail["risk_score"] = int(score.get("score") or 0)
|
||||||
|
|
||||||
|
metadata = manifest.setdefault("metadata", {})
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
metadata["risk_score"] = int(score.get("score") or 0)
|
||||||
|
metadata["risk_level"] = level
|
||||||
|
metadata["risk_level_label"] = str(score.get("level_label") or "")
|
||||||
|
metadata["risk_score_model"] = score.get("model")
|
||||||
|
metadata["risk_score_detail"] = score
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_score_to_config(
|
||||||
|
config_json: dict[str, Any],
|
||||||
|
manifest: dict[str, Any],
|
||||||
|
score: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
level = str(score.get("level") or manifest.get("risk_level") or "medium")
|
||||||
|
config_json["severity"] = level
|
||||||
|
config_json["risk_score"] = int(score.get("score") or 0)
|
||||||
|
config_json["risk_level"] = level
|
||||||
|
config_json["risk_level_label"] = str(score.get("level_label") or "")
|
||||||
|
config_json["risk_score_detail"] = score
|
||||||
|
|
||||||
|
|
||||||
|
def _has_score(value: Any) -> bool:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
score = int(value.get("risk_score") if value.get("risk_score") is not None else value.get("score"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
return 0 <= score <= 100
|
||||||
|
|
||||||
|
|
||||||
|
def _has_current_score(value: Any) -> bool:
|
||||||
|
if not _has_score(value):
|
||||||
|
return False
|
||||||
|
return _read_score_model(value) == RISK_SCORE_MODEL_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def _read_score_model(value: Any) -> str:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return ""
|
||||||
|
detail = value.get("risk_score_detail")
|
||||||
|
if isinstance(detail, dict):
|
||||||
|
model = str(detail.get("model") or "").strip()
|
||||||
|
if model:
|
||||||
|
return model
|
||||||
|
metadata = value.get("metadata")
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
detail = metadata.get("risk_score_detail")
|
||||||
|
if isinstance(detail, dict):
|
||||||
|
model = str(detail.get("model") or "").strip()
|
||||||
|
if model:
|
||||||
|
return model
|
||||||
|
model = str(metadata.get("risk_score_model") or "").strip()
|
||||||
|
if model:
|
||||||
|
return model
|
||||||
|
return str(value.get("risk_score_model") or value.get("model") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_existing_score(manifest: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {}
|
||||||
|
detail = metadata.get("risk_score_detail")
|
||||||
|
if isinstance(detail, dict) and _has_score(detail):
|
||||||
|
return dict(detail)
|
||||||
|
detail = manifest.get("risk_score_detail")
|
||||||
|
if isinstance(detail, dict) and _has_score(detail):
|
||||||
|
return dict(detail)
|
||||||
|
score = int(metadata.get("risk_score") or manifest.get("risk_score") or 0)
|
||||||
|
level = str(metadata.get("risk_level") or manifest.get("risk_level") or "medium")
|
||||||
|
return {
|
||||||
|
"score": score,
|
||||||
|
"level": level,
|
||||||
|
"level_label": str(metadata.get("risk_level_label") or manifest.get("risk_level_label") or ""),
|
||||||
|
"model": metadata.get("risk_score_model"),
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ RISK_LEVEL_LABELS: dict[str, str] = {
|
|||||||
"critical": "极高风险",
|
"critical": "极高风险",
|
||||||
}
|
}
|
||||||
|
|
||||||
RISK_SCORE_MODEL_VERSION = "risk_score_v1"
|
RISK_SCORE_MODEL_VERSION = "risk_score_v3"
|
||||||
|
|
||||||
RISK_SCORE_WEIGHTS: dict[str, float] = {
|
RISK_SCORE_WEIGHTS: dict[str, float] = {
|
||||||
"impact": 0.35,
|
"impact": 0.35,
|
||||||
@@ -115,6 +115,7 @@ def calculate_risk_rule_score(
|
|||||||
draft.get("formula"),
|
draft.get("formula"),
|
||||||
draft.get("message_template"),
|
draft.get("message_template"),
|
||||||
)
|
)
|
||||||
|
hard_signal_text = _strip_negated_risk_context(text)
|
||||||
template_key = str(draft.get("template_key") or "").strip()
|
template_key = str(draft.get("template_key") or "").strip()
|
||||||
field_keys = _read_string_list(draft.get("field_keys"))
|
field_keys = _read_string_list(draft.get("field_keys"))
|
||||||
condition_count = len(draft.get("conditions") if isinstance(draft.get("conditions"), list) else [])
|
condition_count = len(draft.get("conditions") if isinstance(draft.get("conditions"), list) else [])
|
||||||
@@ -122,7 +123,7 @@ def calculate_risk_rule_score(
|
|||||||
components = {
|
components = {
|
||||||
"impact": _component_score(
|
"impact": _component_score(
|
||||||
evidence.get("impact_level"),
|
evidence.get("impact_level"),
|
||||||
_infer_impact_score(text, template_key=template_key),
|
_infer_impact_score(hard_signal_text, template_key=template_key),
|
||||||
),
|
),
|
||||||
"certainty": _component_score(
|
"certainty": _component_score(
|
||||||
evidence.get("violation_certainty"),
|
evidence.get("violation_certainty"),
|
||||||
@@ -142,12 +143,18 @@ def calculate_risk_rule_score(
|
|||||||
),
|
),
|
||||||
"sensitivity": _component_score(
|
"sensitivity": _component_score(
|
||||||
evidence.get("business_sensitivity"),
|
evidence.get("business_sensitivity"),
|
||||||
_infer_sensitivity_score(text, expense_category=expense_category),
|
_infer_sensitivity_score(hard_signal_text, expense_category=expense_category),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
score = _clamp_score(
|
raw_score = _clamp_score(
|
||||||
round(sum(components[key] * RISK_SCORE_WEIGHTS[key] for key in RISK_SCORE_WEIGHTS))
|
round(sum(components[key] * RISK_SCORE_WEIGHTS[key] for key in RISK_SCORE_WEIGHTS))
|
||||||
)
|
)
|
||||||
|
score, calibration = _calibrate_score(
|
||||||
|
raw_score,
|
||||||
|
text=text,
|
||||||
|
hard_signal_text=hard_signal_text,
|
||||||
|
components=components,
|
||||||
|
)
|
||||||
level = risk_level_from_score(score)
|
level = risk_level_from_score(score)
|
||||||
return {
|
return {
|
||||||
"score": score,
|
"score": score,
|
||||||
@@ -156,6 +163,7 @@ def calculate_risk_rule_score(
|
|||||||
"model": RISK_SCORE_MODEL_VERSION,
|
"model": RISK_SCORE_MODEL_VERSION,
|
||||||
"weights": RISK_SCORE_WEIGHTS,
|
"weights": RISK_SCORE_WEIGHTS,
|
||||||
"components": components,
|
"components": components,
|
||||||
|
"calibration": calibration,
|
||||||
"ai_evidence": evidence,
|
"ai_evidence": evidence,
|
||||||
"basis": {
|
"basis": {
|
||||||
"template_key": template_key,
|
"template_key": template_key,
|
||||||
@@ -277,6 +285,8 @@ def _infer_action_score(text: str, draft: dict[str, Any]) -> int:
|
|||||||
return 78
|
return 78
|
||||||
if _contains_any(corpus, "人工复核", "复核", "审核"):
|
if _contains_any(corpus, "人工复核", "复核", "审核"):
|
||||||
return 65
|
return 65
|
||||||
|
if _contains_any(corpus, "提醒", "提示", "补齐"):
|
||||||
|
return 35
|
||||||
if _contains_any(corpus, "补充", "说明"):
|
if _contains_any(corpus, "补充", "说明"):
|
||||||
return 48
|
return 48
|
||||||
return 35
|
return 35
|
||||||
@@ -292,6 +302,69 @@ def _infer_sensitivity_score(text: str, *, expense_category: str | None) -> int:
|
|||||||
return 45
|
return 45
|
||||||
|
|
||||||
|
|
||||||
|
def _calibrate_score(
|
||||||
|
score: int,
|
||||||
|
*,
|
||||||
|
text: str,
|
||||||
|
hard_signal_text: str,
|
||||||
|
components: dict[str, int],
|
||||||
|
) -> tuple[int, dict[str, Any]]:
|
||||||
|
calibration: dict[str, Any] = {"raw_score": score, "rules": []}
|
||||||
|
if _is_low_control_rule(text, hard_signal_text, components):
|
||||||
|
calibrated = min(score, 30)
|
||||||
|
calibration["rules"].append(
|
||||||
|
{
|
||||||
|
"name": "explicit_low_control_cap",
|
||||||
|
"score_before": score,
|
||||||
|
"score_after": calibrated,
|
||||||
|
"reason": "规则语义明确为低风险,且控制动作仅为提醒、提示、补齐或补充说明。",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
score = calibrated
|
||||||
|
return score, calibration
|
||||||
|
|
||||||
|
|
||||||
|
def _is_low_control_rule(text: str, hard_signal_text: str, components: dict[str, int]) -> bool:
|
||||||
|
if not _contains_any(text, "低风险", "轻微风险", "轻微", "提醒", "提示", "补齐"):
|
||||||
|
return False
|
||||||
|
if _contains_any(
|
||||||
|
hard_signal_text,
|
||||||
|
"高风险",
|
||||||
|
"极高风险",
|
||||||
|
"严重",
|
||||||
|
"重大",
|
||||||
|
"造假",
|
||||||
|
"虚假",
|
||||||
|
"伪造",
|
||||||
|
"重复报销",
|
||||||
|
"骗取",
|
||||||
|
"套取",
|
||||||
|
"不一致",
|
||||||
|
"超预算",
|
||||||
|
"超标准",
|
||||||
|
"阻断",
|
||||||
|
"禁止",
|
||||||
|
"退回",
|
||||||
|
"驳回",
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return components.get("action", 100) <= ACTION_SCORE_MAP["supplement"]
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_negated_risk_context(text: str) -> str:
|
||||||
|
normalized = str(text or "")
|
||||||
|
if not normalized:
|
||||||
|
return ""
|
||||||
|
negated_risk_pattern = (
|
||||||
|
r"(?:暂未|未|没有|无|不存在)"
|
||||||
|
r"(?:发现|存在)?"
|
||||||
|
r"[^,。;;,.]*"
|
||||||
|
r"(?:冲突|异常|重复报销|造假|虚假|伪造|超标|超预算|高风险|不一致|迹象)"
|
||||||
|
r"[^,。;;,.]*"
|
||||||
|
)
|
||||||
|
return re.sub(negated_risk_pattern, "", normalized)
|
||||||
|
|
||||||
|
|
||||||
def _replace_or_append_risk_label(value: str, level_label: str) -> str:
|
def _replace_or_append_risk_label(value: str, level_label: str) -> str:
|
||||||
normalized = str(value or "").strip()
|
normalized = str(value or "").strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
|
|||||||
@@ -38,8 +38,10 @@ EXPENSE_TYPE_LABELS = {
|
|||||||
"meal": "业务招待费",
|
"meal": "业务招待费",
|
||||||
"meeting": "会务费",
|
"meeting": "会务费",
|
||||||
"entertainment": "业务招待费",
|
"entertainment": "业务招待费",
|
||||||
|
"marketing": "市场推广费",
|
||||||
"office": "办公用品费",
|
"office": "办公用品费",
|
||||||
"training": "培训费",
|
"training": "培训费",
|
||||||
|
"software": "软件服务费",
|
||||||
"communication": "通讯费",
|
"communication": "通讯费",
|
||||||
"welfare": "福利费",
|
"welfare": "福利费",
|
||||||
"other": "其他费用",
|
"other": "其他费用",
|
||||||
@@ -49,10 +51,12 @@ GROUP_SCENE_LABELS = {
|
|||||||
"travel": "差旅费",
|
"travel": "差旅费",
|
||||||
"entertainment": "业务招待费",
|
"entertainment": "业务招待费",
|
||||||
"meal": "业务招待费",
|
"meal": "业务招待费",
|
||||||
|
"marketing": "市场推广费",
|
||||||
"transport": "交通费",
|
"transport": "交通费",
|
||||||
"hotel": "住宿费",
|
"hotel": "住宿费",
|
||||||
"office": "办公用品费",
|
"office": "办公用品费",
|
||||||
"training": "培训费",
|
"training": "培训费",
|
||||||
|
"software": "软件服务费",
|
||||||
"communication": "通讯费",
|
"communication": "通讯费",
|
||||||
"welfare": "福利费",
|
"welfare": "福利费",
|
||||||
"other": "其他费用",
|
"other": "其他费用",
|
||||||
@@ -64,8 +68,10 @@ EXPENSE_SCENE_SELECTION_OPTIONS = (
|
|||||||
("hotel", "住宿费", "单独住宿、酒店发票等场景。"),
|
("hotel", "住宿费", "单独住宿、酒店发票等场景。"),
|
||||||
("meal", "业务招待费", "客户接待、工作餐、加班餐、餐饮票据等场景。"),
|
("meal", "业务招待费", "客户接待、工作餐、加班餐、餐饮票据等场景。"),
|
||||||
("meeting", "会务费", "会议、论坛、会场、参会等场景。"),
|
("meeting", "会务费", "会议、论坛、会场、参会等场景。"),
|
||||||
|
("marketing", "市场推广费", "广告投放、品牌宣传、营销物料等推广场景。"),
|
||||||
("office", "办公用品费", "办公用品、耗材、办公设备等采购场景。"),
|
("office", "办公用品费", "办公用品、耗材、办公设备等采购场景。"),
|
||||||
("training", "培训费", "培训课程、讲师费、教材、认证等场景。"),
|
("training", "培训费", "培训课程、讲师费、教材、认证等场景。"),
|
||||||
|
("software", "软件服务费", "软件订阅、云资源、平台服务等技术服务场景。"),
|
||||||
("communication", "通讯费", "话费、流量、宽带、网络等场景。"),
|
("communication", "通讯费", "话费、流量、宽带、网络等场景。"),
|
||||||
("welfare", "福利费", "团建、体检、慰问、节日福利等场景。"),
|
("welfare", "福利费", "团建、体检、慰问、节日福利等场景。"),
|
||||||
("other", "其他费用", "暂不属于以上分类的报销场景。"),
|
("other", "其他费用", "暂不属于以上分类的报销场景。"),
|
||||||
@@ -110,7 +116,10 @@ AMOUNT_TEXT_PATTERN = re.compile(
|
|||||||
r"(\d+(?:\.\d+)?)\s*(?:万元|万员|万圆|万园|万块|万元整|元整|块钱|块|元|员|圆|园|万)"
|
r"(\d+(?:\.\d+)?)\s*(?:万元|万员|万圆|万园|万块|万元整|元整|块钱|块|元|员|圆|园|万)"
|
||||||
)
|
)
|
||||||
TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN = re.compile(r"(\d+)\s*(?:晚|间夜)")
|
TRAVEL_REVIEW_HOTEL_NIGHT_PATTERN = re.compile(r"(\d+)\s*(?:晚|间夜)")
|
||||||
TRAVEL_ROUTE_PATTERN = re.compile(r"([\u4e00-\u9fa5]{2,12})\s*(?:至|→|->|-|—)\s*([\u4e00-\u9fa5]{2,12})")
|
TRAVEL_ROUTE_PATTERN = re.compile(
|
||||||
|
r"([\u4e00-\u9fa5]{2,12})\s*(?:至|→|->|-|—)\s*"
|
||||||
|
r"([\u4e00-\u9fa5]{2,12})"
|
||||||
|
)
|
||||||
|
|
||||||
SOURCE_LABELS = {
|
SOURCE_LABELS = {
|
||||||
"user_text": "用户描述",
|
"user_text": "用户描述",
|
||||||
@@ -137,8 +146,10 @@ INFERRED_REASON_LABELS = {
|
|||||||
"meal": "业务招待",
|
"meal": "业务招待",
|
||||||
"meeting": "会务活动",
|
"meeting": "会务活动",
|
||||||
"entertainment": "客户接待",
|
"entertainment": "客户接待",
|
||||||
|
"marketing": "市场推广",
|
||||||
"office": "办公用品采购",
|
"office": "办公用品采购",
|
||||||
"training": "培训学习",
|
"training": "培训学习",
|
||||||
|
"software": "软件服务",
|
||||||
"communication": "通讯使用",
|
"communication": "通讯使用",
|
||||||
"welfare": "员工福利",
|
"welfare": "员工福利",
|
||||||
"other": "其他费用",
|
"other": "其他费用",
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"updated_at": "2026-05-17T09:28:28.999515+00:00",
|
"updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||||
"uploaded_by": "admin",
|
"uploaded_by": "admin",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-17T10:01:33.272539+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.726523+00:00",
|
||||||
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
|
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
|
||||||
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||||
@@ -35,8 +35,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:22.328877+00:00",
|
"updated_at": "2026-05-22T07:00:22.328877+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T09:22:25.565409+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.731130+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T09:22:25.565409+00:00",
|
"ingest_completed_at": "2026-05-22T09:22:25.565409+00:00",
|
||||||
"ingest_document_name": "远光软件会计科目使用说明.xlsx",
|
"ingest_document_name": "远光软件会计科目使用说明.xlsx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:22.328877+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.328877+00:00",
|
||||||
@@ -56,8 +56,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:22.011016+00:00",
|
"updated_at": "2026-05-22T07:00:22.011016+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-23T14:30:33.605531+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.735501+00:00",
|
||||||
"ingest_completed_at": "2026-05-23T14:30:33.605531+00:00",
|
"ingest_completed_at": "2026-05-23T14:30:33.605531+00:00",
|
||||||
"ingest_document_name": "远光软件财务基础知识手册.docx",
|
"ingest_document_name": "远光软件财务基础知识手册.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:22.011016+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.011016+00:00",
|
||||||
@@ -77,8 +77,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:22.352133+00:00",
|
"updated_at": "2026-05-22T07:00:22.352133+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T09:23:11.334499+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.739842+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T09:23:11.334499+00:00",
|
"ingest_completed_at": "2026-05-22T09:23:11.334499+00:00",
|
||||||
"ingest_document_name": "远光软件财务术语解释手册.docx",
|
"ingest_document_name": "远光软件财务术语解释手册.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:22.352133+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.352133+00:00",
|
||||||
@@ -98,8 +98,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:22.304623+00:00",
|
"updated_at": "2026-05-22T07:00:22.304623+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T09:24:18.933073+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.744555+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T09:24:18.933073+00:00",
|
"ingest_completed_at": "2026-05-22T09:24:18.933073+00:00",
|
||||||
"ingest_document_name": "远光软件高新技术企业税收优惠政策汇总.pdf",
|
"ingest_document_name": "远光软件高新技术企业税收优惠政策汇总.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:22.304623+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.304623+00:00",
|
||||||
@@ -119,8 +119,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:18.153373+00:00",
|
"updated_at": "2026-05-22T07:00:18.153373+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:01:43.168774+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.762391+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:01:43.168774+00:00",
|
"ingest_completed_at": "2026-05-22T16:01:43.168774+00:00",
|
||||||
"ingest_document_name": "远光软件公司内部控制基本规范.pdf",
|
"ingest_document_name": "远光软件公司内部控制基本规范.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:18.153373+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:18.153373+00:00",
|
||||||
@@ -140,8 +140,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:18.190399+00:00",
|
"updated_at": "2026-05-22T07:00:18.190399+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:03:00.735908+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.773116+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:03:00.735908+00:00",
|
"ingest_completed_at": "2026-05-22T16:03:00.735908+00:00",
|
||||||
"ingest_document_name": "远光软件公司合同管理制度.docx",
|
"ingest_document_name": "远光软件公司合同管理制度.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:18.190399+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:18.190399+00:00",
|
||||||
@@ -161,8 +161,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:17.798679+00:00",
|
"updated_at": "2026-05-22T07:00:17.798679+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:03:46.921675+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.784020+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:03:46.921675+00:00",
|
"ingest_completed_at": "2026-05-22T16:03:46.921675+00:00",
|
||||||
"ingest_document_name": "远光软件公司财务管理制度总则.docx",
|
"ingest_document_name": "远光软件公司财务管理制度总则.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:17.798679+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:17.798679+00:00",
|
||||||
@@ -182,8 +182,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:18.531598+00:00",
|
"updated_at": "2026-05-22T07:00:18.531598+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:04:58.719410+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.799323+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:04:58.719410+00:00",
|
"ingest_completed_at": "2026-05-22T16:04:58.719410+00:00",
|
||||||
"ingest_document_name": "远光软件公司资产管理制度.pdf",
|
"ingest_document_name": "远光软件公司资产管理制度.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:18.531598+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:18.531598+00:00",
|
||||||
@@ -203,8 +203,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:18.221073+00:00",
|
"updated_at": "2026-05-22T07:00:18.221073+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:06:08.172318+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.814611+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:06:08.172318+00:00",
|
"ingest_completed_at": "2026-05-22T16:06:08.172318+00:00",
|
||||||
"ingest_document_name": "远光软件公司采购管理办法.xlsx",
|
"ingest_document_name": "远光软件公司采购管理办法.xlsx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:18.221073+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:18.221073+00:00",
|
||||||
@@ -224,8 +224,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:19.734422+00:00",
|
"updated_at": "2026-05-22T07:00:19.734422+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:06:48.466110+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.830249+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:06:48.466110+00:00",
|
"ingest_completed_at": "2026-05-22T16:06:48.466110+00:00",
|
||||||
"ingest_document_name": "远光软件公司差旅费管理办法.docx",
|
"ingest_document_name": "远光软件公司差旅费管理办法.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:19.734422+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:19.734422+00:00",
|
||||||
@@ -245,8 +245,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:20.095824+00:00",
|
"updated_at": "2026-05-22T07:00:20.095824+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:07:23.262328+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.847094+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:07:23.262328+00:00",
|
"ingest_completed_at": "2026-05-22T16:07:23.262328+00:00",
|
||||||
"ingest_document_name": "远光软件出差审批流程说明.pdf",
|
"ingest_document_name": "远光软件出差审批流程说明.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:20.095824+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:20.095824+00:00",
|
||||||
@@ -266,8 +266,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:20.128471+00:00",
|
"updated_at": "2026-05-22T07:00:20.128471+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:08:02.190081+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.865452+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:08:02.190081+00:00",
|
"ingest_completed_at": "2026-05-22T16:08:02.190081+00:00",
|
||||||
"ingest_document_name": "远光软件国际出差管理规定.docx",
|
"ingest_document_name": "远光软件国际出差管理规定.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:20.128471+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:20.128471+00:00",
|
||||||
@@ -287,8 +287,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:19.759954+00:00",
|
"updated_at": "2026-05-22T07:00:19.759954+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:09:23.091744+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.888420+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:09:23.091744+00:00",
|
"ingest_completed_at": "2026-05-22T16:09:23.091744+00:00",
|
||||||
"ingest_document_name": "远光软件差旅费标准速查表.xlsx",
|
"ingest_document_name": "远光软件差旅费标准速查表.xlsx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:19.759954+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:19.759954+00:00",
|
||||||
@@ -308,8 +308,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:18.922298+00:00",
|
"updated_at": "2026-05-22T07:00:18.922298+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:11:04.764727+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.905615+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:11:04.764727+00:00",
|
"ingest_completed_at": "2026-05-22T16:11:04.764727+00:00",
|
||||||
"ingest_document_name": "远光软件公司发票审核标准.xlsx",
|
"ingest_document_name": "远光软件公司发票审核标准.xlsx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:18.922298+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:18.922298+00:00",
|
||||||
@@ -329,8 +329,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:18.560177+00:00",
|
"updated_at": "2026-05-22T07:00:18.560177+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:11:54.017817+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.919568+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:11:54.017817+00:00",
|
"ingest_completed_at": "2026-05-22T16:11:54.017817+00:00",
|
||||||
"ingest_document_name": "远光软件公司发票管理规范.docx",
|
"ingest_document_name": "远光软件公司发票管理规范.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:18.560177+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:18.560177+00:00",
|
||||||
@@ -350,8 +350,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:18.888128+00:00",
|
"updated_at": "2026-05-22T07:00:18.888128+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:12:23.821434+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.934348+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:12:23.821434+00:00",
|
"ingest_completed_at": "2026-05-22T16:12:23.821434+00:00",
|
||||||
"ingest_document_name": "远光软件公司增值税发票操作指南.pdf",
|
"ingest_document_name": "远光软件公司增值税发票操作指南.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:18.888128+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:18.888128+00:00",
|
||||||
@@ -371,8 +371,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:18.953110+00:00",
|
"updated_at": "2026-05-22T07:00:18.953110+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:13:15.450300+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.949214+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:13:15.450300+00:00",
|
"ingest_completed_at": "2026-05-22T16:13:15.450300+00:00",
|
||||||
"ingest_document_name": "远光软件公司电子发票管理办法.docx",
|
"ingest_document_name": "远光软件公司电子发票管理办法.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:18.953110+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:18.953110+00:00",
|
||||||
@@ -392,8 +392,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:21.585718+00:00",
|
"updated_at": "2026-05-22T07:00:21.585718+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:13:44.636629+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.963406+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:13:44.636629+00:00",
|
"ingest_completed_at": "2026-05-22T16:13:44.636629+00:00",
|
||||||
"ingest_document_name": "远光软件企业所得税汇算清缴操作手册.pdf",
|
"ingest_document_name": "远光软件企业所得税汇算清缴操作手册.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:21.585718+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:21.585718+00:00",
|
||||||
@@ -413,8 +413,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:20.881351+00:00",
|
"updated_at": "2026-05-22T07:00:20.881351+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:14:50.092490+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.976986+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:14:50.092490+00:00",
|
"ingest_completed_at": "2026-05-22T16:14:50.092490+00:00",
|
||||||
"ingest_document_name": "远光软件公司税务管理制度.docx",
|
"ingest_document_name": "远光软件公司税务管理制度.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:20.881351+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:20.881351+00:00",
|
||||||
@@ -434,8 +434,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:21.606227+00:00",
|
"updated_at": "2026-05-22T07:00:21.606227+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:15:56.676286+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:00.995972+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:15:56.676286+00:00",
|
"ingest_completed_at": "2026-05-22T16:15:56.676286+00:00",
|
||||||
"ingest_document_name": "远光软件研发费用加计扣除管理办法.xlsx",
|
"ingest_document_name": "远光软件研发费用加计扣除管理办法.xlsx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:21.606227+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:21.606227+00:00",
|
||||||
@@ -455,8 +455,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:21.202633+00:00",
|
"updated_at": "2026-05-22T07:00:21.202633+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:16:06.540773+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:01.010947+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:16:06.540773+00:00",
|
"ingest_completed_at": "2026-05-22T16:16:06.540773+00:00",
|
||||||
"ingest_document_name": "远光软件软件产品增值税即征即退操作指南.pdf",
|
"ingest_document_name": "远光软件软件产品增值税即征即退操作指南.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:21.202633+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:21.202633+00:00",
|
||||||
@@ -476,8 +476,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:22.379307+00:00",
|
"updated_at": "2026-05-22T07:00:22.379307+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:23:24.252614+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:01.025910+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:23:24.252614+00:00",
|
"ingest_completed_at": "2026-05-22T16:23:24.252614+00:00",
|
||||||
"ingest_document_name": "远光软件公司预算管理制度.docx",
|
"ingest_document_name": "远光软件公司预算管理制度.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:22.379307+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.379307+00:00",
|
||||||
@@ -497,8 +497,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:22.760169+00:00",
|
"updated_at": "2026-05-22T07:00:22.760169+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:23:29.997956+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:01.044022+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:23:29.997956+00:00",
|
"ingest_completed_at": "2026-05-22T16:23:29.997956+00:00",
|
||||||
"ingest_document_name": "远光软件年度预算编制指南.pdf",
|
"ingest_document_name": "远光软件年度预算编制指南.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:22.760169+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.760169+00:00",
|
||||||
@@ -518,8 +518,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:22.848272+00:00",
|
"updated_at": "2026-05-22T07:00:22.848272+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:24:37.382612+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:17.402454+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:24:37.382612+00:00",
|
"ingest_completed_at": "2026-05-22T16:24:37.382612+00:00",
|
||||||
"ingest_document_name": "远光软件预算执行分析报告模板.docx",
|
"ingest_document_name": "远光软件预算执行分析报告模板.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:22.848272+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.848272+00:00",
|
||||||
@@ -539,8 +539,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:22.803708+00:00",
|
"updated_at": "2026-05-22T07:00:22.803708+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:24:45.161319+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:17.417444+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:24:45.161319+00:00",
|
"ingest_completed_at": "2026-05-22T16:24:45.161319+00:00",
|
||||||
"ingest_document_name": "远光软件预算编制模板.xlsx",
|
"ingest_document_name": "远光软件预算编制模板.xlsx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:22.803708+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.803708+00:00",
|
||||||
@@ -560,8 +560,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:21.971983+00:00",
|
"updated_at": "2026-05-22T07:00:21.971983+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:25:33.968414+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:17.433923+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:25:33.968414+00:00",
|
"ingest_completed_at": "2026-05-22T16:25:33.968414+00:00",
|
||||||
"ingest_document_name": "远光软件财务共享服务SLA标准.xlsx",
|
"ingest_document_name": "远光软件财务共享服务SLA标准.xlsx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:21.971983+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:21.971983+00:00",
|
||||||
@@ -581,8 +581,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:21.634300+00:00",
|
"updated_at": "2026-05-22T07:00:21.634300+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:26:05.301987+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:17.450037+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:26:05.301987+00:00",
|
"ingest_completed_at": "2026-05-22T16:26:05.301987+00:00",
|
||||||
"ingest_document_name": "远光软件财务共享服务中心运营管理办法.docx",
|
"ingest_document_name": "远光软件财务共享服务中心运营管理办法.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:21.634300+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:21.634300+00:00",
|
||||||
@@ -602,8 +602,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:21.945868+00:00",
|
"updated_at": "2026-05-22T07:00:21.945868+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:26:54.048075+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:17.471635+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:26:54.048075+00:00",
|
"ingest_completed_at": "2026-05-22T16:26:54.048075+00:00",
|
||||||
"ingest_document_name": "远光软件财务共享服务操作手册.pdf",
|
"ingest_document_name": "远光软件财务共享服务操作手册.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:21.945868+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:21.945868+00:00",
|
||||||
@@ -623,8 +623,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:19.662743+00:00",
|
"updated_at": "2026-05-22T07:00:19.662743+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:27:31.775974+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:17.489793+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:27:31.775974+00:00",
|
"ingest_completed_at": "2026-05-22T16:27:31.775974+00:00",
|
||||||
"ingest_document_name": "远光软件报销流程培训手册.pdf",
|
"ingest_document_name": "远光软件报销流程培训手册.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:19.662743+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:19.662743+00:00",
|
||||||
@@ -644,8 +644,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:19.323921+00:00",
|
"updated_at": "2026-05-22T07:00:19.323921+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:27:44.244066+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:17.505506+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:27:44.244066+00:00",
|
"ingest_completed_at": "2026-05-22T16:27:44.244066+00:00",
|
||||||
"ingest_document_name": "远光软件新员工财务培训课件.pdf",
|
"ingest_document_name": "远光软件新员工财务培训课件.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:19.323921+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:19.323921+00:00",
|
||||||
@@ -665,8 +665,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:18.988700+00:00",
|
"updated_at": "2026-05-22T07:00:18.988700+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:28:24.573683+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:17.520887+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:28:24.573683+00:00",
|
"ingest_completed_at": "2026-05-22T16:28:24.573683+00:00",
|
||||||
"ingest_document_name": "远光软件财务制度培训手册.docx",
|
"ingest_document_name": "远光软件财务制度培训手册.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:18.988700+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:18.988700+00:00",
|
||||||
@@ -686,8 +686,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:19.686485+00:00",
|
"updated_at": "2026-05-22T07:00:19.686485+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:29:03.349502+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:17.542919+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:29:03.349502+00:00",
|
"ingest_completed_at": "2026-05-22T16:29:03.349502+00:00",
|
||||||
"ingest_document_name": "远光软件财务培训课程安排.xlsx",
|
"ingest_document_name": "远光软件财务培训课程安排.xlsx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:19.686485+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:19.686485+00:00",
|
||||||
@@ -707,8 +707,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:20.476077+00:00",
|
"updated_at": "2026-05-22T07:00:20.476077+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:29:29.050791+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:17.558881+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:29:29.050791+00:00",
|
"ingest_completed_at": "2026-05-22T16:29:29.050791+00:00",
|
||||||
"ingest_document_name": "远光软件报销问题处理指引.xlsx",
|
"ingest_document_name": "远光软件报销问题处理指引.xlsx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:20.476077+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:20.476077+00:00",
|
||||||
@@ -728,8 +728,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:20.453567+00:00",
|
"updated_at": "2026-05-22T07:00:20.453567+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:35:03.548506+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:17.575410+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:35:03.548506+00:00",
|
"ingest_completed_at": "2026-05-22T16:35:03.548506+00:00",
|
||||||
"ingest_document_name": "远光软件财务制度问答汇总.pdf",
|
"ingest_document_name": "远光软件财务制度问答汇总.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:20.453567+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:20.453567+00:00",
|
||||||
@@ -749,8 +749,8 @@
|
|||||||
"updated_at": "2026-05-22T07:00:20.158497+00:00",
|
"updated_at": "2026-05-22T07:00:20.158497+00:00",
|
||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 3,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T16:35:27.056080+00:00",
|
"ingest_status_updated_at": "2026-05-26T02:39:17.593165+00:00",
|
||||||
"ingest_completed_at": "2026-05-22T16:35:27.056080+00:00",
|
"ingest_completed_at": "2026-05-22T16:35:27.056080+00:00",
|
||||||
"ingest_document_name": "远光软件财务报销常见问题解答.docx",
|
"ingest_document_name": "远光软件财务报销常见问题解答.docx",
|
||||||
"ingest_document_updated_at": "2026-05-22T07:00:20.158497+00:00",
|
"ingest_document_updated_at": "2026-05-22T07:00:20.158497+00:00",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from sqlalchemy.pool import StaticPool
|
|||||||
|
|
||||||
from app.api.deps import get_db
|
from app.api.deps import get_db
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.main import create_app
|
|
||||||
from app.schemas.ontology import OntologyParseRequest
|
from app.schemas.ontology import OntologyParseRequest
|
||||||
from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService
|
from app.services.ontology import LlmOntologyParseResult, SemanticOntologyService
|
||||||
|
|
||||||
@@ -27,6 +26,8 @@ def build_session_factory() -> sessionmaker[Session]:
|
|||||||
|
|
||||||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
def override_db() -> Generator[Session, None, None]:
|
def override_db() -> Generator[Session, None, None]:
|
||||||
@@ -260,6 +261,106 @@ def test_semantic_ontology_service_extracts_entities_time_and_constraints() -> N
|
|||||||
assert result.time_range.end_date == "2026-04-30"
|
assert result.time_range.end_date == "2026-04-30"
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_ontology_service_extracts_budget_query_fields() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="查询 CC-4100 2026年度差旅费可用预算和预算占用",
|
||||||
|
user_id="pytest",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_map = {item.type: item.normalized_value for item in result.entities}
|
||||||
|
metric_names = {item.name for item in result.metrics}
|
||||||
|
|
||||||
|
assert result.scenario == "budget"
|
||||||
|
assert result.intent == "query"
|
||||||
|
assert entity_map["cost_center"] == "CC-4100"
|
||||||
|
assert entity_map["budget_period"] == "2026年度"
|
||||||
|
assert entity_map["budget_subject"] == "travel"
|
||||||
|
assert entity_map["expense_type"] == "travel"
|
||||||
|
assert {"available_amount", "reserved_amount"}.issubset(metric_names)
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_ontology_service_extracts_budget_edit_fields() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="编辑预算:2026年度 CC-4100 差旅费预算金额60万元,预警线80%,控制动作提醒",
|
||||||
|
user_id="pytest",
|
||||||
|
context_json={
|
||||||
|
"document_type": "budget_plan",
|
||||||
|
"entry_source": "budget_center",
|
||||||
|
"conversation_scenario": "budget",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_map = {item.type: item.normalized_value for item in result.entities}
|
||||||
|
|
||||||
|
assert result.scenario == "budget"
|
||||||
|
assert result.intent == "draft"
|
||||||
|
assert result.permission.level == "draft_write"
|
||||||
|
assert entity_map["budget_period"] == "2026年度"
|
||||||
|
assert entity_map["budget_subject"] == "travel"
|
||||||
|
assert entity_map["expense_type"] == "travel"
|
||||||
|
assert entity_map["budget_amount"] == "600000"
|
||||||
|
assert entity_map["warning_threshold"] == "80%"
|
||||||
|
assert entity_map["control_action"] == "remind"
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_ontology_service_extracts_quarter_budget_period() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query="查询 CC-4100 2026年Q3 住宿费预算金额",
|
||||||
|
user_id="pytest",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_map = {item.type: item.normalized_value for item in result.entities}
|
||||||
|
|
||||||
|
assert result.scenario == "budget"
|
||||||
|
assert entity_map["budget_period"] == "2026年Q3"
|
||||||
|
assert entity_map["budget_subject"] == "hotel"
|
||||||
|
assert entity_map["expense_type"] == "hotel"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query,expected_code,expected_label",
|
||||||
|
[
|
||||||
|
("查询2026年度市场推广费预算余额", "marketing", "市场推广费"),
|
||||||
|
("查看2026年度软件服务费已占用金额", "software", "软件服务费"),
|
||||||
|
("统计2026年度业务招待费预算金额", "meal", "业务招待费"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_semantic_ontology_service_links_budget_subject_to_expense_type(
|
||||||
|
query: str,
|
||||||
|
expected_code: str,
|
||||||
|
expected_label: str,
|
||||||
|
) -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(query=query, user_id="pytest")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario == "budget"
|
||||||
|
assert any(
|
||||||
|
item.type == "budget_subject" and item.normalized_value == expected_code
|
||||||
|
for item in result.entities
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
item.type == "expense_type"
|
||||||
|
and item.normalized_value == expected_code
|
||||||
|
and item.value == expected_label
|
||||||
|
for item in result.entities
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_semantic_ontology_service_extracts_new_document_numbers() -> None:
|
def test_semantic_ontology_service_extracts_new_document_numbers() -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
@@ -32,6 +34,7 @@ from app.services.risk_rule_flow_diagram import (
|
|||||||
from app.services.risk_rule_generation import RiskRuleGenerationService
|
from app.services.risk_rule_generation import RiskRuleGenerationService
|
||||||
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
|
from app.services.risk_rule_generation_jobs import RiskRuleGenerationJobService
|
||||||
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
from app.services.risk_rule_manifest_normalizer import normalize_risk_rule_manifest
|
||||||
|
from app.services.risk_rule_scoring import calculate_risk_rule_score, risk_level_from_score
|
||||||
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
from app.services.risk_rule_template_executor import RiskRuleTemplateExecutor
|
||||||
|
|
||||||
|
|
||||||
@@ -113,6 +116,8 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
|||||||
assert asset.config_json["evaluator"] == "template_rule"
|
assert asset.config_json["evaluator"] == "template_rule"
|
||||||
assert asset.config_json["expense_category"] == "travel"
|
assert asset.config_json["expense_category"] == "travel"
|
||||||
assert asset.config_json["risk_category"] == "差旅费"
|
assert asset.config_json["risk_category"] == "差旅费"
|
||||||
|
assert asset.config_json["business_stage"] == "reimbursement"
|
||||||
|
assert asset.config_json["business_stage_label"] == "费用报销"
|
||||||
assert asset.scenario_json == ["差旅费"]
|
assert asset.scenario_json == ["差旅费"]
|
||||||
assert asset.current_version == "v0.1.0"
|
assert asset.current_version == "v0.1.0"
|
||||||
|
|
||||||
@@ -122,9 +127,15 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
|||||||
assert payload["rule_code"] == asset.code
|
assert payload["rule_code"] == asset.code
|
||||||
assert payload["name"] == "差旅住宿城市一致性校验"
|
assert payload["name"] == "差旅住宿城市一致性校验"
|
||||||
assert payload["applies_to"]["expense_categories"] == ["travel"]
|
assert payload["applies_to"]["expense_categories"] == ["travel"]
|
||||||
|
assert payload["applies_to"]["business_stages"] == ["reimbursement"]
|
||||||
assert payload["risk_category"] == "差旅费"
|
assert payload["risk_category"] == "差旅费"
|
||||||
assert payload["metadata"]["expense_category"] == "travel"
|
assert payload["metadata"]["expense_category"] == "travel"
|
||||||
|
assert payload["metadata"]["business_stage"] == "reimbursement"
|
||||||
|
assert payload["metadata"]["business_stage_label"] == "费用报销"
|
||||||
assert payload["metadata"]["rule_title"] == "差旅住宿城市一致性校验"
|
assert payload["metadata"]["rule_title"] == "差旅住宿城市一致性校验"
|
||||||
|
assert isinstance(payload["metadata"]["risk_score"], int)
|
||||||
|
assert payload["metadata"]["risk_level"] == payload["outcomes"]["fail"]["severity"]
|
||||||
|
assert asset.config_json["risk_score"] == payload["metadata"]["risk_score"]
|
||||||
assert payload["outcomes"]["fail"]["severity"] == "high"
|
assert payload["outcomes"]["fail"]["severity"] == "high"
|
||||||
assert payload["template_key"] == "field_compare_v1"
|
assert payload["template_key"] == "field_compare_v1"
|
||||||
assert payload["metadata"]["natural_language"].startswith("住宿城市")
|
assert payload["metadata"]["natural_language"].startswith("住宿城市")
|
||||||
@@ -147,7 +158,141 @@ def test_generate_risk_rule_asset_creates_draft_json_rule(tmp_path) -> None:
|
|||||||
assert "feDropShadow" not in payload["flow_diagram_svg"]
|
assert "feDropShadow" not in payload["flow_diagram_svg"]
|
||||||
|
|
||||||
|
|
||||||
def test_set_risk_rule_level_updates_manifest_config_and_flow_svg(tmp_path) -> None:
|
def test_risk_score_model_thresholds_and_critical_level() -> None:
|
||||||
|
assert risk_level_from_score(30) == "low"
|
||||||
|
assert risk_level_from_score(31) == "medium"
|
||||||
|
assert risk_level_from_score(61) == "high"
|
||||||
|
assert risk_level_from_score(81) == "critical"
|
||||||
|
|
||||||
|
result = calculate_risk_rule_score(
|
||||||
|
natural_language="同一发票号码重复报销时禁止提交并进入审计复核。",
|
||||||
|
draft={
|
||||||
|
"template_key": "composite_rule_v1",
|
||||||
|
"field_keys": ["attachment.invoice_no", "claim.amount"],
|
||||||
|
"conditions": [{"id": "duplicate_invoice", "operator": "overlap"}],
|
||||||
|
"risk_scoring_evidence": {
|
||||||
|
"impact_level": "critical",
|
||||||
|
"violation_certainty": "critical",
|
||||||
|
"evidence_strength": "high",
|
||||||
|
"exception_dependence": "medium",
|
||||||
|
"control_action": "block",
|
||||||
|
"business_sensitivity": "critical",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields=[],
|
||||||
|
expense_category="travel",
|
||||||
|
expense_category_label="差旅费",
|
||||||
|
requires_attachment=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["score"] >= 81
|
||||||
|
assert result["level"] == "critical"
|
||||||
|
assert result["level_label"] == "极高风险"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_expense_application_risk_rule_marks_business_stage(tmp_path) -> None:
|
||||||
|
with build_session() as db:
|
||||||
|
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||||
|
service = RiskRuleGenerationService(
|
||||||
|
db,
|
||||||
|
rule_library_manager=manager,
|
||||||
|
runtime_chat_service=NullRuntimeChatService(),
|
||||||
|
)
|
||||||
|
|
||||||
|
asset_id = service.generate_rule_asset(
|
||||||
|
AgentAssetRiskRuleGenerateRequest(
|
||||||
|
business_domain=AgentAssetDomain.EXPENSE,
|
||||||
|
business_stage="expense_application",
|
||||||
|
expense_category="travel",
|
||||||
|
rule_title="差旅申请预算余额校验",
|
||||||
|
natural_language="费用申请时,若差旅申请金额超过可用预算余额,则提示风险并要求补充审批说明。",
|
||||||
|
),
|
||||||
|
actor="pytest",
|
||||||
|
)
|
||||||
|
|
||||||
|
asset = db.get(AgentAsset, asset_id)
|
||||||
|
assert asset is not None
|
||||||
|
assert asset.config_json["business_stage"] == "expense_application"
|
||||||
|
assert asset.config_json["business_stage_label"] == "费用申请"
|
||||||
|
|
||||||
|
payload = manager.read_rule_library_json(
|
||||||
|
library=RISK_RULES_LIBRARY,
|
||||||
|
file_name=asset.config_json["rule_document"]["file_name"],
|
||||||
|
)
|
||||||
|
assert payload["applies_to"]["business_stages"] == ["expense_application"]
|
||||||
|
assert payload["metadata"]["business_stage_label"] == "费用申请"
|
||||||
|
assert payload["params"]["business_stage_label"] == "费用申请"
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_score_model_keeps_explicit_low_control_rules_low() -> None:
|
||||||
|
field_keys = ["attachment.invoice_no", "attachment.goods_name", "claim.reason"]
|
||||||
|
result = calculate_risk_rule_score(
|
||||||
|
natural_language=(
|
||||||
|
"差旅报销时,票据已上传但发票号码或商品服务名称缺失,"
|
||||||
|
"且报销事由、人员和部门能够说明费用归属,则标记为低风险,"
|
||||||
|
"仅提醒补齐票据要素。"
|
||||||
|
),
|
||||||
|
draft={
|
||||||
|
"template_key": "field_required_v1",
|
||||||
|
"field_keys": field_keys,
|
||||||
|
"condition_summary": "票据要素缺失但归属清晰时提醒补齐。",
|
||||||
|
},
|
||||||
|
fields=[SimpleNamespace(key=key, source=key.split(".", 1)[0]) for key in field_keys],
|
||||||
|
expense_category="travel",
|
||||||
|
expense_category_label="差旅费",
|
||||||
|
requires_attachment=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["score"] <= 30
|
||||||
|
assert result["level"] == "low"
|
||||||
|
assert result["calibration"]["rules"][0]["name"] == "explicit_low_control_cap"
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_score_model_ignores_negated_hard_risk_words_for_low_rules() -> None:
|
||||||
|
result = calculate_risk_rule_score(
|
||||||
|
natural_language=(
|
||||||
|
"差旅费报销提交时,若缺少申报目的地、明细地点或明细事由,"
|
||||||
|
"但暂未发现票据城市冲突、金额异常或重复报销迹象,则标记为低风险,"
|
||||||
|
"提示经办人补齐基础差旅信息后继续提交。"
|
||||||
|
),
|
||||||
|
draft={
|
||||||
|
"template_key": "field_required_v1",
|
||||||
|
"field_keys": ["claim.location", "item.item_location", "item.item_reason"],
|
||||||
|
"condition_summary": "基础差旅字段缺失但暂无硬风险迹象时提示补齐。",
|
||||||
|
},
|
||||||
|
fields=[],
|
||||||
|
expense_category="travel",
|
||||||
|
expense_category_label="差旅费",
|
||||||
|
requires_attachment=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["score"] <= 30
|
||||||
|
assert result["level"] == "low"
|
||||||
|
|
||||||
|
|
||||||
|
def test_risk_score_model_does_not_cap_hard_risk_signals() -> None:
|
||||||
|
result = calculate_risk_rule_score(
|
||||||
|
natural_language=(
|
||||||
|
"差旅报销时,交通票或住宿票据城市均无法与申报目的地一致,"
|
||||||
|
"且没有绕行、跨城办事或改签说明,则标记为高风险,要求补充说明或退回修改。"
|
||||||
|
),
|
||||||
|
draft={
|
||||||
|
"template_key": "composite_rule_v1",
|
||||||
|
"field_keys": ["claim.destination_city", "attachment.route_cities"],
|
||||||
|
"conditions": [{"id": "city_mismatch", "operator": "not_overlap"}],
|
||||||
|
},
|
||||||
|
fields=[],
|
||||||
|
expense_category="travel",
|
||||||
|
expense_category_label="差旅费",
|
||||||
|
requires_attachment=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["score"] >= 61
|
||||||
|
assert result["level"] == "high"
|
||||||
|
assert not result["calibration"]["rules"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_risk_rule_level_rejects_manual_override(tmp_path) -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
manager = AgentAssetRuleLibraryManager(rule_root=tmp_path / "rules")
|
||||||
generator = RiskRuleGenerationService(
|
generator = RiskRuleGenerationService(
|
||||||
@@ -169,31 +314,16 @@ def test_set_risk_rule_level_updates_manifest_config_and_flow_svg(tmp_path) -> N
|
|||||||
|
|
||||||
asset_service = AgentAssetService(db)
|
asset_service = AgentAssetService(db)
|
||||||
asset_service.rule_library_manager = manager
|
asset_service.rule_library_manager = manager
|
||||||
updated = asset_service.set_risk_rule_level(
|
with pytest.raises(ValueError, match="评分模型"):
|
||||||
|
asset_service.set_risk_rule_level(
|
||||||
asset_id,
|
asset_id,
|
||||||
risk_level="low",
|
risk_level="low",
|
||||||
actor="pytest",
|
actor="pytest",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert updated.config_json["severity"] == "low"
|
|
||||||
asset = db.get(AgentAsset, asset_id)
|
asset = db.get(AgentAsset, asset_id)
|
||||||
assert asset is not None
|
assert asset is not None
|
||||||
assert asset.config_json["risk_level_label"] == "低风险"
|
assert asset.config_json["severity"] != "low"
|
||||||
file_name = asset.config_json["rule_document"]["file_name"]
|
|
||||||
payload = manager.read_rule_library_json(
|
|
||||||
library=RISK_RULES_LIBRARY,
|
|
||||||
file_name=file_name,
|
|
||||||
)
|
|
||||||
assert payload["outcomes"]["fail"]["severity"] == "low"
|
|
||||||
assert payload["metadata"]["risk_level"] == "low"
|
|
||||||
assert payload["metadata"]["risk_level_label"] == "低风险"
|
|
||||||
assert "低风险" in payload["metadata"]["flow"]["fail"]
|
|
||||||
assert "#2563eb" in payload["flow_diagram_svg"]
|
|
||||||
assert "#dc2626" not in payload["flow_diagram_svg"]
|
|
||||||
|
|
||||||
version = asset_service.repository.get_version(asset_id, asset.working_version)
|
|
||||||
assert version is not None
|
|
||||||
assert '"severity": "low"' in version.content
|
|
||||||
|
|
||||||
|
|
||||||
def test_enqueue_risk_rule_generation_creates_visible_generating_asset(tmp_path) -> None:
|
def test_enqueue_risk_rule_generation_creates_visible_generating_asset(tmp_path) -> None:
|
||||||
@@ -774,7 +904,10 @@ def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> N
|
|||||||
enabled=False,
|
enabled=False,
|
||||||
actor="manager",
|
actor="manager",
|
||||||
)
|
)
|
||||||
|
assert disabled.status == AgentAssetStatus.DISABLED.value
|
||||||
|
assert disabled.published_version == asset.working_version
|
||||||
assert disabled.config_json["enabled"] is False
|
assert disabled.config_json["enabled"] is False
|
||||||
|
assert disabled.config_json["last_operation"]["action"] == "offline"
|
||||||
rule_document = disabled.config_json["rule_document"]
|
rule_document = disabled.config_json["rule_document"]
|
||||||
manifest = manager.read_rule_library_json(
|
manifest = manager.read_rule_library_json(
|
||||||
library=RISK_RULES_LIBRARY,
|
library=RISK_RULES_LIBRARY,
|
||||||
@@ -782,6 +915,11 @@ def test_risk_rule_requires_test_report_before_review_and_publish(tmp_path) -> N
|
|||||||
)
|
)
|
||||||
assert manifest["enabled"] is False
|
assert manifest["enabled"] is False
|
||||||
|
|
||||||
|
enabled = service.set_risk_rule_enabled(asset_id, enabled=True, actor="manager")
|
||||||
|
assert enabled.status == AgentAssetStatus.ACTIVE.value
|
||||||
|
assert enabled.config_json["enabled"] is True
|
||||||
|
assert enabled.config_json["last_operation"]["action"] == "online"
|
||||||
|
|
||||||
attachment_required_id = generator.generate_rule_asset(
|
attachment_required_id = generator.generate_rule_asset(
|
||||||
AgentAssetRiskRuleGenerateRequest(
|
AgentAssetRiskRuleGenerateRequest(
|
||||||
business_domain=AgentAssetDomain.EXPENSE,
|
business_domain=AgentAssetDomain.EXPENSE,
|
||||||
|
|||||||
BIN
web/UI/编辑预算.jpg
Normal file
BIN
web/UI/编辑预算.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
349
web/src/assets/styles/views/budget-center-dialog.css
Normal file
349
web/src/assets/styles/views/budget-center-dialog.css
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
.budget-dialog-backdrop,
|
||||||
|
.budget-dialog-backdrop * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 28px;
|
||||||
|
background: rgba(15, 23, 42, .52);
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-dialog {
|
||||||
|
width: min(1024px, calc(100vw - 48px));
|
||||||
|
max-height: calc(100vh - 56px);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 24px 72px rgba(15, 23, 42, .28);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-head {
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #edf1f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-head strong {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-dialog-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 160ms ease, color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-dialog-close:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-body {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 18px 24px 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-section + .budget-edit-section {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-section h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-form-grid label,
|
||||||
|
.budget-edit-textarea {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-form-grid label.required > span::after {
|
||||||
|
content: "*";
|
||||||
|
margin-left: 3px;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-form-grid select,
|
||||||
|
.budget-edit-textarea textarea,
|
||||||
|
.budget-edit-table input,
|
||||||
|
.budget-edit-table select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-form-grid select {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 34px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-form-grid select:focus,
|
||||||
|
.budget-edit-textarea textarea:focus,
|
||||||
|
.budget-edit-table input:focus,
|
||||||
|
.budget-edit-table select:focus {
|
||||||
|
border-color: #10b981;
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-textarea {
|
||||||
|
position: relative;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-textarea textarea {
|
||||||
|
min-height: 86px;
|
||||||
|
resize: none;
|
||||||
|
padding: 12px 14px 24px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-textarea em {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 9px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-table-wrap {
|
||||||
|
border: 1px solid #edf1f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 760px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-table th,
|
||||||
|
.budget-edit-table td {
|
||||||
|
height: 48px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-right: 1px solid #edf1f6;
|
||||||
|
border-bottom: 1px solid #edf1f6;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-table th:last-child,
|
||||||
|
.budget-edit-table td:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-table tbody tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-table th {
|
||||||
|
background: #fbfcfe;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-table th i {
|
||||||
|
color: #ef4444;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-table th:nth-child(1) { width: 180px; }
|
||||||
|
.budget-edit-table th:nth-child(2) { width: 168px; }
|
||||||
|
.budget-edit-table th:nth-child(3) { width: 120px; }
|
||||||
|
.budget-edit-table th:nth-child(4) { width: 120px; }
|
||||||
|
.budget-edit-table th:nth-child(6) { width: 68px; }
|
||||||
|
|
||||||
|
.budget-edit-table input,
|
||||||
|
.budget-edit-table select {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-table td:nth-child(5) input {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-row-delete {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-row-delete:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-add-row-btn {
|
||||||
|
height: 28px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
border: 1px solid rgba(16, 185, 129, .42);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #059669;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-total {
|
||||||
|
height: 42px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 0 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #edf1f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-total span,
|
||||||
|
.budget-edit-total strong {
|
||||||
|
color: #111827;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-foot {
|
||||||
|
padding: 18px 24px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 18px;
|
||||||
|
border-top: 1px solid #edf1f6;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-foot button {
|
||||||
|
height: 40px;
|
||||||
|
min-width: 156px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-cancel {
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-draft {
|
||||||
|
border: 1px solid #10b981;
|
||||||
|
background: #fff;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-publish {
|
||||||
|
border: 0;
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 10px 24px rgba(5, 150, 105, .20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-dialog-fade-enter-active,
|
||||||
|
.budget-dialog-fade-leave-active {
|
||||||
|
transition: opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-dialog-fade-enter-active .budget-edit-dialog,
|
||||||
|
.budget-dialog-fade-leave-active .budget-edit-dialog {
|
||||||
|
transition: transform 200ms ease, opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-dialog-fade-enter-from,
|
||||||
|
.budget-dialog-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-dialog-fade-enter-from .budget-edit-dialog,
|
||||||
|
.budget-dialog-fade-leave-to .budget-edit-dialog {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.budget-dialog-backdrop {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 36px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-foot {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-edit-foot button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,138 +5,245 @@
|
|||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-local-head {
|
|
||||||
min-height: 34px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-local-head h2 {
|
|
||||||
margin: 0;
|
|
||||||
color: #111827;
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 1.2;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-summary-grid {
|
.budget-summary-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
border: 1px solid #e5eaf1;
|
gap: 12px;
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-summary-card {
|
.budget-summary-card {
|
||||||
min-height: 118px;
|
--accent: #10b981;
|
||||||
padding: 22px 28px;
|
position: relative;
|
||||||
display: grid;
|
min-height: 112px;
|
||||||
grid-template-columns: 64px minmax(0, 1fr);
|
padding: 12px 14px 10px;
|
||||||
align-items: center;
|
display: flex;
|
||||||
gap: 18px;
|
flex-direction: column;
|
||||||
border-right: 1px solid #edf1f6;
|
border: 1px solid #dbe4ee;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||||
|
animation: dashboardItemIn 520ms var(--ease) both;
|
||||||
|
animation-delay: var(--delay, 0ms);
|
||||||
|
transition: box-shadow 200ms ease, transform 200ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-summary-card:last-child {
|
.budget-summary-card:hover {
|
||||||
border-right: 0;
|
box-shadow: 0 4px 20px rgba(0, 0, 0, .06);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-summary-card.green {
|
||||||
|
--accent: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-summary-card.blue {
|
||||||
|
--accent: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-summary-card.orange {
|
||||||
|
--accent: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-summary-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-icon {
|
.summary-icon {
|
||||||
width: 54px;
|
width: 26px;
|
||||||
height: 54px;
|
height: 26px;
|
||||||
border-radius: 50%;
|
border-radius: 7px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 30px;
|
background: color-mix(in srgb, var(--accent) 10%, white);
|
||||||
}
|
color: var(--accent);
|
||||||
|
|
||||||
.summary-icon.green {
|
|
||||||
background: #e8f7ef;
|
|
||||||
color: #07965f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-icon.blue {
|
|
||||||
background: #edf4ff;
|
|
||||||
color: #2f7fd7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-icon.orange {
|
|
||||||
background: #fff4e5;
|
|
||||||
color: #df9300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-summary-card span:not(.summary-icon) {
|
|
||||||
display: block;
|
|
||||||
color: #1f2937;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
flex: 0 0 auto;
|
||||||
|
animation: iconPop 560ms var(--ease) both;
|
||||||
|
animation-delay: calc(var(--delay, 0ms) + 100ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-summary-card strong {
|
.budget-summary-card .summary-label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 8px;
|
min-width: 0;
|
||||||
color: #111827;
|
color: #64748b;
|
||||||
font-size: 24px;
|
font-size: 11px;
|
||||||
line-height: 1;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
display: block;
|
||||||
|
min-height: 22px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: clamp(16px, 1.2vw, 20px);
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 800;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-comparison-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-summary-card em {
|
.comparison-pill b {
|
||||||
display: block;
|
color: inherit;
|
||||||
margin-top: 10px;
|
font-size: 11px;
|
||||||
color: #8a94a6;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
}
|
||||||
|
|
||||||
|
.comparison-pill em {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-pill i {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-pill.up {
|
||||||
|
background: rgba(22, 163, 74, .08);
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comparison-pill.down {
|
||||||
|
background: rgba(239, 68, 68, .08);
|
||||||
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-filter-bar {
|
.budget-filter-bar {
|
||||||
min-height: 62px;
|
border: 1px solid #e2e8f0;
|
||||||
border: 1px solid #e5eaf1;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 12px 18px;
|
padding: 14px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 22px;
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-filter-set,
|
||||||
|
.budget-action-set {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-filter-bar label {
|
.budget-filter-bar label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
color: #1f2937;
|
color: #64748b;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 750;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-filter-bar select,
|
.budget-filter-bar select {
|
||||||
.budget-table-foot select {
|
min-height: 38px;
|
||||||
height: 34px;
|
min-width: 128px;
|
||||||
min-width: 150px;
|
border: 1px solid #d7e0ea;
|
||||||
border: 1px solid #dbe2ec;
|
border-radius: 8px;
|
||||||
border-radius: 5px;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #1f2937;
|
color: #334155;
|
||||||
padding: 0 34px 0 12px;
|
padding: 0 34px 0 14px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
font-weight: 750;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease, color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-filter-bar select:hover {
|
||||||
|
border-color: rgba(16, 185, 129, .32);
|
||||||
|
color: #0f9f78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-filter-bar select:focus {
|
||||||
|
border-color: #10b981;
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, .14);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-primary-btn {
|
.budget-primary-btn {
|
||||||
margin-left: auto;
|
min-height: 40px;
|
||||||
height: 36px;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 5px;
|
border-radius: 10px;
|
||||||
background: #0aa66f;
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 10px 24px rgba(5, 150, 105, .2);
|
||||||
|
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-primary-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 14px 28px rgba(5, 150, 105, .24);
|
||||||
|
filter: saturate(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-ghost-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
padding: 0 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 750;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-ghost-btn:hover {
|
||||||
|
border-color: rgba(16, 185, 129, .32);
|
||||||
|
color: #0f9f78;
|
||||||
|
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-work-grid {
|
.budget-work-grid {
|
||||||
@@ -240,7 +347,7 @@
|
|||||||
border-right: 1px solid #edf1f6;
|
border-right: 1px solid #edf1f6;
|
||||||
color: #273142;
|
color: #273142;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +366,7 @@
|
|||||||
width: 96px;
|
width: 96px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-rate span {
|
.budget-rate span {
|
||||||
@@ -304,6 +412,7 @@
|
|||||||
.budget-row-actions {
|
.budget-row-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,24 +433,84 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-table-foot button {
|
.budget-page-summary {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-pager {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-pager button {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: 1px solid #dbe2ec;
|
border: 0;
|
||||||
border-radius: 5px;
|
border-radius: 9px;
|
||||||
background: #fff;
|
background: transparent;
|
||||||
color: #64748b;
|
color: #334155;
|
||||||
}
|
|
||||||
|
|
||||||
.budget-table-foot button.active {
|
|
||||||
border-color: #10a873;
|
|
||||||
color: #10a873;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.budget-table-foot span {
|
|
||||||
color: #4b5563;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-pager button:hover:not(.active):not(:disabled) {
|
||||||
|
background: #fff;
|
||||||
|
color: #059669;
|
||||||
|
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-pager button.active {
|
||||||
|
background: #059669;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 16px rgba(5, 150, 105, .20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-pager button:disabled {
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-page-size {
|
||||||
|
min-height: 38px;
|
||||||
|
min-width: 112px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 750;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 160ms ease, color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-page-size:hover {
|
||||||
|
border-color: rgba(16, 185, 129, .32);
|
||||||
|
color: #0f9f78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-page-size select {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.budget-bottom-grid {
|
.budget-bottom-grid {
|
||||||
@@ -448,6 +617,32 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes dashboardItemIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes iconPop {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(.82);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
.budget-summary-grid,
|
.budget-summary-grid,
|
||||||
.budget-bottom-grid {
|
.budget-bottom-grid {
|
||||||
@@ -480,7 +675,24 @@
|
|||||||
|
|
||||||
.budget-filter-bar label,
|
.budget-filter-bar label,
|
||||||
.budget-filter-bar select,
|
.budget-filter-bar select,
|
||||||
.budget-primary-btn {
|
.budget-filter-set,
|
||||||
|
.budget-action-set,
|
||||||
|
.budget-primary-btn,
|
||||||
|
.budget-ghost-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-filter-bar label {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-table-foot {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-pager,
|
||||||
|
.budget-page-size {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
|
|
||||||
import { icons } from '../data/icons.js'
|
import { icons } from '../data/icons.js'
|
||||||
|
|
||||||
export const appViews = ['overview', 'workbench', 'documents', 'budget', 'policies', 'audit', 'employees', 'logs', 'settings']
|
export const appViews = ['overview', 'workbench', 'documents', 'budget', 'audit', 'employees', 'policies', 'logs', 'settings']
|
||||||
|
|
||||||
export const navItems = [
|
export const navItems = [
|
||||||
{
|
{
|
||||||
@@ -38,21 +38,13 @@ export const navItems = [
|
|||||||
title: '预算中心',
|
title: '预算中心',
|
||||||
desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。'
|
desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'policies',
|
|
||||||
label: '制度知识',
|
|
||||||
navHint: '查看制度与知识库',
|
|
||||||
icon: icons.file,
|
|
||||||
title: '制度与知识库',
|
|
||||||
desc: '统一管理制度文档、检索入口与知识资产。'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'audit',
|
id: 'audit',
|
||||||
label: '任务规则中心',
|
label: '任务规则中心',
|
||||||
navHint: '查看和管理任务规则配置',
|
navHint: '查看和管理规则配置',
|
||||||
icon: icons.skill,
|
icon: icons.skill,
|
||||||
title: '任务规则中心',
|
title: '任务规则中心',
|
||||||
desc: '集中管理规则文件、外部 MCP 服务与定时任务调度。'
|
desc: '集中管理财务规则、风险规则、技能与外部 MCP 服务。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'employees',
|
id: 'employees',
|
||||||
@@ -62,6 +54,14 @@ export const navItems = [
|
|||||||
title: '员工与组织管理',
|
title: '员工与组织管理',
|
||||||
desc: '维护员工账号、组织结构与角色权限。'
|
desc: '维护员工账号、组织结构与角色权限。'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'policies',
|
||||||
|
label: '制度知识',
|
||||||
|
navHint: '查看制度与知识库',
|
||||||
|
icon: icons.file,
|
||||||
|
title: '制度与知识库',
|
||||||
|
desc: '统一管理制度文档、检索入口与知识资产。'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'logs',
|
id: 'logs',
|
||||||
label: '日志管理',
|
label: '日志管理',
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ const EXPENSE_TYPE_LABELS = {
|
|||||||
ride_ticket: '乘车',
|
ride_ticket: '乘车',
|
||||||
travel_allowance: '出差补贴',
|
travel_allowance: '出差补贴',
|
||||||
entertainment: '业务招待费',
|
entertainment: '业务招待费',
|
||||||
|
marketing: '市场推广费',
|
||||||
office: '办公用品费',
|
office: '办公用品费',
|
||||||
meeting: '会务费',
|
meeting: '会务费',
|
||||||
training: '培训费',
|
training: '培训费',
|
||||||
|
software: '软件服务费',
|
||||||
hotel: '住宿费',
|
hotel: '住宿费',
|
||||||
transport: '交通费',
|
transport: '交通费',
|
||||||
meal: '业务招待费',
|
meal: '业务招待费',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const icons = {
|
|||||||
workspace: iconPath('<path d="M4 20h16"/><path d="M6 20V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12"/><path d="M9 10h6"/><path d="M9 14h6"/><path d="M12 3v3"/>'),
|
workspace: iconPath('<path d="M4 20h16"/><path d="M6 20V8a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v12"/><path d="M9 10h6"/><path d="M9 14h6"/><path d="M12 3v3"/>'),
|
||||||
list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'),
|
list: iconPath('<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>'),
|
||||||
approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'),
|
approval: iconPath('<path d="M9 11l2 2 4-5"/><path d="M20 12v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h8"/><path d="M17 3h4v4"/>'),
|
||||||
budget: iconPath('<path d="M4 19V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14"/><path d="M4 19h16"/><path d="M8 15v-4"/><path d="M12 15V8"/><path d="M16 15v-6"/>'),
|
budget: iconPath('<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>'),
|
||||||
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
|
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
|
||||||
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
|
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
|
||||||
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
|
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export function isManagerUser(user) {
|
|||||||
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPlatformAdminUser(user) {
|
||||||
|
return Boolean(user?.isAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
export function isFinanceUser(user) {
|
export function isFinanceUser(user) {
|
||||||
return normalizedRoleCodes(user).includes('finance')
|
return normalizedRoleCodes(user).includes('finance')
|
||||||
}
|
}
|
||||||
|
|||||||
199
web/src/utils/budgetOntology.js
Normal file
199
web/src/utils/budgetOntology.js
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
export const BUDGET_ONTOLOGY_FIELDS = [
|
||||||
|
{
|
||||||
|
key: 'budget_period',
|
||||||
|
label: '预算周期',
|
||||||
|
scope: 'budget_header',
|
||||||
|
required: true,
|
||||||
|
aliases: ['预算周期', '预算期间', '年度', '季度', '月份']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'department',
|
||||||
|
label: '所属部门',
|
||||||
|
scope: 'budget_header',
|
||||||
|
required: true,
|
||||||
|
aliases: ['所属部门', '预算部门', '部门']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cost_center',
|
||||||
|
label: '成本中心',
|
||||||
|
scope: 'budget_header',
|
||||||
|
required: true,
|
||||||
|
aliases: ['成本中心', '成本中心编码']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'budget_owner',
|
||||||
|
label: '预算负责人',
|
||||||
|
scope: 'budget_header',
|
||||||
|
required: true,
|
||||||
|
aliases: ['预算负责人', '负责人', '编制人']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'budget_version',
|
||||||
|
label: '预算版本',
|
||||||
|
scope: 'budget_header',
|
||||||
|
required: true,
|
||||||
|
aliases: ['预算版本', '版本']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'budget_status',
|
||||||
|
label: '预算状态',
|
||||||
|
scope: 'budget_header',
|
||||||
|
required: true,
|
||||||
|
aliases: ['预算状态', '状态']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'budget_description',
|
||||||
|
label: '预算说明',
|
||||||
|
scope: 'budget_header',
|
||||||
|
required: false,
|
||||||
|
aliases: ['预算说明', '编制说明', '说明']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'budget_subject',
|
||||||
|
label: '预算科目',
|
||||||
|
scope: 'budget_detail',
|
||||||
|
required: true,
|
||||||
|
aliases: ['预算科目', '费用类型', '费用科目']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'budget_amount',
|
||||||
|
label: '预算金额',
|
||||||
|
scope: 'budget_detail',
|
||||||
|
required: true,
|
||||||
|
aliases: ['预算金额', '预算额度', '预算总额']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reserved_amount',
|
||||||
|
label: '已占用',
|
||||||
|
scope: 'budget_execution',
|
||||||
|
required: false,
|
||||||
|
aliases: ['已占用', '已预占', '占用金额']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'consumed_amount',
|
||||||
|
label: '已发生',
|
||||||
|
scope: 'budget_execution',
|
||||||
|
required: false,
|
||||||
|
aliases: ['已发生', '已核销', '已消耗', '已使用']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'available_amount',
|
||||||
|
label: '剩余可用',
|
||||||
|
scope: 'budget_execution',
|
||||||
|
required: false,
|
||||||
|
aliases: ['剩余可用', '可用余额', '剩余预算', '可用预算']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'warning_threshold',
|
||||||
|
label: '预警线',
|
||||||
|
scope: 'budget_control',
|
||||||
|
required: true,
|
||||||
|
aliases: ['预警线', '预警阈值', '预算预警']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'control_action',
|
||||||
|
label: '控制动作',
|
||||||
|
scope: 'budget_control',
|
||||||
|
required: true,
|
||||||
|
aliases: ['控制动作', '管控动作', '超预算控制']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'budget_remark',
|
||||||
|
label: '备注',
|
||||||
|
scope: 'budget_detail',
|
||||||
|
required: false,
|
||||||
|
aliases: ['备注', '说明']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const BUDGET_FIELD_KEYS = Object.freeze(
|
||||||
|
BUDGET_ONTOLOGY_FIELDS.reduce((result, field) => {
|
||||||
|
result[field.key] = field.key
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const BUDGET_STATUS_OPTIONS = ['编制中', '已发布', '已冻结']
|
||||||
|
export const BUDGET_WARNING_OPTIONS = ['60%', '70%', '80%', '90%']
|
||||||
|
export const BUDGET_CONTROL_ACTION_OPTIONS = ['正常', '提醒', '管控']
|
||||||
|
export const BUDGET_YEAR_OPTIONS = ['2026', '2027', '2028']
|
||||||
|
export const BUDGET_QUARTER_OPTIONS = ['Q1', 'Q2', 'Q3', 'Q4']
|
||||||
|
export const BUDGET_EXPENSE_TYPE_OPTIONS = Object.freeze([
|
||||||
|
{ value: 'travel', label: '差旅费' },
|
||||||
|
{ value: 'hotel', label: '住宿费' },
|
||||||
|
{ value: 'transport', label: '交通费' },
|
||||||
|
{ value: 'meal', label: '业务招待费' },
|
||||||
|
{ value: 'meeting', label: '会务费' },
|
||||||
|
{ value: 'marketing', label: '市场推广费' },
|
||||||
|
{ value: 'office', label: '办公用品费' },
|
||||||
|
{ value: 'training', label: '培训费' },
|
||||||
|
{ value: 'software', label: '软件服务费' },
|
||||||
|
{ value: 'communication', label: '通讯费' },
|
||||||
|
{ value: 'welfare', label: '福利费' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const BUDGET_EXPENSE_TYPE_BY_CODE = Object.freeze(
|
||||||
|
BUDGET_EXPENSE_TYPE_OPTIONS.reduce((result, item) => {
|
||||||
|
result[item.value] = item
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
)
|
||||||
|
|
||||||
|
export function resolveBudgetExpenseTypeLabel(code, fallback = '') {
|
||||||
|
return BUDGET_EXPENSE_TYPE_BY_CODE[String(code || '').trim()]?.label || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBudgetPeriod(year, quarter) {
|
||||||
|
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
|
||||||
|
const normalizedQuarter = BUDGET_QUARTER_OPTIONS.includes(String(quarter || '').trim())
|
||||||
|
? String(quarter || '').trim()
|
||||||
|
: BUDGET_QUARTER_OPTIONS[0]
|
||||||
|
return `${normalizedYear}年${normalizedQuarter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBudgetOntologyContext({ form = {}, rows = [], departments = [] } = {}) {
|
||||||
|
const department = departments.find((item) => item.code === form.departmentCode) || {}
|
||||||
|
const budgetYear =
|
||||||
|
String(form.budgetYear || '').replace(/[^\d]/g, '') ||
|
||||||
|
String(form.budgetPeriod || '').replace(/[^\d]/g, '').slice(0, 4) ||
|
||||||
|
'2026'
|
||||||
|
const budgetQuarter = BUDGET_QUARTER_OPTIONS.includes(String(form.budgetQuarter || '').trim())
|
||||||
|
? String(form.budgetQuarter || '').trim()
|
||||||
|
: BUDGET_QUARTER_OPTIONS[0]
|
||||||
|
const budgetPeriod = form.budgetYear || form.budgetQuarter
|
||||||
|
? formatBudgetPeriod(budgetYear, budgetQuarter)
|
||||||
|
: form.budgetPeriod || formatBudgetPeriod(budgetYear, budgetQuarter)
|
||||||
|
return {
|
||||||
|
document_type: 'budget_plan',
|
||||||
|
entry_source: 'budget_center',
|
||||||
|
conversation_scenario: 'budget',
|
||||||
|
budget_fields: BUDGET_ONTOLOGY_FIELDS,
|
||||||
|
budget_header: {
|
||||||
|
budget_period: budgetPeriod,
|
||||||
|
budget_year: budgetYear,
|
||||||
|
budget_quarter: budgetQuarter,
|
||||||
|
department: department.name || '',
|
||||||
|
department_code: form.departmentCode || '',
|
||||||
|
cost_center: form.costCenter || department.costCenter || '',
|
||||||
|
budget_owner: form.budgetOwner || '',
|
||||||
|
budget_version: form.budgetVersion || '',
|
||||||
|
budget_status: form.budgetStatus || '',
|
||||||
|
budget_description: form.budgetDescription || ''
|
||||||
|
},
|
||||||
|
budget_details: rows.map((row) => {
|
||||||
|
const code = String(row.budgetSubjectCode || '').trim()
|
||||||
|
const option = BUDGET_EXPENSE_TYPE_BY_CODE[code]
|
||||||
|
const label = option?.label || row.budgetSubject || ''
|
||||||
|
return {
|
||||||
|
budget_subject: label,
|
||||||
|
budget_subject_code: option?.value || code,
|
||||||
|
expense_type: option?.value || code,
|
||||||
|
expense_type_label: label,
|
||||||
|
budget_amount: row.budgetAmount || '',
|
||||||
|
warning_threshold: row.warningThreshold || '',
|
||||||
|
control_action: row.controlAction || '',
|
||||||
|
budget_remark: row.budgetRemark || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ const EXPENSE_TYPE_LABELS = {
|
|||||||
meal: '业务招待费',
|
meal: '业务招待费',
|
||||||
entertainment: '业务招待费',
|
entertainment: '业务招待费',
|
||||||
meeting: '会务费',
|
meeting: '会务费',
|
||||||
|
marketing: '市场推广费',
|
||||||
office: '办公用品费',
|
office: '办公用品费',
|
||||||
training: '培训费',
|
training: '培训费',
|
||||||
|
software: '软件服务费',
|
||||||
communication: '通讯费',
|
communication: '通讯费',
|
||||||
welfare: '福利费',
|
welfare: '福利费',
|
||||||
other: '其他费用'
|
other: '其他费用'
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ const DEFAULT_EXPENSE_TYPE_LABELS = {
|
|||||||
meal: '业务招待费',
|
meal: '业务招待费',
|
||||||
meeting: '会务费',
|
meeting: '会务费',
|
||||||
entertainment: '业务招待费',
|
entertainment: '业务招待费',
|
||||||
|
marketing: '市场推广费',
|
||||||
office: '办公用品费',
|
office: '办公用品费',
|
||||||
training: '培训费',
|
training: '培训费',
|
||||||
|
software: '软件服务费',
|
||||||
communication: '通讯费',
|
communication: '通讯费',
|
||||||
welfare: '福利费',
|
welfare: '福利费',
|
||||||
other: '其他费用'
|
other: '其他费用'
|
||||||
|
|||||||
@@ -261,7 +261,7 @@
|
|||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>基本信息</h3>
|
<h3>基本信息</h3>
|
||||||
<p>这条规则的业务域、风险等级、创建时间、上线状态和审核历史。</p>
|
<p>这条规则的业务域、风险等级、创建时间、上线状态和最近操作。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="json-risk-meta-grid">
|
<div class="json-risk-meta-grid">
|
||||||
@@ -273,6 +273,10 @@
|
|||||||
<span class="json-risk-meta-label">适用场景</span>
|
<span class="json-risk-meta-label">适用场景</span>
|
||||||
<span class="json-risk-meta-value">{{ selectedSkill.riskCategory || selectedSkill.scope || '-' }}</span>
|
<span class="json-risk-meta-value">{{ selectedSkill.riskCategory || selectedSkill.scope || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="json-risk-meta-item">
|
||||||
|
<span class="json-risk-meta-label">业务环节</span>
|
||||||
|
<span class="json-risk-meta-value">{{ selectedSkill.businessStageLabel || '-' }}</span>
|
||||||
|
</div>
|
||||||
<div class="json-risk-meta-item">
|
<div class="json-risk-meta-item">
|
||||||
<span class="json-risk-meta-label">风险等级</span>
|
<span class="json-risk-meta-label">风险等级</span>
|
||||||
<span class="json-risk-meta-value">
|
<span class="json-risk-meta-value">
|
||||||
@@ -288,17 +292,17 @@
|
|||||||
<div class="json-risk-meta-item">
|
<div class="json-risk-meta-item">
|
||||||
<span class="json-risk-meta-label">是否上线</span>
|
<span class="json-risk-meta-label">是否上线</span>
|
||||||
<span class="json-risk-meta-value">
|
<span class="json-risk-meta-value">
|
||||||
<span class="meta-status-indicator" :class="{ 'is-active': selectedSkill.isOnlineLabel === '是' }">
|
<span class="meta-status-indicator" :class="{ 'is-active': selectedSkill.isOnlineValue }">
|
||||||
<span class="indicator-dot"></span>
|
<span class="indicator-dot"></span>
|
||||||
{{ selectedSkill.isOnlineLabel || '否' }}
|
{{ selectedSkill.isOnlineLabel || '待上线' }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="json-risk-meta-item">
|
<div class="json-risk-meta-item">
|
||||||
<span class="json-risk-meta-label">是否启用</span>
|
<span class="json-risk-meta-label">规则状态</span>
|
||||||
<span class="json-risk-meta-value">
|
<span class="json-risk-meta-value">
|
||||||
<span class="json-risk-meta-badge" :class="selectedSkill.isEnabledTone">
|
<span class="json-risk-meta-badge" :class="selectedSkill.statusTone">
|
||||||
{{ selectedSkill.isEnabledLabel || '-' }}
|
{{ selectedSkill.status || '-' }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,6 +333,10 @@
|
|||||||
<span class="json-risk-meta-label">上线时间</span>
|
<span class="json-risk-meta-label">上线时间</span>
|
||||||
<span class="json-risk-meta-value">{{ selectedSkill.publishedAt || '-' }}</span>
|
<span class="json-risk-meta-value">{{ selectedSkill.publishedAt || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="json-risk-meta-item">
|
||||||
|
<span class="json-risk-meta-label">最后操作</span>
|
||||||
|
<span class="json-risk-meta-value">{{ selectedSkill.lastOperationLabel || '-' }}</span>
|
||||||
|
</div>
|
||||||
<div class="json-risk-meta-item full-width">
|
<div class="json-risk-meta-item full-width">
|
||||||
<span class="json-risk-meta-label">使用字段</span>
|
<span class="json-risk-meta-label">使用字段</span>
|
||||||
<span class="json-risk-meta-value">{{ selectedSkill.riskRuleFieldSummary || '-' }}</span>
|
<span class="json-risk-meta-value">{{ selectedSkill.riskRuleFieldSummary || '-' }}</span>
|
||||||
@@ -623,17 +631,6 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<aside v-else class="detail-side">
|
<aside v-else class="detail-side">
|
||||||
<article class="side-card panel">
|
|
||||||
<div class="card-head">
|
|
||||||
<div>
|
|
||||||
<h3>{{ selectedSkill.triggerTitle }}</h3>
|
|
||||||
<p>{{ selectedSkill.triggerDesc }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="tag-list">
|
|
||||||
<span v-for="item in selectedSkill.triggers" :key="item">{{ item }}</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="side-card panel">
|
<article class="side-card panel">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
@@ -702,64 +699,35 @@
|
|||||||
<button
|
<button
|
||||||
v-if="canToggleRiskRuleEnabled"
|
v-if="canToggleRiskRuleEnabled"
|
||||||
class="minor-action enable-action"
|
class="minor-action enable-action"
|
||||||
:class="{ 'is-on': selectedSkill.isEnabledValue }"
|
:class="{ 'is-on': selectedSkill.isOnlineValue }"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="detailBusy"
|
:disabled="detailBusy"
|
||||||
@click="toggleSelectedRiskRuleEnabled"
|
@click="toggleSelectedRiskRuleEnabled"
|
||||||
>
|
>
|
||||||
<i :class="selectedSkill.isEnabledValue ? 'mdi mdi-toggle-switch' : 'mdi mdi-toggle-switch-off-outline'"></i>
|
<i :class="selectedSkill.isOnlineValue ? 'mdi mdi-toggle-switch' : 'mdi mdi-toggle-switch-off-outline'"></i>
|
||||||
<span>{{ selectedSkill.isEnabledValue ? '已启用' : '已停用' }}</span>
|
<span>{{ selectedSkill.isOnlineValue ? '已上线' : '已下线' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canOpenRiskRuleTest"
|
||||||
class="minor-action"
|
class="minor-action"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!canOpenRiskRuleTest"
|
:disabled="detailBusy"
|
||||||
@click="openRiskRuleTestDialog"
|
@click="openRiskRuleTestDialog"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-flask-outline"></i>
|
<i class="mdi mdi-flask-outline"></i>
|
||||||
<span>测试规则</span>
|
<span>测试规则</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="selectedSkillUsesJsonRisk && canEditSelected"
|
v-if="canDeleteRiskRule"
|
||||||
class="minor-action danger-action"
|
class="minor-action danger-action"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!canDeleteRiskRule"
|
:disabled="detailBusy"
|
||||||
@click="openDeleteRiskRuleDialog"
|
@click="openDeleteRiskRuleDialog"
|
||||||
:title="canDeleteRiskRule ? '删除未发布规则' : '已发布过的规则不能删除'"
|
title="删除未发布规则"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-delete-outline"></i>
|
<i class="mdi mdi-delete-outline"></i>
|
||||||
<span>删除规则</span>
|
<span>删除规则</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="canEditSelected && !riskRuleInReview"
|
|
||||||
class="major-action"
|
|
||||||
type="button"
|
|
||||||
:disabled="!canOpenRiskRuleReviewSubmit"
|
|
||||||
@click="openSubmitReviewDialog"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-send-outline"></i>
|
|
||||||
<span>提交审核</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canManageSelected && riskRuleInReview"
|
|
||||||
class="minor-action"
|
|
||||||
type="button"
|
|
||||||
:disabled="!canReturnRiskRule"
|
|
||||||
@click="openReturnRiskRuleDialog"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-keyboard-return"></i>
|
|
||||||
<span>回退规则</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canManageSelected && riskRuleInReview"
|
|
||||||
class="major-action"
|
|
||||||
type="button"
|
|
||||||
:disabled="!canPublishRiskRule"
|
|
||||||
@click="openPublishRiskRuleDialog"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-rocket-launch-outline"></i>
|
|
||||||
<span>发布上线</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
<button
|
<button
|
||||||
v-else-if="selectedSkill.usesSpreadsheetRule"
|
v-else-if="selectedSkill.usesSpreadsheetRule"
|
||||||
@@ -1191,7 +1159,7 @@
|
|||||||
badge="自然语言规则"
|
badge="自然语言规则"
|
||||||
badge-tone="info"
|
badge-tone="info"
|
||||||
title="新建风险规则"
|
title="新建风险规则"
|
||||||
description="默认创建报销类风险规则。选择费用领域后填写规则标题与自然语言描述,系统会根据评分模型自动计算风险分数和等级。"
|
description="默认创建费用类风险规则。选择业务环节和费用领域后填写规则标题与自然语言描述,系统会根据评分模型自动计算风险分数和等级。"
|
||||||
cancel-text="取消"
|
cancel-text="取消"
|
||||||
confirm-text="开始生成"
|
confirm-text="开始生成"
|
||||||
busy-text="生成中..."
|
busy-text="生成中..."
|
||||||
@@ -1203,6 +1171,21 @@
|
|||||||
@confirm="submitRiskRuleCreate"
|
@confirm="submitRiskRuleCreate"
|
||||||
>
|
>
|
||||||
<div class="risk-rule-create-form">
|
<div class="risk-rule-create-form">
|
||||||
|
<label>
|
||||||
|
<span>业务环节</span>
|
||||||
|
<select
|
||||||
|
v-model="riskRuleCreateForm.business_stage"
|
||||||
|
:disabled="riskRuleCreateBusy"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in riskRuleBusinessStageOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>费用领域</span>
|
<span>费用领域</span>
|
||||||
<select
|
<select
|
||||||
@@ -1218,6 +1201,16 @@
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>是否上传附件</span>
|
||||||
|
<select
|
||||||
|
v-model="riskRuleCreateForm.requires_attachment"
|
||||||
|
:disabled="riskRuleCreateBusy"
|
||||||
|
>
|
||||||
|
<option :value="true">是</option>
|
||||||
|
<option :value="false">否</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="span-2">
|
<label class="span-2">
|
||||||
<span>规则标题</span>
|
<span>规则标题</span>
|
||||||
<input
|
<input
|
||||||
@@ -1227,17 +1220,6 @@
|
|||||||
placeholder="例如:差旅目的地与票据城市一致性校验"
|
placeholder="例如:差旅目的地与票据城市一致性校验"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="risk-rule-create-toggle span-2">
|
|
||||||
<input
|
|
||||||
v-model="riskRuleCreateForm.requires_attachment"
|
|
||||||
type="checkbox"
|
|
||||||
:disabled="riskRuleCreateBusy"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>测试时需要上传附件</strong>
|
|
||||||
<small>适用于依赖发票、行程单、合同等单据 OCR 字段的规则;不勾选则测试窗口不显示附件上传。</small>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label class="span-2">
|
<label class="span-2">
|
||||||
<span>自然语言规则</span>
|
<span>自然语言规则</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1,27 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="budget-center-page">
|
<section class="budget-center-page">
|
||||||
<header class="budget-local-head">
|
|
||||||
<h2>预算管理</h2>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="budget-summary-grid" aria-label="预算概览">
|
<section class="budget-summary-grid" aria-label="预算概览">
|
||||||
<article v-for="metric in budgetMetrics" :key="metric.label" class="budget-summary-card">
|
<article
|
||||||
<span class="summary-icon" :class="metric.tone">
|
v-for="(metric, index) in budgetMetrics"
|
||||||
|
:key="metric.label"
|
||||||
|
class="budget-summary-card"
|
||||||
|
:class="metric.tone"
|
||||||
|
:style="{ '--delay': `${index * 55}ms` }"
|
||||||
|
>
|
||||||
|
<div class="budget-summary-head">
|
||||||
|
<span class="summary-icon">
|
||||||
<i :class="metric.icon"></i>
|
<i :class="metric.icon"></i>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<span class="summary-label">{{ metric.label }}</span>
|
||||||
<span>{{ metric.label }}</span>
|
</div>
|
||||||
<strong>{{ metric.value }}</strong>
|
<strong class="summary-value">{{ metric.value }}</strong>
|
||||||
<em>{{ metric.note }}</em>
|
<div class="summary-comparison-row">
|
||||||
|
<span class="comparison-pill" :class="metric.yoy.tone">
|
||||||
|
<b>同比</b>
|
||||||
|
<em>{{ metric.yoy.value }}</em>
|
||||||
|
<i :class="metric.yoy.icon"></i>
|
||||||
|
</span>
|
||||||
|
<span class="comparison-pill" :class="metric.mom.tone">
|
||||||
|
<b>环比</b>
|
||||||
|
<em>{{ metric.mom.value }}</em>
|
||||||
|
<i :class="metric.mom.icon"></i>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="budget-filter-bar">
|
<section class="budget-filter-bar">
|
||||||
|
<div class="budget-filter-set">
|
||||||
<label>
|
<label>
|
||||||
<span>预算周期</span>
|
<span>预算年度</span>
|
||||||
<select v-model="filters.period">
|
<select v-model="filters.year">
|
||||||
<option v-for="period in periods" :key="period">{{ period }}</option>
|
<option v-for="year in years" :key="year" :value="year">{{ year }}年度</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>预算季度</span>
|
||||||
|
<select v-model="filters.quarter">
|
||||||
|
<option v-for="quarter in quarters" :key="quarter" :value="quarter">{{ quarter }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -36,10 +57,17 @@
|
|||||||
<option v-for="status in statuses" :key="status">{{ status }}</option>
|
<option v-for="status in statuses" :key="status">{{ status }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button class="budget-primary-btn" type="button">
|
</div>
|
||||||
<i class="mdi mdi-plus"></i>
|
<div class="budget-action-set">
|
||||||
<span>新建预算</span>
|
<button class="budget-primary-btn" type="button" @click="openBudgetEditDialog">
|
||||||
|
<i class="mdi mdi-pencil-outline"></i>
|
||||||
|
<span>编辑预算</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="budget-ghost-btn" type="button">
|
||||||
|
<i class="mdi mdi-text-box-outline"></i>
|
||||||
|
<span>预算详情</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="budget-work-grid">
|
<section class="budget-work-grid">
|
||||||
@@ -81,7 +109,6 @@
|
|||||||
<th>使用率</th>
|
<th>使用率</th>
|
||||||
<th>预警线</th>
|
<th>预警线</th>
|
||||||
<th>控制动作</th>
|
<th>控制动作</th>
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -99,24 +126,49 @@
|
|||||||
</td>
|
</td>
|
||||||
<td :class="row.warningTone">{{ row.warningLine }}</td>
|
<td :class="row.warningTone">{{ row.warningLine }}</td>
|
||||||
<td>{{ row.action }}</td>
|
<td>{{ row.action }}</td>
|
||||||
<td>
|
|
||||||
<div class="budget-row-actions">
|
|
||||||
<button type="button">详情</button>
|
|
||||||
<button type="button">编辑</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<footer class="budget-table-foot">
|
<footer class="budget-table-foot">
|
||||||
<button type="button" disabled><i class="mdi mdi-chevron-left"></i></button>
|
<div class="budget-pager" aria-label="预算分页">
|
||||||
<button type="button" class="active">1</button>
|
<button
|
||||||
<button type="button" disabled><i class="mdi mdi-chevron-right"></i></button>
|
class="page-nav"
|
||||||
<select aria-label="每页条数">
|
type="button"
|
||||||
<option>10 条/页</option>
|
:disabled="budgetPage <= 1"
|
||||||
|
@click="changeBudgetPage(-1)"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="page in budgetPageNumbers"
|
||||||
|
:key="page"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: page === budgetPage }"
|
||||||
|
@click="goToBudgetPage(page)"
|
||||||
|
>
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="page-nav"
|
||||||
|
type="button"
|
||||||
|
:disabled="budgetPage >= totalBudgetPages"
|
||||||
|
@click="changeBudgetPage(1)"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="budget-page-size">
|
||||||
|
<select v-model.number="budgetPageSize" aria-label="每页条数">
|
||||||
|
<option v-for="size in budgetPageSizeOptions" :key="size" :value="size">
|
||||||
|
{{ size }} 条/页
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<span>共 {{ visibleBudgetRows.length }} 条</span>
|
<i class="mdi mdi-chevron-down"></i>
|
||||||
|
</label>
|
||||||
|
<span class="budget-page-summary">
|
||||||
|
共 {{ totalBudgetRows }} 条,当前第 {{ budgetPage }} / {{ totalBudgetPages }} 页
|
||||||
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -152,9 +204,149 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="budget-dialog-fade">
|
||||||
|
<div v-if="budgetEditOpen" class="budget-dialog-backdrop" @click.self="closeBudgetEditDialog">
|
||||||
|
<section class="budget-edit-dialog" role="dialog" aria-modal="true" aria-label="编辑预算">
|
||||||
|
<header class="budget-edit-head">
|
||||||
|
<strong>编辑预算</strong>
|
||||||
|
<button class="budget-dialog-close" type="button" aria-label="关闭" @click="closeBudgetEditDialog">
|
||||||
|
<i class="mdi mdi-close"></i>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="budget-edit-body">
|
||||||
|
<section class="budget-edit-section">
|
||||||
|
<h3>基本信息</h3>
|
||||||
|
<div class="budget-edit-form-grid">
|
||||||
|
<label class="required">
|
||||||
|
<span>预算年度</span>
|
||||||
|
<select v-model="budgetEditForm.budgetYear">
|
||||||
|
<option v-for="year in years" :key="year" :value="year">{{ year }}年度</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="required">
|
||||||
|
<span>预算季度</span>
|
||||||
|
<select v-model="budgetEditForm.budgetQuarter">
|
||||||
|
<option v-for="quarter in quarters" :key="quarter" :value="quarter">{{ quarter }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="required">
|
||||||
|
<span>所属部门</span>
|
||||||
|
<select v-model="budgetEditForm.departmentCode">
|
||||||
|
<option v-for="department in departments" :key="department.code" :value="department.code">
|
||||||
|
{{ department.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="required">
|
||||||
|
<span>预算负责人</span>
|
||||||
|
<select v-model="budgetEditForm.budgetOwner">
|
||||||
|
<option>张晓明</option>
|
||||||
|
<option>李娜</option>
|
||||||
|
<option>王凯</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="required">
|
||||||
|
<span>预算版本</span>
|
||||||
|
<select v-model="budgetEditForm.budgetVersion">
|
||||||
|
<option>V1.0(初始版本)</option>
|
||||||
|
<option>V1.1(调整版本)</option>
|
||||||
|
<option>V2.0(发布版本)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="required">
|
||||||
|
<span>预算状态</span>
|
||||||
|
<select v-model="budgetEditForm.budgetStatus">
|
||||||
|
<option v-for="status in statusOptions" :key="status">{{ status }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="budget-edit-textarea">
|
||||||
|
<span>预算说明</span>
|
||||||
|
<textarea v-model="budgetEditForm.budgetDescription" maxlength="300"></textarea>
|
||||||
|
<em>{{ budgetEditForm.budgetDescription.length }}/300</em>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="budget-edit-section">
|
||||||
|
<h3>预算明细</h3>
|
||||||
|
<div class="budget-edit-table-wrap">
|
||||||
|
<table class="budget-edit-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>费用类型 <i>*</i></th>
|
||||||
|
<th>预算金额(元) <i>*</i></th>
|
||||||
|
<th>预警线(%) <i>*</i></th>
|
||||||
|
<th>控制动作 <i>*</i></th>
|
||||||
|
<th>备注</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in budgetEditRows" :key="row.id">
|
||||||
|
<td>
|
||||||
|
<select v-model="row.budgetSubjectCode" @change="syncBudgetRowSubject(row)">
|
||||||
|
<option
|
||||||
|
v-for="option in expenseTypeOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input v-model="row.budgetAmount" type="text" inputmode="decimal" /></td>
|
||||||
|
<td>
|
||||||
|
<select v-model="row.warningThreshold">
|
||||||
|
<option v-for="warning in warningOptions" :key="warning">{{ warning }}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select v-model="row.controlAction">
|
||||||
|
<option v-for="action in controlActionOptions" :key="action">{{ action }}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input v-model="row.budgetRemark" type="text" /></td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="budget-row-delete"
|
||||||
|
type="button"
|
||||||
|
aria-label="删除预算明细"
|
||||||
|
@click="removeBudgetDetailRow(row.id)"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-delete-outline"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<button class="budget-add-row-btn" type="button" @click="addBudgetDetailRow">
|
||||||
|
<i class="mdi mdi-plus"></i>
|
||||||
|
<span>添加行</span>
|
||||||
|
</button>
|
||||||
|
<div class="budget-edit-total">
|
||||||
|
<span>合计</span>
|
||||||
|
<strong>{{ budgetEditTotal }}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="budget-edit-foot">
|
||||||
|
<button class="budget-edit-cancel" type="button" @click="closeBudgetEditDialog">取消</button>
|
||||||
|
<button class="budget-edit-draft" type="button" @click="saveBudgetDraft">保存草稿</button>
|
||||||
|
<button class="budget-edit-publish" type="button" @click="publishBudget">保存并发布</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="./scripts/BudgetCenterView.js"></script>
|
<script src="./scripts/BudgetCenterView.js"></script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/views/budget-center-view.css"></style>
|
<style scoped src="../assets/styles/views/budget-center-view.css"></style>
|
||||||
|
<style scoped src="../assets/styles/views/budget-center-dialog.css"></style>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
updateAgentAsset
|
updateAgentAsset
|
||||||
} from '../../services/agentAssets.js'
|
} from '../../services/agentAssets.js'
|
||||||
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
|
import { loadOnlyOfficeApi } from '../../services/onlyoffice.js'
|
||||||
import { isFinanceUser, isManagerUser } from '../../utils/accessControl.js'
|
import { isFinanceUser, isManagerUser, isPlatformAdminUser } from '../../utils/accessControl.js'
|
||||||
import { buildOnlyOfficeEditorConfig } from './onlyOfficePreviewConfig.js'
|
import { buildOnlyOfficeEditorConfig } from './onlyOfficePreviewConfig.js'
|
||||||
import {
|
import {
|
||||||
buildReviewNote,
|
buildReviewNote,
|
||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
} from './auditViewModel.js'
|
} from './auditViewModel.js'
|
||||||
import {
|
import {
|
||||||
createDefaultRiskRuleForm,
|
createDefaultRiskRuleForm,
|
||||||
|
RISK_RULE_BUSINESS_STAGE_OPTIONS,
|
||||||
RISK_RULE_EXPENSE_CATEGORY_OPTIONS
|
RISK_RULE_EXPENSE_CATEGORY_OPTIONS
|
||||||
} from './auditViewRiskRuleModel.js'
|
} from './auditViewRiskRuleModel.js'
|
||||||
|
|
||||||
@@ -144,11 +145,11 @@ export default {
|
|||||||
financialRules: [],
|
financialRules: [],
|
||||||
riskRules: [],
|
riskRules: [],
|
||||||
skills: [],
|
skills: [],
|
||||||
mcp: [],
|
mcp: []
|
||||||
tasks: []
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const isAdmin = computed(() => isManagerUser(currentUser.value))
|
const isAdmin = computed(() => isPlatformAdminUser(currentUser.value))
|
||||||
|
const isRuleManager = computed(() => isManagerUser(currentUser.value))
|
||||||
const isFinance = computed(() => isFinanceUser(currentUser.value))
|
const isFinance = computed(() => isFinanceUser(currentUser.value))
|
||||||
const activeMeta = computed(() => TAB_META[activeType.value])
|
const activeMeta = computed(() => TAB_META[activeType.value])
|
||||||
const activeTabLabel = computed(() => activeMeta.value.label)
|
const activeTabLabel = computed(() => activeMeta.value.label)
|
||||||
@@ -162,7 +163,7 @@ export default {
|
|||||||
const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false)
|
const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false)
|
||||||
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
|
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
|
||||||
const showOnlineColumn = computed(() => false)
|
const showOnlineColumn = computed(() => false)
|
||||||
const showEnabledColumn = computed(() => activeType.value === 'riskRules')
|
const showEnabledColumn = computed(() => false)
|
||||||
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
|
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
|
||||||
const selectedSkillUsesSpreadsheet = computed(
|
const selectedSkillUsesSpreadsheet = computed(
|
||||||
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule)
|
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule)
|
||||||
@@ -171,6 +172,9 @@ export default {
|
|||||||
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesJsonRiskRule)
|
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesJsonRiskRule)
|
||||||
)
|
)
|
||||||
const canManageSelected = computed(
|
const canManageSelected = computed(
|
||||||
|
() => isRuleManager.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
|
||||||
|
)
|
||||||
|
const canAdminOperateSelected = computed(
|
||||||
() => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
|
() => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
|
||||||
)
|
)
|
||||||
const canEditSelected = computed(
|
const canEditSelected = computed(
|
||||||
@@ -180,7 +184,7 @@ export default {
|
|||||||
(isAdmin.value || isFinance.value)
|
(isAdmin.value || isFinance.value)
|
||||||
)
|
)
|
||||||
const canCreateRiskRule = computed(
|
const canCreateRiskRule = computed(
|
||||||
() => activeType.value === 'riskRules' && (isAdmin.value || isFinance.value) && !detailBusy.value
|
() => activeType.value === 'riskRules' && isRuleManager.value && !detailBusy.value
|
||||||
)
|
)
|
||||||
const latestRiskRuleTestSummary = computed(() => selectedSkill.value?.latestTestSummary || null)
|
const latestRiskRuleTestSummary = computed(() => selectedSkill.value?.latestTestSummary || null)
|
||||||
const riskRuleTestPassed = computed(() => Boolean(latestRiskRuleTestSummary.value?.test_passed))
|
const riskRuleTestPassed = computed(() => Boolean(latestRiskRuleTestSummary.value?.test_passed))
|
||||||
@@ -196,27 +200,20 @@ export default {
|
|||||||
const canOpenRiskRuleTest = computed(
|
const canOpenRiskRuleTest = computed(
|
||||||
() =>
|
() =>
|
||||||
selectedSkillUsesJsonRisk.value &&
|
selectedSkillUsesJsonRisk.value &&
|
||||||
canEditSelected.value &&
|
canAdminOperateSelected.value &&
|
||||||
Boolean(selectedSkill.value?.id) &&
|
Boolean(selectedSkill.value?.id) &&
|
||||||
!riskRuleGenerationBusy.value &&
|
!riskRuleGenerationBusy.value &&
|
||||||
!riskRuleGenerationFailed.value &&
|
!riskRuleGenerationFailed.value
|
||||||
!detailBusy.value
|
|
||||||
)
|
)
|
||||||
const canDeleteRiskRule = computed(
|
const canDeleteRiskRule = computed(
|
||||||
() =>
|
() =>
|
||||||
selectedSkillUsesJsonRisk.value &&
|
selectedSkillUsesJsonRisk.value &&
|
||||||
canEditSelected.value &&
|
canAdminOperateSelected.value &&
|
||||||
Boolean(selectedSkill.value?.id) &&
|
Boolean(selectedSkill.value?.id) &&
|
||||||
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '') &&
|
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
|
||||||
!detailBusy.value
|
|
||||||
)
|
)
|
||||||
const canOpenRiskRuleReviewSubmit = computed(
|
const canOpenRiskRuleReviewSubmit = computed(
|
||||||
() =>
|
() => false
|
||||||
selectedSkillUsesJsonRisk.value &&
|
|
||||||
canSubmitReview.value &&
|
|
||||||
!riskRuleInReview.value &&
|
|
||||||
!riskRuleGenerationBusy.value &&
|
|
||||||
!riskRuleGenerationFailed.value
|
|
||||||
)
|
)
|
||||||
const canSubmitRiskRuleReview = computed(
|
const canSubmitRiskRuleReview = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -224,17 +221,14 @@ export default {
|
|||||||
riskRuleTestPassed.value
|
riskRuleTestPassed.value
|
||||||
)
|
)
|
||||||
const canReturnRiskRule = computed(
|
const canReturnRiskRule = computed(
|
||||||
() => selectedSkillUsesJsonRisk.value && canManageSelected.value && riskRuleInReview.value
|
() => false
|
||||||
)
|
)
|
||||||
const canPublishRiskRule = computed(
|
const canPublishRiskRule = computed(
|
||||||
() =>
|
() =>
|
||||||
selectedSkillUsesJsonRisk.value &&
|
false
|
||||||
canManageSelected.value &&
|
|
||||||
riskRuleInReview.value &&
|
|
||||||
riskRuleTestPassed.value
|
|
||||||
)
|
)
|
||||||
const canToggleRiskRuleEnabled = computed(
|
const canToggleRiskRuleEnabled = computed(
|
||||||
() => selectedSkillUsesJsonRisk.value && canManageSelected.value && !detailBusy.value
|
() => selectedSkillUsesJsonRisk.value && canManageSelected.value
|
||||||
)
|
)
|
||||||
const riskRuleCreateBusy = computed(() => actionState.value === 'generate-risk-rule')
|
const riskRuleCreateBusy = computed(() => actionState.value === 'generate-risk-rule')
|
||||||
const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value)
|
const canEditMarkdown = computed(() => canEditSelected.value && selectedSkillIsRule.value)
|
||||||
@@ -242,7 +236,11 @@ export default {
|
|||||||
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
|
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
|
||||||
)
|
)
|
||||||
const canSubmitReview = computed(
|
const canSubmitReview = computed(
|
||||||
() => canEditSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value
|
() =>
|
||||||
|
!selectedSkillUsesJsonRisk.value &&
|
||||||
|
canEditSelected.value &&
|
||||||
|
selectedSkillIsRule.value &&
|
||||||
|
isDisplayingWorkingVersion.value
|
||||||
)
|
)
|
||||||
const hasReviewSubmitReviewers = computed(() => reviewSubmitReviewerOptions.value.length > 0)
|
const hasReviewSubmitReviewers = computed(() => reviewSubmitReviewerOptions.value.length > 0)
|
||||||
const canReviewSelected = computed(
|
const canReviewSelected = computed(
|
||||||
@@ -370,7 +368,7 @@ export default {
|
|||||||
)
|
)
|
||||||
const showStatusFilter = computed(() => true)
|
const showStatusFilter = computed(() => true)
|
||||||
const showOnlineFilter = computed(() => false)
|
const showOnlineFilter = computed(() => false)
|
||||||
const showEnabledFilter = computed(() => activeType.value === 'riskRules')
|
const showEnabledFilter = computed(() => false)
|
||||||
const selectedRiskScenarioLabel = computed(
|
const selectedRiskScenarioLabel = computed(
|
||||||
() =>
|
() =>
|
||||||
RISK_SCENARIO_OPTIONS.find((item) => item.value === selectedRiskScenario.value)?.label ||
|
RISK_SCENARIO_OPTIONS.find((item) => item.value === selectedRiskScenario.value)?.label ||
|
||||||
@@ -646,6 +644,7 @@ export default {
|
|||||||
const detail = await generateRiskRuleAsset(
|
const detail = await generateRiskRuleAsset(
|
||||||
{
|
{
|
||||||
business_domain: 'expense',
|
business_domain: 'expense',
|
||||||
|
business_stage: riskRuleCreateForm.value.business_stage,
|
||||||
expense_category: riskRuleCreateForm.value.expense_category,
|
expense_category: riskRuleCreateForm.value.expense_category,
|
||||||
rule_title: ruleTitle,
|
rule_title: ruleTitle,
|
||||||
requires_attachment: Boolean(riskRuleCreateForm.value.requires_attachment),
|
requires_attachment: Boolean(riskRuleCreateForm.value.requires_attachment),
|
||||||
@@ -1007,8 +1006,13 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadAssets(options = {}) {
|
async function loadAssets(options = {}) {
|
||||||
|
const shouldShowLoading = !options.silent && !options.background
|
||||||
|
if (shouldShowLoading) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
}
|
||||||
|
if (!options.silent) {
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await fetchAgentAssets({ assetType: activeMeta.value.assetType })
|
const payload = await fetchAgentAssets({ assetType: activeMeta.value.assetType })
|
||||||
@@ -1037,6 +1041,9 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (options.silent || options.background) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (activeMeta.value.assetType === 'rule') {
|
if (activeMeta.value.assetType === 'rule') {
|
||||||
assetBuckets.value = {
|
assetBuckets.value = {
|
||||||
...assetBuckets.value,
|
...assetBuckets.value,
|
||||||
@@ -1056,12 +1063,14 @@ export default {
|
|||||||
toast(errorMessage.value)
|
toast(errorMessage.value)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (shouldShowLoading) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshCurrentAssets() {
|
async function refreshCurrentAssets() {
|
||||||
await loadAssets({ force: true, silent: true })
|
await loadAssets({ force: true, silent: true, background: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSelectedAssetDetail(assetId) {
|
async function loadSelectedAssetDetail(assetId) {
|
||||||
@@ -1110,6 +1119,39 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeSelectedRuleLifecycle(detail) {
|
||||||
|
if (!selectedSkill.value || !detail) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = buildDetailViewModel(detail, runs.value)
|
||||||
|
selectedSkill.value = {
|
||||||
|
...selectedSkill.value,
|
||||||
|
status: next.status,
|
||||||
|
statusValue: next.statusValue,
|
||||||
|
statusTone: next.statusTone,
|
||||||
|
publishedVersion: next.publishedVersion,
|
||||||
|
workingVersion: next.workingVersion,
|
||||||
|
currentVersion: next.currentVersion,
|
||||||
|
displayVersion: next.displayVersion,
|
||||||
|
reviewer: next.reviewer,
|
||||||
|
publisher: next.publisher,
|
||||||
|
publishedAt: next.publishedAt,
|
||||||
|
isOnlineValue: next.isOnlineValue,
|
||||||
|
isOnlineLabel: next.isOnlineLabel,
|
||||||
|
isOnlineTone: next.isOnlineTone,
|
||||||
|
isEnabledValue: next.isEnabledValue,
|
||||||
|
isEnabledLabel: next.isEnabledLabel,
|
||||||
|
isEnabledTone: next.isEnabledTone,
|
||||||
|
latestTestSummary: next.latestTestSummary,
|
||||||
|
lastOperationLabel: next.lastOperationLabel,
|
||||||
|
lastOperationTone: next.lastOperationTone,
|
||||||
|
publishMeta: next.publishMeta,
|
||||||
|
publishState: next.publishState,
|
||||||
|
updatedAt: next.updatedAt,
|
||||||
|
configJson: next.configJson
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRiskRuleJson(assetId) {
|
async function loadRiskRuleJson(assetId) {
|
||||||
if (!assetId || !selectedSkill.value?.usesJsonRiskRule) {
|
if (!assetId || !selectedSkill.value?.usesJsonRiskRule) {
|
||||||
return
|
return
|
||||||
@@ -1525,6 +1567,9 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openRiskRuleTestDialog() {
|
function openRiskRuleTestDialog() {
|
||||||
|
if (detailBusy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!canOpenRiskRuleTest.value) {
|
if (!canOpenRiskRuleTest.value) {
|
||||||
if (!selectedSkill.value?.id) {
|
if (!selectedSkill.value?.id) {
|
||||||
toast('规则详情还没有加载完成,请稍后再测试。')
|
toast('规则详情还没有加载完成,请稍后再测试。')
|
||||||
@@ -1544,7 +1589,8 @@ export default {
|
|||||||
}
|
}
|
||||||
await refreshCurrentAssets()
|
await refreshCurrentAssets()
|
||||||
if (selectedSkill.value?.id) {
|
if (selectedSkill.value?.id) {
|
||||||
await loadSelectedAssetDetail(selectedSkill.value.id)
|
const detail = await fetchAgentAssetDetail(selectedSkill.value.id)
|
||||||
|
mergeSelectedRuleLifecycle(detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1659,15 +1705,15 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const assetId = selectedSkill.value.id
|
const assetId = selectedSkill.value.id
|
||||||
const nextEnabled = !selectedSkill.value.isEnabledValue
|
const nextEnabled = !selectedSkill.value.isOnlineValue
|
||||||
actionState.value = 'toggle-risk-rule-enabled'
|
actionState.value = 'toggle-risk-rule-enabled'
|
||||||
try {
|
try {
|
||||||
await setRiskRuleAssetEnabled(assetId, nextEnabled, { actor: resolveActor() })
|
const detail = await setRiskRuleAssetEnabled(assetId, nextEnabled, { actor: resolveActor() })
|
||||||
|
mergeSelectedRuleLifecycle(detail)
|
||||||
await refreshCurrentAssets()
|
await refreshCurrentAssets()
|
||||||
await loadSelectedAssetDetail(assetId)
|
toast(nextEnabled ? '风险规则已上线。' : '风险规则已下线,不会进入业务扫描。')
|
||||||
toast(nextEnabled ? '风险规则已启用。' : '风险规则已停用,不会进入业务扫描。')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error?.message || '风险规则启用状态更新失败,请稍后重试。')
|
toast(error?.message || '风险规则上线状态更新失败,请稍后重试。')
|
||||||
} finally {
|
} finally {
|
||||||
actionState.value = ''
|
actionState.value = ''
|
||||||
}
|
}
|
||||||
@@ -1851,6 +1897,7 @@ export default {
|
|||||||
riskRuleReturnOpen,
|
riskRuleReturnOpen,
|
||||||
riskRulePublishOpen,
|
riskRulePublishOpen,
|
||||||
riskRuleReturnNote,
|
riskRuleReturnNote,
|
||||||
|
riskRuleBusinessStageOptions: RISK_RULE_BUSINESS_STAGE_OPTIONS,
|
||||||
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
|
riskRuleExpenseCategoryOptions: RISK_RULE_EXPENSE_CATEGORY_OPTIONS,
|
||||||
showReviewNote,
|
showReviewNote,
|
||||||
spreadsheetUploadInput,
|
spreadsheetUploadInput,
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
|
||||||
import { fetchEmployeeMeta } from '../../services/employees.js'
|
import { fetchEmployeeMeta } from '../../services/employees.js'
|
||||||
|
import {
|
||||||
|
BUDGET_CONTROL_ACTION_OPTIONS,
|
||||||
|
BUDGET_EXPENSE_TYPE_OPTIONS,
|
||||||
|
BUDGET_QUARTER_OPTIONS,
|
||||||
|
BUDGET_STATUS_OPTIONS,
|
||||||
|
BUDGET_WARNING_OPTIONS,
|
||||||
|
BUDGET_YEAR_OPTIONS,
|
||||||
|
buildBudgetOntologyContext,
|
||||||
|
formatBudgetPeriod,
|
||||||
|
resolveBudgetExpenseTypeLabel
|
||||||
|
} from '../../utils/budgetOntology.js'
|
||||||
|
|
||||||
const FALLBACK_DEPARTMENTS = [
|
const FALLBACK_DEPARTMENTS = [
|
||||||
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
|
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
|
||||||
@@ -12,13 +23,34 @@ const FALLBACK_DEPARTMENTS = [
|
|||||||
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
|
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const EXPENSE_BLUEPRINTS = [
|
const EXPENSE_BUDGET_SEED = {
|
||||||
{ expenseType: '市场推广费', total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' },
|
travel: { total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
|
||||||
{ expenseType: '差旅费', total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
|
hotel: { total: 360000, used: 139800, occupied: 84000, warning: 80, action: '提醒' },
|
||||||
{ expenseType: '办公费', total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' },
|
transport: { total: 280000, used: 104600, occupied: 56000, warning: 75, action: '提醒' },
|
||||||
{ expenseType: '培训费', total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' },
|
meal: { total: 420000, used: 168200, occupied: 118000, warning: 80, action: '管控' },
|
||||||
{ expenseType: '软件服务费', total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' }
|
meeting: { total: 260000, used: 84500, occupied: 52000, warning: 75, action: '提醒' },
|
||||||
]
|
marketing: { total: 500000, used: 186400, occupied: 120000, warning: 80, action: '提醒' },
|
||||||
|
office: { total: 300000, used: 68500, occupied: 60000, warning: 70, action: '正常' },
|
||||||
|
training: { total: 200000, used: 42300, occupied: 20000, warning: 70, action: '正常' },
|
||||||
|
software: { total: 600000, used: 249500, occupied: 240800, warning: 80, action: '管控' },
|
||||||
|
communication: { total: 120000, used: 38600, occupied: 18000, warning: 70, action: '正常' },
|
||||||
|
welfare: { total: 240000, used: 96500, occupied: 42000, warning: 75, action: '提醒' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EXPENSE_BUDGET = {
|
||||||
|
total: 100000,
|
||||||
|
used: 0,
|
||||||
|
occupied: 0,
|
||||||
|
warning: 70,
|
||||||
|
action: '正常'
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPENSE_BLUEPRINTS = BUDGET_EXPENSE_TYPE_OPTIONS.map((option) => ({
|
||||||
|
...DEFAULT_EXPENSE_BUDGET,
|
||||||
|
...EXPENSE_BUDGET_SEED[option.value],
|
||||||
|
budgetSubjectCode: option.value,
|
||||||
|
expenseType: option.label
|
||||||
|
}))
|
||||||
|
|
||||||
const currency = (value) =>
|
const currency = (value) =>
|
||||||
Number(value || 0).toLocaleString('zh-CN', {
|
Number(value || 0).toLocaleString('zh-CN', {
|
||||||
@@ -26,14 +58,29 @@ const currency = (value) =>
|
|||||||
maximumFractionDigits: 2
|
maximumFractionDigits: 2
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const comparison = (value, direction) => ({
|
||||||
|
value,
|
||||||
|
tone: direction === 'down' ? 'down' : 'up',
|
||||||
|
icon: direction === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseBudgetAmount = (value) => Number(String(value || '').replace(/[^\d.-]/g, '')) || 0
|
||||||
|
const makeBudgetRowId = () => `budget-row-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
const BUDGET_PAGE_SIZE_OPTIONS = [5, 10]
|
||||||
|
|
||||||
function buildDepartmentRows(departmentCode) {
|
function buildDepartmentRows(departmentCode) {
|
||||||
const seed = Array.from(String(departmentCode || '')).reduce((sum, char) => sum + char.charCodeAt(0), 0)
|
const seed = Array.from(String(departmentCode || '')).reduce(
|
||||||
|
(sum, char) => sum + char.charCodeAt(0),
|
||||||
|
0
|
||||||
|
)
|
||||||
const factor = 0.88 + (seed % 18) / 100
|
const factor = 0.88 + (seed % 18) / 100
|
||||||
|
|
||||||
return EXPENSE_BLUEPRINTS.map((item, index) => {
|
return EXPENSE_BLUEPRINTS.map((item, index) => {
|
||||||
const totalAmount = Math.round(item.total * factor)
|
const totalAmount = Math.round(item.total * factor)
|
||||||
const usedAmount = Math.round(item.used * (0.9 + ((seed + index) % 12) / 100))
|
const usedAmount = Math.round(item.used * (0.9 + ((seed + index) % 12) / 100))
|
||||||
const occupiedAmount = Math.round(item.occupied * (0.92 + ((seed + index * 3) % 10) / 100))
|
const occupiedAmount = Math.round(
|
||||||
|
item.occupied * (0.92 + ((seed + index * 3) % 10) / 100)
|
||||||
|
)
|
||||||
const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0)
|
const leftAmount = Math.max(totalAmount - usedAmount - occupiedAmount, 0)
|
||||||
const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2))
|
const rate = Number((((usedAmount + occupiedAmount) / totalAmount) * 100).toFixed(2))
|
||||||
|
|
||||||
@@ -80,18 +127,36 @@ export default {
|
|||||||
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
|
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
|
||||||
const departmentKeyword = ref('')
|
const departmentKeyword = ref('')
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
period: '2026年度',
|
year: '2026',
|
||||||
|
quarter: 'Q1',
|
||||||
expenseType: '全部',
|
expenseType: '全部',
|
||||||
status: '全部'
|
status: '全部'
|
||||||
})
|
})
|
||||||
|
const budgetPage = ref(1)
|
||||||
|
const budgetPageSize = ref(5)
|
||||||
|
const budgetEditOpen = ref(false)
|
||||||
|
const budgetEditForm = ref({
|
||||||
|
budgetYear: '2026',
|
||||||
|
budgetQuarter: 'Q1',
|
||||||
|
budgetPeriod: '2026年Q1',
|
||||||
|
departmentCode: FALLBACK_DEPARTMENTS[0].code,
|
||||||
|
costCenter: FALLBACK_DEPARTMENTS[0].costCenter,
|
||||||
|
budgetOwner: '张晓明',
|
||||||
|
budgetVersion: 'V1.0(初始版本)',
|
||||||
|
budgetStatus: '编制中',
|
||||||
|
budgetDescription: ''
|
||||||
|
})
|
||||||
|
const budgetEditRows = ref([])
|
||||||
|
|
||||||
const activeDepartment = computed(() =>
|
const activeDepartment = computed(() =>
|
||||||
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
|
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
|
||||||
)
|
)
|
||||||
|
|
||||||
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
|
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
|
||||||
const departmentRows = computed(() => buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value))
|
const departmentRows = computed(() =>
|
||||||
const visibleBudgetRows = computed(() =>
|
buildDepartmentRows(activeDepartment.value?.code || activeDepartmentCode.value)
|
||||||
|
)
|
||||||
|
const filteredBudgetRows = computed(() =>
|
||||||
departmentRows.value
|
departmentRows.value
|
||||||
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
|
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
|
||||||
.filter((row) => {
|
.filter((row) => {
|
||||||
@@ -101,6 +166,21 @@ export default {
|
|||||||
return row.rateTone === 'ok'
|
return row.rateTone === 'ok'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
const totalBudgetRows = computed(() => filteredBudgetRows.value.length)
|
||||||
|
const totalBudgetPages = computed(() =>
|
||||||
|
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 5)))
|
||||||
|
)
|
||||||
|
const currentBudgetPage = computed(() =>
|
||||||
|
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
|
||||||
|
)
|
||||||
|
const budgetPageNumbers = computed(() =>
|
||||||
|
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
|
||||||
|
)
|
||||||
|
const visibleBudgetRows = computed(() => {
|
||||||
|
const pageSize = Number(budgetPageSize.value || 5)
|
||||||
|
const start = (currentBudgetPage.value - 1) * pageSize
|
||||||
|
return filteredBudgetRows.value.slice(start, start + pageSize)
|
||||||
|
})
|
||||||
|
|
||||||
const totals = computed(() => {
|
const totals = computed(() => {
|
||||||
const rows = departmentRows.value
|
const rows = departmentRows.value
|
||||||
@@ -119,30 +199,34 @@ export default {
|
|||||||
{
|
{
|
||||||
label: '预算总额',
|
label: '预算总额',
|
||||||
value: `¥${currency(totals.value.total)}`,
|
value: `¥${currency(totals.value.total)}`,
|
||||||
note: '本年累计',
|
yoy: comparison('+8.42%', 'up'),
|
||||||
|
mom: comparison('+2.16%', 'up'),
|
||||||
tone: 'green',
|
tone: 'green',
|
||||||
icon: 'mdi mdi-wallet-outline'
|
icon: 'mdi mdi-wallet-outline'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '已发生',
|
label: '已发生',
|
||||||
value: `¥${currency(totals.value.used)}`,
|
value: `¥${currency(totals.value.used)}`,
|
||||||
note: `占比 ${((totals.value.used / totals.value.total) * 100).toFixed(2)}%`,
|
yoy: comparison('+12.68%', 'up'),
|
||||||
|
mom: comparison('+4.35%', 'up'),
|
||||||
tone: 'blue',
|
tone: 'blue',
|
||||||
icon: 'mdi mdi-chart-line'
|
icon: 'mdi mdi-chart-line'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '已占用',
|
label: '已占用',
|
||||||
value: `¥${currency(totals.value.occupied)}`,
|
value: `¥${currency(totals.value.occupied)}`,
|
||||||
note: `占比 ${((totals.value.occupied / totals.value.total) * 100).toFixed(2)}%`,
|
yoy: comparison('+6.37%', 'up'),
|
||||||
|
mom: comparison('-1.84%', 'down'),
|
||||||
tone: 'orange',
|
tone: 'orange',
|
||||||
icon: 'mdi mdi-briefcase-check-outline'
|
icon: 'mdi mdi-briefcase-check-outline'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '剩余可用',
|
label: '剩余可用',
|
||||||
value: `¥${currency(totals.value.left)}`,
|
value: `¥${currency(totals.value.left)}`,
|
||||||
note: `占比 ${((totals.value.left / totals.value.total) * 100).toFixed(2)}%`,
|
yoy: comparison('-3.26%', 'down'),
|
||||||
|
mom: comparison('-2.08%', 'down'),
|
||||||
tone: 'green',
|
tone: 'green',
|
||||||
icon: 'mdi mdi-currency-cny'
|
icon: 'mdi mdi-cash'
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -170,6 +254,103 @@ export default {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const trendData = computed(() => buildTrendData(departmentRows.value))
|
const trendData = computed(() => buildTrendData(departmentRows.value))
|
||||||
|
const budgetEditTotal = computed(() =>
|
||||||
|
currency(
|
||||||
|
budgetEditRows.value.reduce(
|
||||||
|
(sum, row) => sum + parseBudgetAmount(row.budgetAmount),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const budgetOntologyContext = computed(() =>
|
||||||
|
buildBudgetOntologyContext({
|
||||||
|
form: budgetEditForm.value,
|
||||||
|
rows: budgetEditRows.value,
|
||||||
|
departments: departments.value
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
function buildEditableRows() {
|
||||||
|
return departmentRows.value.map((row) => ({
|
||||||
|
id: makeBudgetRowId(),
|
||||||
|
budgetSubject: row.expenseType,
|
||||||
|
budgetSubjectCode: row.budgetSubjectCode || '',
|
||||||
|
budgetAmount: currency(row.totalAmount),
|
||||||
|
warningThreshold: `${row.warning}%`,
|
||||||
|
controlAction: row.action,
|
||||||
|
budgetRemark: `${row.expenseType}相关费用`
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNextExpenseTypeOption() {
|
||||||
|
const usedCodes = new Set(budgetEditRows.value.map((row) => row.budgetSubjectCode))
|
||||||
|
return (
|
||||||
|
BUDGET_EXPENSE_TYPE_OPTIONS.find((item) => !usedCodes.has(item.value)) ||
|
||||||
|
BUDGET_EXPENSE_TYPE_OPTIONS[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBudgetRowSubject(row) {
|
||||||
|
row.budgetSubject = resolveBudgetExpenseTypeLabel(row.budgetSubjectCode, row.budgetSubject)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBudgetEditDialog() {
|
||||||
|
const department = activeDepartment.value
|
||||||
|
const budgetPeriod = formatBudgetPeriod(filters.value.year, filters.value.quarter)
|
||||||
|
budgetEditForm.value = {
|
||||||
|
budgetYear: filters.value.year,
|
||||||
|
budgetQuarter: filters.value.quarter,
|
||||||
|
budgetPeriod,
|
||||||
|
departmentCode: department?.code || activeDepartmentCode.value,
|
||||||
|
costCenter: department?.costCenter || '',
|
||||||
|
budgetOwner: '张晓明',
|
||||||
|
budgetVersion: 'V1.0(初始版本)',
|
||||||
|
budgetStatus: '编制中',
|
||||||
|
budgetDescription: `${department?.name || '当前部门'}2026年度预算编制,用于指导费用支出及控制成本,确保资源合理使用。`
|
||||||
|
}
|
||||||
|
budgetEditRows.value = buildEditableRows()
|
||||||
|
budgetEditOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBudgetEditDialog() {
|
||||||
|
budgetEditOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBudgetDetailRow() {
|
||||||
|
const option = resolveNextExpenseTypeOption()
|
||||||
|
budgetEditRows.value.push({
|
||||||
|
id: makeBudgetRowId(),
|
||||||
|
budgetSubject: option.label,
|
||||||
|
budgetSubjectCode: option.value,
|
||||||
|
budgetAmount: '0.00',
|
||||||
|
warningThreshold: '70%',
|
||||||
|
controlAction: '正常',
|
||||||
|
budgetRemark: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBudgetDetailRow(rowId) {
|
||||||
|
if (budgetEditRows.value.length <= 1) return
|
||||||
|
budgetEditRows.value = budgetEditRows.value.filter((row) => row.id !== rowId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToBudgetPage(page) {
|
||||||
|
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeBudgetPage(direction) {
|
||||||
|
goToBudgetPage(currentBudgetPage.value + direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBudgetDraft() {
|
||||||
|
budgetEditForm.value.budgetStatus = '编制中'
|
||||||
|
closeBudgetEditDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishBudget() {
|
||||||
|
budgetEditForm.value.budgetStatus = '已发布'
|
||||||
|
closeBudgetEditDialog()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDepartments() {
|
async function loadDepartments() {
|
||||||
try {
|
try {
|
||||||
@@ -198,19 +379,65 @@ export default {
|
|||||||
void loadDepartments()
|
void loadDepartments()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
activeDepartmentCode,
|
||||||
|
budgetPageSize,
|
||||||
|
() => filters.value.year,
|
||||||
|
() => filters.value.quarter,
|
||||||
|
() => filters.value.expenseType,
|
||||||
|
() => filters.value.status
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
budgetPage.value = 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(totalBudgetPages, (pages) => {
|
||||||
|
if (budgetPage.value > pages) {
|
||||||
|
budgetPage.value = pages
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeDepartmentCode,
|
activeDepartmentCode,
|
||||||
activeDepartmentName,
|
activeDepartmentName,
|
||||||
|
addBudgetDetailRow,
|
||||||
|
budgetEditForm,
|
||||||
|
budgetEditOpen,
|
||||||
|
budgetEditRows,
|
||||||
|
budgetEditTotal,
|
||||||
budgetMetrics,
|
budgetMetrics,
|
||||||
|
budgetOntologyContext,
|
||||||
|
budgetPage: currentBudgetPage,
|
||||||
|
budgetPageNumbers,
|
||||||
|
budgetPageSize,
|
||||||
|
budgetPageSizeOptions: BUDGET_PAGE_SIZE_OPTIONS,
|
||||||
|
closeBudgetEditDialog,
|
||||||
|
controlActionOptions: BUDGET_CONTROL_ACTION_OPTIONS,
|
||||||
|
changeBudgetPage,
|
||||||
departmentKeyword,
|
departmentKeyword,
|
||||||
|
departments,
|
||||||
|
expenseTypeOptions: BUDGET_EXPENSE_TYPE_OPTIONS,
|
||||||
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
|
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
|
||||||
filters,
|
filters,
|
||||||
periods: ['2026年度', '2026年Q2', '2026年5月'],
|
openBudgetEditDialog,
|
||||||
|
quarters: BUDGET_QUARTER_OPTIONS,
|
||||||
|
publishBudget,
|
||||||
|
removeBudgetDetailRow,
|
||||||
|
saveBudgetDraft,
|
||||||
|
statusOptions: BUDGET_STATUS_OPTIONS,
|
||||||
statuses: ['全部', '正常', '预警', '管控'],
|
statuses: ['全部', '正常', '预警', '管控'],
|
||||||
|
syncBudgetRowSubject,
|
||||||
|
goToBudgetPage,
|
||||||
|
totalBudgetPages,
|
||||||
|
totalBudgetRows,
|
||||||
trendData,
|
trendData,
|
||||||
visibleBudgetRows,
|
visibleBudgetRows,
|
||||||
visibleDepartments,
|
visibleDepartments,
|
||||||
warnings
|
warningOptions: BUDGET_WARNING_OPTIONS,
|
||||||
|
warnings,
|
||||||
|
years: BUDGET_YEAR_OPTIONS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,23 +56,6 @@ export const TYPE_META = {
|
|||||||
version: '当前版本',
|
version: '当前版本',
|
||||||
metric: '超时配置'
|
metric: '超时配置'
|
||||||
}
|
}
|
||||||
},
|
|
||||||
tasks: {
|
|
||||||
assetType: 'task',
|
|
||||||
label: '任务',
|
|
||||||
typeLabel: '任务',
|
|
||||||
createButtonLabel: '任务已接入',
|
|
||||||
hintText: '任务页签已接到真实资产 API,可查看调度周期、执行 Agent 和最近执行结果。',
|
|
||||||
searchPlaceholder: '搜索任务名称、编码或负责人',
|
|
||||||
tableColumns: {
|
|
||||||
name: '任务名称',
|
|
||||||
category: '业务域',
|
|
||||||
owner: '负责人',
|
|
||||||
scope: '适用场景',
|
|
||||||
runtime: '调度周期',
|
|
||||||
version: '当前版本',
|
|
||||||
metric: '执行 Agent'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,20 +96,15 @@ export const TAB_META = {
|
|||||||
...TYPE_META.mcp,
|
...TYPE_META.mcp,
|
||||||
typeKey: 'mcp',
|
typeKey: 'mcp',
|
||||||
badgeTone: 'amber'
|
badgeTone: 'amber'
|
||||||
},
|
|
||||||
tasks: {
|
|
||||||
...TYPE_META.tasks,
|
|
||||||
typeKey: 'tasks',
|
|
||||||
badgeTone: 'violet'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STATUS_META = {
|
export const STATUS_META = {
|
||||||
generating: { label: '生成中', tone: 'info' },
|
generating: { label: '生成中', tone: 'info' },
|
||||||
draft: { label: '草稿中', tone: 'draft' },
|
draft: { label: '待上线', tone: 'draft' },
|
||||||
review: { label: '待审核', tone: 'warning' },
|
review: { label: '待审核', tone: 'warning' },
|
||||||
active: { label: '已上线', tone: 'success' },
|
active: { label: '已上线', tone: 'success' },
|
||||||
disabled: { label: '已停用', tone: 'disabled' },
|
disabled: { label: '已下线', tone: 'disabled' },
|
||||||
failed: { label: '生成失败', tone: 'danger' }
|
failed: { label: '生成失败', tone: 'danger' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,34 +208,16 @@ export const DETAIL_TITLES = {
|
|||||||
historyDesc: '最近版本记录',
|
historyDesc: '最近版本记录',
|
||||||
publishTitle: '服务状态',
|
publishTitle: '服务状态',
|
||||||
publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。'
|
publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。'
|
||||||
},
|
|
||||||
tasks: {
|
|
||||||
configTitle: '任务配置',
|
|
||||||
configDesc: '展示调度周期、执行 Agent 和任务编码。',
|
|
||||||
detailTitle: '任务结构',
|
|
||||||
detailDesc: '按调度计划、目标场景和运行结果组织任务信息。',
|
|
||||||
outputTitle: '运行要求',
|
|
||||||
outputDesc: '任务详情重点展示调度 Agent、最近运行结果和运行日志入口。',
|
|
||||||
ruleListTitle: '运行要求',
|
|
||||||
checkListTitle: '最近执行',
|
|
||||||
triggerTitle: '适用场景',
|
|
||||||
triggerDesc: '当前任务覆盖的业务场景',
|
|
||||||
toolTitle: '最近调用',
|
|
||||||
toolDesc: '根据 AgentRun 中的最近执行记录回显任务运行情况',
|
|
||||||
historyTitle: '版本历史',
|
|
||||||
historyDesc: '最近版本记录',
|
|
||||||
publishTitle: '调度状态',
|
|
||||||
publishDesc: '任务资产已接入规则中心,后续 Day 4 运行时会继续消费这些配置。'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STATUS_OPTIONS = [
|
export const STATUS_OPTIONS = [
|
||||||
{ value: '', label: '全部状态' },
|
{ value: '', label: '全部状态' },
|
||||||
{ value: 'generating', label: '生成中' },
|
{ value: 'generating', label: '生成中' },
|
||||||
{ value: 'draft', label: '草稿中' },
|
{ value: 'draft', label: '待上线' },
|
||||||
{ value: 'review', label: '待审核' },
|
{ value: 'review', label: '待审核' },
|
||||||
{ value: 'active', label: '已上线' },
|
{ value: 'active', label: '已上线' },
|
||||||
{ value: 'disabled', label: '已停用' },
|
{ value: 'disabled', label: '已下线' },
|
||||||
{ value: 'failed', label: '生成失败' }
|
{ value: 'failed', label: '生成失败' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -207,6 +207,65 @@ export function resolveRiskRuleEnabled(source, rulePayload = null) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LAST_OPERATION_LABELS = {
|
||||||
|
generate: '开始生成',
|
||||||
|
create: '创建',
|
||||||
|
test: '测试',
|
||||||
|
online: '上线',
|
||||||
|
offline: '下线',
|
||||||
|
delete: '删除',
|
||||||
|
update: '更新'
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_RULE_BUSINESS_STAGE_LABELS = {
|
||||||
|
expense_application: '费用申请',
|
||||||
|
reimbursement: '费用报销'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRiskRuleBusinessStage(source, rulePayload = null) {
|
||||||
|
const configJson = readConfigJson(source)
|
||||||
|
const metadata = rulePayload && typeof rulePayload === 'object' ? rulePayload.metadata || {} : {}
|
||||||
|
const stage =
|
||||||
|
normalizeText(configJson.business_stage) ||
|
||||||
|
normalizeText(metadata.business_stage) ||
|
||||||
|
normalizeText(rulePayload?.business_stage)
|
||||||
|
const label =
|
||||||
|
normalizeText(configJson.business_stage_label) ||
|
||||||
|
normalizeText(metadata.business_stage_label) ||
|
||||||
|
RISK_RULE_BUSINESS_STAGE_LABELS[stage]
|
||||||
|
return {
|
||||||
|
value: stage || 'reimbursement',
|
||||||
|
label: label || '费用报销'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRiskRuleOnlineMeta(statusValue) {
|
||||||
|
if (statusValue === 'active') {
|
||||||
|
return { label: '已上线', tone: 'success', online: true }
|
||||||
|
}
|
||||||
|
if (statusValue === 'disabled') {
|
||||||
|
return { label: '已下线', tone: 'disabled', online: false }
|
||||||
|
}
|
||||||
|
if (statusValue === 'generating') {
|
||||||
|
return { label: '生成中', tone: 'info', online: false }
|
||||||
|
}
|
||||||
|
if (statusValue === 'failed') {
|
||||||
|
return { label: '生成失败', tone: 'danger', online: false }
|
||||||
|
}
|
||||||
|
return { label: '待上线', tone: 'draft', online: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLastOperationLabel(source, fallback = {}) {
|
||||||
|
const configJson = readConfigJson(source)
|
||||||
|
const operation = isPlainObject(configJson.last_operation) ? configJson.last_operation : {}
|
||||||
|
const action = normalizeText(operation.action) || normalizeText(fallback.action) || 'create'
|
||||||
|
const actor = normalizeText(operation.actor) || normalizeText(fallback.actor) || '系统'
|
||||||
|
const at = normalizeText(operation.at) || normalizeText(fallback.at)
|
||||||
|
const actionLabel = LAST_OPERATION_LABELS[action] || action
|
||||||
|
const timeLabel = formatDateTime(at)
|
||||||
|
return timeLabel && timeLabel !== '-' ? `${actionLabel}:${actor} · ${timeLabel}` : `${actionLabel}:${actor}`
|
||||||
|
}
|
||||||
|
|
||||||
export function readRuleDocumentMeta(value) {
|
export function readRuleDocumentMeta(value) {
|
||||||
const configJson = readConfigJson(value)
|
const configJson = readConfigJson(value)
|
||||||
return isPlainObject(configJson.rule_document) ? configJson.rule_document : null
|
return isPlainObject(configJson.rule_document) ? configJson.rule_document : null
|
||||||
@@ -458,12 +517,13 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
|
|||||||
normalizeText(apiConfig.expense_category_label) ||
|
normalizeText(apiConfig.expense_category_label) ||
|
||||||
normalizeText(rulePayload.risk_category) ||
|
normalizeText(rulePayload.risk_category) ||
|
||||||
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
|
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
|
||||||
|
const businessStage = resolveRiskRuleBusinessStage(target, rulePayload)
|
||||||
const riskRuleFields = resolveRiskRuleFields(rulePayload)
|
const riskRuleFields = resolveRiskRuleFields(rulePayload)
|
||||||
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
|
const riskRuleCreatedAt = resolveRiskRuleCreatedAt(rulePayload, target.createdAt || target.updatedAt)
|
||||||
const riskRuleScoreLevel = resolveRiskRuleScoreLevel(rulePayload, apiConfig)
|
const riskRuleScoreLevel = resolveRiskRuleScoreLevel(rulePayload, apiConfig)
|
||||||
|
|
||||||
const statusValue = apiPayload?.status || target.statusValue || 'draft'
|
const statusValue = apiPayload?.status || target.statusValue || 'draft'
|
||||||
const isOnlineLabel = statusValue === 'active' ? '是' : '否'
|
const onlineMeta = resolveRiskRuleOnlineMeta(statusValue)
|
||||||
const isEnabledValue = resolveRiskRuleEnabled(target, rulePayload)
|
const isEnabledValue = resolveRiskRuleEnabled(target, rulePayload)
|
||||||
|
|
||||||
const publisher =
|
const publisher =
|
||||||
@@ -488,6 +548,8 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
|
|||||||
riskRuleBusinessDescription: resolveRiskRuleBusinessDescription(rulePayload, fullDescription),
|
riskRuleBusinessDescription: resolveRiskRuleBusinessDescription(rulePayload, fullDescription),
|
||||||
riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48),
|
riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48),
|
||||||
riskCategory,
|
riskCategory,
|
||||||
|
businessStageValue: businessStage.value,
|
||||||
|
businessStageLabel: businessStage.label,
|
||||||
scope: riskCategory,
|
scope: riskCategory,
|
||||||
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
|
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
|
||||||
riskRuleSeverity: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
|
riskRuleSeverity: riskRuleScoreLevel || resolveRiskRuleSeverity(rulePayload),
|
||||||
@@ -521,10 +583,16 @@ export function applyRiskRuleJsonState(target, payload, apiPayload) {
|
|||||||
outcomes: apiPayload?.outcomes || rulePayload.outcomes || {}
|
outcomes: apiPayload?.outcomes || rulePayload.outcomes || {}
|
||||||
},
|
},
|
||||||
riskRuleJsonText: JSON.stringify(rulePayload, null, 2),
|
riskRuleJsonText: JSON.stringify(rulePayload, null, 2),
|
||||||
isOnlineLabel,
|
isOnlineValue: onlineMeta.online,
|
||||||
|
isOnlineLabel: onlineMeta.label,
|
||||||
|
isOnlineTone: onlineMeta.tone,
|
||||||
isEnabledValue,
|
isEnabledValue,
|
||||||
isEnabledLabel: isEnabledValue ? '是' : '否',
|
isEnabledLabel: isEnabledValue ? '是' : '否',
|
||||||
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
|
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
|
||||||
|
lastOperationLabel: resolveLastOperationLabel(target, {
|
||||||
|
actor: publisher,
|
||||||
|
at: riskRuleCreatedAt
|
||||||
|
}),
|
||||||
publisher,
|
publisher,
|
||||||
publishedAt
|
publishedAt
|
||||||
}
|
}
|
||||||
@@ -747,7 +815,7 @@ export function resolveTypeKey(assetType) {
|
|||||||
if (assetType === 'mcp') {
|
if (assetType === 'mcp') {
|
||||||
return 'mcp'
|
return 'mcp'
|
||||||
}
|
}
|
||||||
return 'tasks'
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSeverity(value) {
|
export function formatSeverity(value) {
|
||||||
@@ -778,23 +846,6 @@ export function formatOutputSummary(items) {
|
|||||||
return `${items.length} 项输出`
|
return `${items.length} 项输出`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTaskRisk(scenarios) {
|
|
||||||
if (Array.isArray(scenarios) && scenarios.includes('risk_check')) {
|
|
||||||
return '高风险'
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
Array.isArray(scenarios) &&
|
|
||||||
(scenarios.includes('accounts_receivable') || scenarios.includes('accounts_payable'))
|
|
||||||
) {
|
|
||||||
return '中风险'
|
|
||||||
}
|
|
||||||
return '常规'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findLatestTaskRun(runs, assetId) {
|
|
||||||
return runs.find((item) => item.task_id === assetId) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findLatestMcpCall(runs, assetCode) {
|
export function findLatestMcpCall(runs, assetCode) {
|
||||||
const expectedToolName = normalizeText(assetCode).replace(/^mcp\./, '')
|
const expectedToolName = normalizeText(assetCode).replace(/^mcp\./, '')
|
||||||
|
|
||||||
@@ -827,7 +878,7 @@ export function buildRowRuntime(asset, typeKey) {
|
|||||||
if (typeKey === 'mcp') {
|
if (typeKey === 'mcp') {
|
||||||
return normalizeText(asset.config_json?.endpoint) || '未配置地址'
|
return normalizeText(asset.config_json?.endpoint) || '未配置地址'
|
||||||
}
|
}
|
||||||
return normalizeText(asset.config_json?.cron) || '未配置调度'
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRowMetric(asset, typeKey) {
|
export function buildRowMetric(asset, typeKey) {
|
||||||
@@ -840,7 +891,7 @@ export function buildRowMetric(asset, typeKey) {
|
|||||||
if (typeKey === 'mcp') {
|
if (typeKey === 'mcp') {
|
||||||
return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时'
|
return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时'
|
||||||
}
|
}
|
||||||
return normalizeText(asset.config_json?.agent) || '未配置 Agent'
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSpreadsheetChangeSummary(summary) {
|
export function formatSpreadsheetChangeSummary(summary) {
|
||||||
@@ -885,7 +936,8 @@ export function buildListItem(asset) {
|
|||||||
const listSubtitle = isRiskRule
|
const listSubtitle = isRiskRule
|
||||||
? buildRiskListSubtitle(asset.description)
|
? buildRiskListSubtitle(asset.description)
|
||||||
: normalizeText(asset.description)
|
: normalizeText(asset.description)
|
||||||
const isOnlineValue = asset.status === 'active'
|
const onlineMeta = resolveRiskRuleOnlineMeta(asset.status)
|
||||||
|
const isOnlineValue = onlineMeta.online
|
||||||
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(asset) : true
|
const isEnabledValue = usesJsonRiskRule ? resolveRiskRuleEnabled(asset) : true
|
||||||
const reviewer = normalizeText(asset.reviewer) || '待分配'
|
const reviewer = normalizeText(asset.reviewer) || '待分配'
|
||||||
const creator =
|
const creator =
|
||||||
@@ -895,6 +947,9 @@ export function buildListItem(asset) {
|
|||||||
'未知'
|
'未知'
|
||||||
const publisher = isRiskRule ? creator : ''
|
const publisher = isRiskRule ? creator : ''
|
||||||
const riskRuleCreatedAt = formatDateTime(asset.created_at || asset.updated_at)
|
const riskRuleCreatedAt = formatDateTime(asset.created_at || asset.updated_at)
|
||||||
|
const businessStage = usesJsonRiskRule
|
||||||
|
? resolveRiskRuleBusinessStage(asset)
|
||||||
|
: { value: '', label: '' }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
@@ -915,6 +970,8 @@ export function buildListItem(asset) {
|
|||||||
reviewer,
|
reviewer,
|
||||||
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
|
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
|
||||||
riskCategory: ruleScenarioCategory,
|
riskCategory: ruleScenarioCategory,
|
||||||
|
businessStageValue: businessStage.value,
|
||||||
|
businessStageLabel: businessStage.label,
|
||||||
model: buildRowRuntime(asset, typeKey),
|
model: buildRowRuntime(asset, typeKey),
|
||||||
version: workingVersion,
|
version: workingVersion,
|
||||||
versionDisplay: typeKey === 'rules' ? `${changeCount} 次` : workingVersion,
|
versionDisplay: typeKey === 'rules' ? `${changeCount} 次` : workingVersion,
|
||||||
@@ -928,8 +985,8 @@ export function buildListItem(asset) {
|
|||||||
publisher,
|
publisher,
|
||||||
publishedAt: isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-',
|
publishedAt: isOnlineValue ? formatDateTime(asset.published_at || asset.updated_at) : '-',
|
||||||
isOnlineValue,
|
isOnlineValue,
|
||||||
isOnlineLabel: isOnlineValue ? '是' : '否',
|
isOnlineLabel: onlineMeta.label,
|
||||||
isOnlineTone: isOnlineValue ? 'success' : 'disabled',
|
isOnlineTone: onlineMeta.tone,
|
||||||
isEnabledValue,
|
isEnabledValue,
|
||||||
isEnabledLabel: isEnabledValue ? '是' : '否',
|
isEnabledLabel: isEnabledValue ? '是' : '否',
|
||||||
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
|
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
|
||||||
@@ -1002,21 +1059,7 @@ export function buildMcpFields(detail, latestCall) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTaskFields(detail, latestRun) {
|
export function buildFields(detail, typeKey, latestCall) {
|
||||||
const content = detail.current_version_content || {}
|
|
||||||
return [
|
|
||||||
{ label: '任务编码', value: detail.code },
|
|
||||||
{ label: 'Cron', value: normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置' },
|
|
||||||
{ label: '执行 Agent', value: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置' },
|
|
||||||
{ label: '风险等级', value: formatTaskRisk(detail.scenario_json) },
|
|
||||||
{
|
|
||||||
label: '最近执行',
|
|
||||||
value: latestRun ? formatDateTime(latestRun.started_at) : '暂无执行记录'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildFields(detail, typeKey, latestRun, latestCall) {
|
|
||||||
if (typeKey === 'rules') {
|
if (typeKey === 'rules') {
|
||||||
return buildRuleFields(detail)
|
return buildRuleFields(detail)
|
||||||
}
|
}
|
||||||
@@ -1026,10 +1069,10 @@ export function buildFields(detail, typeKey, latestRun, latestCall) {
|
|||||||
if (typeKey === 'mcp') {
|
if (typeKey === 'mcp') {
|
||||||
return buildMcpFields(detail, latestCall)
|
return buildMcpFields(detail, latestCall)
|
||||||
}
|
}
|
||||||
return buildTaskFields(detail, latestRun)
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPromptSections(detail, typeKey, latestRun, latestCall) {
|
export function buildPromptSections(detail, typeKey) {
|
||||||
const content = detail.current_version_content || {}
|
const content = detail.current_version_content || {}
|
||||||
|
|
||||||
if (typeKey === 'skills') {
|
if (typeKey === 'skills') {
|
||||||
@@ -1075,26 +1118,10 @@ export function buildPromptSections(detail, typeKey, latestRun, latestCall) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return []
|
||||||
{
|
|
||||||
title: '任务场景',
|
|
||||||
intent: '调度目标',
|
|
||||||
content: formatScenarioList(detail.scenario_json)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '执行 Agent',
|
|
||||||
intent: '运行主体',
|
|
||||||
content: normalizeText(content.target_agent) || normalizeText(detail.config_json?.agent) || '未配置执行 Agent。'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '最近执行结果',
|
|
||||||
intent: '运行反馈',
|
|
||||||
content: latestRun?.result_summary || latestRun?.error_message || '暂无执行记录。'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOutputRules(detail, typeKey, latestRun, latestCall) {
|
export function buildOutputRules(detail, typeKey) {
|
||||||
const content = detail.current_version_content || {}
|
const content = detail.current_version_content || {}
|
||||||
|
|
||||||
if (typeKey === 'rules') {
|
if (typeKey === 'rules') {
|
||||||
@@ -1130,15 +1157,10 @@ export function buildOutputRules(detail, typeKey, latestRun, latestCall) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return []
|
||||||
`调度周期:${normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置'}`,
|
|
||||||
`执行 Agent:${normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置'}`,
|
|
||||||
`风险等级:${formatTaskRisk(detail.scenario_json)}`,
|
|
||||||
`最近执行结果:${latestRun?.status || '暂无执行记录'}`
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTests(detail, typeKey, latestRun, latestCall) {
|
export function buildTests(detail, typeKey, latestCall) {
|
||||||
if (typeKey === 'rules') {
|
if (typeKey === 'rules') {
|
||||||
const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status)
|
const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status)
|
||||||
return [
|
return [
|
||||||
@@ -1195,23 +1217,10 @@ export function buildTests(detail, typeKey, latestRun, latestCall) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return []
|
||||||
{
|
|
||||||
name: '最近运行状态',
|
|
||||||
input: latestRun?.run_id || '暂无运行',
|
|
||||||
result: latestRun?.status || '未记录',
|
|
||||||
tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'success'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '结果摘要',
|
|
||||||
input: latestRun?.agent || normalizeText(detail.config_json?.agent) || '未配置',
|
|
||||||
result: latestRun?.result_summary || '暂无摘要',
|
|
||||||
tone: 'success'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTools(detail, typeKey, latestRun, latestCall) {
|
export function buildTools(detail, typeKey, latestCall) {
|
||||||
const content = detail.current_version_content || {}
|
const content = detail.current_version_content || {}
|
||||||
|
|
||||||
if (typeKey === 'skills') {
|
if (typeKey === 'skills') {
|
||||||
@@ -1246,26 +1255,7 @@ export function buildTools(detail, typeKey, latestRun, latestCall) {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return []
|
||||||
{
|
|
||||||
name: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置 Agent',
|
|
||||||
scope: '执行 Agent',
|
|
||||||
mode: '调度',
|
|
||||||
tone: 'active'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: latestRun?.run_id || '暂无执行记录',
|
|
||||||
scope: '最近 Run',
|
|
||||||
mode: latestRun?.status || '未执行',
|
|
||||||
tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'active'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: latestRun?.permission_level || '未记录',
|
|
||||||
scope: '权限级别',
|
|
||||||
mode: 'Trace',
|
|
||||||
tone: 'safe'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPublishDescription(detail, typeKey) {
|
export function buildPublishDescription(detail, typeKey) {
|
||||||
@@ -1279,14 +1269,16 @@ export function buildPublishDescription(detail, typeKey) {
|
|||||||
return '当前规则需要先完成审核,再调用上线接口正式激活。'
|
return '当前规则需要先完成审核,再调用上线接口正式激活。'
|
||||||
}
|
}
|
||||||
|
|
||||||
return DETAIL_TITLES[typeKey].publishDesc
|
return DETAIL_TITLES[typeKey]?.publishDesc || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDetailViewModel(detail, runs) {
|
export function buildDetailViewModel(detail, runs) {
|
||||||
const typeKey = resolveTypeKey(detail.asset_type)
|
const typeKey = resolveTypeKey(detail.asset_type)
|
||||||
const tabId = resolveTabId(detail, typeKey) || typeKey
|
const tabId = resolveTabId(detail, typeKey) || typeKey
|
||||||
|
if (!typeKey || !tabId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const tabMeta = resolveTabMeta(tabId, typeKey)
|
const tabMeta = resolveTabMeta(tabId, typeKey)
|
||||||
const latestRun = typeKey === 'tasks' ? findLatestTaskRun(runs, detail.id) : null
|
|
||||||
const latestCall = typeKey === 'mcp' ? findLatestMcpCall(runs, detail.code) : null
|
const latestCall = typeKey === 'mcp' ? findLatestMcpCall(runs, detail.code) : null
|
||||||
const configJson = readConfigJson(detail)
|
const configJson = readConfigJson(detail)
|
||||||
const statusMeta = resolveStatusMeta(detail.status)
|
const statusMeta = resolveStatusMeta(detail.status)
|
||||||
@@ -1320,6 +1312,14 @@ export function buildDetailViewModel(detail, runs) {
|
|||||||
normalizeText(detail.owner) ||
|
normalizeText(detail.owner) ||
|
||||||
normalizeText(detail.recent_versions?.[0]?.created_by) ||
|
normalizeText(detail.recent_versions?.[0]?.created_by) ||
|
||||||
'未知'
|
'未知'
|
||||||
|
const onlineMeta = resolveRiskRuleOnlineMeta(detail.status)
|
||||||
|
const businessStage = usesJsonRiskRule
|
||||||
|
? resolveRiskRuleBusinessStage(detail)
|
||||||
|
: { value: '', label: '' }
|
||||||
|
|
||||||
|
const initialRiskRuleScore = resolveRiskRuleScore(configJson, configJson)
|
||||||
|
const initialRiskRuleScoreLevel = resolveRiskRuleScoreLevel(configJson, configJson)
|
||||||
|
const initialRiskRuleSeverity = initialRiskRuleScoreLevel || resolveRiskRuleSeverity(configJson)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: detail.id,
|
id: detail.id,
|
||||||
@@ -1335,6 +1335,8 @@ export function buildDetailViewModel(detail, runs) {
|
|||||||
reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配',
|
reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配',
|
||||||
category: resolveDomainLabel(detail.domain),
|
category: resolveDomainLabel(detail.domain),
|
||||||
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(detail.scenario_json),
|
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(detail.scenario_json),
|
||||||
|
businessStageValue: businessStage.value,
|
||||||
|
businessStageLabel: businessStage.label,
|
||||||
version: detail.working_version || detail.current_version || '-',
|
version: detail.working_version || detail.current_version || '-',
|
||||||
currentVersion: detail.current_version || '-',
|
currentVersion: detail.current_version || '-',
|
||||||
publishedVersion: detail.published_version || '-',
|
publishedVersion: detail.published_version || '-',
|
||||||
@@ -1356,15 +1358,19 @@ export function buildDetailViewModel(detail, runs) {
|
|||||||
riskRuleBusinessDescription: '',
|
riskRuleBusinessDescription: '',
|
||||||
riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '',
|
riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '',
|
||||||
riskRuleSourceRef: '',
|
riskRuleSourceRef: '',
|
||||||
riskRuleSeverity: 'medium',
|
riskRuleSeverity: initialRiskRuleSeverity,
|
||||||
riskRuleSeverityLabel: '中风险',
|
riskRuleScore: initialRiskRuleScore,
|
||||||
riskRuleScore: null,
|
riskRuleScoreLevel: initialRiskRuleScoreLevel || initialRiskRuleSeverity,
|
||||||
riskRuleScoreLabel: '待计算',
|
riskRuleScoreDetail: resolveRiskRuleScoreDetail(configJson, configJson),
|
||||||
riskRuleScoreLevel: 'medium',
|
riskRuleSeverityLabel: initialRiskRuleScoreLevel
|
||||||
riskRuleScoreDetail: null,
|
? resolveRiskRuleScoreLabel(configJson, configJson)
|
||||||
|
: resolveRiskRuleSeverityLabel(configJson),
|
||||||
|
riskRuleScoreLabel: resolveRiskRuleScoreLabel(configJson, configJson),
|
||||||
riskRuleCreatedAt: formatDateTime(detail.created_at),
|
riskRuleCreatedAt: formatDateTime(detail.created_at),
|
||||||
riskRuleAgeLabel: formatRiskRuleAge(detail.created_at),
|
riskRuleAgeLabel: formatRiskRuleAge(detail.created_at),
|
||||||
isOnlineLabel: detail.status === 'active' ? '是' : '否',
|
isOnlineValue: onlineMeta.online,
|
||||||
|
isOnlineLabel: onlineMeta.label,
|
||||||
|
isOnlineTone: onlineMeta.tone,
|
||||||
isEnabledValue,
|
isEnabledValue,
|
||||||
isEnabledLabel: isEnabledValue ? '是' : '否',
|
isEnabledLabel: isEnabledValue ? '是' : '否',
|
||||||
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
|
isEnabledTone: isEnabledValue ? 'success' : 'disabled',
|
||||||
@@ -1381,6 +1387,11 @@ export function buildDetailViewModel(detail, runs) {
|
|||||||
history.find((item) => item.isPublished || item.lifecycleState === 'published')?.time ||
|
history.find((item) => item.isPublished || item.lifecycleState === 'published')?.time ||
|
||||||
(detail.published_at ? formatDateTime(detail.published_at) : '') ||
|
(detail.published_at ? formatDateTime(detail.published_at) : '') ||
|
||||||
(detail.latest_review?.reviewed_at ? formatDateTime(detail.latest_review.reviewed_at) : '-'),
|
(detail.latest_review?.reviewed_at ? formatDateTime(detail.latest_review.reviewed_at) : '-'),
|
||||||
|
lastOperationLabel: resolveLastOperationLabel(detail, {
|
||||||
|
actor: riskRuleCreator,
|
||||||
|
at: detail.created_at
|
||||||
|
}),
|
||||||
|
lastOperationTone: onlineMeta.tone,
|
||||||
riskRuleFields: [],
|
riskRuleFields: [],
|
||||||
riskRuleFieldSummary: '未识别字段',
|
riskRuleFieldSummary: '未识别字段',
|
||||||
riskRuleFlow: resolveRiskRuleFlow({}, []),
|
riskRuleFlow: resolveRiskRuleFlow({}, []),
|
||||||
@@ -1411,13 +1422,12 @@ export function buildDetailViewModel(detail, runs) {
|
|||||||
reviewStatusValue: detail.latest_review?.review_status || '',
|
reviewStatusValue: detail.latest_review?.review_status || '',
|
||||||
reviewTimeLabel: formatDateTime(detail.latest_review?.reviewed_at),
|
reviewTimeLabel: formatDateTime(detail.latest_review?.reviewed_at),
|
||||||
reviewNote: detail.latest_review?.review_note || '',
|
reviewNote: detail.latest_review?.review_note || '',
|
||||||
latestRun,
|
|
||||||
latestCall,
|
latestCall,
|
||||||
fields: buildFields(detail, typeKey, latestRun, latestCall),
|
fields: buildFields(detail, typeKey, latestCall),
|
||||||
promptSections:
|
promptSections:
|
||||||
typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey, latestRun, latestCall),
|
typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey),
|
||||||
outputRules: buildOutputRules(detail, typeKey, latestRun, latestCall),
|
outputRules: buildOutputRules(detail, typeKey),
|
||||||
tests: buildTests(detail, typeKey, latestRun, latestCall),
|
tests: buildTests(detail, typeKey, latestCall),
|
||||||
triggers:
|
triggers:
|
||||||
typeKey === 'rules'
|
typeKey === 'rules'
|
||||||
? [ruleScenarioCategory || '通用']
|
? [ruleScenarioCategory || '通用']
|
||||||
@@ -1446,7 +1456,7 @@ export function buildDetailViewModel(detail, runs) {
|
|||||||
tone: 'safe'
|
tone: 'safe'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: buildTools(detail, typeKey, latestRun, latestCall),
|
: buildTools(detail, typeKey, latestCall),
|
||||||
history,
|
history,
|
||||||
configTitle: titles.configTitle,
|
configTitle: titles.configTitle,
|
||||||
configDesc: titles.configDesc,
|
configDesc: titles.configDesc,
|
||||||
@@ -1467,8 +1477,6 @@ export function buildDetailViewModel(detail, runs) {
|
|||||||
publishMeta:
|
publishMeta:
|
||||||
typeKey === 'rules'
|
typeKey === 'rules'
|
||||||
? `最近保存:${formatDateTime(detail.updated_at)}`
|
? `最近保存:${formatDateTime(detail.updated_at)}`
|
||||||
: latestRun
|
|
||||||
? `最近运行:${formatDateTime(latestRun.started_at)}`
|
|
||||||
: `最近更新:${formatDateTime(detail.updated_at)}`,
|
: `最近更新:${formatDateTime(detail.updated_at)}`,
|
||||||
publishState: statusMeta.label,
|
publishState: statusMeta.label,
|
||||||
latestReviewVersion: detail.latest_review?.version || detail.current_version || '-',
|
latestReviewVersion: detail.latest_review?.version || detail.current_version || '-',
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export const RISK_RULE_EXPENSE_CATEGORY_OPTIONS = [
|
|||||||
{ value: 'welfare', label: '福利费' }
|
{ value: 'welfare', label: '福利费' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const RISK_RULE_BUSINESS_STAGE_OPTIONS = [
|
||||||
|
{ value: 'expense_application', label: '费用申请' },
|
||||||
|
{ value: 'reimbursement', label: '费用报销' }
|
||||||
|
]
|
||||||
|
|
||||||
export const RISK_RULE_LEVEL_OPTIONS = [
|
export const RISK_RULE_LEVEL_OPTIONS = [
|
||||||
{ value: 'low', label: '低风险' },
|
{ value: 'low', label: '低风险' },
|
||||||
{ value: 'medium', label: '中风险' },
|
{ value: 'medium', label: '中风险' },
|
||||||
@@ -49,6 +54,7 @@ const CITY_ROUTE_SEMANTIC_TYPES = new Set([
|
|||||||
export function createDefaultRiskRuleForm() {
|
export function createDefaultRiskRuleForm() {
|
||||||
return {
|
return {
|
||||||
business_domain: 'expense',
|
business_domain: 'expense',
|
||||||
|
business_stage: 'reimbursement',
|
||||||
expense_category: 'travel',
|
expense_category: 'travel',
|
||||||
rule_title: '',
|
rule_title: '',
|
||||||
requires_attachment: false,
|
requires_attachment: false,
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ export const EXPENSE_TYPE_LABELS = {
|
|||||||
meal: '业务招待费',
|
meal: '业务招待费',
|
||||||
meeting: '会务费',
|
meeting: '会务费',
|
||||||
entertainment: '业务招待费',
|
entertainment: '业务招待费',
|
||||||
|
marketing: '市场推广费',
|
||||||
office: '办公用品费',
|
office: '办公用品费',
|
||||||
training: '培训费',
|
training: '培训费',
|
||||||
|
software: '软件服务费',
|
||||||
communication: '通讯费',
|
communication: '通讯费',
|
||||||
welfare: '福利费',
|
welfare: '福利费',
|
||||||
other: '其他费用'
|
other: '其他费用'
|
||||||
@@ -96,8 +98,10 @@ export const REVIEW_FALLBACK_GROUP_CODES = [
|
|||||||
'hotel',
|
'hotel',
|
||||||
'meal',
|
'meal',
|
||||||
'meeting',
|
'meeting',
|
||||||
|
'marketing',
|
||||||
'office',
|
'office',
|
||||||
'training',
|
'training',
|
||||||
|
'software',
|
||||||
'communication',
|
'communication',
|
||||||
'welfare'
|
'welfare'
|
||||||
]
|
]
|
||||||
@@ -113,7 +117,9 @@ export const REVIEW_CATEGORY_PRESET_OPTIONS = [
|
|||||||
|
|
||||||
export const REVIEW_OTHER_CATEGORY_OPTIONS = [
|
export const REVIEW_OTHER_CATEGORY_OPTIONS = [
|
||||||
{ key: 'meeting', label: '会务费' },
|
{ key: 'meeting', label: '会务费' },
|
||||||
|
{ key: 'marketing', label: '市场推广费' },
|
||||||
{ key: 'training', label: '培训费' },
|
{ key: 'training', label: '培训费' },
|
||||||
|
{ key: 'software', label: '软件服务费' },
|
||||||
{ key: 'communication', label: '通讯费' },
|
{ key: 'communication', label: '通讯费' },
|
||||||
{ key: 'welfare', label: '福利费' },
|
{ key: 'welfare', label: '福利费' },
|
||||||
{ key: 'other', label: '其他费用' }
|
{ key: 'other', label: '其他费用' }
|
||||||
@@ -140,9 +146,11 @@ export const CATEGORY_CONFIDENCE_KEYWORDS = {
|
|||||||
transport: [TRANSPORT_KEYWORD_PATTERN],
|
transport: [TRANSPORT_KEYWORD_PATTERN],
|
||||||
meal: [/业务招待|招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同|餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
|
meal: [/业务招待|招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同|餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
|
||||||
meeting: [/会务|会议|论坛|展会|参会|会场/],
|
meeting: [/会务|会议|论坛|展会|参会|会场/],
|
||||||
|
marketing: [/市场推广|推广费|广告|投放|品牌宣传|营销物料|推广物料/],
|
||||||
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
|
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
|
||||||
office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/],
|
office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/],
|
||||||
training: [/培训|授课|讲师|课程|签到|讲义/],
|
training: [/培训|授课|讲师|课程|签到|讲义/],
|
||||||
|
software: [/软件|SaaS|订阅|系统服务|云服务|云资源|平台服务|技术服务/],
|
||||||
communication: [/通讯|电话|流量|话费|宽带|网络/],
|
communication: [/通讯|电话|流量|话费|宽带|网络/],
|
||||||
welfare: [/福利|体检|团建|节日|慰问|关怀/]
|
welfare: [/福利|体检|团建|节日|慰问|关怀/]
|
||||||
}
|
}
|
||||||
|
|||||||
91
web/tests/budget-ontology.test.mjs
Normal file
91
web/tests/budget-ontology.test.mjs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BUDGET_EXPENSE_TYPE_OPTIONS,
|
||||||
|
BUDGET_ONTOLOGY_FIELDS,
|
||||||
|
BUDGET_QUARTER_OPTIONS,
|
||||||
|
BUDGET_YEAR_OPTIONS,
|
||||||
|
buildBudgetOntologyContext
|
||||||
|
} from '../src/utils/budgetOntology.js'
|
||||||
|
|
||||||
|
test('budget ontology fields expose required budget center keys', () => {
|
||||||
|
const requiredKeys = BUDGET_ONTOLOGY_FIELDS
|
||||||
|
.filter((field) => field.required)
|
||||||
|
.map((field) => field.key)
|
||||||
|
|
||||||
|
assert.deepEqual(requiredKeys, [
|
||||||
|
'budget_period',
|
||||||
|
'department',
|
||||||
|
'cost_center',
|
||||||
|
'budget_owner',
|
||||||
|
'budget_version',
|
||||||
|
'budget_status',
|
||||||
|
'budget_subject',
|
||||||
|
'budget_amount',
|
||||||
|
'warning_threshold',
|
||||||
|
'control_action'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('budget ontology context maps dialog fields to ontology payload', () => {
|
||||||
|
const context = buildBudgetOntologyContext({
|
||||||
|
form: {
|
||||||
|
budgetYear: '2026',
|
||||||
|
budgetQuarter: 'Q2',
|
||||||
|
departmentCode: 'MARKET-DEPT',
|
||||||
|
budgetOwner: '张晓明',
|
||||||
|
budgetVersion: 'V1.0(初始版本)',
|
||||||
|
budgetStatus: '编制中',
|
||||||
|
budgetDescription: '市场部预算编制'
|
||||||
|
},
|
||||||
|
departments: [
|
||||||
|
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' }
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
budgetSubject: '差旅费',
|
||||||
|
budgetSubjectCode: 'travel',
|
||||||
|
budgetAmount: '600,000.00',
|
||||||
|
warningThreshold: '80%',
|
||||||
|
controlAction: '提醒',
|
||||||
|
budgetRemark: '差旅相关费用'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(context.document_type, 'budget_plan')
|
||||||
|
assert.equal(context.conversation_scenario, 'budget')
|
||||||
|
assert.equal(context.budget_header.budget_period, '2026年Q2')
|
||||||
|
assert.equal(context.budget_header.budget_year, '2026')
|
||||||
|
assert.equal(context.budget_header.budget_quarter, 'Q2')
|
||||||
|
assert.equal(context.budget_header.department, '市场部')
|
||||||
|
assert.equal(context.budget_header.cost_center, 'CC-4100')
|
||||||
|
assert.equal(context.budget_details[0].budget_subject_code, 'travel')
|
||||||
|
assert.equal(context.budget_details[0].expense_type, 'travel')
|
||||||
|
assert.equal(context.budget_details[0].expense_type_label, '差旅费')
|
||||||
|
assert.equal(context.budget_details[0].warning_threshold, '80%')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('budget expense type options expose real expense type codes', () => {
|
||||||
|
const optionCodes = BUDGET_EXPENSE_TYPE_OPTIONS.map((item) => item.value)
|
||||||
|
|
||||||
|
assert.deepEqual(optionCodes, [
|
||||||
|
'travel',
|
||||||
|
'hotel',
|
||||||
|
'transport',
|
||||||
|
'meal',
|
||||||
|
'meeting',
|
||||||
|
'marketing',
|
||||||
|
'office',
|
||||||
|
'training',
|
||||||
|
'software',
|
||||||
|
'communication',
|
||||||
|
'welfare'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('budget center exposes separate year and quarter dimensions', () => {
|
||||||
|
assert.deepEqual(BUDGET_YEAR_OPTIONS, ['2026', '2027', '2028'])
|
||||||
|
assert.deepEqual(BUDGET_QUARTER_OPTIONS, ['Q1', 'Q2', 'Q3', 'Q4'])
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user