diff --git a/Dockerfile b/Dockerfile index 968103e..7033efe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/alembic/env.py b/alembic/env.py index d1e61f4..8e771f4 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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, ) diff --git a/alembic/versions/202607060001_initial_schema.py b/alembic/versions/202607060001_initial_schema.py index ee234e2..c790e42 100644 --- a/alembic/versions/202607060001_initial_schema.py +++ b/alembic/versions/202607060001_initial_schema.py @@ -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: diff --git a/alembic/versions/202607080002_v3_enterprise_automation_foundation.py b/alembic/versions/202607080002_v3_enterprise_automation_foundation.py new file mode 100644 index 0000000..4616f87 --- /dev/null +++ b/alembic/versions/202607080002_v3_enterprise_automation_foundation.py @@ -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") diff --git a/app/api/router.py b/app/api/router.py index e6b80ff..d95e06f 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -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"]) diff --git a/app/core/config.py b/app/core/config.py index 4a18829..332ea32 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 diff --git a/app/core/constants.py b/app/core/constants.py index 78b1329..5aac183 100644 --- a/app/core/constants.py +++ b/app/core/constants.py @@ -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): diff --git a/app/core/middleware.py b/app/core/middleware.py new file mode 100644 index 0000000..a7dd376 --- /dev/null +++ b/app/core/middleware.py @@ -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) diff --git a/app/core/request_context.py b/app/core/request_context.py new file mode 100644 index 0000000..534dab8 --- /dev/null +++ b/app/core/request_context.py @@ -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() diff --git a/app/main.py b/app/main.py index 58c3975..9a7ac9b 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/modules/approvals/constants.py b/app/modules/approvals/constants.py index 68f3eaa..98cc1c3 100644 --- a/app/modules/approvals/constants.py +++ b/app/modules/approvals/constants.py @@ -11,6 +11,7 @@ class ApprovalStatus(StrEnum): class ApprovalActionValue(StrEnum): CREATE = "create" UPDATE = "update" + WRITEBACK = "writeback" class ApprovalErrorDetail(StrEnum): diff --git a/app/modules/approvals/service.py b/app/modules/approvals/service.py index 2b3d352..70590ff 100644 --- a/app/modules/approvals/service.py +++ b/app/modules/approvals/service.py @@ -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( diff --git a/app/modules/audit/constants.py b/app/modules/audit/constants.py index dde9e0b..88c7ded 100644 --- a/app/modules/audit/constants.py +++ b/app/modules/audit/constants.py @@ -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): diff --git a/app/modules/audit/models.py b/app/modules/audit/models.py index 32a412c..99750db 100644 --- a/app/modules/audit/models.py +++ b/app/modules/audit/models.py @@ -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) diff --git a/app/modules/audit/schemas.py b/app/modules/audit/schemas.py index b27ffd1..71bb14e 100644 --- a/app/modules/audit/schemas.py +++ b/app/modules/audit/schemas.py @@ -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 diff --git a/app/modules/audit/service.py b/app/modules/audit/service.py index ded98b3..2c46e46 100644 --- a/app/modules/audit/service.py +++ b/app/modules/audit/service.py @@ -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() diff --git a/app/modules/business/constants.py b/app/modules/business/constants.py index a58d408..aee8462 100644 --- a/app/modules/business/constants.py +++ b/app/modules/business/constants.py @@ -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}'" diff --git a/app/modules/business/registry.py b/app/modules/business/registry.py index c7fb923..2bdd25e 100644 --- a/app/modules/business/registry.py +++ b/app/modules/business/registry.py @@ -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)] diff --git a/app/modules/business/schemas.py b/app/modules/business/schemas.py index 60e81f2..bae2df7 100644 --- a/app/modules/business/schemas.py +++ b/app/modules/business/schemas.py @@ -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.", ) diff --git a/app/modules/business/service.py b/app/modules/business/service.py index aca62a8..ee08321 100644 --- a/app/modules/business/service.py +++ b/app/modules/business/service.py @@ -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, diff --git a/app/modules/dashboard/service.py b/app/modules/dashboard/service.py index 9cdcf01..abc1ccc 100644 --- a/app/modules/dashboard/service.py +++ b/app/modules/dashboard/service.py @@ -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"]), }, diff --git a/app/modules/events/__init__.py b/app/modules/events/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/modules/events/__init__.py @@ -0,0 +1 @@ + diff --git a/app/modules/events/constants.py b/app/modules/events/constants.py new file mode 100644 index 0000000..e4778a6 --- /dev/null +++ b/app/modules/events/constants.py @@ -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" diff --git a/app/modules/events/models.py b/app/modules/events/models.py new file mode 100644 index 0000000..25e4111 --- /dev/null +++ b/app/modules/events/models.py @@ -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) diff --git a/app/modules/events/routes.py b/app/modules/events/routes.py new file mode 100644 index 0000000..c1270db --- /dev/null +++ b/app/modules/events/routes.py @@ -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)} diff --git a/app/modules/events/schemas.py b/app/modules/events/schemas.py new file mode 100644 index 0000000..c9787bc --- /dev/null +++ b/app/modules/events/schemas.py @@ -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]] diff --git a/app/modules/events/service.py b/app/modules/events/service.py new file mode 100644 index 0000000..95bd0bc --- /dev/null +++ b/app/modules/events/service.py @@ -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, + ) diff --git a/app/modules/feishu/constants.py b/app/modules/feishu/constants.py index a10c2f5..5a94912 100644 --- a/app/modules/feishu/constants.py +++ b/app/modules/feishu/constants.py @@ -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 diff --git a/app/modules/feishu/events.py b/app/modules/feishu/events.py index 2d99a14..9004f5f 100644 --- a/app/modules/feishu/events.py +++ b/app/modules/feishu/events.py @@ -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, diff --git a/app/modules/feishu/service.py b/app/modules/feishu/service.py index 3ccbdb7..edba4bb 100644 --- a/app/modules/feishu/service.py +++ b/app/modules/feishu/service.py @@ -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, + }, }, ], } diff --git a/app/modules/legacy_mysql/service.py b/app/modules/legacy_mysql/service.py index 1c1ee2e..1800f55 100644 --- a/app/modules/legacy_mysql/service.py +++ b/app/modules/legacy_mysql/service.py @@ -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 diff --git a/app/modules/observability/__init__.py b/app/modules/observability/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/modules/observability/__init__.py @@ -0,0 +1 @@ + diff --git a/app/modules/observability/constants.py b/app/modules/observability/constants.py new file mode 100644 index 0000000..30b22ba --- /dev/null +++ b/app/modules/observability/constants.py @@ -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" diff --git a/app/modules/observability/routes.py b/app/modules/observability/routes.py new file mode 100644 index 0000000..56eb628 --- /dev/null +++ b/app/modules/observability/routes.py @@ -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() diff --git a/app/modules/observability/service.py b/app/modules/observability/service.py new file mode 100644 index 0000000..3256551 --- /dev/null +++ b/app/modules/observability/service.py @@ -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, + } diff --git a/app/modules/reports/constants.py b/app/modules/reports/constants.py index 9cb07a1..ecd8157 100644 --- a/app/modules/reports/constants.py +++ b/app/modules/reports/constants.py @@ -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" diff --git a/app/modules/reports/routes.py b/app/modules/reports/routes.py index e68d593..8ae10e3 100644 --- a/app/modules/reports/routes.py +++ b/app/modules/reports/routes.py @@ -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}") diff --git a/app/modules/reports/service.py b/app/modules/reports/service.py index 90e8db4..326a557 100644 --- a/app/modules/reports/service.py +++ b/app/modules/reports/service.py @@ -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, diff --git a/app/modules/risk/constants.py b/app/modules/risk/constants.py index 0ffeb5c..5ba2319 100644 --- a/app/modules/risk/constants.py +++ b/app/modules/risk/constants.py @@ -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 = { diff --git a/app/modules/risk/routes.py b/app/modules/risk/routes.py index 218b177..06d4f66 100644 --- a/app/modules/risk/routes.py +++ b/app/modules/risk/routes.py @@ -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, ) diff --git a/app/modules/risk/schemas.py b/app/modules/risk/schemas.py index 49102b5..d2c5eb2 100644 --- a/app/modules/risk/schemas.py +++ b/app/modules/risk/schemas.py @@ -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 diff --git a/app/modules/risk/service.py b/app/modules/risk/service.py index b1a598c..2e5186b 100644 --- a/app/modules/risk/service.py +++ b/app/modules/risk/service.py @@ -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.""" diff --git a/app/modules/workflows/__init__.py b/app/modules/workflows/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/modules/workflows/__init__.py @@ -0,0 +1 @@ + diff --git a/app/modules/workflows/constants.py b/app/modules/workflows/constants.py new file mode 100644 index 0000000..53572b3 --- /dev/null +++ b/app/modules/workflows/constants.py @@ -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" diff --git a/app/modules/workflows/models.py b/app/modules/workflows/models.py new file mode 100644 index 0000000..1f3bba2 --- /dev/null +++ b/app/modules/workflows/models.py @@ -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) diff --git a/app/modules/workflows/routes.py b/app/modules/workflows/routes.py new file mode 100644 index 0000000..5a773c9 --- /dev/null +++ b/app/modules/workflows/routes.py @@ -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)} diff --git a/app/modules/workflows/schemas.py b/app/modules/workflows/schemas.py new file mode 100644 index 0000000..13a6d8a --- /dev/null +++ b/app/modules/workflows/schemas.py @@ -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]] diff --git a/app/modules/workflows/service.py b/app/modules/workflows/service.py new file mode 100644 index 0000000..2058db0 --- /dev/null +++ b/app/modules/workflows/service.py @@ -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} diff --git a/app/modules/writebacks/__init__.py b/app/modules/writebacks/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/modules/writebacks/__init__.py @@ -0,0 +1 @@ + diff --git a/app/modules/writebacks/adapters.py b/app/modules/writebacks/adapters.py new file mode 100644 index 0000000..9414349 --- /dev/null +++ b/app/modules/writebacks/adapters.py @@ -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() diff --git a/app/modules/writebacks/constants.py b/app/modules/writebacks/constants.py new file mode 100644 index 0000000..5a723da --- /dev/null +++ b/app/modules/writebacks/constants.py @@ -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" diff --git a/app/modules/writebacks/models.py b/app/modules/writebacks/models.py new file mode 100644 index 0000000..1c6810c --- /dev/null +++ b/app/modules/writebacks/models.py @@ -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) diff --git a/app/modules/writebacks/routes.py b/app/modules/writebacks/routes.py new file mode 100644 index 0000000..a42869a --- /dev/null +++ b/app/modules/writebacks/routes.py @@ -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, + ) + } diff --git a/app/modules/writebacks/schemas.py b/app/modules/writebacks/schemas.py new file mode 100644 index 0000000..19d0588 --- /dev/null +++ b/app/modules/writebacks/schemas.py @@ -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 diff --git a/app/modules/writebacks/service.py b/app/modules/writebacks/service.py new file mode 100644 index 0000000..b3f8213 --- /dev/null +++ b/app/modules/writebacks/service.py @@ -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, + } diff --git a/app/tools/init_db.py b/app/tools/init_db.py index ee6ebb2..e5063ca 100644 --- a/app/tools/init_db.py +++ b/app/tools/init_db.py @@ -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, ] diff --git a/docker-compose.yml b/docker-compose.yml index a65f303..634e62a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/scripts/verify_smoke.py b/scripts/verify_smoke.py index d51aa67..654827a 100644 --- a/scripts/verify_smoke.py +++ b/scripts/verify_smoke.py @@ -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"]) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 30f6852..3503fc9 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -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"