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:
2026-07-08 14:08:03 +08:00
parent 92f490b97e
commit 19e59e83cc
59 changed files with 3271 additions and 247 deletions

View File

@@ -23,6 +23,8 @@ RUN pip install --no-cache-dir \
cryptography==44.0.0 \
pandas==2.2.3
COPY alembic.ini /app/alembic.ini
COPY alembic /app/alembic
COPY app /app/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]

View File

@@ -8,7 +8,10 @@ from app.core.db_base import Base
from app.modules.approvals import models as approval_models
from app.modules.audit import models as audit_models
from app.modules.business import models as business_models
from app.modules.events import models as event_models
from app.modules.feishu import models as feishu_models
from app.modules.workflows import models as workflow_models
from app.modules.writebacks import models as writeback_models
config = context.config
@@ -23,7 +26,10 @@ _REGISTERED_MODEL_MODULES = (
approval_models,
audit_models,
business_models,
event_models,
feishu_models,
workflow_models,
writeback_models,
)

View File

@@ -6,29 +6,550 @@ Create Date: 2026-07-06
"""
from alembic import op
import sqlalchemy as sa
from app.core.db_base import Base
from app.modules.approvals import models as approval_models
from app.modules.audit import models as audit_models
from app.modules.business import models as business_models
from app.modules.feishu import models as feishu_models
revision = "202607060001"
down_revision = None
branch_labels = None
depends_on = None
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
_REGISTERED_MODEL_MODULES = (
approval_models,
audit_models,
business_models,
feishu_models,
)
def upgrade() -> None:
Base.metadata.create_all(bind=op.get_bind())
op.create_table(
"approval_requests",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("ticket_id", sa.String(length=64), nullable=False),
sa.Column("domain", sa.String(length=128), nullable=False),
sa.Column("record_id", sa.String(length=128), nullable=True),
sa.Column("action", sa.String(length=128), nullable=False),
sa.Column("applicant", sa.String(length=128), nullable=False),
sa.Column("approver", sa.String(length=128), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("payload", sa.Text(), nullable=True),
sa.Column("decision_comment", sa.Text(), nullable=True),
sa.Column("used_by", sa.String(length=128), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("decided_at", sa.DateTime(), nullable=True),
sa.Column("used_at", sa.DateTime(), nullable=True),
)
op.create_index(op.f("ix_approval_requests_action"), "approval_requests", ['action'], unique=False)
op.create_index(op.f("ix_approval_requests_applicant"), "approval_requests", ['applicant'], unique=False)
op.create_index(op.f("ix_approval_requests_approver"), "approval_requests", ['approver'], unique=False)
op.create_index(op.f("ix_approval_requests_created_at"), "approval_requests", ['created_at'], unique=False)
op.create_index(op.f("ix_approval_requests_domain"), "approval_requests", ['domain'], unique=False)
op.create_index(op.f("ix_approval_requests_record_id"), "approval_requests", ['record_id'], unique=False)
op.create_index(op.f("ix_approval_requests_status"), "approval_requests", ['status'], unique=False)
op.create_index(op.f("ix_approval_requests_ticket_id"), "approval_requests", ['ticket_id'], unique=True)
op.create_index(op.f("ix_approval_requests_used_by"), "approval_requests", ['used_by'], unique=False)
op.create_table(
"attendance_records",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("employee_name", sa.String(length=128), nullable=False),
sa.Column("employee_id", sa.String(length=128), nullable=True),
sa.Column("department", sa.String(length=128), nullable=True),
sa.Column("project_code", sa.String(length=64), nullable=True),
sa.Column("work_date", sa.Date(), nullable=False),
sa.Column("check_in_at", sa.DateTime(), nullable=True),
sa.Column("check_out_at", sa.DateTime(), nullable=True),
sa.Column("status", sa.String(length=64), nullable=False),
sa.Column("location", sa.String(length=255), nullable=True),
sa.Column("source_system", sa.String(length=64), nullable=False),
sa.Column("external_id", sa.String(length=128), nullable=True),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_attendance_records_code"), "attendance_records", ['code'], unique=True)
op.create_index(op.f("ix_attendance_records_department"), "attendance_records", ['department'], unique=False)
op.create_index(op.f("ix_attendance_records_employee_id"), "attendance_records", ['employee_id'], unique=False)
op.create_index(op.f("ix_attendance_records_employee_name"), "attendance_records", ['employee_name'], unique=False)
op.create_index(op.f("ix_attendance_records_external_id"), "attendance_records", ['external_id'], unique=False)
op.create_index(op.f("ix_attendance_records_project_code"), "attendance_records", ['project_code'], unique=False)
op.create_index(op.f("ix_attendance_records_status"), "attendance_records", ['status'], unique=False)
op.create_index(op.f("ix_attendance_records_work_date"), "attendance_records", ['work_date'], unique=False)
op.create_table(
"audit_logs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("source", sa.String(length=64), nullable=False),
sa.Column("action", sa.String(length=128), nullable=False),
sa.Column("target_type", sa.String(length=128), nullable=True),
sa.Column("target_id", sa.String(length=128), nullable=True),
sa.Column("risk_level", sa.String(length=32), nullable=False),
sa.Column("request_payload", sa.Text(), nullable=True),
sa.Column("response_payload", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("request_id", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_audit_logs_action"), "audit_logs", ['action'], unique=False)
op.create_index(op.f("ix_audit_logs_actor"), "audit_logs", ['actor'], unique=False)
op.create_index(op.f("ix_audit_logs_created_at"), "audit_logs", ['created_at'], unique=False)
op.create_index(op.f("ix_audit_logs_request_id"), "audit_logs", ['request_id'], unique=False)
op.create_index(op.f("ix_audit_logs_risk_level"), "audit_logs", ['risk_level'], unique=False)
op.create_index(op.f("ix_audit_logs_source"), "audit_logs", ['source'], unique=False)
op.create_index(op.f("ix_audit_logs_status"), "audit_logs", ['status'], unique=False)
op.create_table(
"domain_events",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("event_id", sa.String(length=64), nullable=False),
sa.Column("event_type", sa.String(length=128), nullable=False),
sa.Column("source", sa.String(length=64), nullable=False),
sa.Column("aggregate_type", sa.String(length=128), nullable=False),
sa.Column("aggregate_id", sa.String(length=128), nullable=True),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("processed_at", sa.DateTime(), nullable=True),
)
op.create_index(op.f("ix_domain_events_actor"), "domain_events", ['actor'], unique=False)
op.create_index(op.f("ix_domain_events_aggregate_id"), "domain_events", ['aggregate_id'], unique=False)
op.create_index(op.f("ix_domain_events_aggregate_type"), "domain_events", ['aggregate_type'], unique=False)
op.create_index(op.f("ix_domain_events_created_at"), "domain_events", ['created_at'], unique=False)
op.create_index(op.f("ix_domain_events_event_id"), "domain_events", ['event_id'], unique=True)
op.create_index(op.f("ix_domain_events_event_type"), "domain_events", ['event_type'], unique=False)
op.create_index(op.f("ix_domain_events_idempotency_key"), "domain_events", ['idempotency_key'], unique=True)
op.create_index(op.f("ix_domain_events_processed_at"), "domain_events", ['processed_at'], unique=False)
op.create_index(op.f("ix_domain_events_source"), "domain_events", ['source'], unique=False)
op.create_index(op.f("ix_domain_events_status"), "domain_events", ['status'], unique=False)
op.create_table(
"expenses",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("expense_type", sa.String(length=128), nullable=False),
sa.Column("amount", sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column("applicant", sa.String(length=128), nullable=True),
sa.Column("department", sa.String(length=128), nullable=True),
sa.Column("project_code", sa.String(length=64), nullable=True),
sa.Column("budget_subject", sa.String(length=128), nullable=True),
sa.Column("payment_account", sa.String(length=128), nullable=True),
sa.Column("invoice_status", sa.String(length=64), nullable=False),
sa.Column("approval_status", sa.String(length=64), nullable=False),
sa.Column("payment_status", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_expenses_approval_status"), "expenses", ['approval_status'], unique=False)
op.create_index(op.f("ix_expenses_code"), "expenses", ['code'], unique=True)
op.create_index(op.f("ix_expenses_department"), "expenses", ['department'], unique=False)
op.create_index(op.f("ix_expenses_expense_type"), "expenses", ['expense_type'], unique=False)
op.create_index(op.f("ix_expenses_payment_status"), "expenses", ['payment_status'], unique=False)
op.create_index(op.f("ix_expenses_project_code"), "expenses", ['project_code'], unique=False)
op.create_table(
"feishu_event_receipts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("event_key", sa.String(length=256), nullable=False),
sa.Column("source", sa.String(length=64), nullable=False),
sa.Column("event_id", sa.String(length=128), nullable=True),
sa.Column("message_id", sa.String(length=128), nullable=True),
sa.Column("received_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_feishu_event_receipts_event_id"), "feishu_event_receipts", ['event_id'], unique=False)
op.create_index(op.f("ix_feishu_event_receipts_event_key"), "feishu_event_receipts", ['event_key'], unique=True)
op.create_index(op.f("ix_feishu_event_receipts_message_id"), "feishu_event_receipts", ['message_id'], unique=False)
op.create_index(op.f("ix_feishu_event_receipts_received_at"), "feishu_event_receipts", ['received_at'], unique=False)
op.create_index(op.f("ix_feishu_event_receipts_source"), "feishu_event_receipts", ['source'], unique=False)
op.create_table(
"fund_accounts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("account_type", sa.String(length=64), nullable=False),
sa.Column("current_balance", sa.Numeric(precision=16, scale=2), nullable=False),
sa.Column("expected_receivable", sa.Numeric(precision=16, scale=2), nullable=False),
sa.Column("expected_payable", sa.Numeric(precision=16, scale=2), nullable=False),
sa.Column("safety_line", sa.Numeric(precision=16, scale=2), nullable=False),
sa.Column("risk_level", sa.String(length=32), nullable=False),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_fund_accounts_code"), "fund_accounts", ['code'], unique=True)
op.create_index(op.f("ix_fund_accounts_name"), "fund_accounts", ['name'], unique=False)
op.create_index(op.f("ix_fund_accounts_risk_level"), "fund_accounts", ['risk_level'], unique=False)
op.create_table(
"legacy_sync_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("domain", sa.String(length=128), nullable=False),
sa.Column("source_table", sa.String(length=128), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("started_at", sa.DateTime(), nullable=False),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.Column("created_count", sa.Integer(), nullable=False),
sa.Column("updated_count", sa.Integer(), nullable=False),
sa.Column("skipped_count", sa.Integer(), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_legacy_sync_runs_code"), "legacy_sync_runs", ['code'], unique=True)
op.create_index(op.f("ix_legacy_sync_runs_domain"), "legacy_sync_runs", ['domain'], unique=False)
op.create_index(op.f("ix_legacy_sync_runs_source_table"), "legacy_sync_runs", ['source_table'], unique=False)
op.create_index(op.f("ix_legacy_sync_runs_started_at"), "legacy_sync_runs", ['started_at'], unique=False)
op.create_index(op.f("ix_legacy_sync_runs_status"), "legacy_sync_runs", ['status'], unique=False)
op.create_table(
"official_writeback_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("domain", sa.String(length=128), nullable=False),
sa.Column("record_id", sa.String(length=128), nullable=True),
sa.Column("action", sa.String(length=128), nullable=False),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("approval_ticket_id", sa.String(length=64), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
sa.Column("request_payload", sa.JSON(), nullable=True),
sa.Column("provider_response", sa.JSON(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("submitted_at", sa.DateTime(), nullable=True),
sa.Column("sent_at", sa.DateTime(), nullable=True),
)
op.create_index(op.f("ix_official_writeback_runs_action"), "official_writeback_runs", ['action'], unique=False)
op.create_index(op.f("ix_official_writeback_runs_actor"), "official_writeback_runs", ['actor'], unique=False)
op.create_index(op.f("ix_official_writeback_runs_approval_ticket_id"), "official_writeback_runs", ['approval_ticket_id'], unique=False)
op.create_index(op.f("ix_official_writeback_runs_code"), "official_writeback_runs", ['code'], unique=True)
op.create_index(op.f("ix_official_writeback_runs_created_at"), "official_writeback_runs", ['created_at'], unique=False)
op.create_index(op.f("ix_official_writeback_runs_domain"), "official_writeback_runs", ['domain'], unique=False)
op.create_index(op.f("ix_official_writeback_runs_idempotency_key"), "official_writeback_runs", ['idempotency_key'], unique=True)
op.create_index(op.f("ix_official_writeback_runs_record_id"), "official_writeback_runs", ['record_id'], unique=False)
op.create_index(op.f("ix_official_writeback_runs_sent_at"), "official_writeback_runs", ['sent_at'], unique=False)
op.create_index(op.f("ix_official_writeback_runs_status"), "official_writeback_runs", ['status'], unique=False)
op.create_index(op.f("ix_official_writeback_runs_submitted_at"), "official_writeback_runs", ['submitted_at'], unique=False)
op.create_table(
"performance_metrics",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("applies_to_role", sa.String(length=128), nullable=True),
sa.Column("formula", sa.Text(), nullable=True),
sa.Column("weight", sa.Numeric(precision=5, scale=2), nullable=False),
sa.Column("data_source", sa.String(length=255), nullable=True),
sa.Column("auto_score", sa.Numeric(precision=8, scale=2), nullable=False),
sa.Column("confirmed_score", sa.Numeric(precision=8, scale=2), nullable=True),
sa.Column("status", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_performance_metrics_code"), "performance_metrics", ['code'], unique=True)
op.create_index(op.f("ix_performance_metrics_name"), "performance_metrics", ['name'], unique=False)
op.create_index(op.f("ix_performance_metrics_status"), "performance_metrics", ['status'], unique=False)
op.create_table(
"policies",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("policy_type", sa.String(length=128), nullable=True),
sa.Column("owner_department", sa.String(length=128), nullable=True),
sa.Column("version", sa.String(length=32), nullable=False),
sa.Column("status", sa.String(length=64), nullable=False),
sa.Column("effective_date", sa.Date(), nullable=True),
sa.Column("feishu_doc_url", sa.String(length=1024), nullable=True),
sa.Column("summary", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_policies_code"), "policies", ['code'], unique=True)
op.create_index(op.f("ix_policies_status"), "policies", ['status'], unique=False)
op.create_index(op.f("ix_policies_title"), "policies", ['title'], unique=False)
op.create_table(
"procurements",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("applicant", sa.String(length=128), nullable=True),
sa.Column("project_code", sa.String(length=64), nullable=True),
sa.Column("supplier_name", sa.String(length=255), nullable=True),
sa.Column("budget_subject", sa.String(length=128), nullable=True),
sa.Column("expected_amount", sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column("actual_amount", sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column("approval_status", sa.String(length=64), nullable=False),
sa.Column("delivery_status", sa.String(length=64), nullable=False),
sa.Column("payment_status", sa.String(length=64), nullable=False),
sa.Column("comparison_summary", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_procurements_approval_status"), "procurements", ['approval_status'], unique=False)
op.create_index(op.f("ix_procurements_code"), "procurements", ['code'], unique=True)
op.create_index(op.f("ix_procurements_delivery_status"), "procurements", ['delivery_status'], unique=False)
op.create_index(op.f("ix_procurements_name"), "procurements", ['name'], unique=False)
op.create_index(op.f("ix_procurements_payment_status"), "procurements", ['payment_status'], unique=False)
op.create_index(op.f("ix_procurements_project_code"), "procurements", ['project_code'], unique=False)
op.create_table(
"projects",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("owner", sa.String(length=128), nullable=True),
sa.Column("status", sa.String(length=64), nullable=False),
sa.Column("priority", sa.String(length=32), nullable=False),
sa.Column("progress_percent", sa.Integer(), nullable=False),
sa.Column("risk_level", sa.String(length=32), nullable=False),
sa.Column("budget_amount", sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column("actual_amount", sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column("start_date", sa.Date(), nullable=True),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("source_system", sa.String(length=64), nullable=False),
sa.Column("external_id", sa.String(length=128), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_projects_code"), "projects", ['code'], unique=True)
op.create_index(op.f("ix_projects_due_date"), "projects", ['due_date'], unique=False)
op.create_index(op.f("ix_projects_external_id"), "projects", ['external_id'], unique=False)
op.create_index(op.f("ix_projects_name"), "projects", ['name'], unique=False)
op.create_index(op.f("ix_projects_owner"), "projects", ['owner'], unique=False)
op.create_index(op.f("ix_projects_risk_level"), "projects", ['risk_level'], unique=False)
op.create_index(op.f("ix_projects_status"), "projects", ['status'], unique=False)
op.create_table(
"report_push_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("report_type", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=255), nullable=True),
sa.Column("receive_id", sa.String(length=128), nullable=True),
sa.Column("receive_id_type", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("task_id", sa.String(length=128), nullable=True),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("provider_response", sa.JSON(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("queued_at", sa.DateTime(), nullable=False),
sa.Column("sent_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_report_push_runs_actor"), "report_push_runs", ['actor'], unique=False)
op.create_index(op.f("ix_report_push_runs_code"), "report_push_runs", ['code'], unique=True)
op.create_index(op.f("ix_report_push_runs_queued_at"), "report_push_runs", ['queued_at'], unique=False)
op.create_index(op.f("ix_report_push_runs_receive_id"), "report_push_runs", ['receive_id'], unique=False)
op.create_index(op.f("ix_report_push_runs_receive_id_type"), "report_push_runs", ['receive_id_type'], unique=False)
op.create_index(op.f("ix_report_push_runs_report_type"), "report_push_runs", ['report_type'], unique=False)
op.create_index(op.f("ix_report_push_runs_status"), "report_push_runs", ['status'], unique=False)
op.create_index(op.f("ix_report_push_runs_task_id"), "report_push_runs", ['task_id'], unique=False)
op.create_table(
"risk_event_actions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("risk_event_id", sa.Integer(), nullable=False),
sa.Column("action", sa.String(length=64), nullable=False),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("from_status", sa.String(length=32), nullable=True),
sa.Column("to_status", sa.String(length=32), nullable=True),
sa.Column("assigned_to", sa.String(length=128), nullable=True),
sa.Column("comment", sa.Text(), nullable=True),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_risk_event_actions_action"), "risk_event_actions", ['action'], unique=False)
op.create_index(op.f("ix_risk_event_actions_actor"), "risk_event_actions", ['actor'], unique=False)
op.create_index(op.f("ix_risk_event_actions_assigned_to"), "risk_event_actions", ['assigned_to'], unique=False)
op.create_index(op.f("ix_risk_event_actions_code"), "risk_event_actions", ['code'], unique=True)
op.create_index(op.f("ix_risk_event_actions_created_at"), "risk_event_actions", ['created_at'], unique=False)
op.create_index(op.f("ix_risk_event_actions_risk_event_id"), "risk_event_actions", ['risk_event_id'], unique=False)
op.create_table(
"risk_events",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("risk_type", sa.String(length=64), nullable=False),
sa.Column("risk_level", sa.String(length=32), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("source_domain", sa.String(length=128), nullable=False),
sa.Column("source_record_id", sa.String(length=128), nullable=True),
sa.Column("project_code", sa.String(length=64), nullable=True),
sa.Column("owner", sa.String(length=128), nullable=True),
sa.Column("detected_at", sa.DateTime(), nullable=False),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column("assigned_to", sa.String(length=128), nullable=True),
sa.Column("resolved_at", sa.DateTime(), nullable=True),
sa.Column("closed_at", sa.DateTime(), nullable=True),
sa.Column("closed_reason", sa.Text(), nullable=True),
sa.Column("review_summary", sa.Text(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("mitigation", sa.Text(), nullable=True),
sa.Column("evidence", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_risk_events_assigned_to"), "risk_events", ['assigned_to'], unique=False)
op.create_index(op.f("ix_risk_events_code"), "risk_events", ['code'], unique=True)
op.create_index(op.f("ix_risk_events_detected_at"), "risk_events", ['detected_at'], unique=False)
op.create_index(op.f("ix_risk_events_due_date"), "risk_events", ['due_date'], unique=False)
op.create_index(op.f("ix_risk_events_owner"), "risk_events", ['owner'], unique=False)
op.create_index(op.f("ix_risk_events_project_code"), "risk_events", ['project_code'], unique=False)
op.create_index(op.f("ix_risk_events_risk_level"), "risk_events", ['risk_level'], unique=False)
op.create_index(op.f("ix_risk_events_risk_type"), "risk_events", ['risk_type'], unique=False)
op.create_index(op.f("ix_risk_events_source_domain"), "risk_events", ['source_domain'], unique=False)
op.create_index(op.f("ix_risk_events_source_record_id"), "risk_events", ['source_record_id'], unique=False)
op.create_index(op.f("ix_risk_events_status"), "risk_events", ['status'], unique=False)
op.create_index(op.f("ix_risk_events_title"), "risk_events", ['title'], unique=False)
op.create_table(
"standards",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("standard_type", sa.String(length=128), nullable=True),
sa.Column("applies_to", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=64), nullable=False),
sa.Column("check_items", sa.Text(), nullable=True),
sa.Column("remediation", sa.Text(), nullable=True),
sa.Column("policy_code", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_standards_code"), "standards", ['code'], unique=True)
op.create_index(op.f("ix_standards_policy_code"), "standards", ['policy_code'], unique=False)
op.create_index(op.f("ix_standards_status"), "standards", ['status'], unique=False)
op.create_index(op.f("ix_standards_title"), "standards", ['title'], unique=False)
op.create_table(
"suppliers",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("category", sa.String(length=128), nullable=True),
sa.Column("contact", sa.String(length=128), nullable=True),
sa.Column("quality_score", sa.Numeric(precision=5, scale=2), nullable=False),
sa.Column("delivery_score", sa.Numeric(precision=5, scale=2), nullable=False),
sa.Column("price_score", sa.Numeric(precision=5, scale=2), nullable=False),
sa.Column("risk_level", sa.String(length=32), nullable=False),
sa.Column("blacklist_status", sa.String(length=32), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_suppliers_blacklist_status"), "suppliers", ['blacklist_status'], unique=False)
op.create_index(op.f("ix_suppliers_category"), "suppliers", ['category'], unique=False)
op.create_index(op.f("ix_suppliers_code"), "suppliers", ['code'], unique=True)
op.create_index(op.f("ix_suppliers_name"), "suppliers", ['name'], unique=False)
op.create_index(op.f("ix_suppliers_risk_level"), "suppliers", ['risk_level'], unique=False)
op.create_table(
"work_reports",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("report_type", sa.String(length=32), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("reporter", sa.String(length=128), nullable=False),
sa.Column("department", sa.String(length=128), nullable=True),
sa.Column("project_code", sa.String(length=64), nullable=True),
sa.Column("period_start", sa.Date(), nullable=False),
sa.Column("period_end", sa.Date(), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("metrics", sa.JSON(), nullable=True),
sa.Column("risk_summary", sa.JSON(), nullable=True),
sa.Column("status", sa.String(length=64), nullable=False),
sa.Column("source_system", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_work_reports_code"), "work_reports", ['code'], unique=True)
op.create_index(op.f("ix_work_reports_department"), "work_reports", ['department'], unique=False)
op.create_index(op.f("ix_work_reports_period_end"), "work_reports", ['period_end'], unique=False)
op.create_index(op.f("ix_work_reports_period_start"), "work_reports", ['period_start'], unique=False)
op.create_index(op.f("ix_work_reports_project_code"), "work_reports", ['project_code'], unique=False)
op.create_index(op.f("ix_work_reports_report_type"), "work_reports", ['report_type'], unique=False)
op.create_index(op.f("ix_work_reports_reporter"), "work_reports", ['reporter'], unique=False)
op.create_index(op.f("ix_work_reports_status"), "work_reports", ['status'], unique=False)
op.create_index(op.f("ix_work_reports_title"), "work_reports", ['title'], unique=False)
op.create_table(
"work_tasks",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("project_code", sa.String(length=64), nullable=True),
sa.Column("owner", sa.String(length=128), nullable=True),
sa.Column("status", sa.String(length=64), nullable=False),
sa.Column("priority", sa.String(length=32), nullable=False),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.Column("blocker", sa.Text(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("source_system", sa.String(length=64), nullable=False),
sa.Column("external_id", sa.String(length=128), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_work_tasks_code"), "work_tasks", ['code'], unique=True)
op.create_index(op.f("ix_work_tasks_due_date"), "work_tasks", ['due_date'], unique=False)
op.create_index(op.f("ix_work_tasks_external_id"), "work_tasks", ['external_id'], unique=False)
op.create_index(op.f("ix_work_tasks_owner"), "work_tasks", ['owner'], unique=False)
op.create_index(op.f("ix_work_tasks_project_code"), "work_tasks", ['project_code'], unique=False)
op.create_index(op.f("ix_work_tasks_status"), "work_tasks", ['status'], unique=False)
op.create_index(op.f("ix_work_tasks_title"), "work_tasks", ['title'], unique=False)
op.create_table(
"workflow_actions",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("workflow_code", sa.String(length=64), nullable=False),
sa.Column("action", sa.String(length=128), nullable=False),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("from_status", sa.String(length=32), nullable=True),
sa.Column("to_status", sa.String(length=32), nullable=True),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index(op.f("ix_workflow_actions_action"), "workflow_actions", ['action'], unique=False)
op.create_index(op.f("ix_workflow_actions_actor"), "workflow_actions", ['actor'], unique=False)
op.create_index(op.f("ix_workflow_actions_code"), "workflow_actions", ['code'], unique=True)
op.create_index(op.f("ix_workflow_actions_created_at"), "workflow_actions", ['created_at'], unique=False)
op.create_index(op.f("ix_workflow_actions_workflow_code"), "workflow_actions", ['workflow_code'], unique=False)
op.create_table(
"workflow_instances",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("workflow_type", sa.String(length=128), nullable=False),
sa.Column("aggregate_type", sa.String(length=128), nullable=False),
sa.Column("aggregate_id", sa.String(length=128), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("current_step", sa.String(length=128), nullable=True),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("completed_at", sa.DateTime(), nullable=True),
)
op.create_index(op.f("ix_workflow_instances_actor"), "workflow_instances", ['actor'], unique=False)
op.create_index(op.f("ix_workflow_instances_aggregate_id"), "workflow_instances", ['aggregate_id'], unique=False)
op.create_index(op.f("ix_workflow_instances_aggregate_type"), "workflow_instances", ['aggregate_type'], unique=False)
op.create_index(op.f("ix_workflow_instances_code"), "workflow_instances", ['code'], unique=True)
op.create_index(op.f("ix_workflow_instances_completed_at"), "workflow_instances", ['completed_at'], unique=False)
op.create_index(op.f("ix_workflow_instances_created_at"), "workflow_instances", ['created_at'], unique=False)
op.create_index(op.f("ix_workflow_instances_status"), "workflow_instances", ['status'], unique=False)
op.create_index(op.f("ix_workflow_instances_workflow_type"), "workflow_instances", ['workflow_type'], unique=False)
def downgrade() -> None:

View File

@@ -0,0 +1,208 @@
"""Add V3 enterprise automation foundation tables.
Revision ID: 202607080002
Revises: 202607080001
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
revision = "202607080002"
down_revision = "202607080001"
branch_labels = None
depends_on = None
AUDIT_LOGS_TABLE = "audit_logs"
DOMAIN_EVENTS_TABLE = "domain_events"
WORKFLOW_INSTANCES_TABLE = "workflow_instances"
WORKFLOW_ACTIONS_TABLE = "workflow_actions"
OFFICIAL_WRITEBACK_RUNS_TABLE = "official_writeback_runs"
def _table_exists(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _column_names(table_name: str) -> set[str]:
inspector = inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {column["name"] for column in inspector.get_columns(table_name)}
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
if column.name not in _column_names(table_name):
op.add_column(table_name, column)
def _create_indexes(table_name: str, indexes: list[tuple[str, bool]]) -> None:
for column_name, unique in indexes:
op.create_index(
op.f(f"ix_{table_name}_{column_name}"),
table_name,
[column_name],
unique=unique,
if_not_exists=True,
)
def upgrade() -> None:
_add_column_if_missing(
AUDIT_LOGS_TABLE,
sa.Column("request_id", sa.String(length=64), nullable=True),
)
op.create_index(
op.f("ix_audit_logs_request_id"),
AUDIT_LOGS_TABLE,
["request_id"],
unique=False,
if_not_exists=True,
)
if not _table_exists(DOMAIN_EVENTS_TABLE):
op.create_table(
DOMAIN_EVENTS_TABLE,
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("event_id", sa.String(length=64), nullable=False),
sa.Column("event_type", sa.String(length=128), nullable=False),
sa.Column("source", sa.String(length=64), nullable=False),
sa.Column("aggregate_type", sa.String(length=128), nullable=False),
sa.Column("aggregate_id", sa.String(length=128), nullable=True),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("processed_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
_create_indexes(
DOMAIN_EVENTS_TABLE,
[
("event_id", True),
("event_type", False),
("source", False),
("aggregate_type", False),
("aggregate_id", False),
("actor", False),
("status", False),
("idempotency_key", True),
("created_at", False),
("processed_at", False),
],
)
if not _table_exists(WORKFLOW_INSTANCES_TABLE):
op.create_table(
WORKFLOW_INSTANCES_TABLE,
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("workflow_type", sa.String(length=128), nullable=False),
sa.Column("aggregate_type", sa.String(length=128), nullable=False),
sa.Column("aggregate_id", sa.String(length=128), nullable=True),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("current_step", sa.String(length=128), nullable=True),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("completed_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
_create_indexes(
WORKFLOW_INSTANCES_TABLE,
[
("code", True),
("workflow_type", False),
("aggregate_type", False),
("aggregate_id", False),
("status", False),
("actor", False),
("created_at", False),
("completed_at", False),
],
)
if not _table_exists(WORKFLOW_ACTIONS_TABLE):
op.create_table(
WORKFLOW_ACTIONS_TABLE,
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("workflow_code", sa.String(length=64), nullable=False),
sa.Column("action", sa.String(length=128), nullable=False),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("from_status", sa.String(length=32), nullable=True),
sa.Column("to_status", sa.String(length=32), nullable=True),
sa.Column("payload", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
_create_indexes(
WORKFLOW_ACTIONS_TABLE,
[
("code", True),
("workflow_code", False),
("action", False),
("actor", False),
("created_at", False),
],
)
if not _table_exists(OFFICIAL_WRITEBACK_RUNS_TABLE):
op.create_table(
OFFICIAL_WRITEBACK_RUNS_TABLE,
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("domain", sa.String(length=128), nullable=False),
sa.Column("record_id", sa.String(length=128), nullable=True),
sa.Column("action", sa.String(length=128), nullable=False),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("status", sa.String(length=32), nullable=False),
sa.Column("approval_ticket_id", sa.String(length=64), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=True),
sa.Column("request_payload", sa.JSON(), nullable=True),
sa.Column("provider_response", sa.JSON(), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("submitted_at", sa.DateTime(), nullable=True),
sa.Column("sent_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
_create_indexes(
OFFICIAL_WRITEBACK_RUNS_TABLE,
[
("code", True),
("domain", False),
("record_id", False),
("action", False),
("actor", False),
("status", False),
("approval_ticket_id", False),
("idempotency_key", True),
("created_at", False),
("submitted_at", False),
("sent_at", False),
],
)
def downgrade() -> None:
for table_name in [
OFFICIAL_WRITEBACK_RUNS_TABLE,
WORKFLOW_ACTIONS_TABLE,
WORKFLOW_INSTANCES_TABLE,
DOMAIN_EVENTS_TABLE,
]:
if _table_exists(table_name):
op.drop_table(table_name)
if "request_id" in _column_names(AUDIT_LOGS_TABLE):
op.drop_index(op.f("ix_audit_logs_request_id"), table_name=AUDIT_LOGS_TABLE)
op.drop_column(AUDIT_LOGS_TABLE, "request_id")

View File

@@ -6,10 +6,14 @@ from app.modules.approvals.routes import router as approvals_router
from app.modules.audit.routes import router as audit_router
from app.modules.business.routes import router as business_router
from app.modules.dashboard.routes import router as dashboard_router
from app.modules.events.routes import router as events_router
from app.modules.feishu.routes import router as feishu_router
from app.modules.legacy_mysql.routes import router as legacy_mysql_router
from app.modules.observability.routes import router as observability_router
from app.modules.reports.routes import router as reports_router
from app.modules.risk.routes import router as risk_router
from app.modules.workflows.routes import router as workflows_router
from app.modules.writebacks.routes import router as writebacks_router
api_router = APIRouter()
@@ -30,3 +34,7 @@ api_router.include_router(approvals_router, prefix="/approvals", tags=["approval
api_router.include_router(reports_router, prefix="/reports", tags=["reports"])
api_router.include_router(risk_router, prefix="/risks", tags=["risks"])
api_router.include_router(audit_router, prefix="/audit", tags=["audit"])
api_router.include_router(events_router, prefix="/events", tags=["events"])
api_router.include_router(workflows_router, prefix="/workflows", tags=["workflows"])
api_router.include_router(writebacks_router, prefix="/writebacks", tags=["writebacks"])
api_router.include_router(observability_router, tags=["observability"])

View File

@@ -1,9 +1,9 @@
import json
from functools import lru_cache
from typing import Any
from typing import Annotated, Any
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
from app.core.constants import (
ActorValue,
@@ -50,6 +50,7 @@ class Settings(BaseSettings):
feishu_verification_token: str | None = None
feishu_encrypt_key: str | None = None
feishu_default_chat_id: str | None = None
feishu_approval_approver_ids: Annotated[list[str], NoDecode] = Field(default_factory=list)
model_provider: str = DEFAULT_MODEL_PROVIDER
openclaw_base_url: str = "http://127.0.0.1:2070"
@@ -57,8 +58,8 @@ class Settings(BaseSettings):
openclaw_ws_url: str | None = None
openclaw_api_key: str | None = None
openclaw_gateway_token: str | None = None
openclaw_allowed_tools: list[str] = Field(default_factory=list)
openclaw_allowed_actions: list[str] = Field(
openclaw_allowed_tools: Annotated[list[str], NoDecode] = Field(default_factory=list)
openclaw_allowed_actions: Annotated[list[str], NoDecode] = Field(
default_factory=lambda: [DEFAULT_OPENCLAW_ACTION_JSON]
)
hermes_base_url: str = "http://127.0.0.1:2073/v1"
@@ -83,6 +84,10 @@ class Settings(BaseSettings):
legacy_project_sync_cron_minute: int = 0
legacy_task_sync_cron_hour: int = 2
legacy_task_sync_cron_minute: int = 30
official_writeback_enabled: bool = False
official_api_base_url: str | None = None
official_api_token: str | None = None
official_api_timeout_seconds: float = 10.0
@field_validator("cors_origins", mode="before")
@classmethod
@@ -99,12 +104,27 @@ class Settings(BaseSettings):
return [str(item).strip() for item in data if str(item).strip()]
return [item.strip() for item in text.split(",") if item.strip()]
@field_validator("openclaw_allowed_tools", "openclaw_allowed_actions", mode="before")
@field_validator(
"openclaw_allowed_tools",
"openclaw_allowed_actions",
"feishu_approval_approver_ids",
mode="before",
)
@classmethod
def parse_csv_list(cls, value: str | list[str]) -> list[str]:
def parse_csv_list(cls, value: str | list[str] | None) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return value
return [item.strip() for item in value.split(",") if item.strip()]
return [str(item).strip() for item in value if str(item).strip()]
text = value.strip()
if not text:
return []
if text.startswith("["):
data = json.loads(text)
if not isinstance(data, list):
raise ValueError(ConfigErrorDetail.CORS_ORIGINS_FORMAT)
return [str(item).strip() for item in data if str(item).strip()]
return [item.strip() for item in text.split(",") if item.strip()]
@field_validator("masked_response_fields", mode="before")
@classmethod

View File

@@ -15,6 +15,7 @@ class HttpHeader(StrEnum):
X_API_KEY = "X-API-Key"
X_AUDIT_API_KEY = "X-Audit-API-Key"
X_APPROVAL_API_KEY = "X-Approval-API-Key"
X_REQUEST_ID = "X-Request-ID"
class ApiResponseKey(StrEnum):

18
app/core/middleware.py Normal file
View File

@@ -0,0 +1,18 @@
import uuid
from collections.abc import Callable
from fastapi import Request, Response
from app.core.constants import HttpHeader
from app.core.request_context import reset_request_id, set_request_id
async def request_id_middleware(request: Request, call_next: Callable) -> Response:
request_id = request.headers.get(HttpHeader.X_REQUEST_ID) or uuid.uuid4().hex
token = set_request_id(request_id)
try:
response = await call_next(request)
response.headers[HttpHeader.X_REQUEST_ID] = request_id
return response
finally:
reset_request_id(token)

View File

@@ -0,0 +1,16 @@
from contextvars import ContextVar, Token
_request_id: ContextVar[str | None] = ContextVar("request_id", default=None)
def set_request_id(request_id: str) -> Token:
return _request_id.set(request_id)
def reset_request_id(token: Token) -> None:
_request_id.reset(token)
def get_request_id() -> str | None:
return _request_id.get()

View File

@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
from app.core.middleware import request_id_middleware
from app.core.scheduler import attach_scheduler
@@ -24,6 +25,7 @@ def create_app() -> FastAPI:
),
)
app.middleware("http")(request_id_middleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,

View File

@@ -11,6 +11,7 @@ class ApprovalStatus(StrEnum):
class ApprovalActionValue(StrEnum):
CREATE = "create"
UPDATE = "update"
WRITEBACK = "writeback"
class ApprovalErrorDetail(StrEnum):

View File

@@ -22,6 +22,13 @@ from app.modules.approvals.schemas import ApprovalCreate
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.events.constants import (
EventAggregateType,
EventPayloadKey,
EventSource,
EventType,
)
from app.modules.events.service import EventService
class ApprovalService:
@@ -121,6 +128,23 @@ class ApprovalService:
response_payload={ApprovalPayloadKey.STATUS: ticket.status},
)
)
EventService(self.db).emit(
event_type=EventType.APPROVAL_DECIDED,
source=EventSource.APPROVAL,
aggregate_type=EventAggregateType.APPROVAL,
aggregate_id=ticket.ticket_id,
actor=approver,
payload={
EventPayloadKey.TICKET_ID: ticket.ticket_id,
EventPayloadKey.DOMAIN: ticket.domain,
EventPayloadKey.RECORD_ID: ticket.record_id,
EventPayloadKey.ACTION: ticket.action,
EventPayloadKey.STATUS: ticket.status,
EventPayloadKey.APPROVED: approved,
EventPayloadKey.COMMENT: comment,
},
idempotency_key=f"approval:{ticket.ticket_id}:{ticket.status}",
)
return ticket
def consume_for(

View File

@@ -17,6 +17,8 @@ class AuditAction(StrEnum):
LEGACY_SYNC_TASKS = "sync_tasks"
RISK_EVENT_ACTION = "risk_event_action"
REPORT_PUSH = "report_push"
WRITEBACK_CREATE = "writeback.create"
WRITEBACK_SUBMIT = "writeback.submit"
class AuditRiskLevel(StrEnum):
@@ -33,6 +35,7 @@ class AuditSource(StrEnum):
APPROVAL = "approval"
LEGACY_MYSQL = "legacy_mysql"
REPORTS = "reports"
WRITEBACK = "writeback"
class AuditTargetType(StrEnum):

View File

@@ -22,4 +22,5 @@ class AuditLog(Base):
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(32), default=AuditStatus.SUCCESS, index=True)
request_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)

View File

@@ -17,6 +17,7 @@ class AuditLogCreate(BaseModel):
request_payload: Any | None = None
response_payload: Any | None = None
status: str = AuditStatus.SUCCESS
request_id: str | None = None
class AuditLogRead(BaseModel):
@@ -32,4 +33,5 @@ class AuditLogRead(BaseModel):
request_payload: str | None
response_payload: str | None
status: str
request_id: str | None
created_at: datetime

View File

@@ -5,6 +5,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.pagination import bounded_limit
from app.core.request_context import get_request_id
from app.modules.audit.constants import AUDIT_REDACTED_VALUE, AUDIT_SENSITIVE_KEYS
from app.modules.audit.models import AuditLog
from app.modules.audit.schemas import AuditLogCreate
@@ -54,6 +55,7 @@ class AuditService:
request_payload=_dump(payload.request_payload),
response_payload=_dump(payload.response_payload),
status=payload.status,
request_id=payload.request_id or get_request_id(),
)
self.db.add(record)
self.db.commit()

View File

@@ -102,6 +102,7 @@ class BusinessErrorDetail(StrEnum):
UNKNOWN_FIELD_TEMPLATE = "Unknown field '{field}'"
READ_ONLY_FIELD_TEMPLATE = "Field '{field}' is read-only"
INVALID_FIELD_VALUE_TEMPLATE = "Invalid value for field '{field}'"

View File

@@ -20,12 +20,199 @@ DOMAIN_MODELS: dict[BusinessDomain, type[DeclarativeMeta]] = {
BusinessDomain.LEGACY_SYNC_RUNS: models.LegacySyncRun,
}
HIGH_RISK_DOMAINS = frozenset(
{
BusinessDomain.FUND_ACCOUNTS,
BusinessDomain.PERFORMANCE_METRICS,
}
)
DOMAIN_WRITABLE_FIELDS: dict[BusinessDomain, frozenset[str]] = {
BusinessDomain.PROJECTS: frozenset(
{
"code",
"name",
"owner",
"status",
"priority",
"progress_percent",
"risk_level",
"budget_amount",
"actual_amount",
"start_date",
"due_date",
"description",
}
),
BusinessDomain.TASKS: frozenset(
{
"code",
"title",
"project_code",
"owner",
"status",
"priority",
"due_date",
"completed_at",
"blocker",
"description",
}
),
BusinessDomain.PROCUREMENTS: frozenset(
{
"code",
"name",
"applicant",
"project_code",
"supplier_name",
"budget_subject",
"expected_amount",
"actual_amount",
"approval_status",
"delivery_status",
"payment_status",
"comparison_summary",
}
),
BusinessDomain.EXPENSES: frozenset(
{
"code",
"expense_type",
"amount",
"applicant",
"department",
"project_code",
"budget_subject",
"payment_account",
"invoice_status",
"approval_status",
"payment_status",
}
),
BusinessDomain.FUND_ACCOUNTS: frozenset(
{
"code",
"name",
"account_type",
"current_balance",
"expected_receivable",
"expected_payable",
"safety_line",
"risk_level",
"note",
}
),
BusinessDomain.POLICIES: frozenset(
{
"code",
"title",
"policy_type",
"owner_department",
"version",
"status",
"effective_date",
"feishu_doc_url",
"summary",
}
),
BusinessDomain.STANDARDS: frozenset(
{
"code",
"title",
"standard_type",
"applies_to",
"status",
"check_items",
"remediation",
"policy_code",
}
),
BusinessDomain.PERFORMANCE_METRICS: frozenset(
{
"code",
"name",
"applies_to_role",
"formula",
"weight",
"data_source",
"auto_score",
"confirmed_score",
"status",
}
),
BusinessDomain.SUPPLIERS: frozenset(
{
"code",
"name",
"category",
"contact",
"quality_score",
"delivery_score",
"price_score",
"risk_level",
"blacklist_status",
}
),
BusinessDomain.ATTENDANCE_RECORDS: frozenset(
{
"code",
"employee_name",
"employee_id",
"department",
"project_code",
"work_date",
"check_in_at",
"check_out_at",
"status",
"location",
"note",
}
),
BusinessDomain.WORK_REPORTS: frozenset(
{
"code",
"report_type",
"title",
"reporter",
"department",
"project_code",
"period_start",
"period_end",
"content",
"metrics",
"risk_summary",
"status",
}
),
BusinessDomain.RISK_EVENTS: frozenset(
{
"code",
"title",
"risk_type",
"risk_level",
"status",
"source_domain",
"source_record_id",
"project_code",
"owner",
"due_date",
"assigned_to",
"closed_reason",
"review_summary",
"description",
"mitigation",
"evidence",
}
),
BusinessDomain.LEGACY_SYNC_RUNS: frozenset(
{
"code",
"domain",
"source_table",
"status",
"created_count",
"updated_count",
"skipped_count",
"error_message",
"note",
}
),
}
HIGH_RISK_DOMAINS = frozenset(DOMAIN_MODELS)
LOW_RISK_DOMAINS = frozenset(set(DOMAIN_MODELS) - HIGH_RISK_DOMAINS)
@@ -47,3 +234,7 @@ def is_high_risk_domain(domain: str | BusinessDomain) -> bool:
def get_domain_model(domain: str | BusinessDomain) -> type[DeclarativeMeta]:
return DOMAIN_MODELS[normalize_domain(domain)]
def get_writable_fields(domain: str | BusinessDomain) -> frozenset[str]:
return DOMAIN_WRITABLE_FIELDS[normalize_domain(domain)]

View File

@@ -10,7 +10,7 @@ class DomainRecordCreate(BaseModel):
actor: str = ActorValue.API
approval_ticket_id: str | None = Field(
default=None,
description="Required by policy for high-risk creates such as funds or performance.",
description="Required by policy for business record creates.",
)
@@ -19,7 +19,7 @@ class DomainRecordUpdate(BaseModel):
actor: str = ActorValue.API
approval_ticket_id: str | None = Field(
default=None,
description="Required by policy for high-risk updates such as funds or performance.",
description="Required by policy for business record updates.",
)

View File

@@ -17,9 +17,10 @@ from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.approvals.constants import ApprovalActionValue, approval_action
from app.modules.approvals.service import ApprovalService
from app.modules.business.registry import get_domain_model, is_high_risk_domain
from app.modules.business.registry import get_domain_model, get_writable_fields, is_high_risk_domain
from app.modules.business.constants import (
INVALID_FIELD_VALUE_TEMPLATE,
READ_ONLY_FIELD_TEMPLATE,
UNKNOWN_FIELD_TEMPLATE,
BusinessErrorDetail,
BusinessField,
@@ -56,7 +57,7 @@ def _coerce_column_value(column: Column, value: Any) -> Any:
return value
def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
def _model_payload(domain: str, model: Any, data: dict[str, Any]) -> dict[str, Any]:
"""Validate keys and coerce values according to model column types."""
columns = {
@@ -64,6 +65,7 @@ def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
for column in model.__table__.columns
if column.name != BusinessField.ID
}
writable_fields = get_writable_fields(domain)
payload: dict[str, Any] = {}
for key, value in data.items():
column = columns.get(key)
@@ -72,6 +74,11 @@ def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=UNKNOWN_FIELD_TEMPLATE.format(field=key),
)
if key not in writable_fields:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=READ_ONLY_FIELD_TEMPLATE.format(field=key),
)
try:
payload[key] = _coerce_column_value(column, value)
except (ValueError, TypeError, InvalidOperation) as exc:
@@ -127,7 +134,7 @@ class BusinessService:
) -> dict[str, Any]:
model = get_domain_model(domain)
high_risk = is_high_risk_domain(domain)
payload = _model_payload(model, data)
payload = _model_payload(domain, model, data)
record = model(**payload)
self.db.add(record)
if high_risk:
@@ -176,7 +183,7 @@ class BusinessService:
status_code=status.HTTP_404_NOT_FOUND,
detail=BusinessErrorDetail.RECORD_NOT_FOUND,
)
payload = _model_payload(model, data)
payload = _model_payload(domain, model, data)
if high_risk:
self._consume_approval(
approval_ticket_id,

View File

@@ -16,8 +16,14 @@ from app.modules.business.models import (
WorkTask,
)
from app.modules.business.service import serialize_model
from app.modules.events.constants import EventStatus
from app.modules.events.models import DomainEvent
from app.modules.reports.constants import ReportPushStatus
from app.modules.risk.service import RiskService
from app.modules.workflows.constants import WorkflowStatus
from app.modules.workflows.models import WorkflowInstance
from app.modules.writebacks.constants import WritebackStatus
from app.modules.writebacks.models import OfficialWritebackRun
class DashboardService:
@@ -41,6 +47,24 @@ class DashboardService:
RiskEvent.assigned_to.is_(None),
)
failed_push_runs = self._count(ReportPushRun, ReportPushRun.status == ReportPushStatus.FAILED)
pending_events = self._count(DomainEvent, DomainEvent.status == EventStatus.PENDING)
failed_events = self._count(DomainEvent, DomainEvent.status == EventStatus.FAILED)
running_workflows = self._count(
WorkflowInstance,
WorkflowInstance.status == WorkflowStatus.RUNNING,
)
failed_workflows = self._count(
WorkflowInstance,
WorkflowInstance.status == WorkflowStatus.FAILED,
)
disabled_writebacks = self._count(
OfficialWritebackRun,
OfficialWritebackRun.status == WritebackStatus.DISABLED,
)
failed_writebacks = self._count(
OfficialWritebackRun,
OfficialWritebackRun.status == WritebackStatus.FAILED,
)
latest_reports = self.db.execute(
select(WorkReport).order_by(WorkReport.id.desc()).limit(5)
).scalars()
@@ -62,6 +86,12 @@ class DashboardService:
"open_risk_events": open_risk_events,
"unassigned_open_risks": unassigned_open_risks,
"failed_push_runs": failed_push_runs,
"pending_events": pending_events,
"failed_events": failed_events,
"running_workflows": running_workflows,
"failed_workflows": failed_workflows,
"disabled_writebacks": disabled_writebacks,
"failed_writebacks": failed_writebacks,
"risk_level": risk_summary["risk_level"],
"risk_score": float(risk_summary["risk_score"]),
},

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,62 @@
from enum import StrEnum
class EventStatus(StrEnum):
PENDING = "pending"
PROCESSED = "processed"
FAILED = "failed"
class EventType(StrEnum):
APPROVAL_DECIDED = "approval.decided"
RISK_ACTION_RECORDED = "risk.action_recorded"
REPORT_PUSH_SUCCEEDED = "report.push_succeeded"
REPORT_PUSH_FAILED = "report.push_failed"
LEGACY_SYNC_COMPLETED = "legacy.sync_completed"
WRITEBACK_REQUESTED = "writeback.requested"
WRITEBACK_SUBMITTED = "writeback.submitted"
class EventSource(StrEnum):
APPROVAL = "approval"
RISK = "risk"
REPORTS = "reports"
LEGACY_MYSQL = "legacy_mysql"
WRITEBACK = "writeback"
API = "api"
class EventAggregateType(StrEnum):
APPROVAL = "approval"
RISK_EVENT = "risk-event"
REPORT_PUSH_RUN = "report-push-run"
LEGACY_SYNC_RUN = "legacy-sync-run"
WRITEBACK_RUN = "writeback-run"
class EventResponseKey(StrEnum):
ITEMS = "items"
EVENT = "event"
TOTAL = "total"
class EventPayloadKey(StrEnum):
ACTION = "action"
STATUS = "status"
TICKET_ID = "ticket_id"
DOMAIN = "domain"
RECORD_ID = "record_id"
APPROVED = "approved"
COMMENT = "comment"
CODE = "code"
CREATED = "created"
UPDATED = "updated"
SKIPPED = "skipped"
ERROR_MESSAGE = "error_message"
class EventErrorDetail(StrEnum):
EVENT_NOT_FOUND = "Domain event not found"
EVENT_CODE_PREFIX = "EVT"

View File

@@ -0,0 +1,33 @@
from datetime import datetime
from sqlalchemy import JSON, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.constants import ActorValue
from app.core.db_base import Base
from app.core.time import utc_now
from app.modules.events.constants import EventSource, EventStatus
class DomainEvent(Base):
__tablename__ = "domain_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
event_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
event_type: Mapped[str] = mapped_column(String(128), index=True)
source: Mapped[str] = mapped_column(String(64), default=EventSource.API, index=True)
aggregate_type: Mapped[str] = mapped_column(String(128), index=True)
aggregate_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
status: Mapped[str] = mapped_column(String(32), default=EventStatus.PENDING, index=True)
attempts: Mapped[int] = mapped_column(Integer, default=0)
idempotency_key: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
unique=True,
index=True,
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
processed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)

View File

@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_api_key
from app.modules.events.constants import EventResponseKey
from app.modules.events.service import EventService, _serialize_event
router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("")
def list_events(
status: str | None = None,
event_type: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> dict:
return {
EventResponseKey.ITEMS: EventService(db).list_events(
status_filter=status,
event_type=event_type,
limit=limit,
)
}
@router.post("/{event_id}/dispatch")
def dispatch_event(
event_id: str,
db: Session = Depends(get_db),
) -> dict:
return {EventResponseKey.EVENT: _serialize_event(EventService(db).dispatch_event(event_id))}
@router.post("/dispatch-pending")
def dispatch_pending(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> dict:
return {EventResponseKey.ITEMS: EventService(db).dispatch_pending(limit=limit)}

View File

@@ -0,0 +1,23 @@
from typing import Any
from pydantic import BaseModel
class DomainEventRead(BaseModel):
event_id: str
event_type: str
source: str
aggregate_type: str
aggregate_id: str | None
actor: str
payload: dict[str, Any] | None
status: str
attempts: int
idempotency_key: str | None
last_error: str | None
created_at: str
processed_at: str | None
class DomainEventListRead(BaseModel):
items: list[dict[str, Any]]

View File

@@ -0,0 +1,184 @@
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.pagination import bounded_limit
from app.core.time import utc_now
from app.modules.events.constants import (
EVENT_CODE_PREFIX,
EventAggregateType,
EventErrorDetail,
EventPayloadKey,
EventStatus,
EventType,
)
from app.modules.events.models import DomainEvent
def _serialize_event(record: DomainEvent) -> dict[str, Any]:
return {
column.name: getattr(record, column.name)
for column in record.__table__.columns
}
class EventService:
"""Persist outbox events and dispatch the V3 internal handlers."""
def __init__(self, db: Session):
self.db = db
def emit(
self,
event_type: str,
source: str,
aggregate_type: str,
aggregate_id: str | int | None,
actor: str = ActorValue.SYSTEM,
payload: dict[str, Any] | None = None,
idempotency_key: str | None = None,
dispatch: bool = False,
) -> DomainEvent:
if idempotency_key:
existing = self.db.execute(
select(DomainEvent).where(DomainEvent.idempotency_key == idempotency_key)
).scalar_one_or_none()
if existing is not None:
if dispatch and existing.status == EventStatus.PENDING:
return self.dispatch_event(existing.event_id)
return existing
record = DomainEvent(
event_id=f"{EVENT_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
event_type=event_type,
source=source,
aggregate_type=aggregate_type,
aggregate_id=str(aggregate_id) if aggregate_id is not None else None,
actor=actor,
payload=payload or {},
idempotency_key=idempotency_key,
)
self.db.add(record)
self.db.commit()
self.db.refresh(record)
if dispatch:
return self.dispatch_event(record.event_id)
return record
def list_events(
self,
status_filter: str | None = None,
event_type: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
stmt = select(DomainEvent).order_by(DomainEvent.id.desc()).limit(bounded_limit(limit))
if status_filter:
stmt = stmt.where(DomainEvent.status == status_filter)
if event_type:
stmt = stmt.where(DomainEvent.event_type == event_type)
return [_serialize_event(item) for item in self.db.execute(stmt).scalars()]
def count_by_status(self) -> dict[str, int]:
rows = self.db.execute(
select(DomainEvent.status, func.count()).group_by(DomainEvent.status)
).all()
return {str(status_value): int(count) for status_value, count in rows}
def get_event(self, event_id: str) -> DomainEvent:
record = self.db.execute(
select(DomainEvent).where(DomainEvent.event_id == event_id)
).scalar_one_or_none()
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=EventErrorDetail.EVENT_NOT_FOUND,
)
return record
def dispatch_event(self, event_id: str) -> DomainEvent:
record = self.get_event(event_id)
if record.status == EventStatus.PROCESSED:
return record
record.attempts += 1
try:
self._handle_event(record)
except Exception as exc:
record.status = EventStatus.FAILED
record.last_error = str(exc)
self.db.commit()
self.db.refresh(record)
return record
record.status = EventStatus.PROCESSED
record.last_error = None
record.processed_at = utc_now()
self.db.commit()
self.db.refresh(record)
return record
def dispatch_pending(self, limit: int = 100) -> list[dict[str, Any]]:
stmt = (
select(DomainEvent)
.where(DomainEvent.status == EventStatus.PENDING)
.order_by(DomainEvent.id.asc())
.limit(bounded_limit(limit))
)
records = list(self.db.execute(stmt).scalars())
return [_serialize_event(self.dispatch_event(record.event_id)) for record in records]
def _handle_event(self, record: DomainEvent) -> None:
if record.event_type == EventType.RISK_ACTION_RECORDED:
self._handle_risk_action(record)
return
if record.event_type in {EventType.WRITEBACK_REQUESTED, EventType.WRITEBACK_SUBMITTED}:
self._handle_writeback(record)
def _handle_risk_action(self, record: DomainEvent) -> None:
from app.modules.risk.constants import RiskEventActionValue
from app.modules.workflows.service import WorkflowService
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
payload = record.payload or {}
action = str(payload.get(EventPayloadKey.ACTION) or "")
if action == RiskEventActionValue.CLOSE:
workflow_status = WorkflowStatus.COMPLETED
elif action == RiskEventActionValue.RESOLVE:
workflow_status = WorkflowStatus.WAITING_REVIEW
else:
workflow_status = WorkflowStatus.RUNNING
WorkflowService(self.db).start_or_update(
workflow_type=WorkflowType.RISK_EVENT_REVIEW,
aggregate_type=EventAggregateType.RISK_EVENT,
aggregate_id=record.aggregate_id,
status_value=workflow_status,
action=action or record.event_type,
actor=record.actor,
payload=payload,
)
def _handle_writeback(self, record: DomainEvent) -> None:
from app.modules.workflows.service import WorkflowService
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
from app.modules.writebacks.constants import WritebackStatus
payload = record.payload or {}
run_status = str(payload.get(EventPayloadKey.STATUS) or "")
if run_status == WritebackStatus.SENT:
workflow_status = WorkflowStatus.COMPLETED
elif run_status == WritebackStatus.FAILED:
workflow_status = WorkflowStatus.FAILED
elif run_status == WritebackStatus.DISABLED:
workflow_status = WorkflowStatus.BLOCKED
else:
workflow_status = WorkflowStatus.WAITING_APPROVAL
WorkflowService(self.db).start_or_update(
workflow_type=WorkflowType.OFFICIAL_WRITEBACK,
aggregate_type=EventAggregateType.WRITEBACK_RUN,
aggregate_id=record.aggregate_id,
status_value=workflow_status,
action=record.event_type,
actor=record.actor,
payload=payload,
)

View File

@@ -64,6 +64,8 @@ class FeishuResponseKey(StrEnum):
RESULT = "result"
CHALLENGE = "challenge"
PROVIDER_RESPONSE = "provider_response"
STATUS = "status"
APPROVER = "approver"
class FeishuCommandResultKey(StrEnum):
@@ -96,6 +98,24 @@ class FeishuEventReceiptKey(StrEnum):
MESSAGE_ID = "message_id"
class FeishuApprovalAction(StrEnum):
APPROVE = "approve"
REJECT = "reject"
class FeishuApprovalValueKey(StrEnum):
TICKET_ID = "ticket_id"
DECISION = "decision"
ACTION = "action"
COMMENT = "comment"
class FeishuCardKey(StrEnum):
ACTIONS = "actions"
BUTTON_TYPE = "type"
VALUE = "value"
FEISHU_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token/internal"
FEISHU_MESSAGE_PATH = "/im/v1/messages"
FEISHU_DEFAULT_OPEN_API_DOMAIN = "https://open.feishu.cn"
@@ -103,6 +123,10 @@ FEISHU_AUTH_MISSING = "Feishu app credentials are not configured"
FEISHU_VERIFICATION_TOKEN_REQUIRED = "FEISHU_VERIFICATION_TOKEN is required"
FEISHU_INVALID_TOKEN = "Invalid Feishu token"
FEISHU_RECEIVE_ID_MISSING = "receive_id or FEISHU_DEFAULT_CHAT_ID is required"
FEISHU_APPROVAL_ACTION_INVALID = "Invalid Feishu approval action payload"
FEISHU_APPROVAL_APPROVER_IDS_REQUIRED = "FEISHU_APPROVAL_APPROVER_IDS is required"
FEISHU_APPROVER_NOT_ALLOWED = "Feishu approver is not allowed"
FEISHU_APPROVAL_CARD_ACTION_TARGET = "approval_card_action"
FEISHU_SUCCESS_CODE = 0
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300

View File

@@ -6,11 +6,19 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.config import get_settings
from app.modules.approvals.service import ApprovalService
from app.modules.audit.constants import AuditAction, AuditSource
from app.modules.audit.schemas import AuditLogCreate
from app.modules.feishu.commands import FeishuCommandService
from app.modules.feishu.constants import (
FEISHU_APPROVAL_ACTION_INVALID,
FEISHU_APPROVAL_APPROVER_IDS_REQUIRED,
FEISHU_APPROVAL_CARD_ACTION_TARGET,
FEISHU_APPROVER_NOT_ALLOWED,
FeishuApprovalAction,
FeishuApprovalValueKey,
FeishuCardKey,
FeishuCommandKey,
FeishuEventReceiptKey,
FeishuEventSource,
@@ -87,15 +95,38 @@ class FeishuEventService:
self.feishu.verify_event(payload)
value = _approval_action_value(payload)
ticket_id = str(value.get("ticket_id") or "").strip()
decision = str(value.get("decision") or value.get("action") or "").lower()
if not ticket_id or decision not in {"approve", "reject"}:
ticket_id = str(value.get(FeishuApprovalValueKey.TICKET_ID) or "").strip()
decision = str(
value.get(FeishuApprovalValueKey.DECISION)
or value.get(FeishuApprovalValueKey.ACTION)
or ""
).lower()
if not ticket_id or decision not in set(FeishuApprovalAction):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid Feishu approval action payload",
detail=FEISHU_APPROVAL_ACTION_INVALID,
)
comment = value.get("comment")
comment = value.get(FeishuApprovalValueKey.COMMENT)
actor = _approval_operator(payload)
try:
_ensure_approval_operator_allowed(actor)
except HTTPException as exc:
self.feishu.audit.log(
AuditLogCreate(
actor=actor,
source=AuditSource.FEISHU,
action=AuditAction.FEISHU_WEBHOOK_EVENT,
target_type=FEISHU_APPROVAL_CARD_ACTION_TARGET,
target_id=ticket_id,
request_payload=payload,
response_payload={
FeishuResponseKey.OK: False,
"status_code": exc.status_code,
"detail": exc.detail,
},
)
)
raise
event_identity = _approval_event_identity(payload, ticket_id, decision, actor)
if not self._register_event(event_identity):
ticket = ApprovalService(self.db).get_by_ticket(ticket_id)
@@ -104,15 +135,15 @@ class FeishuEventService:
FeishuResponseKey.HANDLED: True,
FeishuResponseKey.DUPLICATE: True,
FeishuResponseKey.RESULT: {
"ticket_id": ticket.ticket_id,
"status": ticket.status,
"approver": ticket.approver,
FeishuApprovalValueKey.TICKET_ID: ticket.ticket_id,
FeishuResponseKey.STATUS: ticket.status,
FeishuResponseKey.APPROVER: ticket.approver,
},
}
ticket = ApprovalService(self.db).decide(
ticket_id,
actor,
approved=decision == "approve",
approved=decision == FeishuApprovalAction.APPROVE,
comment=str(comment) if comment is not None else None,
)
self.feishu.audit.log(
@@ -120,19 +151,22 @@ class FeishuEventService:
actor=actor,
source=AuditSource.FEISHU,
action=AuditAction.FEISHU_WEBHOOK_EVENT,
target_type="approval_card_action",
target_type=FEISHU_APPROVAL_CARD_ACTION_TARGET,
target_id=ticket_id,
request_payload=payload,
response_payload={"status": ticket.status, "decision": decision},
response_payload={
FeishuResponseKey.STATUS: ticket.status,
FeishuApprovalValueKey.DECISION: decision,
},
)
)
return {
FeishuResponseKey.OK: True,
FeishuResponseKey.HANDLED: True,
FeishuResponseKey.RESULT: {
"ticket_id": ticket.ticket_id,
"status": ticket.status,
"approver": ticket.approver,
FeishuApprovalValueKey.TICKET_ID: ticket.ticket_id,
FeishuResponseKey.STATUS: ticket.status,
FeishuResponseKey.APPROVER: ticket.approver,
},
}
@@ -183,10 +217,15 @@ def _event_identity(
def _approval_action_value(payload: dict[str, Any]) -> dict[str, Any]:
action = payload.get("action") or {}
action = payload.get(FeishuApprovalValueKey.ACTION) or {}
event = payload.get(FeishuPayloadKey.EVENT) or {}
event_action = event.get("action") or {}
value = action.get("value") or event_action.get("value") or payload.get("value") or {}
event_action = event.get(FeishuApprovalValueKey.ACTION) or {}
value = (
action.get(FeishuCardKey.VALUE)
or event_action.get(FeishuCardKey.VALUE)
or payload.get(FeishuCardKey.VALUE)
or {}
)
if isinstance(value, str):
try:
parsed = json.loads(value)
@@ -210,6 +249,24 @@ def _approval_operator(payload: dict[str, Any]) -> str:
)
def _ensure_approval_operator_allowed(actor: str) -> None:
allowed_ids = {
item.strip()
for item in get_settings().feishu_approval_approver_ids
if item.strip()
}
if not allowed_ids:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=FEISHU_APPROVAL_APPROVER_IDS_REQUIRED,
)
if actor not in allowed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=FEISHU_APPROVER_NOT_ALLOWED,
)
def _approval_event_identity(
payload: dict[str, Any],
ticket_id: str,

View File

@@ -14,6 +14,9 @@ from app.modules.feishu.constants import (
FEISHU_EMPTY_CARD_TEXT,
FEISHU_INVALID_TOKEN,
FEISHU_VERIFICATION_TOKEN_REQUIRED,
FeishuApprovalAction,
FeishuApprovalValueKey,
FeishuCardKey,
FeishuPayloadKey,
FeishuReceiveIdType,
)
@@ -119,16 +122,19 @@ class FeishuService:
card = FeishuService.build_basic_card(title, lines)
card[FeishuPayloadKey.ELEMENTS].append(
{
FeishuPayloadKey.TAG: "action",
"actions": [
FeishuPayloadKey.TAG: FeishuApprovalValueKey.ACTION,
FeishuCardKey.ACTIONS: [
{
FeishuPayloadKey.TAG: "button",
FeishuPayloadKey.TEXT: {
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
FeishuPayloadKey.CONTENT: "批准",
},
"type": "primary",
"value": {"ticket_id": ticket_id, "decision": "approve"},
FeishuCardKey.BUTTON_TYPE: "primary",
FeishuCardKey.VALUE: {
FeishuApprovalValueKey.TICKET_ID: ticket_id,
FeishuApprovalValueKey.DECISION: FeishuApprovalAction.APPROVE,
},
},
{
FeishuPayloadKey.TAG: "button",
@@ -136,8 +142,11 @@ class FeishuService:
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
FeishuPayloadKey.CONTENT: "拒绝",
},
"type": "danger",
"value": {"ticket_id": ticket_id, "decision": "reject"},
FeishuCardKey.BUTTON_TYPE: "danger",
FeishuCardKey.VALUE: {
FeishuApprovalValueKey.TICKET_ID: ticket_id,
FeishuApprovalValueKey.DECISION: FeishuApprovalAction.REJECT,
},
},
],
}

View File

@@ -19,6 +19,13 @@ from app.modules.audit.service import AuditService
from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue
from app.modules.business.models import LegacySyncRun, Project, WorkTask
from app.modules.business.service import serialize_model
from app.modules.events.constants import (
EventAggregateType,
EventPayloadKey,
EventSource,
EventType,
)
from app.modules.events.service import EventService
from app.modules.legacy_mysql.constants import (
LEGACY_PROJECT_QUERY_SOURCE,
LEGACY_PROJECT_SYNC_NOTE,
@@ -491,6 +498,22 @@ class LegacyMySQLService:
},
)
)
EventService(self.db).emit(
event_type=EventType.LEGACY_SYNC_COMPLETED,
source=EventSource.LEGACY_MYSQL,
aggregate_type=EventAggregateType.LEGACY_SYNC_RUN,
aggregate_id=sync_run.code,
actor=actor,
payload={
EventPayloadKey.CODE: sync_run.code,
EventPayloadKey.DOMAIN: BusinessDomain.PROJECTS,
EventPayloadKey.STATUS: sync_run.status,
EventPayloadKey.CREATED: created,
EventPayloadKey.UPDATED: updated,
EventPayloadKey.SKIPPED: skipped,
},
idempotency_key=f"legacy-sync:{sync_run.code}",
)
return result
def sync_tasks(
@@ -629,4 +652,20 @@ class LegacyMySQLService:
},
)
)
EventService(self.db).emit(
event_type=EventType.LEGACY_SYNC_COMPLETED,
source=EventSource.LEGACY_MYSQL,
aggregate_type=EventAggregateType.LEGACY_SYNC_RUN,
aggregate_id=sync_run.code,
actor=actor,
payload={
EventPayloadKey.CODE: sync_run.code,
EventPayloadKey.DOMAIN: BusinessDomain.TASKS,
EventPayloadKey.STATUS: sync_run.status,
EventPayloadKey.CREATED: created,
EventPayloadKey.UPDATED: updated,
EventPayloadKey.SKIPPED: skipped,
},
idempotency_key=f"legacy-sync:{sync_run.code}",
)
return result

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,27 @@
from enum import StrEnum
class ObservabilityKey(StrEnum):
STATUS = "status"
CHECKS = "checks"
METRICS = "metrics"
DATABASE = "database"
REDIS = "redis"
EVENTS = "events"
WORKFLOWS = "workflows"
WRITEBACKS = "writebacks"
class ObservabilityStatus(StrEnum):
OK = "ok"
DEGRADED = "degraded"
SKIPPED = "skipped"
ERROR = "error"
class ObservabilityMetricKey(StrEnum):
ERROR = "error"
PENDING = "pending"
FAILED = "failed"
RUNNING = "running"
DISABLED = "disabled"

