feat: 添加数据库迁移脚本并更新Dockerfile配置

- 在Dockerfile中添加alembic配置文件和目录的复制指令
- 更新alembic/env.py注册新的模块模型:events、workflows、writebacks
- 生成完整的初始数据库schema迁移脚本,包含以下表:
  - approval_requests, attendance_records, audit_logs, domain_events
  - expenses, feishu_event_receipts, fund_accounts, legacy_sync_runs
  - official_writeback_runs, performance_metrics, policies, procurements
  - projects, report_push_runs, risk_event_actions, risk_events
  - standards, suppliers, work_reports, work_tasks, workflow_actions
  - workflow_instances等21个数据表结构定义
- 在API路由器中添加新模块的路由:events、workflows、writebacks、observability
```
This commit is contained in:
2026-07-08 14:08:03 +08:00
parent 92f490b97e
commit 19e59e83cc
59 changed files with 3271 additions and 247 deletions

View File

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

View File

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

View File

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

View File

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