feat: 添加生命周期报告和AI规则管理功能

- 在Dockerfile中添加pillow依赖包用于图像处理
- 实现生命周期报告调度任务,支持日报和周报两种类型
- 新增TASK_RUN_LIFECYCLE任务常量和相关配置选项
- 扩展AI Agent服务以支持用户规则,并在分析时应用规则
- 添加AI用户规则创建、更新和查询接口
- 增加项目生命周期和财务需求分析技能
- 扩展现有模型以支持更完整的业务数据字段
- 实现飞书图片上传功能用于报告展示
```
This commit is contained in:
2026-07-12 17:44:49 +08:00
parent bf309ecdf7
commit 9cf7c44393
45 changed files with 5036 additions and 49 deletions

View File

@@ -1,7 +1,7 @@
import json
import os
import tempfile
from datetime import date, timedelta
from datetime import date, datetime, timedelta
from pathlib import Path
import pytest
@@ -49,8 +49,35 @@ from app.modules.events.constants import (
)
from app.modules.events.services import EventService
from app.modules.business.registry import get_domain_model
from app.modules.business.models import (
Employee,
Project,
ProjectCashFlow,
ProjectContract,
ProjectMember,
ProjectMilestone,
)
from app.modules.business.service import _model_payload, serialize_model
from app.modules.legacy_mysql.services import LegacyMySQLService
from app.modules.legacy_mysql.intasect import (
CONTRACT_RECEIVABLE_SQL,
CONTRACT_SQL,
EPOCH,
IntasectSyncService,
PROJECT_FUND_SQL,
_contract_payload,
_contract_receivable_payload,
_employee_payload,
_project_fund_payload,
_project_payload,
)
from app.modules.business.constants import (
CashFlowDirection,
CashFlowType,
DataQualityStatus,
)
from app.modules.ai_memory.service import AIMemoryService
from app.modules.ai_memory.models import AIMemoryEntry
from app.modules.observability.constants import (
HeartbeatComponent,
ObservabilityKey,
@@ -66,6 +93,13 @@ from app.modules.reports.constants import (
ReportTitle,
ReportType,
)
from app.modules.reports.lifecycle_pipeline import LifecyclePipelineService
from app.modules.reports.chart import render_lifecycle_chart
from app.modules.reports.services import ReportService
from app.modules.feishu.service import FeishuService
from app.modules.feishu.commands import FeishuCommandService
from app.modules.feishu.events import FeishuEventService
from app.modules.feishu.constants import FeishuEventSource
from app.modules.risk.constants import RiskEventActionValue
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
from app.modules.workflows.models import WorkflowInstance
@@ -178,6 +212,106 @@ def test_feishu_webhook_routes_message_event() -> None:
assert AUDIT_REDACTED_VALUE in audit_payload
def test_feishu_rule_commands_create_list_disable_and_enable(monkeypatch) -> None:
monkeypatch.setattr(
FeishuService,
"send_text",
lambda *args, **kwargs: pytest.fail("auto_reply=False must not send to Feishu"),
)
rule_text = "每日建议必须说明负责人角色、截止时间和验收指标"
created = client.post(
"/api/v1/integrations/feishu/commands/preview",
headers=headers,
json={"text": f"学习规则 80{rule_text}", "auto_reply": False},
)
assert created.status_code == 200
assert created.json()["command"] == "rule_create"
assert "规则已学习" in created.json()["content"]
db = SessionLocal()
try:
rule = db.execute(
select(AIMemoryEntry).where(AIMemoryEntry.content == rule_text)
).scalar_one()
assert rule.importance == 80
assert rule.scope == "global"
assert rule.subject == "company"
assert "feishu" in rule.tags
service = FeishuCommandService(db)
listed = service.handle_text("查看规则", auto_reply=False)
assert listed["command"] == "rule_list"
assert rule.code in listed["content"]
disabled = service.handle_text(f"停用规则 {rule.code}", auto_reply=False)
assert disabled["command"] == "rule_disable"
assert "已停用" in disabled["content"]
db.refresh(rule)
assert rule.status == AIMemoryStatus.ARCHIVED
enabled = service.handle_text(f"启用规则 {rule.code}", auto_reply=False)
assert enabled["command"] == "rule_enable"
assert "已启用" in enabled["content"]
db.refresh(rule)
assert rule.status == AIMemoryStatus.ACTIVE
finally:
db.close()
def test_feishu_rule_command_preserves_sender_and_rejects_invalid_input() -> None:
payload = {
"schema": "2.0",
"header": {
"event_id": "evt-smoke-rule-actor-001",
"event_type": "im.message.receive_v1",
"token": "test-feishu-token",
},
"event": {
"sender": {"sender_id": {"open_id": "ou_rule_teacher"}},
"message": {
"chat_id": "oc_test",
"message_id": "om_smoke_rule_actor_001",
"message_type": "text",
"content": json.dumps(
{"text": "学习规则:风险建议先写事实依据再写行动"}
),
},
},
}
db = SessionLocal()
try:
result = FeishuEventService(db).handle_event(
payload,
source=FeishuEventSource.WEBHOOK,
auto_reply=False,
)
assert result["result"]["command"] == "rule_create"
rule = db.execute(
select(AIMemoryEntry).where(
AIMemoryEntry.content == "风险建议先写事实依据再写行动"
)
).scalar_one()
assert rule.actor == "ou_rule_teacher"
empty = FeishuCommandService(db).handle_text("学习规则:", auto_reply=False)
assert empty["command"] == "rule_create"
assert "不能为空" in empty["content"]
invalid_priority = FeishuCommandService(db).handle_text(
"学习规则 101先写结论",
auto_reply=False,
)
assert "1 到 100" in invalid_priority["content"]
secret = FeishuCommandService(db).handle_text(
"学习规则:请保存 password=example",
auto_reply=False,
)
assert "已拒绝学习" in secret["content"]
finally:
db.close()
def test_v3_request_id_health_and_metrics() -> None:
db = SessionLocal()
try:
@@ -1052,3 +1186,757 @@ def test_legacy_readonly_query_clamps_param_limit(monkeypatch) -> None:
finally:
monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False)
get_settings.cache_clear()
def test_intasect_mapping_uses_stable_ids_and_preserves_unknown_stage() -> None:
seen_at = datetime(2026, 7, 12, 9, 0)
project = _project_payload(
{
"source_id": 99,
"business_code": None,
"pro_sn": None,
"name": "Lifecycle Project",
"mgr_deptid": 7,
"mgr_deptname": "Delivery",
"mgr_user_id": 8,
"mgr_user_name": "Manager",
"project_stage": "ZZYGD",
"stage_label": "ZZYGD",
"archive_flag": "0",
"source_created_at": seen_at,
"source_updated_at": seen_at,
"contract_date": None,
"project_information": None,
},
seen_at,
)
employee = _employee_payload(
{
"source_id": 8,
"employee_name": "Employee",
"dept_id": 7,
"dept_name": "Delivery",
"employment_status": "0",
"ding_id": None,
"title": "Engineer",
"hired_date": None,
"source_created_at": seen_at,
"source_updated_at": seen_at,
},
seen_at,
)
assert project["code"] == "INTASECT-PROJECT-99"
assert project["display_code"] is None
assert project["source_stage_label"] == "ZZYGD"
assert project["progress_percent"] == 0
assert employee["code"] == "INTASECT-EMPLOYEE-8"
assert employee["ding_user_id"] is None
def test_intasect_full_sync_marks_missing_projects_inactive() -> None:
row = {
"source_key": "99101",
"source_id": 99101,
"business_code": "B-99101",
"pro_sn": None,
"name": "Synced Project",
"mgr_deptid": None,
"mgr_deptname": None,
"mgr_user_id": None,
"mgr_user_name": None,
"project_stage": "XMQD",
"stage_label": "项目启动",
"archive_flag": "0",
"source_created_at": datetime(2026, 1, 1),
"source_updated_at": datetime(2026, 7, 1),
"contract_date": None,
"project_done_date": None,
"project_information": None,
}
class FakeSource:
def __init__(self, rows):
self.rows = rows
def fetch_page(self, dataset, after_key, watermark_at, limit):
assert dataset == "projects"
assert watermark_at == EPOCH
return self.rows if not after_key else []
db = SessionLocal()
try:
IntasectSyncService(db, FakeSource([row])).sync_dataset("projects", "RUN-1")
project = db.execute(
select(Project).where(Project.external_id == "99101")
).scalar_one()
assert project.is_active is True
assert project.display_code == "B-99101"
IntasectSyncService(db, FakeSource([])).sync_dataset("projects", "RUN-2")
db.refresh(project)
assert project.is_active is False
finally:
db.close()
def test_personnel_lifecycle_does_not_treat_missing_ding_mapping_as_absence() -> None:
db = SessionLocal()
try:
employee = Employee(
code="INTASECT-EMPLOYEE-99102",
name="Lifecycle Employee",
department_name="Delivery",
employment_status="在职",
source_system="legacy_mysql",
external_id="99102",
ding_user_id=None,
is_active=True,
)
project = Project(
code="INTASECT-PROJECT-99102",
name="Lifecycle Report Project",
status=StatusValue.RUNNING,
source_system="legacy_mysql",
external_id="99102",
source_archived=False,
is_active=True,
)
db.add_all([employee, project])
db.flush()
db.add(
ProjectMember(
code="INTASECT-MEMBER-99102",
project_code=project.code,
employee_code=employee.code,
workload_percent=120,
source_system="legacy_mysql",
external_id="99102",
is_active=True,
)
)
db.add(
ProjectMilestone(
code="INTASECT-MILESTONE-99102",
project_code=project.code,
source_stage_id="stage-1",
stage_name="项目启动",
plan_end=date.today() - timedelta(days=1),
status=StatusValue.RUNNING,
is_overdue=True,
source_system="legacy_mysql",
external_id="99102",
is_active=True,
)
)
db.commit()
report = ReportService(db).personnel_lifecycle_report(
employee_code=employee.code,
project_code=project.code,
)
item = report["items"][0]
assert item["attendance_covered"] is False
assert item["attendance_abnormal"] == 0
assert item["needs_attention"] is True
management = ReportService(db).management_lifecycle_report(
ReportType.DAILY,
include_ai=True,
)
assert management["metrics"]["projects"]["milestones"]["overdue"] >= 1
assert management["ai_analysis"]["ok"] is False
assert "降级为确定性基础报告" not in management["content"]
assert "首期未接入" in management["content"]
finally:
db.close()
def test_lifecycle_pipeline_is_idempotent(monkeypatch) -> None:
monkeypatch.setattr(
IntasectSyncService,
"sync_all",
lambda self, run_code, force_full=False, batch_size=500: {
"projects": {"processed": 1}
},
)
monkeypatch.setattr(
ReportService,
"management_lifecycle_report",
lambda self, report_type, actor, include_ai: {
"title": "Lifecycle",
"report_type": report_type,
"lines": ["ok"],
"content": "ok",
"ai_analysis": {"ok": True, "answer": "analysis"},
},
)
monkeypatch.setattr(ReportService, "push_report", lambda self, *args, **kwargs: {"ok": True})
db = SessionLocal()
try:
service = LifecyclePipelineService(db)
first = service.run(ReportType.WEEKLY, actor="pytest")
second = service.run(ReportType.WEEKLY, actor="pytest")
assert first["deduplicated"] is False
assert second["deduplicated"] is True
assert first["workflow_code"] == second["workflow_code"]
finally:
db.close()
def test_lifecycle_enqueue_respects_read_only_guard(monkeypatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "true")
get_settings.cache_clear()
try:
response = client.post(
"/api/v1/reports/lifecycle/enqueue",
headers=headers,
json={"report_type": "daily"},
)
assert response.status_code == 405
finally:
monkeypatch.delenv("READ_ONLY_MODE", raising=False)
get_settings.cache_clear()
def test_lifecycle_report_api_validates_filters_and_report_type() -> None:
response = client.get(
"/api/v1/reports/personnel-lifecycle",
headers=headers,
params={"department": "Delivery"},
)
assert response.status_code == 200
assert "metrics" in response.json()
invalid = client.post(
"/api/v1/reports/lifecycle/enqueue",
headers=headers,
json={"report_type": "monthly"},
)
assert invalid.status_code == 422
def test_ai_unavailable_sends_notice_without_business_report(monkeypatch) -> None:
monkeypatch.setattr(
IntasectSyncService,
"sync_all",
lambda self, run_code, force_full=False, batch_size=500: {
"projects": {"processed": 1}
},
)
monkeypatch.setattr(
ReportService,
"management_lifecycle_report",
lambda self, report_type, actor, include_ai: {
"title": "Lifecycle",
"report_type": report_type,
"lines": ["must not be sent"],
"content": "must not be sent",
"ai_analysis": {"ok": False, "type": "TimeoutError"},
},
)
monkeypatch.setattr(
ReportService,
"push_report",
lambda self, *args, **kwargs: pytest.fail("business report must not be sent"),
)
monkeypatch.setattr(
LifecyclePipelineService,
"_notify_ai_unavailable",
lambda self, *args, **kwargs: True,
)
db = SessionLocal()
try:
result = LifecyclePipelineService(db).run(ReportType.DAILY, actor="pytest")
assert result["status"] == WorkflowStatus.FAILED
assert result["ai_unavailable"] is True
assert result["notified"] is True
finally:
db.close()
def test_user_rules_are_prioritized_in_ai_context(monkeypatch) -> None:
captured: dict = {}
class RuleAwareAdapter:
provider_name = "rule-aware"
def ask(self, prompt, context):
captured["context"] = context
return {"answer": "followed", "raw": {}}
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "false")
get_settings.cache_clear()
monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: RuleAwareAdapter())
db = SessionLocal()
try:
rule = AIMemoryService(db).create_rule(
content="所有项目风险建议必须注明负责人角色和完成时间",
scope="global",
subject="company",
priority=90,
tags=["report"],
actor="pytest",
)
from app.modules.ai_agent.service import AIService
response = AIService(db).ask("分析项目风险", actor="pytest")
assert response["answer"] == "followed"
assert captured["context"]["user_rules"][0]["rule"] == rule["content"]
AIMemoryService(db).update_rule(
code=rule["code"],
content=None,
priority=None,
tags=None,
enabled=False,
actor="pytest",
)
assert all(item["code"] != rule["code"] for item in AIMemoryService(db).active_rules())
finally:
db.close()
monkeypatch.delenv("AI_MEMORY_AUTO_WRITE_ENABLED", raising=False)
get_settings.cache_clear()
def test_lifecycle_chart_is_uploaded_and_embedded_in_feishu_card(monkeypatch) -> None:
chart_data = {
"period": "2026-07-11",
"projects": {"total": 100, "unarchived": 70, "archived": 30},
"risks": {"overdue_milestones": 5, "overdue_tasks": 8, "open_events": 3},
"people": {"active": 60, "attention": 7, "attendance_mapped": 42},
}
png = render_lifecycle_chart(chart_data)
assert png.startswith(b"\x89PNG\r\n\x1a\n")
captured: dict = {}
monkeypatch.setattr(
FeishuService,
"upload_image",
lambda self, image, actor: {"data": {"image_key": "img_test"}},
)
def fake_send_card(self, card, receive_id, receive_id_type, actor):
captured["card"] = card
return {"code": 0}
monkeypatch.setattr(FeishuService, "send_card", fake_send_card)
db = SessionLocal()
try:
ReportService(db).push_report(
{
"title": "Lifecycle",
"report_type": "daily",
"lines": ["AI analysis"],
"content": "AI analysis",
"chart_data": chart_data,
},
"chat-test",
"chat_id",
"pytest",
)
assert captured["card"]["elements"][0]["tag"] == "img"
assert captured["card"]["elements"][0]["img_key"] == "img_test"
finally:
db.close()
def test_user_rule_api_creates_and_disables_rule(monkeypatch) -> None:
monkeypatch.setenv("READ_ONLY_MODE", "false")
get_settings.cache_clear()
try:
created = client.post(
"/api/v1/ai/rules",
headers=headers,
json={
"content": "日报分析先说明延期项目,再给出负责人和时限",
"scope": "global",
"subject": "company",
"priority": 80,
"tags": ["daily"],
},
)
assert created.status_code == 200
code = created.json()["data"]["code"]
disabled = client.patch(
f"/api/v1/ai/rules/{code}",
headers=headers,
json={"enabled": False},
)
assert disabled.status_code == 200
assert disabled.json()["data"]["status"] == "archived"
finally:
monkeypatch.delenv("READ_ONLY_MODE", raising=False)
get_settings.cache_clear()
def test_intasect_finance_mapping_normalizes_units_and_excludes_sensitive_fields() -> None:
seen_at = datetime(2026, 7, 12, 10, 0)
project = _project_payload(
{
"source_id": 99301,
"business_code": "FIN-99301",
"pro_sn": None,
"name": "Finance Project",
"mgr_deptid": None,
"mgr_deptname": None,
"mgr_user_id": None,
"mgr_user_name": None,
"project_stage": "JD50",
"stage_label": "执行中",
"archive_flag": "0",
"contract_money": "12.34",
"project_invest_amount": "56.78",
"contract_date": None,
"project_done_date": None,
"project_information": None,
"source_created_at": seen_at,
"source_updated_at": seen_at,
},
seen_at,
)
contract = _contract_payload(
{
"source_id": 501,
"project_id": 99301,
"linked_project_id": 99301,
"project_del_flag": "0",
"contract_type_code": "3",
"contract_type_label": "监理合同",
"contract_amount": "123400",
"contract_date": date(2026, 1, 1),
"date_start": date(2026, 1, 1),
"date_end": date(2026, 12, 31),
"invoice_type_code": "D1",
"source_created_at": seen_at,
},
seen_at,
)
receivable = _contract_receivable_payload(
{
"source_id": 601,
"source_contract_id": 501,
"linked_contract_id": 501,
"project_id": 99301,
"linked_project_id": 99301,
"project_del_flag": "0",
"category_code": "HTQSH",
"category_label": "合同签署后",
"planned_amount": "1000",
"actual_amount": "200",
"planned_date": date(2026, 7, 20),
"actual_date": date(2026, 7, 10),
"payment_status": "N",
"invoice_status": "Y",
"source_created_at": seen_at,
},
seen_at,
)
fund = _project_fund_payload(
{
"source_id": 701,
"project_id": 99301,
"linked_project_id": 99301,
"project_del_flag": "0",
"cost_class": "0",
"category_code": "2",
"category_label": "投标保证金",
"planned_amount": "3000",
"approval_status": "1",
"confirm_status": "Y",
"trade_time": datetime(2026, 7, 11, 9, 0),
"source_created_at": seen_at,
"source_updated_at": seen_at,
},
seen_at,
)
assert project["source_contract_amount"] == 123400
assert project["source_project_investment_amount"] == 567800
assert contract["amount"] == 123400
assert receivable["direction"] == CashFlowDirection.INFLOW
assert receivable["data_quality_status"] == DataQualityStatus.STATUS_AMOUNT_MISMATCH
assert fund["direction"] == CashFlowDirection.OUTFLOW
assert fund["actual_amount"] == 3000
for sql in (CONTRACT_SQL, CONTRACT_RECEIVABLE_SQL, PROJECT_FUND_SQL):
lowered = sql.lower()
assert "bank_account" not in lowered
assert "phone" not in lowered
assert "payee_mobile" not in lowered
def test_project_finance_needs_calculates_horizons_and_funding_range() -> None:
db = SessionLocal()
project_code = "INTASECT-PROJECT-99302"
reference = date(2026, 7, 12)
try:
project = Project(
code=project_code,
display_code="FIN-99302",
name="Funding Needs Project",
status=StatusValue.RUNNING,
source_system="legacy_mysql",
external_id="99302",
source_archived=False,
is_active=True,
)
db.add(project)
db.add(
ProjectContract(
code="INTASECT-CONTRACT-99302",
project_code=project_code,
amount=10000,
data_quality_status=DataQualityStatus.VALID,
source_system="legacy_mysql",
external_id="99302",
is_active=True,
)
)
db.add_all(
[
ProjectCashFlow(
code="INTASECT-RECEIVABLE-9930201",
project_code=project_code,
contract_code="INTASECT-CONTRACT-99302",
flow_type=CashFlowType.CONTRACT_RECEIVABLE,
direction=CashFlowDirection.INFLOW,
planned_amount=1000,
actual_amount=200,
planned_date=reference + timedelta(days=8),
payment_status="N",
data_quality_status=DataQualityStatus.STATUS_AMOUNT_MISMATCH,
source_system="legacy_mysql",
external_id="receivable:9930201",
is_active=True,
),
ProjectCashFlow(
code="INTASECT-RECEIVABLE-9930202",
project_code=project_code,
contract_code="INTASECT-CONTRACT-99302",
flow_type=CashFlowType.CONTRACT_RECEIVABLE,
direction=CashFlowDirection.INFLOW,
planned_amount=500,
actual_amount=0,
planned_date=reference - timedelta(days=1),
payment_status="N",
data_quality_status=DataQualityStatus.VALID,
source_system="legacy_mysql",
external_id="receivable:9930202",
is_active=True,
),
ProjectCashFlow(
code="INTASECT-FUND-9930203",
project_code=project_code,
flow_type=CashFlowType.PROJECT_FUND,
direction=CashFlowDirection.OUTFLOW,
planned_amount=1500,
approval_status="1",
confirmation_status="N",
data_quality_status=DataQualityStatus.VALID,
source_system="legacy_mysql",
external_id="fund:9930203",
is_active=True,
),
ProjectCashFlow(
code="INTASECT-FUND-9930204",
project_code=project_code,
flow_type=CashFlowType.PROJECT_FUND,
direction=CashFlowDirection.OUTFLOW,
planned_amount=300,
actual_amount=300,
approval_status="1",
confirmation_status="Y",
data_quality_status=DataQualityStatus.VALID,
source_system="legacy_mysql",
external_id="fund:9930204",
is_active=True,
),
]
)
db.commit()
report = ReportService(db).project_finance_needs_report(
project_code=project_code,
as_of=reference,
)
item = report["items"][0]
assert item["actual_receipt"] == 200
assert item["overdue_receivable"] == 500
assert item["receivable_due"]["7"] == 0
assert item["receivable_due"]["30"] == 800
assert item["funding_need"]["7"] == {"lower": 1500, "upper": 1500}
assert item["funding_need"]["30"] == {"lower": 700, "upper": 1500}
assert report["summary"]["confirmed_outflow"] == 300
assert report["disclaimer"].startswith("项目资金安排需求不包含公司账户余额")
assert "不代表真实融资缺口" in report["content"]
finally:
db.close()
def test_project_finance_needs_does_not_render_missing_data_as_zero() -> None:
db = SessionLocal()
project_code = "INTASECT-PROJECT-99304"
try:
db.add(
Project(
code=project_code,
name="No Finance Data Project",
status=StatusValue.RUNNING,
source_system="legacy_mysql",
external_id="99304",
source_archived=False,
is_active=True,
)
)
db.commit()
report = ReportService(db).project_finance_needs_report(project_code=project_code)
assert report["summary"]["data_available"] is False
assert report["summary"]["contract_revenue"] is None
assert report["items"][0]["pending_outflow"] is None
assert "金额不按零值解释" in report["content"]
assert report["finance_chart_data"] is None
finally:
db.close()
def test_finance_sync_soft_deactivates_missing_contract() -> None:
row = {
"source_key": "99303",
"source_id": 99303,
"project_id": 99303,
"linked_project_id": 99303,
"project_del_flag": "0",
"contract_type_code": "3",
"contract_type_label": "监理合同",
"contract_amount": 5000,
"contract_date": date(2026, 1, 1),
"date_start": None,
"date_end": None,
"invoice_type_code": "D1",
"source_created_at": datetime(2026, 1, 1),
}
class FakeSource:
def __init__(self, rows):
self.rows = rows
def fetch_page(self, dataset, after_key, watermark_at, limit):
assert dataset == "contracts"
assert limit == 500
return self.rows if not after_key else []
db = SessionLocal()
try:
IntasectSyncService(db, FakeSource([row])).sync_dataset("contracts", "FIN-RUN-1")
contract = db.execute(
select(ProjectContract).where(ProjectContract.external_id == "99303")
).scalar_one()
assert contract.is_active is True
IntasectSyncService(db, FakeSource([])).sync_dataset("contracts", "FIN-RUN-2")
db.refresh(contract)
assert contract.is_active is False
finally:
db.close()
def test_finance_api_and_feishu_command_fail_closed_when_ai_unavailable(monkeypatch) -> None:
monkeypatch.setenv("FINANCE_NEEDS_ENABLED", "true")
get_settings.cache_clear()
try:
response = client.get(
"/api/v1/reports/project-finance-needs",
headers=headers,
params={"project_code": "INTASECT-PROJECT-99302", "as_of": "2026-07-12"},
)
assert response.status_code == 200
assert response.json()["currency"] == "CNY"
db = SessionLocal()
try:
result = FeishuCommandService(db).handle_text(
"项目资金 FIN-99302",
actor="ou_finance_test",
auto_reply=False,
)
assert result["command"] == "project_finance"
assert result["reply_type"] == "text"
assert "AI 当前不可用" in result["content"]
assert "Funding Needs Project" not in result["content"]
finally:
db.close()
finally:
monkeypatch.delenv("FINANCE_NEEDS_ENABLED", raising=False)
get_settings.cache_clear()
def test_feishu_finance_command_returns_ai_analysis_when_available(monkeypatch) -> None:
class FinanceAdapter:
provider_name = "finance-test"
def ask(self, prompt, context):
assert context["summary"]["data_available"] is True
assert "user_rules" in context
assert "owner" not in json.dumps(context["attention"], ensure_ascii=False)
return {"answer": "优先安排关键项目资金,并由财务负责人复核。", "raw": {}}
monkeypatch.setenv("FINANCE_NEEDS_ENABLED", "true")
monkeypatch.setenv("AI_MEMORY_AUTO_WRITE_ENABLED", "false")
get_settings.cache_clear()
monkeypatch.setattr("app.modules.ai_agent.service.get_adapter", lambda: FinanceAdapter())
db = SessionLocal()
rule = None
try:
rule = AIMemoryService(db).create_rule(
content="资金建议必须要求人工确认",
scope="global",
subject="company",
priority=95,
tags=["finance"],
actor="pytest",
)
result = FeishuCommandService(db).handle_text(
"项目资金 FIN-99302",
actor="ou_finance_test",
auto_reply=False,
)
assert result["command"] == "project_finance"
assert result["reply_type"] == "card"
assert "优先安排关键项目资金" in result["content"]
finally:
if rule is not None:
AIMemoryService(db).update_rule(
code=rule["code"],
content=None,
priority=None,
tags=None,
enabled=False,
actor="pytest",
)
db.close()
monkeypatch.delenv("FINANCE_NEEDS_ENABLED", raising=False)
monkeypatch.delenv("AI_MEMORY_AUTO_WRITE_ENABLED", raising=False)
get_settings.cache_clear()
def test_lifecycle_chart_renders_finance_section() -> None:
png = render_lifecycle_chart(
{
"period": "2026-07-12",
"projects": {"total": 1, "unarchived": 1, "archived": 0},
"risks": {},
"people": {},
"finance": {
"cashflows": {
"confirmed_inflow": 1000,
"confirmed_outflow": 300,
"pending_outflow": 700,
},
"top_projects": [{"name": "Project A", "amount": 700}],
},
}
)
assert png.startswith(b"\x89PNG\r\n\x1a\n")