View File

@@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_api_key
from app.modules.observability.service import ObservabilityService
router = APIRouter()
@router.get("/health/live")
def live(
db: Session = Depends(get_db),
) -> dict:
_ = db
return ObservabilityService(db).live()
@router.get("/health/ready")
def ready(
db: Session = Depends(get_db),
) -> dict:
return ObservabilityService(db).ready()
@router.get("/metrics", dependencies=[Depends(require_api_key)])
def metrics(
db: Session = Depends(get_db),
) -> dict:
return ObservabilityService(db).metrics()

View File

@@ -0,0 +1,114 @@
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.modules.events.constants import EventStatus
from app.modules.events.service import EventService
from app.modules.observability.constants import (
ObservabilityKey,
ObservabilityMetricKey,
ObservabilityStatus,
)
from app.modules.workflows.constants import WorkflowStatus
from app.modules.workflows.service import WorkflowService
from app.modules.writebacks.constants import WritebackStatus
from app.modules.writebacks.service import WritebackService
class ObservabilityService:
"""Build health, readiness, and JSON metrics for V3 operations."""
def __init__(self, db: Session):
self.db = db
def live(self) -> dict[str, str]:
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
def ready(self) -> dict[str, Any]:
checks = {
ObservabilityKey.DATABASE: self._database_check(),
ObservabilityKey.REDIS: self._redis_check(),
ObservabilityKey.EVENTS: self._events_check(),
ObservabilityKey.WORKFLOWS: self._workflows_check(),
ObservabilityKey.WRITEBACKS: self._writebacks_check(),
}
degraded = any(
item[ObservabilityKey.STATUS]
in {ObservabilityStatus.DEGRADED, ObservabilityStatus.ERROR}
for item in checks.values()
)
return {
ObservabilityKey.STATUS: (
ObservabilityStatus.DEGRADED if degraded else ObservabilityStatus.OK
),
ObservabilityKey.CHECKS: checks,
}
def metrics(self) -> dict[str, Any]:
return {
ObservabilityKey.METRICS: {
ObservabilityKey.EVENTS: EventService(self.db).count_by_status(),
ObservabilityKey.WORKFLOWS: WorkflowService(self.db).count_by_status(),
ObservabilityKey.WRITEBACKS: WritebackService(self.db).count_by_status(),
}
}
def _database_check(self) -> dict[str, Any]:
try:
self.db.execute(text("select 1")).scalar()
except Exception as exc:
return {
ObservabilityKey.STATUS: ObservabilityStatus.ERROR,
ObservabilityMetricKey.ERROR: str(exc),
}
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
def _redis_check(self) -> dict[str, Any]:
settings = get_settings()
if not settings.task_queue_enabled:
return {ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED}
try:
from redis import Redis
Redis.from_url(settings.redis_url, socket_connect_timeout=1).ping()
except Exception as exc:
return {
ObservabilityKey.STATUS: ObservabilityStatus.DEGRADED,
ObservabilityMetricKey.ERROR: str(exc),
}
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
def _events_check(self) -> dict[str, Any]:
counts = EventService(self.db).count_by_status()
failed = counts.get(EventStatus.FAILED, 0)
return {
ObservabilityKey.STATUS: (
ObservabilityStatus.DEGRADED if failed else ObservabilityStatus.OK
),
ObservabilityMetricKey.PENDING: counts.get(EventStatus.PENDING, 0),
ObservabilityMetricKey.FAILED: failed,
}
def _workflows_check(self) -> dict[str, Any]:
counts = WorkflowService(self.db).count_by_status()
failed = counts.get(WorkflowStatus.FAILED, 0)
return {
ObservabilityKey.STATUS: (
ObservabilityStatus.DEGRADED if failed else ObservabilityStatus.OK
),
ObservabilityMetricKey.RUNNING: counts.get(WorkflowStatus.RUNNING, 0),
ObservabilityMetricKey.FAILED: failed,
}
def _writebacks_check(self) -> dict[str, Any]:
counts = WritebackService(self.db).count_by_status()
failed = counts.get(WritebackStatus.FAILED, 0)
return {
ObservabilityKey.STATUS: (
ObservabilityStatus.DEGRADED if failed else ObservabilityStatus.OK
),
ObservabilityMetricKey.DISABLED: counts.get(WritebackStatus.DISABLED, 0),
ObservabilityMetricKey.FAILED: failed,
}

