Files
company-ai-platform/tests/test_smoke.py
JiuContinent 9cf7c44393 ```
feat: 添加生命周期报告和AI规则管理功能

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

1943 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import os
import tempfile
from datetime import date, datetime, timedelta
from pathlib import Path
import pytest
from fastapi import HTTPException
from sqlalchemy import select
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
from app.modules.business.constants import BusinessResponseKey, StatusValue
_db = tempfile.NamedTemporaryFile(delete=False, suffix=".db")
_db.close()
os.environ["DATABASE_URL"] = "sqlite:///" + _db.name.replace("\\", "/")
os.environ["API_KEY"] = "test-key"
os.environ["AUDIT_API_KEY"] = "audit-key"
os.environ["AUDIT_API_ACTOR"] = "audit-manager"
os.environ["FEISHU_APP_ID"] = ""
os.environ["FEISHU_APP_SECRET"] = ""
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
os.environ["LEGACY_DATABASE_URL"] = ""
os.environ["LEGACY_PROJECT_QUERY"] = ""
os.environ["MODEL_PROVIDER"] = AIProviderName.NOOP
os.environ["SCHEDULER_ENABLED"] = "false"
from fastapi.testclient import TestClient
from app.core.config import Settings, get_settings
from app.core.database import Base, SessionLocal, engine
from app.core.http.pagination import bounded_limit, bounded_offset
from app.core.security import require_api_key, require_audit_api_key
from app.main import _allow_cors_credentials, app
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
from app.modules.ai_memory.constants import (
AIMemoryPayloadKey,
AIMemoryResponseKey,
AIMemoryStatus,
)
from app.modules.events.constants import (
EventAggregateType,
EventPayloadKey,
EventSource,
EventStatus,
EventType,
)
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,
)
from app.modules.observability.service import ObservabilityService
from app.modules.reports.constants import (
EnterpriseAnalyticsKey,
LifecycleAttentionKey,
LifecycleResponseKey,
LifecycleSection,
MetricKey,
ReportPushStatus,
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
Base.metadata.create_all(bind=engine)
client = TestClient(app)
headers = {"X-API-Key": "test-key"}
audit_headers = {"X-API-Key": "test-key", "X-Audit-API-Key": "audit-key"}
class SeedResponse:
def __init__(self, payload: dict, status_code: int = 200):
self.status_code = status_code
self._payload = payload
def json(self) -> dict:
return self._payload
def create_business_record(domain: str, data: dict, actor: str = "pytest") -> SeedResponse:
_ = actor
db = SessionLocal()
try:
model = get_domain_model(domain)
record = model(**_model_payload(domain, model, data))
db.add(record)
db.commit()
db.refresh(record)
return SeedResponse(
{
BusinessResponseKey.DOMAIN: domain,
BusinessResponseKey.DATA: serialize_model(record),
}
)
finally:
db.close()
def teardown_module() -> None:
engine.dispose()
path = Path(_db.name)
if path.exists():
path.unlink()
def test_project_report_and_feishu_command_preview() -> None:
response = create_business_record(
"projects",
{
"code": "P-SMOKE-001",
"name": "Smoke Project",
"owner": "tester",
"status": "执行中",
"budget_amount": 1000,
"actual_amount": 200,
},
)
assert response.status_code == 200
assert response.json()["data"]["code"] == "P-SMOKE-001"
response = client.get("/api/v1/reports/daily-brief", headers=headers)
assert response.status_code == 200
assert response.json()["title"] == "每日经营晨报"
response = client.post(
"/api/v1/integrations/feishu/commands/preview",
headers=headers,
json={"text": "日报", "auto_reply": False},
)
assert response.status_code == 200
assert response.json()["command"] == "daily_brief"
def test_feishu_webhook_routes_message_event() -> None:
payload = {
"schema": "2.0",
"header": {
"event_id": "evt-smoke-risk-001",
"event_type": "im.message.receive_v1",
"token": "test-feishu-token",
},
"event": {
"sender": {"sender_id": {"open_id": "ou_test"}},
"message": {
"chat_id": "oc_test",
"message_id": "om_smoke_risk_001",
"message_type": "text",
"content": json.dumps({"text": "risk"}),
},
},
}
response = client.post("/api/v1/integrations/feishu/webhook", json=payload)
assert response.status_code == 200
data = response.json()
assert data["handled"] is True
assert data["result"]["command"] == "risk_summary"
duplicate_response = client.post("/api/v1/integrations/feishu/webhook", json=payload)
assert duplicate_response.status_code == 200
assert duplicate_response.json()["duplicate"] is True
blocked_logs_response = client.get("/api/v1/audit/logs", headers=headers)
assert blocked_logs_response.status_code == 401
logs_response = client.get("/api/v1/audit/logs", headers=audit_headers)
assert logs_response.status_code == 200
audit_payload = json.dumps(logs_response.json(), ensure_ascii=False)
assert "test-feishu-token" not in audit_payload
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:
ObservabilityService(db).record_heartbeat(
component=HeartbeatComponent.WORKER,
instance_id="pytest-worker",
actor="pytest",
)
finally:
db.close()
response = client.get("/api/v1/health/live", headers={"X-Request-ID": "rid-v3-smoke"})
assert response.status_code == 200
assert response.headers["X-Request-ID"] == "rid-v3-smoke"
assert response.json()["status"] == "ok"
response = client.get("/api/v1/health/ready")
assert response.status_code == 200
assert response.json()["status"] in {"ok", "degraded"}
response = client.get("/api/v1/metrics", headers=headers)
assert response.status_code == 200
metrics = response.json()["metrics"]
assert ObservabilityKey.EVENTS in metrics
assert ObservabilityKey.HEARTBEATS in metrics
def test_v3_event_idempotency_and_workflow_dispatch() -> None:
from app.core.database import SessionLocal
db = SessionLocal()
try:
service = EventService(db)
event = service.emit(
event_type=EventType.RISK_ACTION_RECORDED,
source=EventSource.RISK,
aggregate_type=EventAggregateType.RISK_EVENT,
aggregate_id="risk-v3-idem",
actor="pytest",
payload={
EventPayloadKey.ACTION: RiskEventActionValue.ASSIGN,
EventPayloadKey.STATUS: StatusValue.OPEN,
},
idempotency_key="v3-risk-idempotency",
dispatch=True,
)
duplicate = service.emit(
event_type=EventType.RISK_ACTION_RECORDED,
source=EventSource.RISK,
aggregate_type=EventAggregateType.RISK_EVENT,
aggregate_id="risk-v3-idem",
actor="pytest",
payload={
EventPayloadKey.ACTION: RiskEventActionValue.ASSIGN,
EventPayloadKey.STATUS: StatusValue.OPEN,
},
idempotency_key="v3-risk-idempotency",
dispatch=True,
)
assert duplicate.event_id == event.event_id
assert event.status == EventStatus.PROCESSED
workflow = db.execute(
select(WorkflowInstance).where(
WorkflowInstance.workflow_type == WorkflowType.RISK_EVENT_REVIEW,
WorkflowInstance.aggregate_id == "risk-v3-idem",
)
).scalar_one()
assert workflow.status == WorkflowStatus.RUNNING
finally:
db.close()
def test_v3_event_retry_and_dispatch_pending_route() -> None:
db = SessionLocal()
try:
event = EventService(db).emit(
event_type=EventType.REPORT_PUSH_FAILED,
source=EventSource.REPORTS,
aggregate_type=EventAggregateType.REPORT_PUSH_RUN,
aggregate_id="push-v3-retry",
actor="pytest",
payload={
EventPayloadKey.CODE: "push-v3-retry",
EventPayloadKey.STATUS: ReportPushStatus.FAILED,
},
idempotency_key="v3-report-push-retry",
)
event.status = EventStatus.FAILED
event.last_error = "transient"
event.attempts = event.max_attempts
db.commit()
event_id = event.event_id
finally:
db.close()
retry_response = client.post(f"/api/v1/events/{event_id}/retry", headers=headers)
assert retry_response.status_code == 200
assert retry_response.json()["event"]["status"] == EventStatus.PENDING
assert retry_response.json()["event"]["attempts"] == 0
dispatch_response = client.post("/api/v1/events/dispatch-pending", headers=headers)
assert dispatch_response.status_code == 200
dispatched = [
item for item in dispatch_response.json()["items"] if item["event_id"] == event_id
]
assert dispatched
assert dispatched[0]["status"] == EventStatus.PROCESSED
def test_v3_ai_memory_recall_and_auto_write() -> None:
response = client.post(
"/api/v1/ai/ask",
headers=headers,
json={
"prompt": "Summarize quarterly cash planning for project memory smoke",
"context": {
AIMemoryPayloadKey.SCOPE: "project",
AIMemoryPayloadKey.SUBJECT: "P-MEM-SMOKE",
},
},
)
assert response.status_code == 200
data = response.json()
assert data[AIResponseKey.PROVIDER] == AIProviderName.NOOP
assert data[AIResponseKey.RAW][AIResponseKey.MEMORY_WRITE][AIMemoryPayloadKey.STATUS] == (
AIMemoryStatus.ACTIVE
)
list_response = client.get(
"/api/v1/ai/memory?scope=project&subject=P-MEM-SMOKE",
headers=headers,
)
assert list_response.status_code == 200
assert list_response.json()[AIMemoryResponseKey.ITEMS]
recall_response = client.post(
"/api/v1/ai/memory/recall",
headers=headers,
json={
"query": "quarterly cash planning",
"scope": "project",
"subject": "P-MEM-SMOKE",
},
)
assert recall_response.status_code == 200
assert recall_response.json()[AIMemoryResponseKey.ITEMS]
def test_v3_risk_action_routes_are_disabled_in_read_only_mode() -> None:
response = create_business_record(
"risk-events",
{
"code": "RISK-V3-WF-001",
"title": "V3 workflow risk",
"risk_type": "manual",
"source_domain": "projects",
},
)
assert response.status_code == 200
risk_id = response.json()["data"]["id"]
response = client.post(
f"/api/v1/risks/events/{risk_id}/assign",
headers=headers,
json={"assigned_to": "risk-owner", "comment": "route to owner"},
)
assert response.status_code == 405
def test_writeback_and_approval_routes_are_removed() -> None:
response = client.post(
"/api/v1/writebacks",
headers=headers,
json={
"domain": "projects",
"record_id": "P-V3-WB",
"action": "sync",
"payload": {"code": "P-V3-WB", "name": "Writeback target"},
},
)
assert response.status_code == 404
response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "projects",
"record_id": "P-V3-WB",
"action": "writeback:projects",
"reason": "V3 writeback gate",
"payload": {"code": "P-V3-WB", "name": "Writeback target"},
},
)
assert response.status_code == 404
def test_feishu_webhook_challenge_uses_event_service_verification() -> None:
response = client.post(
"/api/v1/integrations/feishu/webhook",
json={
"challenge": "challenge-token",
"token": "test-feishu-token",
},
)
assert response.status_code == 200
assert response.json()["challenge"] == "challenge-token"
def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None:
monkeypatch.setenv("API_KEY", "")
get_settings.cache_clear()
try:
with pytest.raises(HTTPException) as exc_info:
require_api_key("test-key")
assert exc_info.value.status_code == 503
monkeypatch.setenv("API_KEY", "test-key")
get_settings.cache_clear()
response = client.post(
"/api/v1/integrations/feishu/webhook",
json={"schema": "2.0", "header": {"event_type": "im.message.receive_v1"}},
)
assert response.status_code == 401
monkeypatch.setenv("AUDIT_API_KEY", "")
get_settings.cache_clear()
with pytest.raises(HTTPException) as audit_exc_info:
require_audit_api_key("audit-key")
assert audit_exc_info.value.status_code == 503
finally:
monkeypatch.setenv("API_KEY", "test-key")
monkeypatch.setenv("AUDIT_API_KEY", "audit-key")
get_settings.cache_clear()
def test_service_key_rotation_config(monkeypatch) -> None:
monkeypatch.setenv("API_KEY", "")
monkeypatch.setenv(
"API_KEYS",
json.dumps(
[
{"key": "disabled-key", "actor": "disabled", "enabled": False},
{"key": "rotated-key", "actor": "rotated-api", "enabled": True},
]
),
)
get_settings.cache_clear()
try:
assert require_api_key("rotated-key").actor == "rotated-api"
with pytest.raises(HTTPException) as exc_info:
require_api_key("disabled-key")
assert exc_info.value.status_code == 401
finally:
monkeypatch.setenv("API_KEY", "test-key")
monkeypatch.delenv("API_KEYS", raising=False)
get_settings.cache_clear()
def test_config_and_pagination_guardrails() -> None:
settings = Settings(cors_origins='["https://app.example.com", "https://admin.example.com"]')
assert settings.cors_origins == ["https://app.example.com", "https://admin.example.com"]
assert _allow_cors_credentials(["*"]) is False
assert _allow_cors_credentials(["https://app.example.com"]) is True
assert bounded_limit(-1) == 1
assert bounded_limit(1000) == 500
assert bounded_offset(-10) == 0
negative_limit_response = client.get("/api/v1/business/projects?limit=-1", headers=headers)
assert negative_limit_response.status_code == 422
oversized_limit_response = client.get("/api/v1/risks/events?limit=501", headers=headers)
assert oversized_limit_response.status_code == 422
negative_offset_response = client.get("/api/v1/business/projects?offset=-1", headers=headers)
assert negative_offset_response.status_code == 422
def test_dashboard_and_response_masking() -> None:
expense_response = create_business_record(
"expenses",
{
"code": "EXP-MASK-001",
"expense_type": "办公",
"amount": 20,
"payment_account": "6222000000000000",
},
)
assert expense_response.status_code == 200
masked_response = client.get("/api/v1/business/expenses", headers=headers)
assert masked_response.status_code == 200
masked_items = masked_response.json()["items"]
assert any(item["code"] == "EXP-MASK-001" for item in masked_items)
assert next(
item["payment_account"] for item in masked_items if item["code"] == "EXP-MASK-001"
) == "[MASKED]"
dashboard_response = client.get("/api/v1/dashboard/summary", headers=headers)
assert dashboard_response.status_code == 200
assert "metrics" in dashboard_response.json()
def test_configured_domain_response_masking(monkeypatch) -> None:
monkeypatch.setenv("MASKED_RESPONSE_FIELDS", json.dumps(["expenses.amount"]))
get_settings.cache_clear()
try:
response = create_business_record(
"expenses",
{
"code": "EXP-MASK-CONFIG-001",
"expense_type": "测试",
"amount": 123,
},
)
assert response.status_code == 200
list_response = client.get("/api/v1/business/expenses", headers=headers)
assert list_response.status_code == 200
item = next(
item
for item in list_response.json()["items"]
if item["code"] == "EXP-MASK-CONFIG-001"
)
assert item["amount"] == "[MASKED]"
finally:
monkeypatch.delenv("MASKED_RESPONSE_FIELDS", raising=False)
get_settings.cache_clear()
def test_business_write_routes_are_disabled_in_read_only_mode() -> None:
create_response = client.post(
"/api/v1/business/projects",
headers=headers,
json={
"data": {
"code": "P-APPROVAL-BLOCKED",
"name": "Blocked project",
},
},
)
assert create_response.status_code == 405
update_response = client.patch(
"/api/v1/business/projects/1",
headers=headers,
json={
"data": {
"name": "Blocked update",
},
},
)
assert update_response.status_code == 405
def test_approval_and_feishu_approval_card_routes_are_removed() -> None:
approval_response = client.post(
"/api/v1/approvals",
headers=headers,
json={
"domain": "fund-accounts",
"record_id": "feishu-card-test",
"action": "update:fund-accounts",
"reason": "Card action smoke test",
"payload": {"current_balance": 300},
},
)
assert approval_response.status_code == 404
callback_response = client.post(
"/api/v1/integrations/feishu/approval-card-action",
json={
"token": "test-feishu-token",
"operator": {"operator_id": {"open_id": "ou_card_approver"}},
"action": {
"value": {
"ticket_id": "APR-DISABLED",
"decision": "approve",
"comment": "approved from card",
}
},
},
)
assert callback_response.status_code == 404
def test_new_ledgers_reports_and_risk_events() -> None:
domains_response = client.get("/api/v1/business/domains", headers=headers)
assert domains_response.status_code == 200
domains = domains_response.json()["domains"]
assert "attendance-records" in domains
assert "work-reports" in domains
assert "risk-events" in domains
today = date.today()
attendance_response = create_business_record(
"attendance-records",
{
"code": "ATT-SMOKE-001",
"employee_name": "Tester",
"department": "QA",
"work_date": today.isoformat(),
"status": "正常",
},
)
assert attendance_response.status_code == 200
task_response = create_business_record(
"tasks",
{
"code": "TASK-RISK-001",
"title": "Overdue smoke task",
"owner": "tester",
"status": "待办",
"due_date": (today - timedelta(days=1)).isoformat(),
},
)
assert task_response.status_code == 200
attendance_summary = client.get("/api/v1/reports/attendance-summary", headers=headers)
assert attendance_summary.status_code == 200
assert attendance_summary.json()["total"] >= 1
report_response = client.post(
"/api/v1/reports/work-reports/generate",
headers=headers,
json={"report_type": ReportType.DAILY, "reporter": "pytest", "actor": "pytest"},
)
assert report_response.status_code == 200
assert report_response.json()["data"] is None
assert report_response.json()["report"]["report_type"] == ReportType.DAILY
risk_response = client.post(
"/api/v1/risks/events/generate?actor=pytest",
headers=headers,
)
assert risk_response.status_code == 405
enqueue_response = client.post("/api/v1/risks/events/enqueue", headers=headers)
assert enqueue_response.status_code == 405
overdue_response = client.get("/api/v1/risks/overdue-tasks", headers=headers)
assert overdue_response.status_code == 200
assert any(item["code"] == "TASK-RISK-001" for item in overdue_response.json()["items"])
def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None:
today = date.today()
project_code = "P-LIFECYCLE-001"
project_response = create_business_record(
"projects",
{
"code": project_code,
"name": "Lifecycle Project",
"owner": "lifecycle-owner",
"status": "执行中",
"progress_percent": 40,
"budget_amount": 1000,
"actual_amount": 1500,
"due_date": (today - timedelta(days=1)).isoformat(),
},
)
assert project_response.status_code == 200
task_response = create_business_record(
"tasks",
{
"code": "TASK-LIFECYCLE-001",
"title": "Lifecycle overdue task",
"project_code": project_code,
"owner": "lifecycle-owner",
"status": "待办",
"due_date": (today - timedelta(days=1)).isoformat(),
"blocker": "waiting for decision",
},
)
assert task_response.status_code == 200
procurement_response = create_business_record(
"procurements",
{
"code": "PROC-LIFECYCLE-001",
"name": "Lifecycle procurement",
"project_code": project_code,
"expected_amount": 300,
"actual_amount": 100,
"approval_status": StatusValue.PENDING_APPROVAL,
"delivery_status": StatusValue.UNDELIVERED,
"payment_status": StatusValue.UNPAID,
},
)
assert procurement_response.status_code == 200
expense_response = create_business_record(
"expenses",
{
"code": "EXP-LIFECYCLE-001",
"expense_type": "差旅",
"amount": 80,
"project_code": project_code,
"approval_status": StatusValue.PENDING_APPROVAL,
"payment_status": StatusValue.UNPAID,
},
)
assert expense_response.status_code == 200
attendance_response = create_business_record(
"attendance-records",
{
"code": "ATT-LIFECYCLE-001",
"employee_name": "Lifecycle Tester",
"project_code": project_code,
"work_date": today.isoformat(),
"status": StatusValue.MISSING_PUNCH,
},
)
assert attendance_response.status_code == 200
response = client.get(
f"/api/v1/reports/project-lifecycle?project_code={project_code}",
headers=headers,
)
assert response.status_code == 200
data = response.json()
assert data[LifecycleResponseKey.TITLE] == ReportTitle.PROJECT_LIFECYCLE
assert data[LifecycleResponseKey.METRICS][LifecycleSection.PROJECTS][MetricKey.TOTAL] == 1
assert data[LifecycleResponseKey.METRICS][LifecycleSection.PROJECTS][MetricKey.DELAYED] == 1
assert (
data[LifecycleResponseKey.METRICS][LifecycleSection.PROJECTS][MetricKey.OVER_BUDGET]
== 1
)
assert data[LifecycleResponseKey.METRICS][LifecycleSection.TASKS][MetricKey.OVERDUE] == 1
assert (
data[LifecycleResponseKey.METRICS][LifecycleSection.PROCUREMENTS][
MetricKey.PENDING_APPROVAL
]
== 1
)
assert (
data[LifecycleResponseKey.METRICS][LifecycleSection.EXPENSES][
MetricKey.PENDING_APPROVAL
]
== 1
)
assert data[LifecycleResponseKey.METRICS][LifecycleSection.ATTENDANCE][MetricKey.ABNORMAL] == 1
assert (
data[LifecycleResponseKey.ATTENTION][LifecycleAttentionKey.DELAYED_PROJECTS][0]["code"]
== project_code
)
assert "生命周期健康分" in data[LifecycleResponseKey.CONTENT]
assert data[LifecycleResponseKey.RECOMMENDATIONS]
ai_response = client.get(
f"/api/v1/reports/project-lifecycle?project_code={project_code}&include_ai=true",
headers=headers,
)
assert ai_response.status_code == 200
ai_data = ai_response.json()
assert ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.OK] is True
assert (
ai_data[LifecycleResponseKey.AI_ANALYSIS][AIResponseKey.PROVIDER]
== AIProviderName.NOOP
)
def test_v3_enterprise_analytics_returns_read_only_sections() -> None:
performance_response = create_business_record(
"performance-metrics",
{
"code": "PERF-V3-001",
"name": "V3 delivery score",
"weight": 20,
"auto_score": 82,
"confirmed_score": 78,
"status": StatusValue.REVIEWED,
},
)
assert performance_response.status_code == 200
response = client.get("/api/v1/reports/enterprise-analytics", headers=headers)
assert response.status_code == 200
data = response.json()
assert data[EnterpriseAnalyticsKey.TITLE] == ReportTitle.ENTERPRISE_ANALYTICS
assert EnterpriseAnalyticsKey.FINANCE in data
assert EnterpriseAnalyticsKey.PROCUREMENT in data
assert EnterpriseAnalyticsKey.PERFORMANCE in data
assert EnterpriseAnalyticsKey.OPERATIONS in data
assert data[EnterpriseAnalyticsKey.PERFORMANCE][MetricKey.CONFIRMED] >= 1
db = SessionLocal()
try:
workflow = db.execute(
select(WorkflowInstance).where(
WorkflowInstance.workflow_type == WorkflowType.ENTERPRISE_ANALYTICS,
WorkflowInstance.aggregate_id == data[EnterpriseAnalyticsKey.CODE],
)
).scalar_one()
assert workflow.status == WorkflowStatus.COMPLETED
finally:
db.close()
def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
today = date.today()
project_code = "P-BACKLOG-001"
report_day = today - timedelta(days=7)
procurement_response = create_business_record(
"procurements",
{
"code": "PROC-BACKLOG-001",
"name": "Backlog procurement",
"project_code": project_code,
"approval_status": StatusValue.PENDING_APPROVAL,
},
)
assert procurement_response.status_code == 200
expense_response = create_business_record(
"expenses",
{
"code": "EXP-BACKLOG-001",
"expense_type": "办公",
"amount": 50,
"project_code": project_code,
"approval_status": StatusValue.PENDING_APPROVAL,
},
)
assert expense_response.status_code == 200
report_response = client.post(
"/api/v1/reports/work-reports/generate",
headers=headers,
json={
"report_type": ReportType.DAILY,
"project_code": project_code,
"period_start": report_day.isoformat(),
"period_end": report_day.isoformat(),
"persist": False,
},
)
assert report_response.status_code == 200
metrics = report_response.json()["report"]["metrics"]
assert metrics["procurements_pending"] == 1
assert metrics["expenses_pending"] == 1
def test_legacy_task_read_query_allowed_but_sync_disabled(monkeypatch) -> None:
rows = [
{
"id": 9001,
"task_name": "Legacy task one",
"project_code": "P-SMOKE-001",
"owner": "legacy-owner",
"status": "待办",
}
]
def fake_execute_allowed_query(self, query_name, params=None, limit=100):
return {"columns": list(rows[0]), "rows": rows, "row_count": len(rows)}
monkeypatch.setattr(
LegacyMySQLService,
"execute_allowed_query",
fake_execute_allowed_query,
)
query_response = client.post(
"/api/v1/integrations/mysql/query",
headers=headers,
json={"query_name": "legacy_tasks"},
)
assert query_response.status_code == 200
assert query_response.json()["row_count"] == 1
assert query_response.json()["rows"][0]["task_name"] == "Legacy task one"
response = client.post(
"/api/v1/integrations/mysql/tasks/sync",
headers=headers,
json={
"dry_run": False,
"field_map": {"title": "task_name"},
},
)
assert response.status_code == 405
def test_risk_event_action_routes_are_disabled_in_read_only_mode() -> None:
create_response = create_business_record(
"risk-events",
{
"code": "RISK-FLOW-001",
"title": "Workflow risk",
"risk_type": "manual",
"risk_level": "medium",
"source_domain": "projects",
"source_record_id": "P-SMOKE-001",
"status": "open",
},
)
assert create_response.status_code == 200
event_id = create_response.json()["data"]["id"]
assign_response = client.post(
f"/api/v1/risks/events/{event_id}/assign",
headers=headers,
json={"assigned_to": "risk-owner", "comment": "please handle"},
)
assert assign_response.status_code == 405
comment_response = client.post(
f"/api/v1/risks/events/{event_id}/comment",
headers=headers,
json={"comment": "working on it", "payload": {"step": 1}},
)
assert comment_response.status_code == 405
resolve_response = client.post(
f"/api/v1/risks/events/{event_id}/resolve",
headers=headers,
json={"comment": "resolved"},
)
assert resolve_response.status_code == 405
close_response = client.post(
f"/api/v1/risks/events/{event_id}/close",
headers=headers,
json={
"closed_reason": "verified",
"review_summary": "handled",
},
)
assert close_response.status_code == 405
reopen_response = client.post(
f"/api/v1/risks/events/{event_id}/reopen",
headers=headers,
json={"comment": "recheck"},
)
assert reopen_response.status_code == 405
actions_response = client.get(
f"/api/v1/risks/events/{event_id}/actions",
headers=headers,
)
assert actions_response.status_code == 200
assert actions_response.json()["items"] == []
def test_report_push_failure_is_recorded() -> None:
response = client.post(
"/api/v1/reports/daily-brief/push",
headers=headers,
json={"receive_id": "oc_missing_config"},
)
assert response.status_code == 503
runs_response = client.get(
f"/api/v1/reports/push-runs?status={ReportPushStatus.FAILED}",
headers=headers,
)
assert runs_response.status_code == 200
assert any(
item["title"] == ReportTitle.DAILY_BRIEF
for item in runs_response.json()["items"]
)
dashboard_response = client.get("/api/v1/dashboard/summary", headers=headers)
assert dashboard_response.status_code == 200
assert dashboard_response.json()["metrics"]["failed_push_runs"] >= 1
def test_ai_noop_provider() -> None:
response = client.post(
"/api/v1/ai/ask",
headers=headers,
json={
"prompt": "生成项目摘要",
"actor": "pytest",
"context": {"project": "P-SMOKE-001"},
},
)
assert response.status_code == 200
assert response.json()[AIResponseKey.PROVIDER] == AIProviderName.NOOP
def test_legacy_project_payload_does_not_create_legacy_none_code() -> None:
payload = LegacyMySQLService(None)._project_payload({"name": "Missing Id"}, {})
assert payload["code"] is None
assert payload["external_id"] is None
def test_legacy_readonly_query_requires_allowlist(monkeypatch) -> None:
monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False)
get_settings.cache_clear()
try:
with pytest.raises(HTTPException) as blocked_exc_info:
LegacyMySQLService(None).execute_readonly("SELECT id FROM secret_projects")
assert blocked_exc_info.value.status_code == 403
monkeypatch.setenv("LEGACY_PROJECT_QUERY", "SELECT id FROM projects")
get_settings.cache_clear()
def unavailable_engine():
raise HTTPException(status_code=503, detail="legacy unavailable")
monkeypatch.setattr(
LegacyMySQLService,
"_ensure_engine",
staticmethod(unavailable_engine),
)
with pytest.raises(HTTPException) as engine_exc_info:
LegacyMySQLService(None).execute_readonly("SELECT id FROM projects")
assert engine_exc_info.value.status_code == 503
finally:
monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False)
get_settings.cache_clear()
def test_legacy_readonly_query_clamps_param_limit(monkeypatch) -> None:
captured: dict[str, dict] = {}
class FakeResult:
def mappings(self) -> "FakeResult":
return self
def all(self) -> list:
return []
class FakeConnection:
def __enter__(self) -> "FakeConnection":
return self
def __exit__(self, exc_type, exc, traceback) -> None:
return None
def execute(self, statement, params):
captured["params"] = params
return FakeResult()
class FakeEngine:
def connect(self) -> FakeConnection:
return FakeConnection()
monkeypatch.setenv("LEGACY_PROJECT_QUERY", "SELECT id FROM projects LIMIT :limit")
get_settings.cache_clear()
monkeypatch.setattr(
LegacyMySQLService,
"_ensure_engine",
staticmethod(lambda: FakeEngine()),
)
try:
result = LegacyMySQLService(None).execute_readonly(
"SELECT id FROM projects LIMIT :limit",
{"limit": 9999},
limit=9999,
)
assert result["row_count"] == 0
assert captured["params"]["limit"] == 500
with pytest.raises(HTTPException) as exc_info:
LegacyMySQLService(None).execute_readonly(
"SELECT id FROM projects LIMIT :limit",
{"limit": "invalid"},
)
assert exc_info.value.status_code == 422
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")