```
feat: 添加数据库迁移脚本并更新Dockerfile配置 - 在Dockerfile中添加alembic配置文件和目录的复制指令 - 更新alembic/env.py注册新的模块模型:events、workflows、writebacks - 生成完整的初始数据库schema迁移脚本,包含以下表: - approval_requests, attendance_records, audit_logs, domain_events - expenses, feishu_event_receipts, fund_accounts, legacy_sync_runs - official_writeback_runs, performance_metrics, policies, procurements - projects, report_push_runs, risk_event_actions, risk_events - standards, suppliers, work_reports, work_tasks, workflow_actions - workflow_instances等21个数据表结构定义 - 在API路由器中添加新模块的路由:events、workflows、writebacks、observability ```
This commit is contained in:
@@ -6,6 +6,7 @@ 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 StatusValue
|
||||
@@ -22,6 +23,7 @@ os.environ["APPROVAL_API_ACTOR"] = "approval-manager"
|
||||
os.environ["FEISHU_APP_ID"] = ""
|
||||
os.environ["FEISHU_APP_SECRET"] = ""
|
||||
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
|
||||
os.environ["FEISHU_APPROVAL_APPROVER_IDS"] = json.dumps(["ou_card_approver"])
|
||||
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
|
||||
os.environ["LEGACY_DATABASE_URL"] = ""
|
||||
os.environ["LEGACY_PROJECT_QUERY"] = ""
|
||||
@@ -36,6 +38,18 @@ from app.core.pagination import bounded_limit, bounded_offset
|
||||
from app.core.security import require_api_key, require_approval_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.events.constants import (
|
||||
EventAggregateType,
|
||||
EventPayloadKey,
|
||||
EventSource,
|
||||
EventStatus,
|
||||
EventType,
|
||||
)
|
||||
from app.modules.events.service import EventService
|
||||
from app.modules.feishu.constants import (
|
||||
FEISHU_APPROVAL_APPROVER_IDS_REQUIRED,
|
||||
FEISHU_APPROVER_NOT_ALLOWED,
|
||||
)
|
||||
from app.modules.legacy_mysql.service import LegacyMySQLService
|
||||
from app.modules.reports.constants import (
|
||||
LifecycleAttentionKey,
|
||||
@@ -46,6 +60,10 @@ from app.modules.reports.constants import (
|
||||
ReportTitle,
|
||||
ReportType,
|
||||
)
|
||||
from app.modules.risk.constants import RiskEventActionValue
|
||||
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
|
||||
from app.modules.workflows.models import WorkflowInstance
|
||||
from app.modules.writebacks.constants import WritebackStatus
|
||||
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -55,6 +73,42 @@ audit_headers = {"X-API-Key": "test-key", "X-Audit-API-Key": "audit-key"}
|
||||
approval_headers = {"X-API-Key": "test-key", "X-Approval-API-Key": "approval-key"}
|
||||
|
||||
|
||||
def approve_change(
|
||||
domain: str,
|
||||
action: str,
|
||||
payload: dict,
|
||||
record_id: str | int | None = None,
|
||||
) -> str:
|
||||
request_payload: dict[str, object] = {
|
||||
"domain": domain,
|
||||
"action": action,
|
||||
"reason": "pytest approval",
|
||||
"payload": payload,
|
||||
}
|
||||
if record_id is not None:
|
||||
request_payload["record_id"] = str(record_id)
|
||||
response = client.post("/api/v1/approvals", headers=headers, json=request_payload)
|
||||
assert response.status_code == 200
|
||||
ticket_id = response.json()["ticket_id"]
|
||||
|
||||
approve_response = client.post(
|
||||
f"/api/v1/approvals/{ticket_id}/approve",
|
||||
headers=approval_headers,
|
||||
json={"comment": "pytest approved"},
|
||||
)
|
||||
assert approve_response.status_code == 200
|
||||
return ticket_id
|
||||
|
||||
|
||||
def create_business_record(domain: str, data: dict, actor: str = "pytest"):
|
||||
ticket_id = approve_change(domain, f"create:{domain}", data)
|
||||
return client.post(
|
||||
f"/api/v1/business/{domain}",
|
||||
headers=headers,
|
||||
json={"actor": actor, "approval_ticket_id": ticket_id, "data": data},
|
||||
)
|
||||
|
||||
|
||||
def teardown_module() -> None:
|
||||
engine.dispose()
|
||||
path = Path(_db.name)
|
||||
@@ -63,19 +117,15 @@ def teardown_module() -> None:
|
||||
|
||||
|
||||
def test_project_report_and_feishu_command_preview() -> None:
|
||||
response = client.post(
|
||||
"/api/v1/business/projects",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "pytest",
|
||||
"data": {
|
||||
"code": "P-SMOKE-001",
|
||||
"name": "Smoke Project",
|
||||
"owner": "tester",
|
||||
"status": "执行中",
|
||||
"budget_amount": 1000,
|
||||
"actual_amount": 200,
|
||||
},
|
||||
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
|
||||
@@ -132,6 +182,167 @@ def test_feishu_webhook_routes_message_event() -> None:
|
||||
assert AUDIT_REDACTED_VALUE in audit_payload
|
||||
|
||||
|
||||
def test_v3_request_id_health_and_metrics() -> None:
|
||||
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
|
||||
assert "metrics" in response.json()
|
||||
|
||||
|
||||
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_risk_action_creates_workflow() -> 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 == 200
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows",
|
||||
headers=headers,
|
||||
params={"workflow_type": WorkflowType.RISK_EVENT_REVIEW},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
workflows = response.json()["items"]
|
||||
assert any(
|
||||
item["aggregate_id"] == str(risk_id) and item["status"] == WorkflowStatus.RUNNING
|
||||
for item in workflows
|
||||
)
|
||||
|
||||
|
||||
def test_v3_writeback_disabled_requires_approval_without_consuming_ticket() -> 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 == 200
|
||||
writeback_code = response.json()["data"]["code"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/writebacks/{writeback_code}/submit",
|
||||
headers=headers,
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
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 == 200
|
||||
ticket_id = response.json()["ticket_id"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/approvals/{ticket_id}/approve",
|
||||
headers=approval_headers,
|
||||
json={"comment": "approved for disabled adapter test"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/writebacks/{writeback_code}/submit",
|
||||
headers=headers,
|
||||
json={"approval_ticket_id": ticket_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["status"] == WritebackStatus.DISABLED
|
||||
|
||||
response = client.get(f"/api/v1/approvals/{ticket_id}", headers=approval_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "approved"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/workflows",
|
||||
headers=headers,
|
||||
params={"workflow_type": WorkflowType.OFFICIAL_WRITEBACK},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
workflows = response.json()["items"]
|
||||
assert any(
|
||||
item["aggregate_id"] == writeback_code and item["status"] == WorkflowStatus.BLOCKED
|
||||
for item in workflows
|
||||
)
|
||||
|
||||
|
||||
def test_feishu_webhook_challenge_uses_event_service_verification() -> None:
|
||||
response = client.post(
|
||||
"/api/v1/integrations/feishu/webhook",
|
||||
@@ -224,16 +435,13 @@ def test_config_and_pagination_guardrails() -> None:
|
||||
|
||||
|
||||
def test_dashboard_and_response_masking() -> None:
|
||||
expense_response = client.post(
|
||||
"/api/v1/business/expenses",
|
||||
headers=headers,
|
||||
json={
|
||||
"data": {
|
||||
"code": "EXP-MASK-001",
|
||||
"expense_type": "办公",
|
||||
"amount": 20,
|
||||
"payment_account": "6222000000000000",
|
||||
},
|
||||
expense_response = create_business_record(
|
||||
"expenses",
|
||||
{
|
||||
"code": "EXP-MASK-001",
|
||||
"expense_type": "办公",
|
||||
"amount": 20,
|
||||
"payment_account": "6222000000000000",
|
||||
},
|
||||
)
|
||||
assert expense_response.status_code == 200
|
||||
@@ -255,15 +463,12 @@ def test_configured_domain_response_masking(monkeypatch) -> None:
|
||||
monkeypatch.setenv("MASKED_RESPONSE_FIELDS", json.dumps(["expenses.amount"]))
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
response = client.post(
|
||||
"/api/v1/business/expenses",
|
||||
headers=headers,
|
||||
json={
|
||||
"data": {
|
||||
"code": "EXP-MASK-CONFIG-001",
|
||||
"expense_type": "测试",
|
||||
"amount": 123,
|
||||
},
|
||||
response = create_business_record(
|
||||
"expenses",
|
||||
{
|
||||
"code": "EXP-MASK-CONFIG-001",
|
||||
"expense_type": "测试",
|
||||
"amount": 123,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -282,6 +487,37 @@ def test_configured_domain_response_masking(monkeypatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_business_writes_require_approval_and_reject_read_only_fields() -> None:
|
||||
blocked_response = client.post(
|
||||
"/api/v1/business/projects",
|
||||
headers=headers,
|
||||
json={
|
||||
"data": {
|
||||
"code": "P-APPROVAL-BLOCKED",
|
||||
"name": "Blocked project",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert blocked_response.status_code == 409
|
||||
|
||||
readonly_payload = {
|
||||
"code": "P-READONLY-001",
|
||||
"name": "Readonly project",
|
||||
"created_at": "2026-07-08T00:00:00",
|
||||
}
|
||||
ticket_id = approve_change("projects", "create:projects", readonly_payload)
|
||||
readonly_response = client.post(
|
||||
"/api/v1/business/projects",
|
||||
headers=headers,
|
||||
json={
|
||||
"approval_ticket_id": ticket_id,
|
||||
"data": readonly_payload,
|
||||
},
|
||||
)
|
||||
assert readonly_response.status_code == 422
|
||||
assert readonly_response.json()["detail"] == "Field 'created_at' is read-only"
|
||||
|
||||
|
||||
def test_approval_gate_for_high_risk_update() -> None:
|
||||
create_payload = {
|
||||
"code": "FUND-SMOKE-001",
|
||||
@@ -494,6 +730,76 @@ def test_feishu_approval_card_action_approves_ticket() -> None:
|
||||
assert duplicate_response.json()["result"]["status"] == "approved"
|
||||
|
||||
|
||||
def test_feishu_approval_card_action_requires_approver_allowlist(monkeypatch) -> None:
|
||||
monkeypatch.setenv("FEISHU_APPROVAL_APPROVER_IDS", "")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
approval_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
json={
|
||||
"domain": "fund-accounts",
|
||||
"record_id": "feishu-card-missing-allowlist",
|
||||
"action": "update:fund-accounts",
|
||||
"reason": "Card action missing allowlist test",
|
||||
"payload": {"current_balance": 301},
|
||||
},
|
||||
)
|
||||
assert approval_response.status_code == 200
|
||||
ticket_id = approval_response.json()["ticket_id"]
|
||||
|
||||
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": ticket_id,
|
||||
"decision": "approve",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
assert callback_response.status_code == 503
|
||||
assert callback_response.json()["detail"] == FEISHU_APPROVAL_APPROVER_IDS_REQUIRED
|
||||
finally:
|
||||
monkeypatch.setenv("FEISHU_APPROVAL_APPROVER_IDS", "ou_card_approver")
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_feishu_approval_card_action_rejects_unlisted_approver() -> None:
|
||||
approval_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
json={
|
||||
"domain": "fund-accounts",
|
||||
"record_id": "feishu-card-unlisted-approver",
|
||||
"action": "update:fund-accounts",
|
||||
"reason": "Card action allowlist test",
|
||||
"payload": {"current_balance": 302},
|
||||
},
|
||||
)
|
||||
assert approval_response.status_code == 200
|
||||
ticket_id = approval_response.json()["ticket_id"]
|
||||
|
||||
callback_response = client.post(
|
||||
"/api/v1/integrations/feishu/approval-card-action",
|
||||
json={
|
||||
"token": "test-feishu-token",
|
||||
"operator": {"operator_id": {"open_id": "ou_not_allowed"}},
|
||||
"action": {
|
||||
"value": {
|
||||
"ticket_id": ticket_id,
|
||||
"decision": "approve",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
assert callback_response.status_code == 403
|
||||
assert callback_response.json()["detail"] == FEISHU_APPROVER_NOT_ALLOWED
|
||||
|
||||
|
||||
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
|
||||
@@ -503,34 +809,26 @@ def test_new_ledgers_reports_and_risk_events() -> None:
|
||||
assert "risk-events" in domains
|
||||
|
||||
today = date.today()
|
||||
attendance_response = client.post(
|
||||
"/api/v1/business/attendance-records",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "pytest",
|
||||
"data": {
|
||||
"code": "ATT-SMOKE-001",
|
||||
"employee_name": "Tester",
|
||||
"department": "QA",
|
||||
"work_date": today.isoformat(),
|
||||
"status": "正常",
|
||||
},
|
||||
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 = client.post(
|
||||
"/api/v1/business/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "pytest",
|
||||
"data": {
|
||||
"code": "TASK-RISK-001",
|
||||
"title": "Overdue smoke task",
|
||||
"owner": "tester",
|
||||
"status": "待办",
|
||||
"due_date": (today - timedelta(days=1)).isoformat(),
|
||||
},
|
||||
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
|
||||
@@ -568,91 +866,71 @@ def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None:
|
||||
today = date.today()
|
||||
project_code = "P-LIFECYCLE-001"
|
||||
|
||||
project_response = client.post(
|
||||
"/api/v1/business/projects",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "pytest",
|
||||
"data": {
|
||||
"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(),
|
||||
},
|
||||
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 = client.post(
|
||||
"/api/v1/business/tasks",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "pytest",
|
||||
"data": {
|
||||
"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",
|
||||
},
|
||||
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 = client.post(
|
||||
"/api/v1/business/procurements",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "pytest",
|
||||
"data": {
|
||||
"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,
|
||||
},
|
||||
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 = client.post(
|
||||
"/api/v1/business/expenses",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "pytest",
|
||||
"data": {
|
||||
"code": "EXP-LIFECYCLE-001",
|
||||
"expense_type": "差旅",
|
||||
"amount": 80,
|
||||
"project_code": project_code,
|
||||
"approval_status": StatusValue.PENDING_APPROVAL,
|
||||
"payment_status": StatusValue.UNPAID,
|
||||
},
|
||||
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 = client.post(
|
||||
"/api/v1/business/attendance-records",
|
||||
headers=headers,
|
||||
json={
|
||||
"actor": "pytest",
|
||||
"data": {
|
||||
"code": "ATT-LIFECYCLE-001",
|
||||
"employee_name": "Lifecycle Tester",
|
||||
"project_code": project_code,
|
||||
"work_date": today.isoformat(),
|
||||
"status": StatusValue.MISSING_PUNCH,
|
||||
},
|
||||
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
|
||||
@@ -707,35 +985,27 @@ def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None:
|
||||
def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
|
||||
today = date.today()
|
||||
project_code = "P-BACKLOG-001"
|
||||
old_created_at = (today - timedelta(days=30)).isoformat() + "T00:00:00"
|
||||
report_day = today - timedelta(days=7)
|
||||
|
||||
procurement_response = client.post(
|
||||
"/api/v1/business/procurements",
|
||||
headers=headers,
|
||||
json={
|
||||
"data": {
|
||||
"code": "PROC-BACKLOG-001",
|
||||
"name": "Backlog procurement",
|
||||
"project_code": project_code,
|
||||
"approval_status": StatusValue.PENDING_APPROVAL,
|
||||
"created_at": old_created_at,
|
||||
},
|
||||
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 = client.post(
|
||||
"/api/v1/business/expenses",
|
||||
headers=headers,
|
||||
json={
|
||||
"data": {
|
||||
"code": "EXP-BACKLOG-001",
|
||||
"expense_type": "办公",
|
||||
"amount": 50,
|
||||
"project_code": project_code,
|
||||
"approval_status": StatusValue.PENDING_APPROVAL,
|
||||
"created_at": old_created_at,
|
||||
},
|
||||
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
|
||||
@@ -746,8 +1016,8 @@ def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
|
||||
json={
|
||||
"report_type": ReportType.DAILY,
|
||||
"project_code": project_code,
|
||||
"period_start": today.isoformat(),
|
||||
"period_end": today.isoformat(),
|
||||
"period_start": report_day.isoformat(),
|
||||
"period_end": report_day.isoformat(),
|
||||
"persist": False,
|
||||
},
|
||||
)
|
||||
@@ -810,19 +1080,16 @@ def test_legacy_task_sync_creates_and_updates_internal_tasks(monkeypatch) -> Non
|
||||
|
||||
|
||||
def test_risk_event_workflow_records_actions() -> None:
|
||||
create_response = client.post(
|
||||
"/api/v1/business/risk-events",
|
||||
headers=headers,
|
||||
json={
|
||||
"data": {
|
||||
"code": "RISK-FLOW-001",
|
||||
"title": "Workflow risk",
|
||||
"risk_type": "manual",
|
||||
"risk_level": "medium",
|
||||
"source_domain": "projects",
|
||||
"source_record_id": "P-SMOKE-001",
|
||||
"status": "open",
|
||||
},
|
||||
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
|
||||
@@ -851,20 +1118,85 @@ def test_risk_event_workflow_records_actions() -> None:
|
||||
assert resolve_response.status_code == 200
|
||||
assert resolve_response.json()["risk_event"]["status"] == "resolved"
|
||||
|
||||
blocked_close_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/close",
|
||||
headers=headers,
|
||||
json={
|
||||
"closed_reason": "verified",
|
||||
"review_summary": "handled",
|
||||
},
|
||||
)
|
||||
assert blocked_close_response.status_code == 409
|
||||
|
||||
close_ticket_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
json={
|
||||
"domain": "risk-events",
|
||||
"record_id": str(event_id),
|
||||
"action": "update:risk-events",
|
||||
"reason": "Close risk event",
|
||||
"payload": {
|
||||
"status": "closed",
|
||||
"closed_reason": "verified",
|
||||
"review_summary": "handled",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert close_ticket_response.status_code == 200
|
||||
close_ticket_id = close_ticket_response.json()["ticket_id"]
|
||||
approve_close_response = client.post(
|
||||
f"/api/v1/approvals/{close_ticket_id}/approve",
|
||||
headers=approval_headers,
|
||||
json={"comment": "risk close approved"},
|
||||
)
|
||||
assert approve_close_response.status_code == 200
|
||||
|
||||
close_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/close",
|
||||
headers=headers,
|
||||
json={"closed_reason": "verified", "review_summary": "handled"},
|
||||
json={
|
||||
"closed_reason": "verified",
|
||||
"review_summary": "handled",
|
||||
"approval_ticket_id": close_ticket_id,
|
||||
},
|
||||
)
|
||||
assert close_response.status_code == 200
|
||||
assert close_response.json()["risk_event"]["status"] == "closed"
|
||||
assert close_response.json()["risk_event"]["closed_reason"] == "verified"
|
||||
|
||||
reopen_response = client.post(
|
||||
blocked_reopen_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/reopen",
|
||||
headers=headers,
|
||||
json={"comment": "recheck"},
|
||||
)
|
||||
assert blocked_reopen_response.status_code == 409
|
||||
|
||||
reopen_ticket_response = client.post(
|
||||
"/api/v1/approvals",
|
||||
headers=headers,
|
||||
json={
|
||||
"domain": "risk-events",
|
||||
"record_id": str(event_id),
|
||||
"action": "update:risk-events",
|
||||
"reason": "Reopen risk event",
|
||||
"payload": {"status": "open", "comment": "recheck"},
|
||||
},
|
||||
)
|
||||
assert reopen_ticket_response.status_code == 200
|
||||
reopen_ticket_id = reopen_ticket_response.json()["ticket_id"]
|
||||
approve_reopen_response = client.post(
|
||||
f"/api/v1/approvals/{reopen_ticket_id}/approve",
|
||||
headers=approval_headers,
|
||||
json={"comment": "risk reopen approved"},
|
||||
)
|
||||
assert approve_reopen_response.status_code == 200
|
||||
|
||||
reopen_response = client.post(
|
||||
f"/api/v1/risks/events/{event_id}/reopen",
|
||||
headers=headers,
|
||||
json={"comment": "recheck", "approval_ticket_id": reopen_ticket_id},
|
||||
)
|
||||
assert reopen_response.status_code == 200
|
||||
assert reopen_response.json()["risk_event"]["status"] == "open"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user