View File

@@ -34,6 +34,10 @@ class ReportPushKey(StrEnum):
STATUS = "status"
class ReportErrorDetail(StrEnum):
PUSH_RUN_NOT_FOUND = "Report push run not found"
class LifecycleSection(StrEnum):
HEALTH = "health"
PROJECTS = "projects"

View File

@@ -11,6 +11,7 @@ from app.modules.reports.schemas import (
ReportResponse,
WorkReportGenerateRequest,
)
from app.modules.reports.constants import ReportPushKey
from app.modules.reports.service import ReportService
router = APIRouter(dependencies=[Depends(require_api_key)])
@@ -64,7 +65,7 @@ def list_push_runs(
limit: int = 100,
db: Session = Depends(get_db),
) -> dict:
return {"items": ReportService(db).list_push_runs(status_filter=status, limit=limit)}
return {ReportPushKey.ITEMS: ReportService(db).list_push_runs(status_filter=status, limit=limit)}
@router.get("/push-runs/{code}")

View File

@@ -34,6 +34,13 @@ from app.modules.business.models import (
WorkTask,
)
from app.modules.business.service import serialize_model
from app.modules.events.constants import (
EventAggregateType,
EventPayloadKey,
EventSource,
EventType,
)
from app.modules.events.service import EventService
from app.modules.feishu.service import FeishuService
from app.modules.reports.constants import (
ATTENTION_SCORE_THRESHOLD,
@@ -50,6 +57,7 @@ from app.modules.reports.constants import (
MetricKey,
ReportResponseKey,
ReportPushStatus,
ReportErrorDetail,
ReportStatus,
ReportText,
ReportTitle,
@@ -226,7 +234,7 @@ class ReportService:
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report push run not found",
detail=ReportErrorDetail.PUSH_RUN_NOT_FOUND,
)
return record
@@ -1113,18 +1121,43 @@ class ReportService:
try:
result = FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)
except Exception as exc:
self.update_push_run(
failed_run = self.update_push_run(
push_run.code,
ReportPushStatus.FAILED,
error_message=str(exc),
)
EventService(self.db).emit(
event_type=EventType.REPORT_PUSH_FAILED,
source=EventSource.REPORTS,
aggregate_type=EventAggregateType.REPORT_PUSH_RUN,
aggregate_id=failed_run.code,
actor=actor,
payload={
EventPayloadKey.CODE: failed_run.code,
EventPayloadKey.STATUS: failed_run.status,
EventPayloadKey.ERROR_MESSAGE: failed_run.error_message,
},
idempotency_key=f"report-push:{failed_run.code}:{failed_run.status}",
)
raise
self.update_push_run(
success_run = self.update_push_run(
push_run.code,
ReportPushStatus.SUCCESS,
provider_response=result,
sent=True,
)
EventService(self.db).emit(
event_type=EventType.REPORT_PUSH_SUCCEEDED,
source=EventSource.REPORTS,
aggregate_type=EventAggregateType.REPORT_PUSH_RUN,
aggregate_id=success_run.code,
actor=actor,
payload={
EventPayloadKey.CODE: success_run.code,
EventPayloadKey.STATUS: success_run.status,
},
idempotency_key=f"report-push:{success_run.code}:{success_run.status}",
)
AuditService(self.db).log(
AuditLogCreate(
actor=actor,

View File

@@ -59,6 +59,16 @@ class RiskEventActionKey(StrEnum):
RISK_EVENT = "risk_event"
ACTION_RECORD = "action_record"
ITEMS = "items"
FROM_STATUS = "from_status"
TO_STATUS = "to_status"
COMMENT = "comment"
PAYLOAD = "payload"
ASSIGNED_TO = "assigned_to"
REVIEW_SUMMARY = "review_summary"
class RiskErrorDetail(StrEnum):
RISK_EVENT_NOT_FOUND = "Risk event not found"
RISK_SCORE_WEIGHTS = {

View File

@@ -144,6 +144,7 @@ def close_risk_event(
closed_reason=payload.closed_reason,
review_summary=payload.review_summary,
actor=principal.actor,
approval_ticket_id=payload.approval_ticket_id,
)
@@ -158,6 +159,7 @@ def reopen_risk_event(
event_id,
comment=payload.comment,
actor=principal.actor,
approval_ticket_id=payload.approval_ticket_id,
)

View File

@@ -21,7 +21,9 @@ class RiskResolveRequest(BaseModel):
class RiskCloseRequest(BaseModel):
closed_reason: str = Field(..., min_length=1)
review_summary: str | None = None
approval_ticket_id: str | None = None
class RiskReopenRequest(BaseModel):
comment: str | None = None
approval_ticket_id: str | None = None

View File

@@ -2,6 +2,7 @@ from datetime import date
from decimal import Decimal
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -16,12 +17,15 @@ from app.modules.audit.constants import (
)
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.approvals.constants import ApprovalActionValue, approval_action
from app.modules.approvals.service import ApprovalService
from app.modules.business.constants import (
CLOSED_RISK_STATUSES,
DONE_STATUSES,
GENERATED_RISK_EVENT_TYPES,
PROJECT_CLOSED_STATUSES,
SUPPLIER_RISK_LEVELS,
BusinessErrorDetail,
BusinessDomain,
RiskEventType,
RiskLevel,
@@ -36,10 +40,18 @@ from app.modules.business.models import (
WorkTask,
)
from app.modules.business.service import serialize_model
from app.modules.events.constants import (
EventAggregateType,
EventPayloadKey,
EventSource,
EventType,
)
from app.modules.events.service import EventService
from app.modules.risk.constants import (
RISK_SCORE_WEIGHTS,
RiskEventActionKey,
RiskEventActionValue,
RiskErrorDetail,
RiskGenerationAction,
RiskGenerationResultKey,
RiskEventPayloadKey,
@@ -131,7 +143,7 @@ class RiskService:
from_status,
record.status,
comment,
{"assigned_to": assigned_to},
{RiskEventActionKey.ASSIGNED_TO: assigned_to},
)
self.db.commit()
self.db.refresh(record)
@@ -191,8 +203,20 @@ class RiskService:
closed_reason: str,
review_summary: str | None = None,
actor: str = ActorValue.API,
approval_ticket_id: str | None = None,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
approval_payload = {
RiskEventPayloadKey.STATUS: StatusValue.CLOSED,
"closed_reason": closed_reason,
RiskEventActionKey.REVIEW_SUMMARY: review_summary,
}
self._consume_risk_approval(
approval_ticket_id,
risk_event_id,
approval_payload,
actor,
)
from_status = record.status
now = utc_now()
record.status = StatusValue.CLOSED
@@ -208,7 +232,7 @@ class RiskService:
from_status,
record.status,
closed_reason,
{"review_summary": review_summary},
{RiskEventActionKey.REVIEW_SUMMARY: review_summary},
)
self.db.commit()
self.db.refresh(record)
@@ -220,8 +244,19 @@ class RiskService:
risk_event_id: int,
comment: str | None = None,
actor: str = ActorValue.API,
approval_ticket_id: str | None = None,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
approval_payload = {
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventActionKey.COMMENT: comment,
}
self._consume_risk_approval(
approval_ticket_id,
risk_event_id,
approval_payload,
actor,
)
from_status = record.status
record.status = StatusValue.OPEN
record.resolved_at = None
@@ -277,7 +312,10 @@ class RiskService:
if record is None:
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Risk event not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=RiskErrorDetail.RISK_EVENT_NOT_FOUND,
)
return record
def _record_action(
@@ -312,13 +350,31 @@ class RiskService:
risk_level=AuditRiskLevel.MEDIUM,
request_payload={
RiskEventActionKey.ACTION: action,
"from_status": from_status,
"to_status": to_status,
"comment": comment,
"payload": payload,
RiskEventActionKey.FROM_STATUS: from_status,
RiskEventActionKey.TO_STATUS: to_status,
RiskEventActionKey.COMMENT: comment,
RiskEventActionKey.PAYLOAD: payload,
},
)
)
EventService(self.db).emit(
event_type=EventType.RISK_ACTION_RECORDED,
source=EventSource.RISK,
aggregate_type=EventAggregateType.RISK_EVENT,
aggregate_id=record.id,
actor=actor,
payload={
EventPayloadKey.ACTION: action,
EventPayloadKey.STATUS: to_status,
EventPayloadKey.RECORD_ID: str(record.id),
RiskEventActionKey.FROM_STATUS: from_status,
RiskEventActionKey.TO_STATUS: to_status,
RiskEventActionKey.COMMENT: comment,
RiskEventActionKey.PAYLOAD: payload,
},
idempotency_key=f"risk:{record.id}:{action_record.code}",
dispatch=True,
)
return action_record
@staticmethod
@@ -328,6 +384,27 @@ class RiskService:
RiskEventActionKey.ACTION_RECORD: serialize_model(action),
}
def _consume_risk_approval(
self,
approval_ticket_id: str | None,
risk_event_id: int,
payload: dict[str, Any],
actor: str,
) -> None:
if not approval_ticket_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=BusinessErrorDetail.HIGH_RISK_APPROVAL_REQUIRED,
)
ApprovalService(self.db).consume_for(
approval_ticket_id,
BusinessDomain.RISK_EVENTS,
risk_event_id,
approval_action(ApprovalActionValue.UPDATE, BusinessDomain.RISK_EVENTS),
payload,
actor,
)
def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]:
"""Generate or refresh risk-event ledger entries from current signals."""

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,29 @@
from enum import StrEnum
class WorkflowType(StrEnum):
RISK_EVENT_REVIEW = "risk_event_review"
OFFICIAL_WRITEBACK = "official_writeback"
class WorkflowStatus(StrEnum):
WAITING_APPROVAL = "waiting_approval"
WAITING_REVIEW = "waiting_review"
RUNNING = "running"
BLOCKED = "blocked"
COMPLETED = "completed"
FAILED = "failed"
class WorkflowResponseKey(StrEnum):
ITEMS = "items"
WORKFLOW = "workflow"
ACTION = "action"
class WorkflowErrorDetail(StrEnum):
WORKFLOW_NOT_FOUND = "Workflow instance not found"
WORKFLOW_CODE_PREFIX = "WF"
WORKFLOW_ACTION_CODE_PREFIX = "WF-ACTION"

