Files
company-ai-platform/app/modules/business/service.py
JiuContinent 19e59e83cc ```
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
```
2026-07-08 14:08:03 +08:00

240 lines
8.4 KiB
Python

from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import Date as SQLDate
from sqlalchemy import DateTime as SQLDateTime
from sqlalchemy import Numeric as SQLNumeric
from sqlalchemy import Select, func, select
from sqlalchemy.sql.schema import Column
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.pagination import bounded_limit, bounded_offset
from app.modules.audit.constants import AuditRiskLevel, AuditSource
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, 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,
BusinessPayloadKey,
)
def serialize_model(record: Any) -> dict[str, Any]:
"""Convert a SQLAlchemy model instance into a JSON-friendly dictionary."""
data: dict[str, Any] = {}
for column in record.__table__.columns:
value = getattr(record, column.name)
if isinstance(value, (datetime, date)):
data[column.name] = value.isoformat()
elif isinstance(value, Decimal):
data[column.name] = float(value)
else:
data[column.name] = value
return data
def _coerce_column_value(column: Column, value: Any) -> Any:
"""Coerce API JSON values into the Python type expected by a SQLAlchemy column."""
if value is None:
return None
if isinstance(column.type, SQLDateTime) and isinstance(value, str):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
if isinstance(column.type, SQLDate) and isinstance(value, str):
return date.fromisoformat(value)
if isinstance(column.type, SQLNumeric) and not isinstance(value, Decimal):
return Decimal(str(value))
return value
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 = {
column.name: column
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)
if column is None:
raise HTTPException(
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:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=INVALID_FIELD_VALUE_TEMPLATE.format(field=key),
) from exc
return payload
class BusinessService:
"""Manage generic CRUD operations across registered business domains."""
def __init__(self, db: Session):
self.db = db
self.audit = AuditService(db)
def list_records(
self,
domain: str,
limit: int = 50,
offset: int = 0,
status_filter: str | None = None,
) -> tuple[int, list[dict[str, Any]]]:
model = get_domain_model(domain)
stmt: Select = select(model)
count_stmt = select(func.count()).select_from(model)
if status_filter and hasattr(model, BusinessField.STATUS):
stmt = stmt.where(model.status == status_filter)
count_stmt = count_stmt.where(model.status == status_filter)
stmt = stmt.order_by(model.id.desc()).limit(bounded_limit(limit)).offset(
bounded_offset(offset)
)
total = int(self.db.execute(count_stmt).scalar() or 0)
return total, [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def get_record(self, domain: str, record_id: int) -> dict[str, Any]:
model = get_domain_model(domain)
record = self.db.get(model, record_id)
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=BusinessErrorDetail.RECORD_NOT_FOUND,
)
return serialize_model(record)
def create_record(
self,
domain: str,
data: dict[str, Any],
actor: str = ActorValue.API,
approval_ticket_id: str | None = None,
) -> dict[str, Any]:
model = get_domain_model(domain)
high_risk = is_high_risk_domain(domain)
payload = _model_payload(domain, model, data)
record = model(**payload)
self.db.add(record)
if high_risk:
self.db.flush()
self._consume_approval(
approval_ticket_id,
domain,
record.id,
approval_action(ApprovalActionValue.CREATE, domain),
data,
actor,
)
self.db.commit()
self.db.refresh(record)
result = serialize_model(record)
self.audit.log(
AuditLogCreate(
actor=actor,
source=AuditSource.API,
action=approval_action(ApprovalActionValue.CREATE, domain),
target_type=domain,
target_id=str(record.id),
risk_level=AuditRiskLevel.HIGH if high_risk else AuditRiskLevel.LOW,
request_payload={
BusinessPayloadKey.DATA: data,
BusinessPayloadKey.APPROVAL_TICKET_ID: approval_ticket_id,
},
response_payload=result,
)
)
return result
def update_record(
self,
domain: str,
record_id: int,
data: dict[str, Any],
actor: str = ActorValue.API,
approval_ticket_id: str | None = None,
) -> dict[str, Any]:
model = get_domain_model(domain)
high_risk = is_high_risk_domain(domain)
record = self.db.get(model, record_id)
if record is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=BusinessErrorDetail.RECORD_NOT_FOUND,
)
payload = _model_payload(domain, model, data)
if high_risk:
self._consume_approval(
approval_ticket_id,
domain,
record_id,
approval_action(ApprovalActionValue.UPDATE, domain),
data,
actor,
)
for key, value in payload.items():
setattr(record, key, value)
self.db.commit()
self.db.refresh(record)
result = serialize_model(record)
self.audit.log(
AuditLogCreate(
actor=actor,
source=AuditSource.API,
action=approval_action(ApprovalActionValue.UPDATE, domain),
target_type=domain,
target_id=str(record.id),
risk_level=AuditRiskLevel.HIGH if high_risk else AuditRiskLevel.LOW,
request_payload={
BusinessPayloadKey.DATA: data,
BusinessPayloadKey.APPROVAL_TICKET_ID: approval_ticket_id,
},
response_payload=result,
)
)
return result
def _consume_approval(
self,
approval_ticket_id: str | None,
domain: str,
record_id: str | int | None,
action: str,
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,
domain,
record_id,
action,
payload,
actor,
)