```
feat: 添加数据库迁移脚本并更新Dockerfile配置 - 在Dockerfile中添加alembic配置文件和目录的复制指令 - 更新alembic/env.py注册新的模块模型:events、workflows、writebacks - 生成完整的初始数据库schema迁移脚本,包含以下表: - approval_requests, attendance_records, audit_logs, domain_events - expenses, feishu_event_receipts, fund_accounts, legacy_sync_runs - official_writeback_runs, performance_metrics, policies, procurements - projects, report_push_runs, risk_event_actions, risk_events - standards, suppliers, work_reports, work_tasks, workflow_actions - workflow_instances等21个数据表结构定义 - 在API路由器中添加新模块的路由:events、workflows、writebacks、observability ```
This commit is contained in:
@@ -23,6 +23,8 @@ RUN pip install --no-cache-dir \
|
|||||||
cryptography==44.0.0 \
|
cryptography==44.0.0 \
|
||||||
pandas==2.2.3
|
pandas==2.2.3
|
||||||
|
|
||||||
|
COPY alembic.ini /app/alembic.ini
|
||||||
|
COPY alembic /app/alembic
|
||||||
COPY app /app/app
|
COPY app /app/app
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ from app.core.db_base import Base
|
|||||||
from app.modules.approvals import models as approval_models
|
from app.modules.approvals import models as approval_models
|
||||||
from app.modules.audit import models as audit_models
|
from app.modules.audit import models as audit_models
|
||||||
from app.modules.business import models as business_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.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
|
config = context.config
|
||||||
|
|
||||||
@@ -23,7 +26,10 @@ _REGISTERED_MODEL_MODULES = (
|
|||||||
approval_models,
|
approval_models,
|
||||||
audit_models,
|
audit_models,
|
||||||
business_models,
|
business_models,
|
||||||
|
event_models,
|
||||||
feishu_models,
|
feishu_models,
|
||||||
|
workflow_models,
|
||||||
|
writeback_models,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,29 +6,550 @@ Create Date: 2026-07-06
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from alembic import op
|
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"
|
revision = "202607060001"
|
||||||
down_revision = None
|
down_revision = None
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = 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:
|
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:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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.audit.routes import router as audit_router
|
||||||
from app.modules.business.routes import router as business_router
|
from app.modules.business.routes import router as business_router
|
||||||
from app.modules.dashboard.routes import router as dashboard_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.feishu.routes import router as feishu_router
|
||||||
from app.modules.legacy_mysql.routes import router as legacy_mysql_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.reports.routes import router as reports_router
|
||||||
from app.modules.risk.routes import router as risk_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()
|
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(reports_router, prefix="/reports", tags=["reports"])
|
||||||
api_router.include_router(risk_router, prefix="/risks", tags=["risks"])
|
api_router.include_router(risk_router, prefix="/risks", tags=["risks"])
|
||||||
api_router.include_router(audit_router, prefix="/audit", tags=["audit"])
|
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"])
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from pydantic import Field, field_validator
|
from pydantic import Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||||
|
|
||||||
from app.core.constants import (
|
from app.core.constants import (
|
||||||
ActorValue,
|
ActorValue,
|
||||||
@@ -50,6 +50,7 @@ class Settings(BaseSettings):
|
|||||||
feishu_verification_token: str | None = None
|
feishu_verification_token: str | None = None
|
||||||
feishu_encrypt_key: str | None = None
|
feishu_encrypt_key: str | None = None
|
||||||
feishu_default_chat_id: 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
|
model_provider: str = DEFAULT_MODEL_PROVIDER
|
||||||
openclaw_base_url: str = "http://127.0.0.1:2070"
|
openclaw_base_url: str = "http://127.0.0.1:2070"
|
||||||
@@ -57,8 +58,8 @@ class Settings(BaseSettings):
|
|||||||
openclaw_ws_url: str | None = None
|
openclaw_ws_url: str | None = None
|
||||||
openclaw_api_key: str | None = None
|
openclaw_api_key: str | None = None
|
||||||
openclaw_gateway_token: str | None = None
|
openclaw_gateway_token: str | None = None
|
||||||
openclaw_allowed_tools: list[str] = Field(default_factory=list)
|
openclaw_allowed_tools: Annotated[list[str], NoDecode] = Field(default_factory=list)
|
||||||
openclaw_allowed_actions: list[str] = Field(
|
openclaw_allowed_actions: Annotated[list[str], NoDecode] = Field(
|
||||||
default_factory=lambda: [DEFAULT_OPENCLAW_ACTION_JSON]
|
default_factory=lambda: [DEFAULT_OPENCLAW_ACTION_JSON]
|
||||||
)
|
)
|
||||||
hermes_base_url: str = "http://127.0.0.1:2073/v1"
|
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_project_sync_cron_minute: int = 0
|
||||||
legacy_task_sync_cron_hour: int = 2
|
legacy_task_sync_cron_hour: int = 2
|
||||||
legacy_task_sync_cron_minute: int = 30
|
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")
|
@field_validator("cors_origins", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -99,12 +104,27 @@ class Settings(BaseSettings):
|
|||||||
return [str(item).strip() for item in data if str(item).strip()]
|
return [str(item).strip() for item in data if str(item).strip()]
|
||||||
return [item.strip() for item in text.split(",") if 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
|
@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):
|
if isinstance(value, list):
|
||||||
return value
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
return [item.strip() for item in value.split(",") if 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")
|
@field_validator("masked_response_fields", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class HttpHeader(StrEnum):
|
|||||||
X_API_KEY = "X-API-Key"
|
X_API_KEY = "X-API-Key"
|
||||||
X_AUDIT_API_KEY = "X-Audit-API-Key"
|
X_AUDIT_API_KEY = "X-Audit-API-Key"
|
||||||
X_APPROVAL_API_KEY = "X-Approval-API-Key"
|
X_APPROVAL_API_KEY = "X-Approval-API-Key"
|
||||||
|
X_REQUEST_ID = "X-Request-ID"
|
||||||
|
|
||||||
|
|
||||||
class ApiResponseKey(StrEnum):
|
class ApiResponseKey(StrEnum):
|
||||||
|
|||||||
18
app/core/middleware.py
Normal file
18
app/core/middleware.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from fastapi import Request, Response
|
||||||
|
|
||||||
|
from app.core.constants import HttpHeader
|
||||||
|
from app.core.request_context import reset_request_id, set_request_id
|
||||||
|
|
||||||
|
|
||||||
|
async def request_id_middleware(request: Request, call_next: Callable) -> Response:
|
||||||
|
request_id = request.headers.get(HttpHeader.X_REQUEST_ID) or uuid.uuid4().hex
|
||||||
|
token = set_request_id(request_id)
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers[HttpHeader.X_REQUEST_ID] = request_id
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
reset_request_id(token)
|
||||||
16
app/core/request_context.py
Normal file
16
app/core/request_context.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from contextvars import ContextVar, Token
|
||||||
|
|
||||||
|
|
||||||
|
_request_id: ContextVar[str | None] = ContextVar("request_id", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def set_request_id(request_id: str) -> Token:
|
||||||
|
return _request_id.set(request_id)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_request_id(token: Token) -> None:
|
||||||
|
_request_id.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_id() -> str | None:
|
||||||
|
return _request_id.get()
|
||||||
@@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.api.router import api_router
|
from app.api.router import api_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.core.middleware import request_id_middleware
|
||||||
from app.core.scheduler import attach_scheduler
|
from app.core.scheduler import attach_scheduler
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ def create_app() -> FastAPI:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.middleware("http")(request_id_middleware)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class ApprovalStatus(StrEnum):
|
|||||||
class ApprovalActionValue(StrEnum):
|
class ApprovalActionValue(StrEnum):
|
||||||
CREATE = "create"
|
CREATE = "create"
|
||||||
UPDATE = "update"
|
UPDATE = "update"
|
||||||
|
WRITEBACK = "writeback"
|
||||||
|
|
||||||
|
|
||||||
class ApprovalErrorDetail(StrEnum):
|
class ApprovalErrorDetail(StrEnum):
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ from app.modules.approvals.schemas import ApprovalCreate
|
|||||||
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
|
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
|
||||||
from app.modules.audit.schemas import AuditLogCreate
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
from app.modules.audit.service import AuditService
|
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:
|
class ApprovalService:
|
||||||
@@ -121,6 +128,23 @@ class ApprovalService:
|
|||||||
response_payload={ApprovalPayloadKey.STATUS: ticket.status},
|
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
|
return ticket
|
||||||
|
|
||||||
def consume_for(
|
def consume_for(
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class AuditAction(StrEnum):
|
|||||||
LEGACY_SYNC_TASKS = "sync_tasks"
|
LEGACY_SYNC_TASKS = "sync_tasks"
|
||||||
RISK_EVENT_ACTION = "risk_event_action"
|
RISK_EVENT_ACTION = "risk_event_action"
|
||||||
REPORT_PUSH = "report_push"
|
REPORT_PUSH = "report_push"
|
||||||
|
WRITEBACK_CREATE = "writeback.create"
|
||||||
|
WRITEBACK_SUBMIT = "writeback.submit"
|
||||||
|
|
||||||
|
|
||||||
class AuditRiskLevel(StrEnum):
|
class AuditRiskLevel(StrEnum):
|
||||||
@@ -33,6 +35,7 @@ class AuditSource(StrEnum):
|
|||||||
APPROVAL = "approval"
|
APPROVAL = "approval"
|
||||||
LEGACY_MYSQL = "legacy_mysql"
|
LEGACY_MYSQL = "legacy_mysql"
|
||||||
REPORTS = "reports"
|
REPORTS = "reports"
|
||||||
|
WRITEBACK = "writeback"
|
||||||
|
|
||||||
|
|
||||||
class AuditTargetType(StrEnum):
|
class AuditTargetType(StrEnum):
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ class AuditLog(Base):
|
|||||||
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
response_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)
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class AuditLogCreate(BaseModel):
|
|||||||
request_payload: Any | None = None
|
request_payload: Any | None = None
|
||||||
response_payload: Any | None = None
|
response_payload: Any | None = None
|
||||||
status: str = AuditStatus.SUCCESS
|
status: str = AuditStatus.SUCCESS
|
||||||
|
request_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AuditLogRead(BaseModel):
|
class AuditLogRead(BaseModel):
|
||||||
@@ -32,4 +33,5 @@ class AuditLogRead(BaseModel):
|
|||||||
request_payload: str | None
|
request_payload: str | None
|
||||||
response_payload: str | None
|
response_payload: str | None
|
||||||
status: str
|
status: str
|
||||||
|
request_id: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.pagination import bounded_limit
|
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.constants import AUDIT_REDACTED_VALUE, AUDIT_SENSITIVE_KEYS
|
||||||
from app.modules.audit.models import AuditLog
|
from app.modules.audit.models import AuditLog
|
||||||
from app.modules.audit.schemas import AuditLogCreate
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
@@ -54,6 +55,7 @@ class AuditService:
|
|||||||
request_payload=_dump(payload.request_payload),
|
request_payload=_dump(payload.request_payload),
|
||||||
response_payload=_dump(payload.response_payload),
|
response_payload=_dump(payload.response_payload),
|
||||||
status=payload.status,
|
status=payload.status,
|
||||||
|
request_id=payload.request_id or get_request_id(),
|
||||||
)
|
)
|
||||||
self.db.add(record)
|
self.db.add(record)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class BusinessErrorDetail(StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
UNKNOWN_FIELD_TEMPLATE = "Unknown field '{field}'"
|
UNKNOWN_FIELD_TEMPLATE = "Unknown field '{field}'"
|
||||||
|
READ_ONLY_FIELD_TEMPLATE = "Field '{field}' is read-only"
|
||||||
INVALID_FIELD_VALUE_TEMPLATE = "Invalid value for field '{field}'"
|
INVALID_FIELD_VALUE_TEMPLATE = "Invalid value for field '{field}'"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,199 @@ DOMAIN_MODELS: dict[BusinessDomain, type[DeclarativeMeta]] = {
|
|||||||
BusinessDomain.LEGACY_SYNC_RUNS: models.LegacySyncRun,
|
BusinessDomain.LEGACY_SYNC_RUNS: models.LegacySyncRun,
|
||||||
}
|
}
|
||||||
|
|
||||||
HIGH_RISK_DOMAINS = frozenset(
|
DOMAIN_WRITABLE_FIELDS: dict[BusinessDomain, frozenset[str]] = {
|
||||||
{
|
BusinessDomain.PROJECTS: frozenset(
|
||||||
BusinessDomain.FUND_ACCOUNTS,
|
{
|
||||||
BusinessDomain.PERFORMANCE_METRICS,
|
"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)
|
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]:
|
def get_domain_model(domain: str | BusinessDomain) -> type[DeclarativeMeta]:
|
||||||
return DOMAIN_MODELS[normalize_domain(domain)]
|
return DOMAIN_MODELS[normalize_domain(domain)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_writable_fields(domain: str | BusinessDomain) -> frozenset[str]:
|
||||||
|
return DOMAIN_WRITABLE_FIELDS[normalize_domain(domain)]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class DomainRecordCreate(BaseModel):
|
|||||||
actor: str = ActorValue.API
|
actor: str = ActorValue.API
|
||||||
approval_ticket_id: str | None = Field(
|
approval_ticket_id: str | None = Field(
|
||||||
default=None,
|
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
|
actor: str = ActorValue.API
|
||||||
approval_ticket_id: str | None = Field(
|
approval_ticket_id: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Required by policy for high-risk updates such as funds or performance.",
|
description="Required by policy for business record updates.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ from app.modules.audit.schemas import AuditLogCreate
|
|||||||
from app.modules.audit.service import AuditService
|
from app.modules.audit.service import AuditService
|
||||||
from app.modules.approvals.constants import ApprovalActionValue, approval_action
|
from app.modules.approvals.constants import ApprovalActionValue, approval_action
|
||||||
from app.modules.approvals.service import ApprovalService
|
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 (
|
from app.modules.business.constants import (
|
||||||
INVALID_FIELD_VALUE_TEMPLATE,
|
INVALID_FIELD_VALUE_TEMPLATE,
|
||||||
|
READ_ONLY_FIELD_TEMPLATE,
|
||||||
UNKNOWN_FIELD_TEMPLATE,
|
UNKNOWN_FIELD_TEMPLATE,
|
||||||
BusinessErrorDetail,
|
BusinessErrorDetail,
|
||||||
BusinessField,
|
BusinessField,
|
||||||
@@ -56,7 +57,7 @@ def _coerce_column_value(column: Column, value: Any) -> Any:
|
|||||||
return value
|
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."""
|
"""Validate keys and coerce values according to model column types."""
|
||||||
|
|
||||||
columns = {
|
columns = {
|
||||||
@@ -64,6 +65,7 @@ def _model_payload(model: Any, data: dict[str, Any]) -> dict[str, Any]:
|
|||||||
for column in model.__table__.columns
|
for column in model.__table__.columns
|
||||||
if column.name != BusinessField.ID
|
if column.name != BusinessField.ID
|
||||||
}
|
}
|
||||||
|
writable_fields = get_writable_fields(domain)
|
||||||
payload: dict[str, Any] = {}
|
payload: dict[str, Any] = {}
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
column = columns.get(key)
|
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,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=UNKNOWN_FIELD_TEMPLATE.format(field=key),
|
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:
|
try:
|
||||||
payload[key] = _coerce_column_value(column, value)
|
payload[key] = _coerce_column_value(column, value)
|
||||||
except (ValueError, TypeError, InvalidOperation) as exc:
|
except (ValueError, TypeError, InvalidOperation) as exc:
|
||||||
@@ -127,7 +134,7 @@ class BusinessService:
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
model = get_domain_model(domain)
|
model = get_domain_model(domain)
|
||||||
high_risk = is_high_risk_domain(domain)
|
high_risk = is_high_risk_domain(domain)
|
||||||
payload = _model_payload(model, data)
|
payload = _model_payload(domain, model, data)
|
||||||
record = model(**payload)
|
record = model(**payload)
|
||||||
self.db.add(record)
|
self.db.add(record)
|
||||||
if high_risk:
|
if high_risk:
|
||||||
@@ -176,7 +183,7 @@ class BusinessService:
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=BusinessErrorDetail.RECORD_NOT_FOUND,
|
detail=BusinessErrorDetail.RECORD_NOT_FOUND,
|
||||||
)
|
)
|
||||||
payload = _model_payload(model, data)
|
payload = _model_payload(domain, model, data)
|
||||||
if high_risk:
|
if high_risk:
|
||||||
self._consume_approval(
|
self._consume_approval(
|
||||||
approval_ticket_id,
|
approval_ticket_id,
|
||||||
|
|||||||
@@ -16,8 +16,14 @@ from app.modules.business.models import (
|
|||||||
WorkTask,
|
WorkTask,
|
||||||
)
|
)
|
||||||
from app.modules.business.service import serialize_model
|
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.reports.constants import ReportPushStatus
|
||||||
from app.modules.risk.service import RiskService
|
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:
|
class DashboardService:
|
||||||
@@ -41,6 +47,24 @@ class DashboardService:
|
|||||||
RiskEvent.assigned_to.is_(None),
|
RiskEvent.assigned_to.is_(None),
|
||||||
)
|
)
|
||||||
failed_push_runs = self._count(ReportPushRun, ReportPushRun.status == ReportPushStatus.FAILED)
|
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(
|
latest_reports = self.db.execute(
|
||||||
select(WorkReport).order_by(WorkReport.id.desc()).limit(5)
|
select(WorkReport).order_by(WorkReport.id.desc()).limit(5)
|
||||||
).scalars()
|
).scalars()
|
||||||
@@ -62,6 +86,12 @@ class DashboardService:
|
|||||||
"open_risk_events": open_risk_events,
|
"open_risk_events": open_risk_events,
|
||||||
"unassigned_open_risks": unassigned_open_risks,
|
"unassigned_open_risks": unassigned_open_risks,
|
||||||
"failed_push_runs": failed_push_runs,
|
"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_level": risk_summary["risk_level"],
|
||||||
"risk_score": float(risk_summary["risk_score"]),
|
"risk_score": float(risk_summary["risk_score"]),
|
||||||
},
|
},
|
||||||
|
|||||||
1
app/modules/events/__init__.py
Normal file
1
app/modules/events/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
62
app/modules/events/constants.py
Normal file
62
app/modules/events/constants.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class EventStatus(StrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
PROCESSED = "processed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(StrEnum):
|
||||||
|
APPROVAL_DECIDED = "approval.decided"
|
||||||
|
RISK_ACTION_RECORDED = "risk.action_recorded"
|
||||||
|
REPORT_PUSH_SUCCEEDED = "report.push_succeeded"
|
||||||
|
REPORT_PUSH_FAILED = "report.push_failed"
|
||||||
|
LEGACY_SYNC_COMPLETED = "legacy.sync_completed"
|
||||||
|
WRITEBACK_REQUESTED = "writeback.requested"
|
||||||
|
WRITEBACK_SUBMITTED = "writeback.submitted"
|
||||||
|
|
||||||
|
|
||||||
|
class EventSource(StrEnum):
|
||||||
|
APPROVAL = "approval"
|
||||||
|
RISK = "risk"
|
||||||
|
REPORTS = "reports"
|
||||||
|
LEGACY_MYSQL = "legacy_mysql"
|
||||||
|
WRITEBACK = "writeback"
|
||||||
|
API = "api"
|
||||||
|
|
||||||
|
|
||||||
|
class EventAggregateType(StrEnum):
|
||||||
|
APPROVAL = "approval"
|
||||||
|
RISK_EVENT = "risk-event"
|
||||||
|
REPORT_PUSH_RUN = "report-push-run"
|
||||||
|
LEGACY_SYNC_RUN = "legacy-sync-run"
|
||||||
|
WRITEBACK_RUN = "writeback-run"
|
||||||
|
|
||||||
|
|
||||||
|
class EventResponseKey(StrEnum):
|
||||||
|
ITEMS = "items"
|
||||||
|
EVENT = "event"
|
||||||
|
TOTAL = "total"
|
||||||
|
|
||||||
|
|
||||||
|
class EventPayloadKey(StrEnum):
|
||||||
|
ACTION = "action"
|
||||||
|
STATUS = "status"
|
||||||
|
TICKET_ID = "ticket_id"
|
||||||
|
DOMAIN = "domain"
|
||||||
|
RECORD_ID = "record_id"
|
||||||
|
APPROVED = "approved"
|
||||||
|
COMMENT = "comment"
|
||||||
|
CODE = "code"
|
||||||
|
CREATED = "created"
|
||||||
|
UPDATED = "updated"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
ERROR_MESSAGE = "error_message"
|
||||||
|
|
||||||
|
|
||||||
|
class EventErrorDetail(StrEnum):
|
||||||
|
EVENT_NOT_FOUND = "Domain event not found"
|
||||||
|
|
||||||
|
|
||||||
|
EVENT_CODE_PREFIX = "EVT"
|
||||||
33
app/modules/events/models.py
Normal file
33
app/modules/events/models.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, DateTime, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.constants import ActorValue
|
||||||
|
from app.core.db_base import Base
|
||||||
|
from app.core.time import utc_now
|
||||||
|
from app.modules.events.constants import EventSource, EventStatus
|
||||||
|
|
||||||
|
|
||||||
|
class DomainEvent(Base):
|
||||||
|
__tablename__ = "domain_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
event_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(64), default=EventSource.API, index=True)
|
||||||
|
aggregate_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
aggregate_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
|
||||||
|
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default=EventStatus.PENDING, index=True)
|
||||||
|
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
idempotency_key: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
unique=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||||
|
processed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||||
41
app/modules/events/routes.py
Normal file
41
app/modules/events/routes.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import require_api_key
|
||||||
|
from app.modules.events.constants import EventResponseKey
|
||||||
|
from app.modules.events.service import EventService, _serialize_event
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_events(
|
||||||
|
status: str | None = None,
|
||||||
|
event_type: str | None = None,
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
EventResponseKey.ITEMS: EventService(db).list_events(
|
||||||
|
status_filter=status,
|
||||||
|
event_type=event_type,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_id}/dispatch")
|
||||||
|
def dispatch_event(
|
||||||
|
event_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return {EventResponseKey.EVENT: _serialize_event(EventService(db).dispatch_event(event_id))}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dispatch-pending")
|
||||||
|
def dispatch_pending(
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return {EventResponseKey.ITEMS: EventService(db).dispatch_pending(limit=limit)}
|
||||||
23
app/modules/events/schemas.py
Normal file
23
app/modules/events/schemas.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class DomainEventRead(BaseModel):
|
||||||
|
event_id: str
|
||||||
|
event_type: str
|
||||||
|
source: str
|
||||||
|
aggregate_type: str
|
||||||
|
aggregate_id: str | None
|
||||||
|
actor: str
|
||||||
|
payload: dict[str, Any] | None
|
||||||
|
status: str
|
||||||
|
attempts: int
|
||||||
|
idempotency_key: str | None
|
||||||
|
last_error: str | None
|
||||||
|
created_at: str
|
||||||
|
processed_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class DomainEventListRead(BaseModel):
|
||||||
|
items: list[dict[str, Any]]
|
||||||
184
app/modules/events/service.py
Normal file
184
app/modules/events/service.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import ActorValue
|
||||||
|
from app.core.pagination import bounded_limit
|
||||||
|
from app.core.time import utc_now
|
||||||
|
from app.modules.events.constants import (
|
||||||
|
EVENT_CODE_PREFIX,
|
||||||
|
EventAggregateType,
|
||||||
|
EventErrorDetail,
|
||||||
|
EventPayloadKey,
|
||||||
|
EventStatus,
|
||||||
|
EventType,
|
||||||
|
)
|
||||||
|
from app.modules.events.models import DomainEvent
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_event(record: DomainEvent) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
column.name: getattr(record, column.name)
|
||||||
|
for column in record.__table__.columns
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class EventService:
|
||||||
|
"""Persist outbox events and dispatch the V3 internal handlers."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def emit(
|
||||||
|
self,
|
||||||
|
event_type: str,
|
||||||
|
source: str,
|
||||||
|
aggregate_type: str,
|
||||||
|
aggregate_id: str | int | None,
|
||||||
|
actor: str = ActorValue.SYSTEM,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
dispatch: bool = False,
|
||||||
|
) -> DomainEvent:
|
||||||
|
if idempotency_key:
|
||||||
|
existing = self.db.execute(
|
||||||
|
select(DomainEvent).where(DomainEvent.idempotency_key == idempotency_key)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
if dispatch and existing.status == EventStatus.PENDING:
|
||||||
|
return self.dispatch_event(existing.event_id)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
record = DomainEvent(
|
||||||
|
event_id=f"{EVENT_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
|
||||||
|
event_type=event_type,
|
||||||
|
source=source,
|
||||||
|
aggregate_type=aggregate_type,
|
||||||
|
aggregate_id=str(aggregate_id) if aggregate_id is not None else None,
|
||||||
|
actor=actor,
|
||||||
|
payload=payload or {},
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
self.db.add(record)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(record)
|
||||||
|
if dispatch:
|
||||||
|
return self.dispatch_event(record.event_id)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def list_events(
|
||||||
|
self,
|
||||||
|
status_filter: str | None = None,
|
||||||
|
event_type: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
stmt = select(DomainEvent).order_by(DomainEvent.id.desc()).limit(bounded_limit(limit))
|
||||||
|
if status_filter:
|
||||||
|
stmt = stmt.where(DomainEvent.status == status_filter)
|
||||||
|
if event_type:
|
||||||
|
stmt = stmt.where(DomainEvent.event_type == event_type)
|
||||||
|
return [_serialize_event(item) for item in self.db.execute(stmt).scalars()]
|
||||||
|
|
||||||
|
def count_by_status(self) -> dict[str, int]:
|
||||||
|
rows = self.db.execute(
|
||||||
|
select(DomainEvent.status, func.count()).group_by(DomainEvent.status)
|
||||||
|
).all()
|
||||||
|
return {str(status_value): int(count) for status_value, count in rows}
|
||||||
|
|
||||||
|
def get_event(self, event_id: str) -> DomainEvent:
|
||||||
|
record = self.db.execute(
|
||||||
|
select(DomainEvent).where(DomainEvent.event_id == event_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=EventErrorDetail.EVENT_NOT_FOUND,
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def dispatch_event(self, event_id: str) -> DomainEvent:
|
||||||
|
record = self.get_event(event_id)
|
||||||
|
if record.status == EventStatus.PROCESSED:
|
||||||
|
return record
|
||||||
|
record.attempts += 1
|
||||||
|
try:
|
||||||
|
self._handle_event(record)
|
||||||
|
except Exception as exc:
|
||||||
|
record.status = EventStatus.FAILED
|
||||||
|
record.last_error = str(exc)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(record)
|
||||||
|
return record
|
||||||
|
record.status = EventStatus.PROCESSED
|
||||||
|
record.last_error = None
|
||||||
|
record.processed_at = utc_now()
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def dispatch_pending(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
stmt = (
|
||||||
|
select(DomainEvent)
|
||||||
|
.where(DomainEvent.status == EventStatus.PENDING)
|
||||||
|
.order_by(DomainEvent.id.asc())
|
||||||
|
.limit(bounded_limit(limit))
|
||||||
|
)
|
||||||
|
records = list(self.db.execute(stmt).scalars())
|
||||||
|
return [_serialize_event(self.dispatch_event(record.event_id)) for record in records]
|
||||||
|
|
||||||
|
def _handle_event(self, record: DomainEvent) -> None:
|
||||||
|
if record.event_type == EventType.RISK_ACTION_RECORDED:
|
||||||
|
self._handle_risk_action(record)
|
||||||
|
return
|
||||||
|
if record.event_type in {EventType.WRITEBACK_REQUESTED, EventType.WRITEBACK_SUBMITTED}:
|
||||||
|
self._handle_writeback(record)
|
||||||
|
|
||||||
|
def _handle_risk_action(self, record: DomainEvent) -> None:
|
||||||
|
from app.modules.risk.constants import RiskEventActionValue
|
||||||
|
from app.modules.workflows.service import WorkflowService
|
||||||
|
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
|
||||||
|
|
||||||
|
payload = record.payload or {}
|
||||||
|
action = str(payload.get(EventPayloadKey.ACTION) or "")
|
||||||
|
if action == RiskEventActionValue.CLOSE:
|
||||||
|
workflow_status = WorkflowStatus.COMPLETED
|
||||||
|
elif action == RiskEventActionValue.RESOLVE:
|
||||||
|
workflow_status = WorkflowStatus.WAITING_REVIEW
|
||||||
|
else:
|
||||||
|
workflow_status = WorkflowStatus.RUNNING
|
||||||
|
WorkflowService(self.db).start_or_update(
|
||||||
|
workflow_type=WorkflowType.RISK_EVENT_REVIEW,
|
||||||
|
aggregate_type=EventAggregateType.RISK_EVENT,
|
||||||
|
aggregate_id=record.aggregate_id,
|
||||||
|
status_value=workflow_status,
|
||||||
|
action=action or record.event_type,
|
||||||
|
actor=record.actor,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_writeback(self, record: DomainEvent) -> None:
|
||||||
|
from app.modules.workflows.service import WorkflowService
|
||||||
|
from app.modules.workflows.constants import WorkflowStatus, WorkflowType
|
||||||
|
from app.modules.writebacks.constants import WritebackStatus
|
||||||
|
|
||||||
|
payload = record.payload or {}
|
||||||
|
run_status = str(payload.get(EventPayloadKey.STATUS) or "")
|
||||||
|
if run_status == WritebackStatus.SENT:
|
||||||
|
workflow_status = WorkflowStatus.COMPLETED
|
||||||
|
elif run_status == WritebackStatus.FAILED:
|
||||||
|
workflow_status = WorkflowStatus.FAILED
|
||||||
|
elif run_status == WritebackStatus.DISABLED:
|
||||||
|
workflow_status = WorkflowStatus.BLOCKED
|
||||||
|
else:
|
||||||
|
workflow_status = WorkflowStatus.WAITING_APPROVAL
|
||||||
|
WorkflowService(self.db).start_or_update(
|
||||||
|
workflow_type=WorkflowType.OFFICIAL_WRITEBACK,
|
||||||
|
aggregate_type=EventAggregateType.WRITEBACK_RUN,
|
||||||
|
aggregate_id=record.aggregate_id,
|
||||||
|
status_value=workflow_status,
|
||||||
|
action=record.event_type,
|
||||||
|
actor=record.actor,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
@@ -64,6 +64,8 @@ class FeishuResponseKey(StrEnum):
|
|||||||
RESULT = "result"
|
RESULT = "result"
|
||||||
CHALLENGE = "challenge"
|
CHALLENGE = "challenge"
|
||||||
PROVIDER_RESPONSE = "provider_response"
|
PROVIDER_RESPONSE = "provider_response"
|
||||||
|
STATUS = "status"
|
||||||
|
APPROVER = "approver"
|
||||||
|
|
||||||
|
|
||||||
class FeishuCommandResultKey(StrEnum):
|
class FeishuCommandResultKey(StrEnum):
|
||||||
@@ -96,6 +98,24 @@ class FeishuEventReceiptKey(StrEnum):
|
|||||||
MESSAGE_ID = "message_id"
|
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_TENANT_TOKEN_PATH = "/auth/v3/tenant_access_token/internal"
|
||||||
FEISHU_MESSAGE_PATH = "/im/v1/messages"
|
FEISHU_MESSAGE_PATH = "/im/v1/messages"
|
||||||
FEISHU_DEFAULT_OPEN_API_DOMAIN = "https://open.feishu.cn"
|
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_VERIFICATION_TOKEN_REQUIRED = "FEISHU_VERIFICATION_TOKEN is required"
|
||||||
FEISHU_INVALID_TOKEN = "Invalid Feishu token"
|
FEISHU_INVALID_TOKEN = "Invalid Feishu token"
|
||||||
FEISHU_RECEIVE_ID_MISSING = "receive_id or FEISHU_DEFAULT_CHAT_ID is required"
|
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_SUCCESS_CODE = 0
|
||||||
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
|
FEISHU_DEFAULT_TOKEN_EXPIRE_SECONDS = 7200
|
||||||
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300
|
FEISHU_TOKEN_EXPIRE_SAFETY_SECONDS = 300
|
||||||
|
|||||||
@@ -6,11 +6,19 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.constants import ActorValue
|
from app.core.constants import ActorValue
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.modules.approvals.service import ApprovalService
|
from app.modules.approvals.service import ApprovalService
|
||||||
from app.modules.audit.constants import AuditAction, AuditSource
|
from app.modules.audit.constants import AuditAction, AuditSource
|
||||||
from app.modules.audit.schemas import AuditLogCreate
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
from app.modules.feishu.commands import FeishuCommandService
|
from app.modules.feishu.commands import FeishuCommandService
|
||||||
from app.modules.feishu.constants import (
|
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,
|
FeishuCommandKey,
|
||||||
FeishuEventReceiptKey,
|
FeishuEventReceiptKey,
|
||||||
FeishuEventSource,
|
FeishuEventSource,
|
||||||
@@ -87,15 +95,38 @@ class FeishuEventService:
|
|||||||
|
|
||||||
self.feishu.verify_event(payload)
|
self.feishu.verify_event(payload)
|
||||||
value = _approval_action_value(payload)
|
value = _approval_action_value(payload)
|
||||||
ticket_id = str(value.get("ticket_id") or "").strip()
|
ticket_id = str(value.get(FeishuApprovalValueKey.TICKET_ID) or "").strip()
|
||||||
decision = str(value.get("decision") or value.get("action") or "").lower()
|
decision = str(
|
||||||
if not ticket_id or decision not in {"approve", "reject"}:
|
value.get(FeishuApprovalValueKey.DECISION)
|
||||||
|
or value.get(FeishuApprovalValueKey.ACTION)
|
||||||
|
or ""
|
||||||
|
).lower()
|
||||||
|
if not ticket_id or decision not in set(FeishuApprovalAction):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
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)
|
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)
|
event_identity = _approval_event_identity(payload, ticket_id, decision, actor)
|
||||||
if not self._register_event(event_identity):
|
if not self._register_event(event_identity):
|
||||||
ticket = ApprovalService(self.db).get_by_ticket(ticket_id)
|
ticket = ApprovalService(self.db).get_by_ticket(ticket_id)
|
||||||
@@ -104,15 +135,15 @@ class FeishuEventService:
|
|||||||
FeishuResponseKey.HANDLED: True,
|
FeishuResponseKey.HANDLED: True,
|
||||||
FeishuResponseKey.DUPLICATE: True,
|
FeishuResponseKey.DUPLICATE: True,
|
||||||
FeishuResponseKey.RESULT: {
|
FeishuResponseKey.RESULT: {
|
||||||
"ticket_id": ticket.ticket_id,
|
FeishuApprovalValueKey.TICKET_ID: ticket.ticket_id,
|
||||||
"status": ticket.status,
|
FeishuResponseKey.STATUS: ticket.status,
|
||||||
"approver": ticket.approver,
|
FeishuResponseKey.APPROVER: ticket.approver,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ticket = ApprovalService(self.db).decide(
|
ticket = ApprovalService(self.db).decide(
|
||||||
ticket_id,
|
ticket_id,
|
||||||
actor,
|
actor,
|
||||||
approved=decision == "approve",
|
approved=decision == FeishuApprovalAction.APPROVE,
|
||||||
comment=str(comment) if comment is not None else None,
|
comment=str(comment) if comment is not None else None,
|
||||||
)
|
)
|
||||||
self.feishu.audit.log(
|
self.feishu.audit.log(
|
||||||
@@ -120,19 +151,22 @@ class FeishuEventService:
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
source=AuditSource.FEISHU,
|
source=AuditSource.FEISHU,
|
||||||
action=AuditAction.FEISHU_WEBHOOK_EVENT,
|
action=AuditAction.FEISHU_WEBHOOK_EVENT,
|
||||||
target_type="approval_card_action",
|
target_type=FEISHU_APPROVAL_CARD_ACTION_TARGET,
|
||||||
target_id=ticket_id,
|
target_id=ticket_id,
|
||||||
request_payload=payload,
|
request_payload=payload,
|
||||||
response_payload={"status": ticket.status, "decision": decision},
|
response_payload={
|
||||||
|
FeishuResponseKey.STATUS: ticket.status,
|
||||||
|
FeishuApprovalValueKey.DECISION: decision,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
FeishuResponseKey.OK: True,
|
FeishuResponseKey.OK: True,
|
||||||
FeishuResponseKey.HANDLED: True,
|
FeishuResponseKey.HANDLED: True,
|
||||||
FeishuResponseKey.RESULT: {
|
FeishuResponseKey.RESULT: {
|
||||||
"ticket_id": ticket.ticket_id,
|
FeishuApprovalValueKey.TICKET_ID: ticket.ticket_id,
|
||||||
"status": ticket.status,
|
FeishuResponseKey.STATUS: ticket.status,
|
||||||
"approver": ticket.approver,
|
FeishuResponseKey.APPROVER: ticket.approver,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,10 +217,15 @@ def _event_identity(
|
|||||||
|
|
||||||
|
|
||||||
def _approval_action_value(payload: dict[str, Any]) -> dict[str, Any]:
|
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 = payload.get(FeishuPayloadKey.EVENT) or {}
|
||||||
event_action = event.get("action") or {}
|
event_action = event.get(FeishuApprovalValueKey.ACTION) or {}
|
||||||
value = action.get("value") or event_action.get("value") or payload.get("value") or {}
|
value = (
|
||||||
|
action.get(FeishuCardKey.VALUE)
|
||||||
|
or event_action.get(FeishuCardKey.VALUE)
|
||||||
|
or payload.get(FeishuCardKey.VALUE)
|
||||||
|
or {}
|
||||||
|
)
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(value)
|
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(
|
def _approval_event_identity(
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
ticket_id: str,
|
ticket_id: str,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ from app.modules.feishu.constants import (
|
|||||||
FEISHU_EMPTY_CARD_TEXT,
|
FEISHU_EMPTY_CARD_TEXT,
|
||||||
FEISHU_INVALID_TOKEN,
|
FEISHU_INVALID_TOKEN,
|
||||||
FEISHU_VERIFICATION_TOKEN_REQUIRED,
|
FEISHU_VERIFICATION_TOKEN_REQUIRED,
|
||||||
|
FeishuApprovalAction,
|
||||||
|
FeishuApprovalValueKey,
|
||||||
|
FeishuCardKey,
|
||||||
FeishuPayloadKey,
|
FeishuPayloadKey,
|
||||||
FeishuReceiveIdType,
|
FeishuReceiveIdType,
|
||||||
)
|
)
|
||||||
@@ -119,16 +122,19 @@ class FeishuService:
|
|||||||
card = FeishuService.build_basic_card(title, lines)
|
card = FeishuService.build_basic_card(title, lines)
|
||||||
card[FeishuPayloadKey.ELEMENTS].append(
|
card[FeishuPayloadKey.ELEMENTS].append(
|
||||||
{
|
{
|
||||||
FeishuPayloadKey.TAG: "action",
|
FeishuPayloadKey.TAG: FeishuApprovalValueKey.ACTION,
|
||||||
"actions": [
|
FeishuCardKey.ACTIONS: [
|
||||||
{
|
{
|
||||||
FeishuPayloadKey.TAG: "button",
|
FeishuPayloadKey.TAG: "button",
|
||||||
FeishuPayloadKey.TEXT: {
|
FeishuPayloadKey.TEXT: {
|
||||||
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
|
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
|
||||||
FeishuPayloadKey.CONTENT: "批准",
|
FeishuPayloadKey.CONTENT: "批准",
|
||||||
},
|
},
|
||||||
"type": "primary",
|
FeishuCardKey.BUTTON_TYPE: "primary",
|
||||||
"value": {"ticket_id": ticket_id, "decision": "approve"},
|
FeishuCardKey.VALUE: {
|
||||||
|
FeishuApprovalValueKey.TICKET_ID: ticket_id,
|
||||||
|
FeishuApprovalValueKey.DECISION: FeishuApprovalAction.APPROVE,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
FeishuPayloadKey.TAG: "button",
|
FeishuPayloadKey.TAG: "button",
|
||||||
@@ -136,8 +142,11 @@ class FeishuService:
|
|||||||
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
|
FeishuPayloadKey.TAG: FeishuPayloadKey.PLAIN_TEXT,
|
||||||
FeishuPayloadKey.CONTENT: "拒绝",
|
FeishuPayloadKey.CONTENT: "拒绝",
|
||||||
},
|
},
|
||||||
"type": "danger",
|
FeishuCardKey.BUTTON_TYPE: "danger",
|
||||||
"value": {"ticket_id": ticket_id, "decision": "reject"},
|
FeishuCardKey.VALUE: {
|
||||||
|
FeishuApprovalValueKey.TICKET_ID: ticket_id,
|
||||||
|
FeishuApprovalValueKey.DECISION: FeishuApprovalAction.REJECT,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ from app.modules.audit.service import AuditService
|
|||||||
from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue
|
from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue
|
||||||
from app.modules.business.models import LegacySyncRun, Project, WorkTask
|
from app.modules.business.models import LegacySyncRun, Project, WorkTask
|
||||||
from app.modules.business.service import serialize_model
|
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 (
|
from app.modules.legacy_mysql.constants import (
|
||||||
LEGACY_PROJECT_QUERY_SOURCE,
|
LEGACY_PROJECT_QUERY_SOURCE,
|
||||||
LEGACY_PROJECT_SYNC_NOTE,
|
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
|
return result
|
||||||
|
|
||||||
def sync_tasks(
|
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
|
return result
|
||||||
|
|||||||
1
app/modules/observability/__init__.py
Normal file
1
app/modules/observability/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
27
app/modules/observability/constants.py
Normal file
27
app/modules/observability/constants.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class ObservabilityKey(StrEnum):
|
||||||
|
STATUS = "status"
|
||||||
|
CHECKS = "checks"
|
||||||
|
METRICS = "metrics"
|
||||||
|
DATABASE = "database"
|
||||||
|
REDIS = "redis"
|
||||||
|
EVENTS = "events"
|
||||||
|
WORKFLOWS = "workflows"
|
||||||
|
WRITEBACKS = "writebacks"
|
||||||
|
|
||||||
|
|
||||||
|
class ObservabilityStatus(StrEnum):
|
||||||
|
OK = "ok"
|
||||||
|
DEGRADED = "degraded"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class ObservabilityMetricKey(StrEnum):
|
||||||
|
ERROR = "error"
|
||||||
|
PENDING = "pending"
|
||||||
|
FAILED = "failed"
|
||||||
|
RUNNING = "running"
|
||||||
|
DISABLED = "disabled"
|
||||||
30
app/modules/observability/routes.py
Normal file
30
app/modules/observability/routes.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import require_api_key
|
||||||
|
from app.modules.observability.service import ObservabilityService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/live")
|
||||||
|
def live(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
_ = db
|
||||||
|
return ObservabilityService(db).live()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/ready")
|
||||||
|
def ready(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return ObservabilityService(db).ready()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics", dependencies=[Depends(require_api_key)])
|
||||||
|
def metrics(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return ObservabilityService(db).metrics()
|
||||||
114
app/modules/observability/service.py
Normal file
114
app/modules/observability/service.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.modules.events.constants import EventStatus
|
||||||
|
from app.modules.events.service import EventService
|
||||||
|
from app.modules.observability.constants import (
|
||||||
|
ObservabilityKey,
|
||||||
|
ObservabilityMetricKey,
|
||||||
|
ObservabilityStatus,
|
||||||
|
)
|
||||||
|
from app.modules.workflows.constants import WorkflowStatus
|
||||||
|
from app.modules.workflows.service import WorkflowService
|
||||||
|
from app.modules.writebacks.constants import WritebackStatus
|
||||||
|
from app.modules.writebacks.service import WritebackService
|
||||||
|
|
||||||
|
|
||||||
|
class ObservabilityService:
|
||||||
|
"""Build health, readiness, and JSON metrics for V3 operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def live(self) -> dict[str, str]:
|
||||||
|
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
|
||||||
|
|
||||||
|
def ready(self) -> dict[str, Any]:
|
||||||
|
checks = {
|
||||||
|
ObservabilityKey.DATABASE: self._database_check(),
|
||||||
|
ObservabilityKey.REDIS: self._redis_check(),
|
||||||
|
ObservabilityKey.EVENTS: self._events_check(),
|
||||||
|
ObservabilityKey.WORKFLOWS: self._workflows_check(),
|
||||||
|
ObservabilityKey.WRITEBACKS: self._writebacks_check(),
|
||||||
|
}
|
||||||
|
degraded = any(
|
||||||
|
item[ObservabilityKey.STATUS]
|
||||||
|
in {ObservabilityStatus.DEGRADED, ObservabilityStatus.ERROR}
|
||||||
|
for item in checks.values()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
ObservabilityKey.STATUS: (
|
||||||
|
ObservabilityStatus.DEGRADED if degraded else ObservabilityStatus.OK
|
||||||
|
),
|
||||||
|
ObservabilityKey.CHECKS: checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
def metrics(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
ObservabilityKey.METRICS: {
|
||||||
|
ObservabilityKey.EVENTS: EventService(self.db).count_by_status(),
|
||||||
|
ObservabilityKey.WORKFLOWS: WorkflowService(self.db).count_by_status(),
|
||||||
|
ObservabilityKey.WRITEBACKS: WritebackService(self.db).count_by_status(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def _database_check(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
self.db.execute(text("select 1")).scalar()
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
ObservabilityKey.STATUS: ObservabilityStatus.ERROR,
|
||||||
|
ObservabilityMetricKey.ERROR: str(exc),
|
||||||
|
}
|
||||||
|
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
|
||||||
|
|
||||||
|
def _redis_check(self) -> dict[str, Any]:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.task_queue_enabled:
|
||||||
|
return {ObservabilityKey.STATUS: ObservabilityStatus.SKIPPED}
|
||||||
|
try:
|
||||||
|
from redis import Redis
|
||||||
|
|
||||||
|
Redis.from_url(settings.redis_url, socket_connect_timeout=1).ping()
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
ObservabilityKey.STATUS: ObservabilityStatus.DEGRADED,
|
||||||
|
ObservabilityMetricKey.ERROR: str(exc),
|
||||||
|
}
|
||||||
|
return {ObservabilityKey.STATUS: ObservabilityStatus.OK}
|
||||||
|
|
||||||
|
def _events_check(self) -> dict[str, Any]:
|
||||||
|
counts = EventService(self.db).count_by_status()
|
||||||
|
failed = counts.get(EventStatus.FAILED, 0)
|
||||||
|
return {
|
||||||
|
ObservabilityKey.STATUS: (
|
||||||
|
ObservabilityStatus.DEGRADED if failed else ObservabilityStatus.OK
|
||||||
|
),
|
||||||
|
ObservabilityMetricKey.PENDING: counts.get(EventStatus.PENDING, 0),
|
||||||
|
ObservabilityMetricKey.FAILED: failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _workflows_check(self) -> dict[str, Any]:
|
||||||
|
counts = WorkflowService(self.db).count_by_status()
|
||||||
|
failed = counts.get(WorkflowStatus.FAILED, 0)
|
||||||
|
return {
|
||||||
|
ObservabilityKey.STATUS: (
|
||||||
|
ObservabilityStatus.DEGRADED if failed else ObservabilityStatus.OK
|
||||||
|
),
|
||||||
|
ObservabilityMetricKey.RUNNING: counts.get(WorkflowStatus.RUNNING, 0),
|
||||||
|
ObservabilityMetricKey.FAILED: failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _writebacks_check(self) -> dict[str, Any]:
|
||||||
|
counts = WritebackService(self.db).count_by_status()
|
||||||
|
failed = counts.get(WritebackStatus.FAILED, 0)
|
||||||
|
return {
|
||||||
|
ObservabilityKey.STATUS: (
|
||||||
|
ObservabilityStatus.DEGRADED if failed else ObservabilityStatus.OK
|
||||||
|
),
|
||||||
|
ObservabilityMetricKey.DISABLED: counts.get(WritebackStatus.DISABLED, 0),
|
||||||
|
ObservabilityMetricKey.FAILED: failed,
|
||||||
|
}
|
||||||
@@ -34,6 +34,10 @@ class ReportPushKey(StrEnum):
|
|||||||
STATUS = "status"
|
STATUS = "status"
|
||||||
|
|
||||||
|
|
||||||
|
class ReportErrorDetail(StrEnum):
|
||||||
|
PUSH_RUN_NOT_FOUND = "Report push run not found"
|
||||||
|
|
||||||
|
|
||||||
class LifecycleSection(StrEnum):
|
class LifecycleSection(StrEnum):
|
||||||
HEALTH = "health"
|
HEALTH = "health"
|
||||||
PROJECTS = "projects"
|
PROJECTS = "projects"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.modules.reports.schemas import (
|
|||||||
ReportResponse,
|
ReportResponse,
|
||||||
WorkReportGenerateRequest,
|
WorkReportGenerateRequest,
|
||||||
)
|
)
|
||||||
|
from app.modules.reports.constants import ReportPushKey
|
||||||
from app.modules.reports.service import ReportService
|
from app.modules.reports.service import ReportService
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||||
@@ -64,7 +65,7 @@ def list_push_runs(
|
|||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> 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}")
|
@router.get("/push-runs/{code}")
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ from app.modules.business.models import (
|
|||||||
WorkTask,
|
WorkTask,
|
||||||
)
|
)
|
||||||
from app.modules.business.service import serialize_model
|
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.feishu.service import FeishuService
|
||||||
from app.modules.reports.constants import (
|
from app.modules.reports.constants import (
|
||||||
ATTENTION_SCORE_THRESHOLD,
|
ATTENTION_SCORE_THRESHOLD,
|
||||||
@@ -50,6 +57,7 @@ from app.modules.reports.constants import (
|
|||||||
MetricKey,
|
MetricKey,
|
||||||
ReportResponseKey,
|
ReportResponseKey,
|
||||||
ReportPushStatus,
|
ReportPushStatus,
|
||||||
|
ReportErrorDetail,
|
||||||
ReportStatus,
|
ReportStatus,
|
||||||
ReportText,
|
ReportText,
|
||||||
ReportTitle,
|
ReportTitle,
|
||||||
@@ -226,7 +234,7 @@ class ReportService:
|
|||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="Report push run not found",
|
detail=ReportErrorDetail.PUSH_RUN_NOT_FOUND,
|
||||||
)
|
)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
@@ -1113,18 +1121,43 @@ class ReportService:
|
|||||||
try:
|
try:
|
||||||
result = FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)
|
result = FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.update_push_run(
|
failed_run = self.update_push_run(
|
||||||
push_run.code,
|
push_run.code,
|
||||||
ReportPushStatus.FAILED,
|
ReportPushStatus.FAILED,
|
||||||
error_message=str(exc),
|
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
|
raise
|
||||||
self.update_push_run(
|
success_run = self.update_push_run(
|
||||||
push_run.code,
|
push_run.code,
|
||||||
ReportPushStatus.SUCCESS,
|
ReportPushStatus.SUCCESS,
|
||||||
provider_response=result,
|
provider_response=result,
|
||||||
sent=True,
|
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(
|
AuditService(self.db).log(
|
||||||
AuditLogCreate(
|
AuditLogCreate(
|
||||||
actor=actor,
|
actor=actor,
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ class RiskEventActionKey(StrEnum):
|
|||||||
RISK_EVENT = "risk_event"
|
RISK_EVENT = "risk_event"
|
||||||
ACTION_RECORD = "action_record"
|
ACTION_RECORD = "action_record"
|
||||||
ITEMS = "items"
|
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 = {
|
RISK_SCORE_WEIGHTS = {
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ def close_risk_event(
|
|||||||
closed_reason=payload.closed_reason,
|
closed_reason=payload.closed_reason,
|
||||||
review_summary=payload.review_summary,
|
review_summary=payload.review_summary,
|
||||||
actor=principal.actor,
|
actor=principal.actor,
|
||||||
|
approval_ticket_id=payload.approval_ticket_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -158,6 +159,7 @@ def reopen_risk_event(
|
|||||||
event_id,
|
event_id,
|
||||||
comment=payload.comment,
|
comment=payload.comment,
|
||||||
actor=principal.actor,
|
actor=principal.actor,
|
||||||
|
approval_ticket_id=payload.approval_ticket_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ class RiskResolveRequest(BaseModel):
|
|||||||
class RiskCloseRequest(BaseModel):
|
class RiskCloseRequest(BaseModel):
|
||||||
closed_reason: str = Field(..., min_length=1)
|
closed_reason: str = Field(..., min_length=1)
|
||||||
review_summary: str | None = None
|
review_summary: str | None = None
|
||||||
|
approval_ticket_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class RiskReopenRequest(BaseModel):
|
class RiskReopenRequest(BaseModel):
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
approval_ticket_id: str | None = None
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from datetime import date
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
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.schemas import AuditLogCreate
|
||||||
from app.modules.audit.service import AuditService
|
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 (
|
from app.modules.business.constants import (
|
||||||
CLOSED_RISK_STATUSES,
|
CLOSED_RISK_STATUSES,
|
||||||
DONE_STATUSES,
|
DONE_STATUSES,
|
||||||
GENERATED_RISK_EVENT_TYPES,
|
GENERATED_RISK_EVENT_TYPES,
|
||||||
PROJECT_CLOSED_STATUSES,
|
PROJECT_CLOSED_STATUSES,
|
||||||
SUPPLIER_RISK_LEVELS,
|
SUPPLIER_RISK_LEVELS,
|
||||||
|
BusinessErrorDetail,
|
||||||
BusinessDomain,
|
BusinessDomain,
|
||||||
RiskEventType,
|
RiskEventType,
|
||||||
RiskLevel,
|
RiskLevel,
|
||||||
@@ -36,10 +40,18 @@ from app.modules.business.models import (
|
|||||||
WorkTask,
|
WorkTask,
|
||||||
)
|
)
|
||||||
from app.modules.business.service import serialize_model
|
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 (
|
from app.modules.risk.constants import (
|
||||||
RISK_SCORE_WEIGHTS,
|
RISK_SCORE_WEIGHTS,
|
||||||
RiskEventActionKey,
|
RiskEventActionKey,
|
||||||
RiskEventActionValue,
|
RiskEventActionValue,
|
||||||
|
RiskErrorDetail,
|
||||||
RiskGenerationAction,
|
RiskGenerationAction,
|
||||||
RiskGenerationResultKey,
|
RiskGenerationResultKey,
|
||||||
RiskEventPayloadKey,
|
RiskEventPayloadKey,
|
||||||
@@ -131,7 +143,7 @@ class RiskService:
|
|||||||
from_status,
|
from_status,
|
||||||
record.status,
|
record.status,
|
||||||
comment,
|
comment,
|
||||||
{"assigned_to": assigned_to},
|
{RiskEventActionKey.ASSIGNED_TO: assigned_to},
|
||||||
)
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(record)
|
self.db.refresh(record)
|
||||||
@@ -191,8 +203,20 @@ class RiskService:
|
|||||||
closed_reason: str,
|
closed_reason: str,
|
||||||
review_summary: str | None = None,
|
review_summary: str | None = None,
|
||||||
actor: str = ActorValue.API,
|
actor: str = ActorValue.API,
|
||||||
|
approval_ticket_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
record = self._get_event(risk_event_id)
|
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
|
from_status = record.status
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
record.status = StatusValue.CLOSED
|
record.status = StatusValue.CLOSED
|
||||||
@@ -208,7 +232,7 @@ class RiskService:
|
|||||||
from_status,
|
from_status,
|
||||||
record.status,
|
record.status,
|
||||||
closed_reason,
|
closed_reason,
|
||||||
{"review_summary": review_summary},
|
{RiskEventActionKey.REVIEW_SUMMARY: review_summary},
|
||||||
)
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(record)
|
self.db.refresh(record)
|
||||||
@@ -220,8 +244,19 @@ class RiskService:
|
|||||||
risk_event_id: int,
|
risk_event_id: int,
|
||||||
comment: str | None = None,
|
comment: str | None = None,
|
||||||
actor: str = ActorValue.API,
|
actor: str = ActorValue.API,
|
||||||
|
approval_ticket_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
record = self._get_event(risk_event_id)
|
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
|
from_status = record.status
|
||||||
record.status = StatusValue.OPEN
|
record.status = StatusValue.OPEN
|
||||||
record.resolved_at = None
|
record.resolved_at = None
|
||||||
@@ -277,7 +312,10 @@ class RiskService:
|
|||||||
if record is None:
|
if record is None:
|
||||||
from fastapi import HTTPException, status
|
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
|
return record
|
||||||
|
|
||||||
def _record_action(
|
def _record_action(
|
||||||
@@ -312,13 +350,31 @@ class RiskService:
|
|||||||
risk_level=AuditRiskLevel.MEDIUM,
|
risk_level=AuditRiskLevel.MEDIUM,
|
||||||
request_payload={
|
request_payload={
|
||||||
RiskEventActionKey.ACTION: action,
|
RiskEventActionKey.ACTION: action,
|
||||||
"from_status": from_status,
|
RiskEventActionKey.FROM_STATUS: from_status,
|
||||||
"to_status": to_status,
|
RiskEventActionKey.TO_STATUS: to_status,
|
||||||
"comment": comment,
|
RiskEventActionKey.COMMENT: comment,
|
||||||
"payload": payload,
|
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
|
return action_record
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -328,6 +384,27 @@ class RiskService:
|
|||||||
RiskEventActionKey.ACTION_RECORD: serialize_model(action),
|
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]:
|
def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]:
|
||||||
"""Generate or refresh risk-event ledger entries from current signals."""
|
"""Generate or refresh risk-event ledger entries from current signals."""
|
||||||
|
|
||||||
|
|||||||
1
app/modules/workflows/__init__.py
Normal file
1
app/modules/workflows/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
29
app/modules/workflows/constants.py
Normal file
29
app/modules/workflows/constants.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowType(StrEnum):
|
||||||
|
RISK_EVENT_REVIEW = "risk_event_review"
|
||||||
|
OFFICIAL_WRITEBACK = "official_writeback"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowStatus(StrEnum):
|
||||||
|
WAITING_APPROVAL = "waiting_approval"
|
||||||
|
WAITING_REVIEW = "waiting_review"
|
||||||
|
RUNNING = "running"
|
||||||
|
BLOCKED = "blocked"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowResponseKey(StrEnum):
|
||||||
|
ITEMS = "items"
|
||||||
|
WORKFLOW = "workflow"
|
||||||
|
ACTION = "action"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowErrorDetail(StrEnum):
|
||||||
|
WORKFLOW_NOT_FOUND = "Workflow instance not found"
|
||||||
|
|
||||||
|
|
||||||
|
WORKFLOW_CODE_PREFIX = "WF"
|
||||||
|
WORKFLOW_ACTION_CODE_PREFIX = "WF-ACTION"
|
||||||
44
app/modules/workflows/models.py
Normal file
44
app/modules/workflows/models.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, DateTime, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.constants import ActorValue
|
||||||
|
from app.core.db_base import Base
|
||||||
|
from app.core.time import utc_now
|
||||||
|
from app.modules.workflows.constants import WorkflowStatus
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowInstance(Base):
|
||||||
|
__tablename__ = "workflow_instances"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
workflow_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
aggregate_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
aggregate_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default=WorkflowStatus.RUNNING, index=True)
|
||||||
|
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
|
||||||
|
current_step: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
default=utc_now,
|
||||||
|
onupdate=utc_now,
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowAction(Base):
|
||||||
|
__tablename__ = "workflow_actions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
workflow_code: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True)
|
||||||
|
from_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
to_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||||
33
app/modules/workflows/routes.py
Normal file
33
app/modules/workflows/routes.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import require_api_key
|
||||||
|
from app.modules.workflows.constants import WorkflowResponseKey
|
||||||
|
from app.modules.workflows.service import WorkflowService
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_workflows(
|
||||||
|
status: str | None = None,
|
||||||
|
workflow_type: str | None = None,
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
WorkflowResponseKey.ITEMS: WorkflowService(db).list_workflows(
|
||||||
|
status_filter=status,
|
||||||
|
workflow_type=workflow_type,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{code}")
|
||||||
|
def get_workflow(
|
||||||
|
code: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return {WorkflowResponseKey.WORKFLOW: WorkflowService(db).get_workflow(code)}
|
||||||
21
app/modules/workflows/schemas.py
Normal file
21
app/modules/workflows/schemas.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowRead(BaseModel):
|
||||||
|
code: str
|
||||||
|
workflow_type: str
|
||||||
|
aggregate_type: str
|
||||||
|
aggregate_id: str | None
|
||||||
|
status: str
|
||||||
|
actor: str
|
||||||
|
current_step: str | None
|
||||||
|
payload: dict[str, Any] | None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
completed_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowListRead(BaseModel):
|
||||||
|
items: list[dict[str, Any]]
|
||||||
134
app/modules/workflows/service.py
Normal file
134
app/modules/workflows/service.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.constants import ActorValue
|
||||||
|
from app.core.pagination import bounded_limit
|
||||||
|
from app.core.time import utc_now
|
||||||
|
from app.modules.business.service import serialize_model
|
||||||
|
from app.modules.workflows.constants import (
|
||||||
|
WORKFLOW_ACTION_CODE_PREFIX,
|
||||||
|
WORKFLOW_CODE_PREFIX,
|
||||||
|
WorkflowErrorDetail,
|
||||||
|
WorkflowStatus,
|
||||||
|
)
|
||||||
|
from app.modules.workflows.models import WorkflowAction, WorkflowInstance
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowService:
|
||||||
|
"""Track V3 workflow instances and append-only workflow actions."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def start_or_update(
|
||||||
|
self,
|
||||||
|
workflow_type: str,
|
||||||
|
aggregate_type: str,
|
||||||
|
aggregate_id: str | int | None,
|
||||||
|
status_value: str,
|
||||||
|
action: str,
|
||||||
|
actor: str = ActorValue.SYSTEM,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> WorkflowInstance:
|
||||||
|
aggregate_id_text = str(aggregate_id) if aggregate_id is not None else None
|
||||||
|
record = self.db.execute(
|
||||||
|
select(WorkflowInstance).where(
|
||||||
|
WorkflowInstance.workflow_type == workflow_type,
|
||||||
|
WorkflowInstance.aggregate_type == aggregate_type,
|
||||||
|
WorkflowInstance.aggregate_id == aggregate_id_text,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
previous_status = None
|
||||||
|
now = utc_now()
|
||||||
|
if record is None:
|
||||||
|
record = WorkflowInstance(
|
||||||
|
code=f"{WORKFLOW_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}",
|
||||||
|
workflow_type=workflow_type,
|
||||||
|
aggregate_type=aggregate_type,
|
||||||
|
aggregate_id=aggregate_id_text,
|
||||||
|
status=status_value,
|
||||||
|
actor=actor,
|
||||||
|
current_step=action,
|
||||||
|
payload=payload or {},
|
||||||
|
)
|
||||||
|
self.db.add(record)
|
||||||
|
self.db.flush()
|
||||||
|
else:
|
||||||
|
previous_status = record.status
|
||||||
|
record.status = status_value
|
||||||
|
record.actor = actor
|
||||||
|
record.current_step = action
|
||||||
|
record.payload = payload or {}
|
||||||
|
record.updated_at = now
|
||||||
|
|
||||||
|
if status_value in {
|
||||||
|
WorkflowStatus.BLOCKED,
|
||||||
|
WorkflowStatus.COMPLETED,
|
||||||
|
WorkflowStatus.FAILED,
|
||||||
|
}:
|
||||||
|
record.completed_at = now
|
||||||
|
elif previous_status in {
|
||||||
|
WorkflowStatus.BLOCKED,
|
||||||
|
WorkflowStatus.COMPLETED,
|
||||||
|
WorkflowStatus.FAILED,
|
||||||
|
}:
|
||||||
|
record.completed_at = None
|
||||||
|
|
||||||
|
self.db.add(
|
||||||
|
WorkflowAction(
|
||||||
|
code=f"{WORKFLOW_ACTION_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}",
|
||||||
|
workflow_code=record.code,
|
||||||
|
action=action,
|
||||||
|
actor=actor,
|
||||||
|
from_status=previous_status,
|
||||||
|
to_status=status_value,
|
||||||
|
payload=payload or {},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def list_workflows(
|
||||||
|
self,
|
||||||
|
status_filter: str | None = None,
|
||||||
|
workflow_type: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
stmt = (
|
||||||
|
select(WorkflowInstance)
|
||||||
|
.order_by(WorkflowInstance.id.desc())
|
||||||
|
.limit(bounded_limit(limit))
|
||||||
|
)
|
||||||
|
if status_filter:
|
||||||
|
stmt = stmt.where(WorkflowInstance.status == status_filter)
|
||||||
|
if workflow_type:
|
||||||
|
stmt = stmt.where(WorkflowInstance.workflow_type == workflow_type)
|
||||||
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||||
|
|
||||||
|
def get_workflow(self, code: str) -> dict[str, Any]:
|
||||||
|
record = self.db.execute(
|
||||||
|
select(WorkflowInstance).where(WorkflowInstance.code == code)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=WorkflowErrorDetail.WORKFLOW_NOT_FOUND,
|
||||||
|
)
|
||||||
|
actions = self.db.execute(
|
||||||
|
select(WorkflowAction)
|
||||||
|
.where(WorkflowAction.workflow_code == code)
|
||||||
|
.order_by(WorkflowAction.id.asc())
|
||||||
|
).scalars()
|
||||||
|
data = serialize_model(record)
|
||||||
|
data["actions"] = [serialize_model(item) for item in actions]
|
||||||
|
return data
|
||||||
|
|
||||||
|
def count_by_status(self) -> dict[str, int]:
|
||||||
|
rows = self.db.execute(
|
||||||
|
select(WorkflowInstance.status, func.count()).group_by(WorkflowInstance.status)
|
||||||
|
).all()
|
||||||
|
return {str(status_value): int(count) for status_value, count in rows}
|
||||||
1
app/modules/writebacks/__init__.py
Normal file
1
app/modules/writebacks/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
74
app/modules/writebacks/adapters.py
Normal file
74
app/modules/writebacks/adapters.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.core.constants import BEARER_TOKEN_TEMPLATE, HttpHeader
|
||||||
|
from app.modules.writebacks.constants import (
|
||||||
|
WRITEBACK_DISABLED_MESSAGE,
|
||||||
|
WritebackErrorDetail,
|
||||||
|
WritebackPayloadKey,
|
||||||
|
WritebackStatus,
|
||||||
|
)
|
||||||
|
from app.modules.writebacks.models import OfficialWritebackRun
|
||||||
|
|
||||||
|
|
||||||
|
class WritebackAdapter(Protocol):
|
||||||
|
def submit(self, run: OfficialWritebackRun) -> dict[str, Any]:
|
||||||
|
"""Submit one writeback run to the configured official integration."""
|
||||||
|
|
||||||
|
|
||||||
|
class DisabledWritebackAdapter:
|
||||||
|
def submit(self, run: OfficialWritebackRun) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
WritebackPayloadKey.STATUS: WritebackStatus.DISABLED,
|
||||||
|
WritebackPayloadKey.ERROR_MESSAGE: WRITEBACK_DISABLED_MESSAGE,
|
||||||
|
WritebackPayloadKey.CODE: run.code,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HttpWritebackAdapter:
|
||||||
|
def __init__(self, settings: Settings):
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
def submit(self, run: OfficialWritebackRun) -> dict[str, Any]:
|
||||||
|
if not self.settings.official_api_base_url or not self.settings.official_api_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=WritebackErrorDetail.OFFICIAL_API_NOT_CONFIGURED,
|
||||||
|
)
|
||||||
|
url = f"{self.settings.official_api_base_url.rstrip('/')}/writebacks/{run.domain}"
|
||||||
|
headers = {
|
||||||
|
HttpHeader.AUTHORIZATION: BEARER_TOKEN_TEMPLATE.format(
|
||||||
|
token=self.settings.official_api_token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
WritebackPayloadKey.CODE: run.code,
|
||||||
|
WritebackPayloadKey.DOMAIN: run.domain,
|
||||||
|
WritebackPayloadKey.RECORD_ID: run.record_id,
|
||||||
|
WritebackPayloadKey.ACTION: run.action,
|
||||||
|
WritebackPayloadKey.PAYLOAD: run.request_payload or {},
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=self.settings.official_api_timeout_seconds) as client:
|
||||||
|
response = client.post(url, json=payload, headers=headers)
|
||||||
|
if response.status_code >= status.HTTP_400_BAD_REQUEST:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=response.text,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
data = {"text": response.text}
|
||||||
|
return {
|
||||||
|
WritebackPayloadKey.STATUS: WritebackStatus.SENT,
|
||||||
|
WritebackPayloadKey.PROVIDER_RESPONSE: data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_writeback_adapter(settings: Settings) -> WritebackAdapter:
|
||||||
|
if settings.official_writeback_enabled:
|
||||||
|
return HttpWritebackAdapter(settings)
|
||||||
|
return DisabledWritebackAdapter()
|
||||||
42
app/modules/writebacks/constants.py
Normal file
42
app/modules/writebacks/constants.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class WritebackStatus(StrEnum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
PENDING_APPROVAL = "pending_approval"
|
||||||
|
DISABLED = "disabled"
|
||||||
|
QUEUED = "queued"
|
||||||
|
SENT = "sent"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class WritebackActionValue(StrEnum):
|
||||||
|
WRITEBACK = "writeback"
|
||||||
|
|
||||||
|
|
||||||
|
class WritebackPayloadKey(StrEnum):
|
||||||
|
CODE = "code"
|
||||||
|
STATUS = "status"
|
||||||
|
DOMAIN = "domain"
|
||||||
|
RECORD_ID = "record_id"
|
||||||
|
ACTION = "action"
|
||||||
|
PAYLOAD = "payload"
|
||||||
|
APPROVAL_TICKET_ID = "approval_ticket_id"
|
||||||
|
PROVIDER_RESPONSE = "provider_response"
|
||||||
|
ERROR_MESSAGE = "error_message"
|
||||||
|
|
||||||
|
|
||||||
|
class WritebackResponseKey(StrEnum):
|
||||||
|
ITEMS = "items"
|
||||||
|
DATA = "data"
|
||||||
|
|
||||||
|
|
||||||
|
class WritebackErrorDetail(StrEnum):
|
||||||
|
RUN_NOT_FOUND = "Writeback run not found"
|
||||||
|
APPROVAL_TICKET_REQUIRED = "approval_ticket_id is required for official writeback"
|
||||||
|
OFFICIAL_API_NOT_CONFIGURED = "Official API is not configured"
|
||||||
|
|
||||||
|
|
||||||
|
WRITEBACK_CODE_PREFIX = "WB"
|
||||||
|
WRITEBACK_DISABLED_MESSAGE = "Official writeback is disabled"
|
||||||
|
WRITEBACK_DEFAULT_ACTION = "sync"
|
||||||
39
app/modules/writebacks/models.py
Normal file
39
app/modules/writebacks/models.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, DateTime, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.constants import ActorValue
|
||||||
|
from app.core.db_base import Base
|
||||||
|
from app.core.time import utc_now
|
||||||
|
from app.modules.writebacks.constants import WritebackStatus
|
||||||
|
|
||||||
|
|
||||||
|
class OfficialWritebackRun(Base):
|
||||||
|
__tablename__ = "official_writeback_runs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
domain: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
actor: Mapped[str] = mapped_column(String(128), default=ActorValue.API, index=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default=WritebackStatus.DRAFT, index=True)
|
||||||
|
approval_ticket_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
idempotency_key: Mapped[str | None] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
unique=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
provider_response: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
default=utc_now,
|
||||||
|
onupdate=utc_now,
|
||||||
|
)
|
||||||
|
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||||
|
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||||
66
app/modules/writebacks/routes.py
Normal file
66
app/modules/writebacks/routes.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import ApiPrincipal, require_api_key
|
||||||
|
from app.modules.writebacks.constants import WritebackResponseKey
|
||||||
|
from app.modules.writebacks.schemas import WritebackCreateRequest, WritebackSubmitRequest
|
||||||
|
from app.modules.writebacks.service import WritebackService
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_writebacks(
|
||||||
|
status: str | None = None,
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
WritebackResponseKey.ITEMS: WritebackService(db).list_runs(
|
||||||
|
status_filter=status,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_writeback(
|
||||||
|
payload: WritebackCreateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
WritebackResponseKey.DATA: WritebackService(db).create_run(
|
||||||
|
domain=payload.domain,
|
||||||
|
record_id=payload.record_id,
|
||||||
|
action=payload.action,
|
||||||
|
payload=payload.payload,
|
||||||
|
actor=principal.actor,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{code}")
|
||||||
|
def get_writeback(
|
||||||
|
code: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
return {WritebackResponseKey.DATA: WritebackService(db).get_run(code)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{code}/submit")
|
||||||
|
def submit_writeback(
|
||||||
|
code: str,
|
||||||
|
payload: WritebackSubmitRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
principal: ApiPrincipal = Depends(require_api_key),
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
WritebackResponseKey.DATA: WritebackService(db).submit_run(
|
||||||
|
code,
|
||||||
|
approval_ticket_id=payload.approval_ticket_id,
|
||||||
|
actor=principal.actor,
|
||||||
|
)
|
||||||
|
}
|
||||||
35
app/modules/writebacks/schemas.py
Normal file
35
app/modules/writebacks/schemas.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.modules.writebacks.constants import WRITEBACK_DEFAULT_ACTION
|
||||||
|
|
||||||
|
|
||||||
|
class WritebackCreateRequest(BaseModel):
|
||||||
|
domain: str = Field(..., min_length=1)
|
||||||
|
record_id: str | None = None
|
||||||
|
action: str = Field(default=WRITEBACK_DEFAULT_ACTION, min_length=1)
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WritebackSubmitRequest(BaseModel):
|
||||||
|
approval_ticket_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WritebackRead(BaseModel):
|
||||||
|
code: str
|
||||||
|
domain: str
|
||||||
|
record_id: str | None
|
||||||
|
action: str
|
||||||
|
actor: str
|
||||||
|
status: str
|
||||||
|
approval_ticket_id: str | None
|
||||||
|
idempotency_key: str | None
|
||||||
|
request_payload: dict[str, Any] | None
|
||||||
|
provider_response: dict[str, Any] | None
|
||||||
|
error_message: str | None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
submitted_at: str | None
|
||||||
|
sent_at: str | None
|
||||||
232
app/modules/writebacks/service.py
Normal file
232
app/modules/writebacks/service.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.constants import ActorValue
|
||||||
|
from app.core.pagination import bounded_limit
|
||||||
|
from app.core.time import utc_now
|
||||||
|
from app.modules.approvals.constants import approval_action
|
||||||
|
from app.modules.approvals.service import ApprovalService
|
||||||
|
from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource
|
||||||
|
from app.modules.audit.schemas import AuditLogCreate
|
||||||
|
from app.modules.audit.service import AuditService
|
||||||
|
from app.modules.business.service import serialize_model
|
||||||
|
from app.modules.events.constants import EventAggregateType, EventSource, EventType
|
||||||
|
from app.modules.events.service import EventService
|
||||||
|
from app.modules.writebacks.adapters import get_writeback_adapter
|
||||||
|
from app.modules.writebacks.constants import (
|
||||||
|
WRITEBACK_CODE_PREFIX,
|
||||||
|
WRITEBACK_DISABLED_MESSAGE,
|
||||||
|
WritebackActionValue,
|
||||||
|
WritebackErrorDetail,
|
||||||
|
WritebackPayloadKey,
|
||||||
|
WritebackStatus,
|
||||||
|
)
|
||||||
|
from app.modules.writebacks.models import OfficialWritebackRun
|
||||||
|
|
||||||
|
|
||||||
|
class WritebackService:
|
||||||
|
"""Create and submit approved official-system writeback runs."""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
self.audit = AuditService(db)
|
||||||
|
|
||||||
|
def create_run(
|
||||||
|
self,
|
||||||
|
domain: str,
|
||||||
|
record_id: str | None,
|
||||||
|
action: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
actor: str = ActorValue.API,
|
||||||
|
idempotency_key: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if idempotency_key:
|
||||||
|
existing = self.db.execute(
|
||||||
|
select(OfficialWritebackRun).where(
|
||||||
|
OfficialWritebackRun.idempotency_key == idempotency_key
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return serialize_model(existing)
|
||||||
|
|
||||||
|
record = OfficialWritebackRun(
|
||||||
|
code=f"{WRITEBACK_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}",
|
||||||
|
domain=domain,
|
||||||
|
record_id=record_id,
|
||||||
|
action=action,
|
||||||
|
actor=actor,
|
||||||
|
request_payload=payload,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
self.db.add(record)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(record)
|
||||||
|
EventService(self.db).emit(
|
||||||
|
event_type=EventType.WRITEBACK_REQUESTED,
|
||||||
|
source=EventSource.WRITEBACK,
|
||||||
|
aggregate_type=EventAggregateType.WRITEBACK_RUN,
|
||||||
|
aggregate_id=record.code,
|
||||||
|
actor=actor,
|
||||||
|
payload=self._event_payload(record),
|
||||||
|
idempotency_key=f"{record.code}:requested",
|
||||||
|
dispatch=True,
|
||||||
|
)
|
||||||
|
self.audit.log(
|
||||||
|
AuditLogCreate(
|
||||||
|
actor=actor,
|
||||||
|
source=AuditSource.API,
|
||||||
|
action=AuditAction.WRITEBACK_CREATE,
|
||||||
|
target_type=domain,
|
||||||
|
target_id=record_id,
|
||||||
|
risk_level=AuditRiskLevel.HIGH,
|
||||||
|
request_payload=payload,
|
||||||
|
response_payload={WritebackPayloadKey.CODE: record.code},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return serialize_model(record)
|
||||||
|
|
||||||
|
def submit_run(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
approval_ticket_id: str | None,
|
||||||
|
actor: str = ActorValue.API,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not approval_ticket_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=WritebackErrorDetail.APPROVAL_TICKET_REQUIRED,
|
||||||
|
)
|
||||||
|
record = self._get_run(code)
|
||||||
|
settings = get_settings()
|
||||||
|
approval_action_name = approval_action(WritebackActionValue.WRITEBACK, record.domain)
|
||||||
|
if settings.official_writeback_enabled:
|
||||||
|
ApprovalService(self.db).consume_for(
|
||||||
|
approval_ticket_id,
|
||||||
|
record.domain,
|
||||||
|
record.record_id,
|
||||||
|
approval_action_name,
|
||||||
|
record.request_payload or {},
|
||||||
|
actor,
|
||||||
|
)
|
||||||
|
elif not ApprovalService(self.db).is_approved_for(
|
||||||
|
approval_ticket_id,
|
||||||
|
record.domain,
|
||||||
|
record.record_id,
|
||||||
|
approval_action_name,
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=WritebackErrorDetail.APPROVAL_TICKET_REQUIRED,
|
||||||
|
)
|
||||||
|
|
||||||
|
record.approval_ticket_id = approval_ticket_id
|
||||||
|
record.submitted_at = utc_now()
|
||||||
|
record.status = WritebackStatus.PENDING_APPROVAL
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(record)
|
||||||
|
|
||||||
|
adapter = get_writeback_adapter(settings)
|
||||||
|
try:
|
||||||
|
result = adapter.submit(record)
|
||||||
|
except Exception as exc:
|
||||||
|
record.status = WritebackStatus.FAILED
|
||||||
|
record.error_message = str(exc)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(record)
|
||||||
|
self._emit_submitted(record, actor)
|
||||||
|
self._audit_submit(record, actor)
|
||||||
|
return serialize_model(record)
|
||||||
|
|
||||||
|
if result.get(WritebackPayloadKey.STATUS) == WritebackStatus.DISABLED:
|
||||||
|
record.status = WritebackStatus.DISABLED
|
||||||
|
record.error_message = WRITEBACK_DISABLED_MESSAGE
|
||||||
|
else:
|
||||||
|
record.status = WritebackStatus.SENT
|
||||||
|
record.provider_response = result.get(WritebackPayloadKey.PROVIDER_RESPONSE) or result
|
||||||
|
record.sent_at = utc_now()
|
||||||
|
record.error_message = None
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(record)
|
||||||
|
self._emit_submitted(record, actor)
|
||||||
|
self._audit_submit(record, actor)
|
||||||
|
return serialize_model(record)
|
||||||
|
|
||||||
|
def list_runs(
|
||||||
|
self,
|
||||||
|
status_filter: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
stmt = (
|
||||||
|
select(OfficialWritebackRun)
|
||||||
|
.order_by(OfficialWritebackRun.id.desc())
|
||||||
|
.limit(bounded_limit(limit))
|
||||||
|
)
|
||||||
|
if status_filter:
|
||||||
|
stmt = stmt.where(OfficialWritebackRun.status == status_filter)
|
||||||
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
||||||
|
|
||||||
|
def get_run(self, code: str) -> dict[str, Any]:
|
||||||
|
return serialize_model(self._get_run(code))
|
||||||
|
|
||||||
|
def count_by_status(self) -> dict[str, int]:
|
||||||
|
rows = self.db.execute(
|
||||||
|
select(OfficialWritebackRun.status, func.count()).group_by(
|
||||||
|
OfficialWritebackRun.status
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return {str(status_value): int(count) for status_value, count in rows}
|
||||||
|
|
||||||
|
def _get_run(self, code: str) -> OfficialWritebackRun:
|
||||||
|
record = self.db.execute(
|
||||||
|
select(OfficialWritebackRun).where(OfficialWritebackRun.code == code)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=WritebackErrorDetail.RUN_NOT_FOUND,
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
|
||||||
|
def _emit_submitted(self, record: OfficialWritebackRun, actor: str) -> None:
|
||||||
|
EventService(self.db).emit(
|
||||||
|
event_type=EventType.WRITEBACK_SUBMITTED,
|
||||||
|
source=EventSource.WRITEBACK,
|
||||||
|
aggregate_type=EventAggregateType.WRITEBACK_RUN,
|
||||||
|
aggregate_id=record.code,
|
||||||
|
actor=actor,
|
||||||
|
payload=self._event_payload(record),
|
||||||
|
idempotency_key=f"{record.code}:submitted:{record.status}",
|
||||||
|
dispatch=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _audit_submit(self, record: OfficialWritebackRun, actor: str) -> None:
|
||||||
|
self.audit.log(
|
||||||
|
AuditLogCreate(
|
||||||
|
actor=actor,
|
||||||
|
source=AuditSource.API,
|
||||||
|
action=AuditAction.WRITEBACK_SUBMIT,
|
||||||
|
target_type=record.domain,
|
||||||
|
target_id=record.record_id,
|
||||||
|
risk_level=AuditRiskLevel.HIGH,
|
||||||
|
request_payload={
|
||||||
|
WritebackPayloadKey.CODE: record.code,
|
||||||
|
WritebackPayloadKey.APPROVAL_TICKET_ID: record.approval_ticket_id,
|
||||||
|
},
|
||||||
|
response_payload=self._event_payload(record),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event_payload(record: OfficialWritebackRun) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
WritebackPayloadKey.CODE: record.code,
|
||||||
|
WritebackPayloadKey.STATUS: record.status,
|
||||||
|
WritebackPayloadKey.DOMAIN: record.domain,
|
||||||
|
WritebackPayloadKey.RECORD_ID: record.record_id,
|
||||||
|
WritebackPayloadKey.ACTION: record.action,
|
||||||
|
WritebackPayloadKey.ERROR_MESSAGE: record.error_message,
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ from app.modules.business.models import (
|
|||||||
WorkTask,
|
WorkTask,
|
||||||
)
|
)
|
||||||
from app.modules.feishu.models import FeishuEventReceipt
|
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 = [
|
_MODELS = [
|
||||||
ApprovalRequest,
|
ApprovalRequest,
|
||||||
@@ -39,6 +42,10 @@ _MODELS = [
|
|||||||
RiskEventAction,
|
RiskEventAction,
|
||||||
LegacySyncRun,
|
LegacySyncRun,
|
||||||
ReportPushRun,
|
ReportPushRun,
|
||||||
|
DomainEvent,
|
||||||
|
WorkflowInstance,
|
||||||
|
WorkflowAction,
|
||||||
|
OfficialWritebackRun,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,86 @@
|
|||||||
services:
|
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:
|
api:
|
||||||
build: .
|
build: .
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
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
|
REDIS_URL: redis://redis:6379/0
|
||||||
ports:
|
ports:
|
||||||
- "8010:8010"
|
- "8010:8010"
|
||||||
depends_on:
|
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:
|
worker:
|
||||||
image: redis:7-alpine
|
build: .
|
||||||
ports:
|
env_file:
|
||||||
- "6379:6379"
|
- .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:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ db.close()
|
|||||||
|
|
||||||
os.environ["DATABASE_URL"] = "sqlite:///" + db.name.replace("\\", "/")
|
os.environ["DATABASE_URL"] = "sqlite:///" + db.name.replace("\\", "/")
|
||||||
os.environ["API_KEY"] = "test-key"
|
os.environ["API_KEY"] = "test-key"
|
||||||
|
os.environ["APPROVAL_API_KEY"] = "approval-key"
|
||||||
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
|
os.environ["LEGACY_ALLOWED_QUERIES"] = "{}"
|
||||||
os.environ["LEGACY_DATABASE_URL"] = ""
|
os.environ["LEGACY_DATABASE_URL"] = ""
|
||||||
os.environ["LEGACY_PROJECT_QUERY"] = ""
|
os.environ["LEGACY_PROJECT_QUERY"] = ""
|
||||||
@@ -34,22 +35,47 @@ def request(method: str, url: str, **kwargs):
|
|||||||
return response
|
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:
|
try:
|
||||||
Base.metadata.create_all(bind=engine)
|
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(
|
project = request(
|
||||||
"post",
|
"post",
|
||||||
"/api/v1/business/projects",
|
"/api/v1/business/projects",
|
||||||
json={
|
json={
|
||||||
"actor": "smoke",
|
"actor": "smoke",
|
||||||
"data": {
|
"approval_ticket_id": approval_ticket_id,
|
||||||
"code": "P-VERIFY-001",
|
"data": project_payload,
|
||||||
"name": "Verify Project",
|
|
||||||
"owner": "tester",
|
|
||||||
BusinessField.STATUS: StatusValue.RUNNING_CN,
|
|
||||||
"budget_amount": 1000,
|
|
||||||
"actual_amount": 200,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
).json()
|
).json()
|
||||||
print("project:", project["data"]["code"])
|
print("project:", project["data"]["code"])
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
|
from app.modules.ai_agent.constants import AIProviderName, AIResponseKey
|
||||||
from app.modules.business.constants import StatusValue
|
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_ID"] = ""
|
||||||
os.environ["FEISHU_APP_SECRET"] = ""
|
os.environ["FEISHU_APP_SECRET"] = ""
|
||||||
os.environ["FEISHU_VERIFICATION_TOKEN"] = "test-feishu-token"
|
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_ALLOWED_QUERIES"] = "{}"
|
||||||
os.environ["LEGACY_DATABASE_URL"] = ""
|
os.environ["LEGACY_DATABASE_URL"] = ""
|
||||||
os.environ["LEGACY_PROJECT_QUERY"] = ""
|
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.core.security import require_api_key, require_approval_api_key, require_audit_api_key
|
||||||
from app.main import _allow_cors_credentials, app
|
from app.main import _allow_cors_credentials, app
|
||||||
from app.modules.audit.constants import AUDIT_REDACTED_VALUE
|
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.legacy_mysql.service import LegacyMySQLService
|
||||||
from app.modules.reports.constants import (
|
from app.modules.reports.constants import (
|
||||||
LifecycleAttentionKey,
|
LifecycleAttentionKey,
|
||||||
@@ -46,6 +60,10 @@ from app.modules.reports.constants import (
|
|||||||
ReportTitle,
|
ReportTitle,
|
||||||
ReportType,
|
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)
|
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"}
|
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:
|
def teardown_module() -> None:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
path = Path(_db.name)
|
path = Path(_db.name)
|
||||||
@@ -63,19 +117,15 @@ def teardown_module() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_project_report_and_feishu_command_preview() -> None:
|
def test_project_report_and_feishu_command_preview() -> None:
|
||||||
response = client.post(
|
response = create_business_record(
|
||||||
"/api/v1/business/projects",
|
"projects",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": "P-SMOKE-001",
|
||||||
"actor": "pytest",
|
"name": "Smoke Project",
|
||||||
"data": {
|
"owner": "tester",
|
||||||
"code": "P-SMOKE-001",
|
"status": "执行中",
|
||||||
"name": "Smoke Project",
|
"budget_amount": 1000,
|
||||||
"owner": "tester",
|
"actual_amount": 200,
|
||||||
"status": "执行中",
|
|
||||||
"budget_amount": 1000,
|
|
||||||
"actual_amount": 200,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 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
|
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:
|
def test_feishu_webhook_challenge_uses_event_service_verification() -> None:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/integrations/feishu/webhook",
|
"/api/v1/integrations/feishu/webhook",
|
||||||
@@ -224,16 +435,13 @@ def test_config_and_pagination_guardrails() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_dashboard_and_response_masking() -> None:
|
def test_dashboard_and_response_masking() -> None:
|
||||||
expense_response = client.post(
|
expense_response = create_business_record(
|
||||||
"/api/v1/business/expenses",
|
"expenses",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": "EXP-MASK-001",
|
||||||
"data": {
|
"expense_type": "办公",
|
||||||
"code": "EXP-MASK-001",
|
"amount": 20,
|
||||||
"expense_type": "办公",
|
"payment_account": "6222000000000000",
|
||||||
"amount": 20,
|
|
||||||
"payment_account": "6222000000000000",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert expense_response.status_code == 200
|
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"]))
|
monkeypatch.setenv("MASKED_RESPONSE_FIELDS", json.dumps(["expenses.amount"]))
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
try:
|
try:
|
||||||
response = client.post(
|
response = create_business_record(
|
||||||
"/api/v1/business/expenses",
|
"expenses",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": "EXP-MASK-CONFIG-001",
|
||||||
"data": {
|
"expense_type": "测试",
|
||||||
"code": "EXP-MASK-CONFIG-001",
|
"amount": 123,
|
||||||
"expense_type": "测试",
|
|
||||||
"amount": 123,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -282,6 +487,37 @@ def test_configured_domain_response_masking(monkeypatch) -> None:
|
|||||||
get_settings.cache_clear()
|
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:
|
def test_approval_gate_for_high_risk_update() -> None:
|
||||||
create_payload = {
|
create_payload = {
|
||||||
"code": "FUND-SMOKE-001",
|
"code": "FUND-SMOKE-001",
|
||||||
@@ -494,6 +730,76 @@ def test_feishu_approval_card_action_approves_ticket() -> None:
|
|||||||
assert duplicate_response.json()["result"]["status"] == "approved"
|
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:
|
def test_new_ledgers_reports_and_risk_events() -> None:
|
||||||
domains_response = client.get("/api/v1/business/domains", headers=headers)
|
domains_response = client.get("/api/v1/business/domains", headers=headers)
|
||||||
assert domains_response.status_code == 200
|
assert domains_response.status_code == 200
|
||||||
@@ -503,34 +809,26 @@ def test_new_ledgers_reports_and_risk_events() -> None:
|
|||||||
assert "risk-events" in domains
|
assert "risk-events" in domains
|
||||||
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
attendance_response = client.post(
|
attendance_response = create_business_record(
|
||||||
"/api/v1/business/attendance-records",
|
"attendance-records",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": "ATT-SMOKE-001",
|
||||||
"actor": "pytest",
|
"employee_name": "Tester",
|
||||||
"data": {
|
"department": "QA",
|
||||||
"code": "ATT-SMOKE-001",
|
"work_date": today.isoformat(),
|
||||||
"employee_name": "Tester",
|
"status": "正常",
|
||||||
"department": "QA",
|
|
||||||
"work_date": today.isoformat(),
|
|
||||||
"status": "正常",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert attendance_response.status_code == 200
|
assert attendance_response.status_code == 200
|
||||||
|
|
||||||
task_response = client.post(
|
task_response = create_business_record(
|
||||||
"/api/v1/business/tasks",
|
"tasks",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": "TASK-RISK-001",
|
||||||
"actor": "pytest",
|
"title": "Overdue smoke task",
|
||||||
"data": {
|
"owner": "tester",
|
||||||
"code": "TASK-RISK-001",
|
"status": "待办",
|
||||||
"title": "Overdue smoke task",
|
"due_date": (today - timedelta(days=1)).isoformat(),
|
||||||
"owner": "tester",
|
|
||||||
"status": "待办",
|
|
||||||
"due_date": (today - timedelta(days=1)).isoformat(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert task_response.status_code == 200
|
assert task_response.status_code == 200
|
||||||
@@ -568,91 +866,71 @@ def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None:
|
|||||||
today = date.today()
|
today = date.today()
|
||||||
project_code = "P-LIFECYCLE-001"
|
project_code = "P-LIFECYCLE-001"
|
||||||
|
|
||||||
project_response = client.post(
|
project_response = create_business_record(
|
||||||
"/api/v1/business/projects",
|
"projects",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": project_code,
|
||||||
"actor": "pytest",
|
"name": "Lifecycle Project",
|
||||||
"data": {
|
"owner": "lifecycle-owner",
|
||||||
"code": project_code,
|
"status": "执行中",
|
||||||
"name": "Lifecycle Project",
|
"progress_percent": 40,
|
||||||
"owner": "lifecycle-owner",
|
"budget_amount": 1000,
|
||||||
"status": "执行中",
|
"actual_amount": 1500,
|
||||||
"progress_percent": 40,
|
"due_date": (today - timedelta(days=1)).isoformat(),
|
||||||
"budget_amount": 1000,
|
|
||||||
"actual_amount": 1500,
|
|
||||||
"due_date": (today - timedelta(days=1)).isoformat(),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert project_response.status_code == 200
|
assert project_response.status_code == 200
|
||||||
|
|
||||||
task_response = client.post(
|
task_response = create_business_record(
|
||||||
"/api/v1/business/tasks",
|
"tasks",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": "TASK-LIFECYCLE-001",
|
||||||
"actor": "pytest",
|
"title": "Lifecycle overdue task",
|
||||||
"data": {
|
"project_code": project_code,
|
||||||
"code": "TASK-LIFECYCLE-001",
|
"owner": "lifecycle-owner",
|
||||||
"title": "Lifecycle overdue task",
|
"status": "待办",
|
||||||
"project_code": project_code,
|
"due_date": (today - timedelta(days=1)).isoformat(),
|
||||||
"owner": "lifecycle-owner",
|
"blocker": "waiting for decision",
|
||||||
"status": "待办",
|
|
||||||
"due_date": (today - timedelta(days=1)).isoformat(),
|
|
||||||
"blocker": "waiting for decision",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert task_response.status_code == 200
|
assert task_response.status_code == 200
|
||||||
|
|
||||||
procurement_response = client.post(
|
procurement_response = create_business_record(
|
||||||
"/api/v1/business/procurements",
|
"procurements",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": "PROC-LIFECYCLE-001",
|
||||||
"actor": "pytest",
|
"name": "Lifecycle procurement",
|
||||||
"data": {
|
"project_code": project_code,
|
||||||
"code": "PROC-LIFECYCLE-001",
|
"expected_amount": 300,
|
||||||
"name": "Lifecycle procurement",
|
"actual_amount": 100,
|
||||||
"project_code": project_code,
|
"approval_status": StatusValue.PENDING_APPROVAL,
|
||||||
"expected_amount": 300,
|
"delivery_status": StatusValue.UNDELIVERED,
|
||||||
"actual_amount": 100,
|
"payment_status": StatusValue.UNPAID,
|
||||||
"approval_status": StatusValue.PENDING_APPROVAL,
|
|
||||||
"delivery_status": StatusValue.UNDELIVERED,
|
|
||||||
"payment_status": StatusValue.UNPAID,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert procurement_response.status_code == 200
|
assert procurement_response.status_code == 200
|
||||||
|
|
||||||
expense_response = client.post(
|
expense_response = create_business_record(
|
||||||
"/api/v1/business/expenses",
|
"expenses",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": "EXP-LIFECYCLE-001",
|
||||||
"actor": "pytest",
|
"expense_type": "差旅",
|
||||||
"data": {
|
"amount": 80,
|
||||||
"code": "EXP-LIFECYCLE-001",
|
"project_code": project_code,
|
||||||
"expense_type": "差旅",
|
"approval_status": StatusValue.PENDING_APPROVAL,
|
||||||
"amount": 80,
|
"payment_status": StatusValue.UNPAID,
|
||||||
"project_code": project_code,
|
|
||||||
"approval_status": StatusValue.PENDING_APPROVAL,
|
|
||||||
"payment_status": StatusValue.UNPAID,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert expense_response.status_code == 200
|
assert expense_response.status_code == 200
|
||||||
|
|
||||||
attendance_response = client.post(
|
attendance_response = create_business_record(
|
||||||
"/api/v1/business/attendance-records",
|
"attendance-records",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": "ATT-LIFECYCLE-001",
|
||||||
"actor": "pytest",
|
"employee_name": "Lifecycle Tester",
|
||||||
"data": {
|
"project_code": project_code,
|
||||||
"code": "ATT-LIFECYCLE-001",
|
"work_date": today.isoformat(),
|
||||||
"employee_name": "Lifecycle Tester",
|
"status": StatusValue.MISSING_PUNCH,
|
||||||
"project_code": project_code,
|
|
||||||
"work_date": today.isoformat(),
|
|
||||||
"status": StatusValue.MISSING_PUNCH,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert attendance_response.status_code == 200
|
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:
|
def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
|
||||||
today = date.today()
|
today = date.today()
|
||||||
project_code = "P-BACKLOG-001"
|
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(
|
procurement_response = create_business_record(
|
||||||
"/api/v1/business/procurements",
|
"procurements",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": "PROC-BACKLOG-001",
|
||||||
"data": {
|
"name": "Backlog procurement",
|
||||||
"code": "PROC-BACKLOG-001",
|
"project_code": project_code,
|
||||||
"name": "Backlog procurement",
|
"approval_status": StatusValue.PENDING_APPROVAL,
|
||||||
"project_code": project_code,
|
|
||||||
"approval_status": StatusValue.PENDING_APPROVAL,
|
|
||||||
"created_at": old_created_at,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert procurement_response.status_code == 200
|
assert procurement_response.status_code == 200
|
||||||
|
|
||||||
expense_response = client.post(
|
expense_response = create_business_record(
|
||||||
"/api/v1/business/expenses",
|
"expenses",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": "EXP-BACKLOG-001",
|
||||||
"data": {
|
"expense_type": "办公",
|
||||||
"code": "EXP-BACKLOG-001",
|
"amount": 50,
|
||||||
"expense_type": "办公",
|
"project_code": project_code,
|
||||||
"amount": 50,
|
"approval_status": StatusValue.PENDING_APPROVAL,
|
||||||
"project_code": project_code,
|
|
||||||
"approval_status": StatusValue.PENDING_APPROVAL,
|
|
||||||
"created_at": old_created_at,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert expense_response.status_code == 200
|
assert expense_response.status_code == 200
|
||||||
@@ -746,8 +1016,8 @@ def test_work_report_counts_pending_approval_backlog_outside_period() -> None:
|
|||||||
json={
|
json={
|
||||||
"report_type": ReportType.DAILY,
|
"report_type": ReportType.DAILY,
|
||||||
"project_code": project_code,
|
"project_code": project_code,
|
||||||
"period_start": today.isoformat(),
|
"period_start": report_day.isoformat(),
|
||||||
"period_end": today.isoformat(),
|
"period_end": report_day.isoformat(),
|
||||||
"persist": False,
|
"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:
|
def test_risk_event_workflow_records_actions() -> None:
|
||||||
create_response = client.post(
|
create_response = create_business_record(
|
||||||
"/api/v1/business/risk-events",
|
"risk-events",
|
||||||
headers=headers,
|
{
|
||||||
json={
|
"code": "RISK-FLOW-001",
|
||||||
"data": {
|
"title": "Workflow risk",
|
||||||
"code": "RISK-FLOW-001",
|
"risk_type": "manual",
|
||||||
"title": "Workflow risk",
|
"risk_level": "medium",
|
||||||
"risk_type": "manual",
|
"source_domain": "projects",
|
||||||
"risk_level": "medium",
|
"source_record_id": "P-SMOKE-001",
|
||||||
"source_domain": "projects",
|
"status": "open",
|
||||||
"source_record_id": "P-SMOKE-001",
|
|
||||||
"status": "open",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert create_response.status_code == 200
|
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.status_code == 200
|
||||||
assert resolve_response.json()["risk_event"]["status"] == "resolved"
|
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(
|
close_response = client.post(
|
||||||
f"/api/v1/risks/events/{event_id}/close",
|
f"/api/v1/risks/events/{event_id}/close",
|
||||||
headers=headers,
|
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.status_code == 200
|
||||||
assert close_response.json()["risk_event"]["status"] == "closed"
|
assert close_response.json()["risk_event"]["status"] == "closed"
|
||||||
assert close_response.json()["risk_event"]["closed_reason"] == "verified"
|
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",
|
f"/api/v1/risks/events/{event_id}/reopen",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={"comment": "recheck"},
|
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.status_code == 200
|
||||||
assert reopen_response.json()["risk_event"]["status"] == "open"
|
assert reopen_response.json()["risk_event"]["status"] == "open"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user