View File

@@ -0,0 +1,44 @@
from datetime import datetime
from sqlalchemy import JSON, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.constants import ActorValue
from app.core.db_base import Base
from app.core.time import utc_now
from app.modules.workflows.constants import WorkflowStatus
class WorkflowInstance(Base):
__tablename__ = "workflow_instances"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
workflow_type: Mapped[str] = mapped_column(String(128), index=True)
aggregate_type: Mapped[str] = mapped_column(String(128), index=True)
aggregate_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(32), default=WorkflowStatus.RUNNING, index=True)
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
current_step: Mapped[str | None] = mapped_column(String(128), nullable=True)
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=utc_now,
onupdate=utc_now,
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
class WorkflowAction(Base):
__tablename__ = "workflow_actions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
workflow_code: Mapped[str] = mapped_column(String(64), index=True)
action: Mapped[str] = mapped_column(String(128), index=True)
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
to_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)

View File

@@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_api_key
from app.modules.workflows.constants import WorkflowResponseKey
from app.modules.workflows.service import WorkflowService
router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("")
def list_workflows(
status: str | None = None,
workflow_type: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> dict:
return {
WorkflowResponseKey.ITEMS: WorkflowService(db).list_workflows(
status_filter=status,
workflow_type=workflow_type,
limit=limit,
)
}
@router.get("/{code}")
def get_workflow(
code: str,
db: Session = Depends(get_db),
) -> dict:
return {WorkflowResponseKey.WORKFLOW: WorkflowService(db).get_workflow(code)}

View File

@@ -0,0 +1,21 @@
from typing import Any
from pydantic import BaseModel
class WorkflowRead(BaseModel):
code: str
workflow_type: str
aggregate_type: str
aggregate_id: str | None
status: str
actor: str
current_step: str | None
payload: dict[str, Any] | None
created_at: str
updated_at: str
completed_at: str | None
class WorkflowListRead(BaseModel):
items: list[dict[str, Any]]

View File

@@ -0,0 +1,134 @@
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.pagination import bounded_limit
from app.core.time import utc_now
from app.modules.business.service import serialize_model
from app.modules.workflows.constants import (
WORKFLOW_ACTION_CODE_PREFIX,
WORKFLOW_CODE_PREFIX,
WorkflowErrorDetail,
WorkflowStatus,
)
from app.modules.workflows.models import WorkflowAction, WorkflowInstance
class WorkflowService:
"""Track V3 workflow instances and append-only workflow actions."""
def __init__(self, db: Session):
self.db = db
def start_or_update(
self,
workflow_type: str,
aggregate_type: str,
aggregate_id: str | int | None,
status_value: str,
action: str,
actor: str = ActorValue.SYSTEM,
payload: dict[str, Any] | None = None,
) -> WorkflowInstance:
aggregate_id_text = str(aggregate_id) if aggregate_id is not None else None
record = self.db.execute(
select(WorkflowInstance).where(
WorkflowInstance.workflow_type == workflow_type,
WorkflowInstance.aggregate_type == aggregate_type,
WorkflowInstance.aggregate_id == aggregate_id_text,
)
).scalar_one_or_none()
previous_status = None
now = utc_now()
if record is None:
record = WorkflowInstance(
code=f"{WORKFLOW_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}",
workflow_type=workflow_type,
aggregate_type=aggregate_type,
aggregate_id=aggregate_id_text,
status=status_value,
actor=actor,
current_step=action,
payload=payload or {},
)
self.db.add(record)
self.db.flush()
else:
previous_status = record.status
record.status = status_value
record.actor = actor
record.current_step = action
record.payload = payload or {}
record.updated_at = now
if status_value in {
WorkflowStatus.BLOCKED,
WorkflowStatus.COMPLETED,
WorkflowStatus.FAILED,
}:
record.completed_at = now
elif previous_status in {
WorkflowStatus.BLOCKED,
WorkflowStatus.COMPLETED,
WorkflowStatus.FAILED,
}:
record.completed_at = None
self.db.add(
WorkflowAction(
code=f"{WORKFLOW_ACTION_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}",
workflow_code=record.code,
action=action,
actor=actor,
from_status=previous_status,
to_status=status_value,
payload=payload or {},
)
)
self.db.commit()
self.db.refresh(record)
return record
def list_workflows(
self,
status_filter: str | None = None,
workflow_type: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
stmt = (
select(WorkflowInstance)
.order_by(WorkflowInstance.id.desc())
.limit(bounded_limit(limit))
)
if status_filter:
stmt = stmt.where(WorkflowInstance.status == status_filter)
if workflow_type:
stmt = stmt.where(WorkflowInstance.workflow_type == workflow_type)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def get_workflow(self, code: str) -> dict[str, Any]:
record = self.db.execute(
select(WorkflowInstance).where(WorkflowInstance.code == code)
).scalar_one_or_none()
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=WorkflowErrorDetail.WORKFLOW_NOT_FOUND,
)
actions = self.db.execute(
select(WorkflowAction)
.where(WorkflowAction.workflow_code == code)
.order_by(WorkflowAction.id.asc())
).scalars()
data = serialize_model(record)
data["actions"] = [serialize_model(item) for item in actions]
return data
def count_by_status(self) -> dict[str, int]:
rows = self.db.execute(
select(WorkflowInstance.status, func.count()).group_by(WorkflowInstance.status)
).all()
return {str(status_value): int(count) for status_value, count in rows}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,74 @@
from typing import Any, Protocol
import httpx
from fastapi import HTTPException, status
from app.core.config import Settings
from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader
from app.modules.writebacks.constants import (
WRITEBACK_DISABLED_MESSAGE,
WritebackErrorDetail,
WritebackPayloadKey,
WritebackStatus,
)
from app.modules.writebacks.models import OfficialWritebackRun
class WritebackAdapter(Protocol):
def submit(self, run: OfficialWritebackRun) -> dict[str, Any]:
"""Submit one writeback run to the configured official integration."""
class DisabledWritebackAdapter:
def submit(self, run: OfficialWritebackRun) -> dict[str, Any]:
return {
WritebackPayloadKey.STATUS: WritebackStatus.DISABLED,
WritebackPayloadKey.ERROR_MESSAGE: WRITEBACK_DISABLED_MESSAGE,
WritebackPayloadKey.CODE: run.code,
}
class HttpWritebackAdapter:
def __init__(self, settings: Settings):
self.settings = settings
def submit(self, run: OfficialWritebackRun) -> dict[str, Any]:
if not self.settings.official_api_base_url or not self.settings.official_api_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=WritebackErrorDetail.OFFICIAL_API_NOT_CONFIGURED,
)
url = f"{self.settings.official_api_base_url.rstrip('/')}/writebacks/{run.domain}"
headers = {
HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(
token=self.settings.official_api_token
)
}
payload = {
WritebackPayloadKey.CODE: run.code,
WritebackPayloadKey.DOMAIN: run.domain,
WritebackPayloadKey.RECORD_ID: run.record_id,
WritebackPayloadKey.ACTION: run.action,
WritebackPayloadKey.PAYLOAD: run.request_payload or {},
}
with httpx.Client(timeout=self.settings.official_api_timeout_seconds) as client:
response = client.post(url, json=payload, headers=headers)
if response.status_code >= status.HTTP_400_BAD_REQUEST:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=response.text,
)
try:
data = response.json()
except ValueError:
data = {"text": response.text}
return {
WritebackPayloadKey.STATUS: WritebackStatus.SENT,
WritebackPayloadKey.PROVIDER_RESPONSE: data,
}
def get_writeback_adapter(settings: Settings) -> WritebackAdapter:
if settings.official_writeback_enabled:
return HttpWritebackAdapter(settings)
return DisabledWritebackAdapter()

