```
feat: 添加公司AI管理平台基础架构 添加了完整的FastAPI后端项目结构,包括: - 环境配置文件(.env.example)和项目说明文档(README.md、AGENTS.md) - Dockerfile用于容器化部署 - 核心基础设施:配置管理、数据库连接、调度器、安全认证 - 模块化设计:AI代理、审批流程、审计日志、业务台账等功能模块 - 支持多数据库连接(主库和遗留系统只读库) - AI适配器支持OpenClaw、Hermes、OpenAI兼容接口 - 飞书集成、报表生成、风险监控等企业级功能 - 完整的依赖管理和测试指南 ```
This commit is contained in:
1
app/modules/business/__init__.py
Normal file
1
app/modules/business/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Business lifecycle module."""
|
||||
159
app/modules/business/models.py
Normal file
159
app/modules/business/models.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Date, DateTime, Integer, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
|
||||
|
||||
class Project(Base, TimestampMixin):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(64), default="立项", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(32), default="P2")
|
||||
progress_percent: Mapped[int] = mapped_column(Integer, default=0)
|
||||
risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True)
|
||||
budget_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0)
|
||||
actual_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0)
|
||||
start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_system: Mapped[str] = mapped_column(String(64), default="internal")
|
||||
external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
|
||||
|
||||
class WorkTask(Base, TimestampMixin):
|
||||
__tablename__ = "work_tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), index=True)
|
||||
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(64), default="待办", index=True)
|
||||
priority: Mapped[str] = mapped_column(String(32), default="P2")
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
blocker: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Procurement(Base, TimestampMixin):
|
||||
__tablename__ = "procurements"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
applicant: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
supplier_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
budget_subject: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
expected_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0)
|
||||
actual_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0)
|
||||
approval_status: Mapped[str] = mapped_column(String(64), default="草稿", index=True)
|
||||
delivery_status: Mapped[str] = mapped_column(String(64), default="未到货", index=True)
|
||||
payment_status: Mapped[str] = mapped_column(String(64), default="未付款", index=True)
|
||||
comparison_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Expense(Base, TimestampMixin):
|
||||
__tablename__ = "expenses"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
expense_type: Mapped[str] = mapped_column(String(128), index=True)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0)
|
||||
applicant: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
department: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
budget_subject: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
payment_account: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
invoice_status: Mapped[str] = mapped_column(String(64), default="未收票")
|
||||
approval_status: Mapped[str] = mapped_column(String(64), default="草稿", index=True)
|
||||
payment_status: Mapped[str] = mapped_column(String(64), default="未付款", index=True)
|
||||
|
||||
|
||||
class FundAccount(Base, TimestampMixin):
|
||||
__tablename__ = "fund_accounts"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
account_type: Mapped[str] = mapped_column(String(64), default="bank")
|
||||
current_balance: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0)
|
||||
expected_receivable: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0)
|
||||
expected_payable: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0)
|
||||
safety_line: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0)
|
||||
risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Policy(Base, TimestampMixin):
|
||||
__tablename__ = "policies"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), index=True)
|
||||
policy_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
owner_department: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
version: Mapped[str] = mapped_column(String(32), default="v1.0")
|
||||
status: Mapped[str] = mapped_column(String(64), default="草案", index=True)
|
||||
effective_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
feishu_doc_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class Standard(Base, TimestampMixin):
|
||||
__tablename__ = "standards"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), index=True)
|
||||
standard_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
applies_to: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(64), default="有效", index=True)
|
||||
check_items: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
remediation: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
policy_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
|
||||
class PerformanceMetric(Base, TimestampMixin):
|
||||
__tablename__ = "performance_metrics"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
applies_to_role: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
formula: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
weight: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0)
|
||||
data_source: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
auto_score: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=0)
|
||||
confirmed_score: Mapped[Decimal | None] = mapped_column(Numeric(8, 2), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(64), default="草稿", index=True)
|
||||
|
||||
|
||||
class Supplier(Base, TimestampMixin):
|
||||
__tablename__ = "suppliers"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
category: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
||||
contact: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
quality_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0)
|
||||
delivery_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0)
|
||||
price_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0)
|
||||
risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True)
|
||||
blacklist_status: Mapped[str] = mapped_column(String(32), default="normal", index=True)
|
||||
26
app/modules/business/registry.py
Normal file
26
app/modules/business/registry.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy.orm import DeclarativeMeta
|
||||
|
||||
from app.modules.business import models
|
||||
|
||||
|
||||
DOMAIN_MODELS: dict[str, type[DeclarativeMeta]] = {
|
||||
"projects": models.Project,
|
||||
"tasks": models.WorkTask,
|
||||
"procurements": models.Procurement,
|
||||
"expenses": models.Expense,
|
||||
"fund-accounts": models.FundAccount,
|
||||
"policies": models.Policy,
|
||||
"standards": models.Standard,
|
||||
"performance-metrics": models.PerformanceMetric,
|
||||
"suppliers": models.Supplier,
|
||||
}
|
||||
|
||||
LOW_RISK_DOMAINS = {"projects", "tasks", "procurements", "expenses", "policies", "standards"}
|
||||
HIGH_RISK_DOMAINS = {"fund-accounts", "performance-metrics"}
|
||||
|
||||
|
||||
def get_domain_model(domain: str) -> type[DeclarativeMeta]:
|
||||
if domain not in DOMAIN_MODELS:
|
||||
supported = ", ".join(sorted(DOMAIN_MODELS))
|
||||
raise KeyError(f"Unsupported domain '{domain}'. Supported: {supported}")
|
||||
return DOMAIN_MODELS[domain]
|
||||
71
app/modules/business/routes.py
Normal file
71
app/modules/business/routes.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_api_key
|
||||
from app.modules.business.registry import DOMAIN_MODELS
|
||||
from app.modules.business.schemas import DomainListRead, DomainRecordCreate, DomainRecordUpdate
|
||||
from app.modules.business.service import BusinessService
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_api_key)])
|
||||
|
||||
|
||||
@router.get("/domains")
|
||||
def list_domains() -> dict[str, list[str]]:
|
||||
return {"domains": sorted(DOMAIN_MODELS)}
|
||||
|
||||
|
||||
@router.get("/{domain}", response_model=DomainListRead)
|
||||
def list_records(
|
||||
domain: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
status: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
try:
|
||||
total, items = BusinessService(db).list_records(domain, limit, offset, status)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"domain": domain, "total": total, "items": items}
|
||||
|
||||
|
||||
@router.get("/{domain}/{record_id}")
|
||||
def get_record(domain: str, record_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
try:
|
||||
return {"domain": domain, "data": BusinessService(db).get_record(domain, record_id)}
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{domain}")
|
||||
def create_record(
|
||||
domain: str,
|
||||
payload: DomainRecordCreate,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
try:
|
||||
data = BusinessService(db).create_record(domain, payload.data, payload.actor)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"domain": domain, "data": data}
|
||||
|
||||
|
||||
@router.patch("/{domain}/{record_id}")
|
||||
def update_record(
|
||||
domain: str,
|
||||
record_id: int,
|
||||
payload: DomainRecordUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
try:
|
||||
data = BusinessService(db).update_record(
|
||||
domain,
|
||||
record_id,
|
||||
payload.data,
|
||||
actor=payload.actor,
|
||||
approval_ticket_id=payload.approval_ticket_id,
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return {"domain": domain, "data": data}
|
||||
28
app/modules/business/schemas.py
Normal file
28
app/modules/business/schemas.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DomainRecordCreate(BaseModel):
|
||||
data: dict[str, Any] = Field(..., description="Domain fields to create.")
|
||||
actor: str = "api"
|
||||
|
||||
|
||||
class DomainRecordUpdate(BaseModel):
|
||||
data: dict[str, Any] = Field(..., description="Domain fields to update.")
|
||||
actor: str = "api"
|
||||
approval_ticket_id: str | None = Field(
|
||||
default=None,
|
||||
description="Required by policy for high-risk updates such as funds or performance.",
|
||||
)
|
||||
|
||||
|
||||
class DomainRecordRead(BaseModel):
|
||||
domain: str
|
||||
data: dict[str, Any]
|
||||
|
||||
|
||||
class DomainListRead(BaseModel):
|
||||
domain: str
|
||||
total: int
|
||||
items: list[dict[str, Any]]
|
||||
138
app/modules/business/service.py
Normal file
138
app/modules/business/service.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.audit.schemas import AuditLogCreate
|
||||
from app.modules.audit.service import AuditService
|
||||
from app.modules.approvals.service import ApprovalService
|
||||
from app.modules.business.registry import HIGH_RISK_DOMAINS, get_domain_model
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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, "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(min(limit, 500)).offset(max(offset, 0))
|
||||
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="Record not found")
|
||||
return serialize_model(record)
|
||||
|
||||
def create_record(
|
||||
self,
|
||||
domain: str,
|
||||
data: dict[str, Any],
|
||||
actor: str = "api",
|
||||
) -> dict[str, Any]:
|
||||
model = get_domain_model(domain)
|
||||
allowed = {column.name for column in model.__table__.columns if column.name != "id"}
|
||||
payload = {key: value for key, value in data.items() if key in allowed}
|
||||
record = model(**payload)
|
||||
self.db.add(record)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
result = serialize_model(record)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source="api",
|
||||
action=f"create:{domain}",
|
||||
target_type=domain,
|
||||
target_id=str(record.id),
|
||||
request_payload=data,
|
||||
response_payload=result,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def update_record(
|
||||
self,
|
||||
domain: str,
|
||||
record_id: int,
|
||||
data: dict[str, Any],
|
||||
actor: str = "api",
|
||||
approval_ticket_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if domain in HIGH_RISK_DOMAINS:
|
||||
if not approval_ticket_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="High-risk domain update requires approval_ticket_id",
|
||||
)
|
||||
if not ApprovalService(self.db).is_approved_for(
|
||||
approval_ticket_id,
|
||||
domain,
|
||||
record_id,
|
||||
f"update:{domain}",
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Approval ticket is not approved for this update",
|
||||
)
|
||||
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="Record not found",
|
||||
)
|
||||
allowed = {column.name for column in model.__table__.columns if column.name != "id"}
|
||||
for key, value in data.items():
|
||||
if key in allowed:
|
||||
setattr(record, key, value)
|
||||
self.db.commit()
|
||||
self.db.refresh(record)
|
||||
result = serialize_model(record)
|
||||
self.audit.log(
|
||||
AuditLogCreate(
|
||||
actor=actor,
|
||||
source="api",
|
||||
action=f"update:{domain}",
|
||||
target_type=domain,
|
||||
target_id=str(record.id),
|
||||
risk_level="high" if domain in HIGH_RISK_DOMAINS else "low",
|
||||
request_payload={"data": data, "approval_ticket_id": approval_ticket_id},
|
||||
response_payload=result,
|
||||
)
|
||||
)
|
||||
return result
|
||||
Reference in New Issue
Block a user