View File

@@ -0,0 +1,42 @@
from enum import StrEnum
class WritebackStatus(StrEnum):
DRAFT = "draft"
PENDING_APPROVAL = "pending_approval"
DISABLED = "disabled"
QUEUED = "queued"
SENT = "sent"
FAILED = "failed"
class WritebackActionValue(StrEnum):
WRITEBACK = "writeback"
class WritebackPayloadKey(StrEnum):
CODE = "code"
STATUS = "status"
DOMAIN = "domain"
RECORD_ID = "record_id"
ACTION = "action"
PAYLOAD = "payload"
APPROVAL_TICKET_ID = "approval_ticket_id"
PROVIDER_RESPONSE = "provider_response"
ERROR_MESSAGE = "error_message"
class WritebackResponseKey(StrEnum):
ITEMS = "items"
DATA = "data"
class WritebackErrorDetail(StrEnum):
RUN_NOT_FOUND = "Writeback run not found"
APPROVAL_TICKET_REQUIRED = "approval_ticket_id is required for official writeback"
OFFICIAL_API_NOT_CONFIGURED = "Official API is not configured"
WRITEBACK_CODE_PREFIX = "WB"
WRITEBACK_DISABLED_MESSAGE = "Official writeback is disabled"
WRITEBACK_DEFAULT_ACTION = "sync"

View File

@@ -0,0 +1,39 @@
from datetime import datetime
from sqlalchemy import JSON, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.constants import ActorValue
from app.core.db_base import Base
from app.core.time import utc_now
from app.modules.writebacks.constants import WritebackStatus
class OfficialWritebackRun(Base):
__tablename__ = "official_writeback_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
domain: Mapped[str] = mapped_column(String(128), index=True)
record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
action: Mapped[str] = mapped_column(String(128), index=True)
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.API, index=True)
status: Mapped[str] = mapped_column(String(32), default=WritebackStatus.DRAFT, index=True)
approval_ticket_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
idempotency_key: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
unique=True,
index=True,
)
request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
provider_response: Mapped[dict | None] = mapped_column(JSON, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=utc_now,
onupdate=utc_now,
)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import ApiPrincipal, require_api_key
from app.modules.writebacks.constants import WritebackResponseKey
from app.modules.writebacks.schemas import WritebackCreateRequest, WritebackSubmitRequest
from app.modules.writebacks.service import WritebackService
router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("")
def list_writebacks(
status: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> dict:
return {
WritebackResponseKey.ITEMS: WritebackService(db).list_runs(
status_filter=status,
limit=limit,
)
}
@router.post("")
def create_writeback(
payload: WritebackCreateRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return {
WritebackResponseKey.DATA: WritebackService(db).create_run(
domain=payload.domain,
record_id=payload.record_id,
action=payload.action,
payload=payload.payload,
actor=principal.actor,
idempotency_key=payload.idempotency_key,
)
}
@router.get("/{code}")
def get_writeback(
code: str,
db: Session = Depends(get_db),
) -> dict:
return {WritebackResponseKey.DATA: WritebackService(db).get_run(code)}
@router.post("/{code}/submit")
def submit_writeback(
code: str,
payload: WritebackSubmitRequest,
db: Session = Depends(get_db),
principal: ApiPrincipal = Depends(require_api_key),
) -> dict:
return {
WritebackResponseKey.DATA: WritebackService(db).submit_run(
code,
approval_ticket_id=payload.approval_ticket_id,
actor=principal.actor,
)
}

View File

@@ -0,0 +1,35 @@
from typing import Any
from pydantic import BaseModel, Field
from app.modules.writebacks.constants import WRITEBACK_DEFAULT_ACTION
class WritebackCreateRequest(BaseModel):
domain: str = Field(..., min_length=1)
record_id: str | None = None
action: str = Field(default=WRITEBACK_DEFAULT_ACTION, min_length=1)
payload: dict[str, Any] = Field(default_factory=dict)
idempotency_key: str | None = None
class WritebackSubmitRequest(BaseModel):
approval_ticket_id: str | None = None
class WritebackRead(BaseModel):
code: str
domain: str
record_id: str | None
action: str
actor: str
status: str
approval_ticket_id: str | None
idempotency_key: str | None
request_payload: dict[str, Any] | None
provider_response: dict[str, Any] | None
error_message: str | None
created_at: str
updated_at: str
submitted_at: str | None
sent_at: str | None

View File

@@ -0,0 +1,232 @@
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.constants import ActorValue
from app.core.pagination import bounded_limit
from app.core.time import utc_now
from app.modules.approvals.constants import approval_action
from app.modules.approvals.service import ApprovalService
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.service import serialize_model
from app.modules.events.constants import EventAggregateType, EventSource, EventType
from app.modules.events.service import EventService
from app.modules.writebacks.adapters import get_writeback_adapter
from app.modules.writebacks.constants import (
WRITEBACK_CODE_PREFIX,
WRITEBACK_DISABLED_MESSAGE,
WritebackActionValue,
WritebackErrorDetail,
WritebackPayloadKey,
WritebackStatus,
)
from app.modules.writebacks.models import OfficialWritebackRun
class WritebackService:
"""Create and submit approved official-system writeback runs."""
def __init__(self, db: Session):
self.db = db
self.audit = AuditService(db)
def create_run(
self,
domain: str,
record_id: str | None,
action: str,
payload: dict[str, Any],
actor: str = ActorValue.API,
idempotency_key: str | None = None,
) -> dict[str, Any]:
if idempotency_key:
existing = self.db.execute(
select(OfficialWritebackRun).where(
OfficialWritebackRun.idempotency_key == idempotency_key
)
).scalar_one_or_none()
if existing is not None:
return serialize_model(existing)
record = OfficialWritebackRun(
code=f"{WRITEBACK_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
domain=domain,
record_id=record_id,
action=action,
actor=actor,
request_payload=payload,
idempotency_key=idempotency_key,
)
self.db.add(record)
self.db.commit()
self.db.refresh(record)
EventService(self.db).emit(
event_type=EventType.WRITEBACK_REQUESTED,
source=EventSource.WRITEBACK,
aggregate_type=EventAggregateType.WRITEBACK_RUN,
aggregate_id=record.code,
actor=actor,
payload=self._event_payload(record),
idempotency_key=f"{record.code}:requested",
dispatch=True,
)
self.audit.log(
AuditLogCreate(
actor=actor,
source=AuditSource.API,
action=AuditAction.WRITEBACK_CREATE,
target_type=domain,
target_id=record_id,
risk_level=AuditRiskLevel.HIGH,
request_payload=payload,
response_payload={WritebackPayloadKey.CODE: record.code},
)
)
return serialize_model(record)
def submit_run(
self,
code: str,
approval_ticket_id: str | None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
if not approval_ticket_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=WritebackErrorDetail.APPROVAL_TICKET_REQUIRED,
)
record = self._get_run(code)
settings = get_settings()
approval_action_name = approval_action(WritebackActionValue.WRITEBACK, record.domain)
if settings.official_writeback_enabled:
ApprovalService(self.db).consume_for(
approval_ticket_id,
record.domain,
record.record_id,
approval_action_name,
record.request_payload or {},
actor,
)
elif not ApprovalService(self.db).is_approved_for(
approval_ticket_id,
record.domain,
record.record_id,
approval_action_name,
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=WritebackErrorDetail.APPROVAL_TICKET_REQUIRED,
)
record.approval_ticket_id = approval_ticket_id
record.submitted_at = utc_now()
record.status = WritebackStatus.PENDING_APPROVAL
self.db.commit()
self.db.refresh(record)
adapter = get_writeback_adapter(settings)
try:
result = adapter.submit(record)
except Exception as exc:
record.status = WritebackStatus.FAILED
record.error_message = str(exc)
self.db.commit()
self.db.refresh(record)
self._emit_submitted(record, actor)
self._audit_submit(record, actor)
return serialize_model(record)
if result.get(WritebackPayloadKey.STATUS) == WritebackStatus.DISABLED:
record.status = WritebackStatus.DISABLED
record.error_message = WRITEBACK_DISABLED_MESSAGE
else:
record.status = WritebackStatus.SENT
record.provider_response = result.get(WritebackPayloadKey.PROVIDER_RESPONSE) or result
record.sent_at = utc_now()
record.error_message = None
self.db.commit()
self.db.refresh(record)
self._emit_submitted(record, actor)
self._audit_submit(record, actor)
return serialize_model(record)
def list_runs(
self,
status_filter: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
stmt = (
select(OfficialWritebackRun)
.order_by(OfficialWritebackRun.id.desc())
.limit(bounded_limit(limit))
)
if status_filter:
stmt = stmt.where(OfficialWritebackRun.status == status_filter)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def get_run(self, code: str) -> dict[str, Any]:
return serialize_model(self._get_run(code))
def count_by_status(self) -> dict[str, int]:
rows = self.db.execute(
select(OfficialWritebackRun.status, func.count()).group_by(
OfficialWritebackRun.status
)
).all()
return {str(status_value): int(count) for status_value, count in rows}
def _get_run(self, code: str) -> OfficialWritebackRun:
record = self.db.execute(
select(OfficialWritebackRun).where(OfficialWritebackRun.code == code)
).scalar_one_or_none()
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=WritebackErrorDetail.RUN_NOT_FOUND,
)
return record
def _emit_submitted(self, record: OfficialWritebackRun, actor: str) -> None:
EventService(self.db).emit(
event_type=EventType.WRITEBACK_SUBMITTED,
source=EventSource.WRITEBACK,
aggregate_type=EventAggregateType.WRITEBACK_RUN,
aggregate_id=record.code,
actor=actor,
payload=self._event_payload(record),
idempotency_key=f"{record.code}:submitted:{record.status}",
dispatch=True,
)
def _audit_submit(self, record: OfficialWritebackRun, actor: str) -> None:
self.audit.log(
AuditLogCreate(
actor=actor,
source=AuditSource.API,
action=AuditAction.WRITEBACK_SUBMIT,
target_type=record.domain,
target_id=record.record_id,
risk_level=AuditRiskLevel.HIGH,
request_payload={
WritebackPayloadKey.CODE: record.code,
WritebackPayloadKey.APPROVAL_TICKET_ID: record.approval_ticket_id,
},
response_payload=self._event_payload(record),
)
)
@staticmethod
def _event_payload(record: OfficialWritebackRun) -> dict[str, Any]:
return {
WritebackPayloadKey.CODE: record.code,
WritebackPayloadKey.STATUS: record.status,
WritebackPayloadKey.DOMAIN: record.domain,
WritebackPayloadKey.RECORD_ID: record.record_id,
WritebackPayloadKey.ACTION: record.action,
WritebackPayloadKey.ERROR_MESSAGE: record.error_message,
}

View File

@@ -19,6 +19,9 @@ from app.modules.business.models import (
WorkTask,
)
from app.modules.feishu.models import FeishuEventReceipt
from app.modules.events.models import DomainEvent
from app.modules.workflows.models import WorkflowAction, WorkflowInstance
from app.modules.writebacks.models import OfficialWritebackRun
_MODELS = [
ApprovalRequest,
@@ -39,6 +42,10 @@ _MODELS = [
RiskEventAction,
LegacySyncRun,
ReportPushRun,
DomainEvent,
WorkflowInstance,
WorkflowAction,
OfficialWritebackRun,
]

View File

@@ -1,16 +1,86 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-company_ai}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB:-company_ai}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
migrate:
build: .
env_file:
- .env
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-company_ai}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/${POSTGRES_DB:-company_ai}
REDIS_URL: redis://redis:6379/0
command: ["alembic", "upgrade", "head"]
depends_on:
db:
condition: service_healthy
restart: "no"
api:
build: .
env_file:
- .env
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-company_ai}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/${POSTGRES_DB:-company_ai}
REDIS_URL: redis://redis:6379/0
ports:
- "8010:8010"
depends_on:
- redis
db:
condition: service_healthy
redis:
condition: service_healthy
migrate:
condition: service_completed_successfully
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8010/api/v1/health/live', timeout=3).read()",
]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
worker:
build: .
env_file:
- .env
environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-company_ai}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@db:5432/${POSTGRES_DB:-company_ai}
REDIS_URL: redis://redis:6379/0
TASK_QUEUE_ENABLED: "true"
command: ["celery", "-A", "app.tasks.celery_app", "worker", "--loglevel=info"]
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
migrate:
condition: service_completed_successfully
volumes:
postgres_data:
redis_data:

View File

@@ -11,6 +11,7 @@ db.close()
os.environ["DATABASE_URL"] = "sqlite:///" + db.name.replace("\\", "/")
os.environ["API_KEY"] = "test-key"
os.environ["APPROVAL_API_KEY"] = "approval-key"
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
os.environ["LEGACY_DATABASE_URL"] = ""
os.environ["LEGACY_PROJECT_QUERY"] = ""
@@ -34,22 +35,47 @@ def request(method: str, url: str, **kwargs):
return response
def approve_change(domain: str, action: str, payload: dict, record_id: str | None = None) -> str:
approval_payload: dict[str, object] = {
"domain": domain,
"action": action,
"reason": "smoke verification",
"payload": payload,
}
if record_id is not None:
approval_payload["record_id"] = record_id
ticket = request("post", "/api/v1/approvals", json=approval_payload).json()["ticket_id"]
request(
"post",
f"/api/v1/approvals/{ticket}/approve",
headers={
HttpHeader.X_API_KEY: "test-key",
HttpHeader.X_APPROVAL_API_KEY: "approval-key",
},
json={"comment": "smoke approved"},
)
return ticket
try:
Base.metadata.create_all(bind=engine)
project_payload = {
"code": "P-VERIFY-001",
"name": "Verify Project",
"owner": "tester",
BusinessField.STATUS: StatusValue.RUNNING_CN,
"budget_amount": 1000,
"actual_amount": 200,
}
approval_ticket_id = approve_change("projects", "create:projects", project_payload)
project = request(
"post",
"/api/v1/business/projects",
json={
"actor": "smoke",
"data": {
"code": "P-VERIFY-001",
"name": "Verify Project",
"owner": "tester",
BusinessField.STATUS: StatusValue.RUNNING_CN,
"budget_amount": 1000,
"actual_amount": 200,
},
"approval_ticket_id": approval_ticket_id,
"data": project_payload,
},
).json()
print("project:", project["data"]["code"])

View File

@@ -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"