feat: 添加公司AI管理平台基础架构

添加了完整的FastAPI后端项目结构,包括:
- 环境配置文件(.env.example)和项目说明文档(README.md、AGENTS.md)
- Dockerfile用于容器化部署
- 核心基础设施:配置管理、数据库连接、调度器、安全认证
- 模块化设计:AI代理、审批流程、审计日志、业务台账等功能模块
- 支持多数据库连接(主库和遗留系统只读库)
- AI适配器支持OpenClaw、Hermes、OpenAI兼容接口
- 飞书集成、报表生成、风险监控等企业级功能
- 完整的依赖管理和测试指南
```
This commit is contained in:
2026-06-21 21:57:28 +08:00
commit 71ca804764
68 changed files with 3662 additions and 0 deletions

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Company AI management platform."""

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API package."""

29
app/api/router.py Normal file
View File

@@ -0,0 +1,29 @@
from fastapi import APIRouter
from app.modules.ai_agent.routes import router as ai_router
from app.modules.approvals.routes import router as approvals_router
from app.modules.audit.routes import router as audit_router
from app.modules.business.routes import router as business_router
from app.modules.feishu.routes import router as feishu_router
from app.modules.legacy_mysql.routes import router as legacy_mysql_router
from app.modules.reports.routes import router as reports_router
from app.modules.risk.routes import router as risk_router
api_router = APIRouter()
@api_router.get("/health", tags=["system"])
def health_check() -> dict[str, str]:
"""Return basic API health status."""
return {"status": "ok"}
api_router.include_router(business_router, prefix="/business", tags=["business"])
api_router.include_router(legacy_mysql_router, prefix="/integrations/mysql", tags=["mysql"])
api_router.include_router(feishu_router, prefix="/integrations/feishu", tags=["feishu"])
api_router.include_router(ai_router, prefix="/ai", tags=["ai"])
api_router.include_router(approvals_router, prefix="/approvals", tags=["approvals"])
api_router.include_router(reports_router, prefix="/reports", tags=["reports"])
api_router.include_router(risk_router, prefix="/risks", tags=["risks"])
api_router.include_router(audit_router, prefix="/audit", tags=["audit"])

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Core infrastructure."""

60
app/core/config.py Normal file
View File

@@ -0,0 +1,60 @@
from functools import lru_cache
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Runtime settings loaded from environment variables and `.env`."""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "Company AI Management Platform"
app_env: str = "local"
debug: bool = False
api_prefix: str = "/api/v1"
api_key: str | None = None
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
database_url: str = "mysql+pymysql://root:password@127.0.0.1:3306/company_ai?charset=utf8mb4"
legacy_database_url: str | None = None
legacy_project_query: str | None = None
legacy_project_code_prefix: str = "LEGACY"
redis_url: str = "redis://127.0.0.1:6379/0"
feishu_base_url: str = "https://open.feishu.cn/open-apis"
feishu_app_id: str | None = None
feishu_app_secret: str | None = None
feishu_verification_token: str | None = None
feishu_encrypt_key: str | None = None
feishu_default_chat_id: str | None = None
model_provider: str = "noop"
openclaw_base_url: str = "http://127.0.0.1:18789"
openclaw_api_key: str | None = None
hermes_base_url: str = "http://127.0.0.1:8080"
hermes_api_key: str | None = None
direct_llm_base_url: str = "https://api.openai.com/v1"
direct_llm_api_key: str | None = None
direct_llm_model: str = "gpt-4.1-mini"
scheduler_enabled: bool = False
daily_brief_cron_hour: int = 9
daily_brief_cron_minute: int = 0
weekly_project_report_day_of_week: str = "mon"
weekly_project_report_cron_hour: int = 9
weekly_project_report_cron_minute: int = 30
@field_validator("cors_origins", mode="before")
@classmethod
def parse_cors_origins(cls, value: str | list[str]) -> list[str]:
if isinstance(value, list):
return value
return [item.strip() for item in value.split(",") if item.strip()]
@lru_cache
def get_settings() -> Settings:
"""Return cached application settings."""
return Settings()

51
app/core/database.py Normal file
View File

@@ -0,0 +1,51 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import get_settings
class Base(DeclarativeBase):
"""Base class for SQLAlchemy ORM models."""
pass
settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True, pool_recycle=3600)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
legacy_engine = (
create_engine(settings.legacy_database_url, pool_pre_ping=True, pool_recycle=3600)
if settings.legacy_database_url
else None
)
LegacySessionLocal = (
sessionmaker(bind=legacy_engine, autoflush=False, autocommit=False, expire_on_commit=False)
if legacy_engine
else None
)
def get_db() -> Generator[Session, None, None]:
"""Yield an application database session for FastAPI dependencies."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_legacy_db() -> Generator[Session, None, None]:
"""Yield a legacy database session when the legacy connection is configured."""
if LegacySessionLocal is None:
raise RuntimeError("LEGACY_DATABASE_URL is not configured")
db = LegacySessionLocal()
try:
yield db
finally:
db.close()

82
app/core/scheduler.py Normal file
View File

@@ -0,0 +1,82 @@
from fastapi import FastAPI
from app.core.config import get_settings
def attach_scheduler(app: FastAPI) -> None:
"""Attach optional APScheduler jobs to the FastAPI application."""
settings = get_settings()
if not settings.scheduler_enabled:
return
from apscheduler.schedulers.background import BackgroundScheduler
from app.core.database import SessionLocal
from app.modules.reports.service import ReportService
scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
def run_daily_brief() -> None:
db = SessionLocal()
try:
report = ReportService(db).daily_brief()
app.state.last_daily_brief = report
if (
settings.feishu_app_id
and settings.feishu_app_secret
and settings.feishu_default_chat_id
):
ReportService(db).push_report(
report,
receive_id=settings.feishu_default_chat_id,
receive_id_type="chat_id",
actor="scheduler",
)
finally:
db.close()
def run_project_weekly() -> None:
db = SessionLocal()
try:
report = ReportService(db).project_weekly()
app.state.last_project_weekly = report
if (
settings.feishu_app_id
and settings.feishu_app_secret
and settings.feishu_default_chat_id
):
ReportService(db).push_report(
report,
receive_id=settings.feishu_default_chat_id,
receive_id_type="chat_id",
actor="scheduler",
)
finally:
db.close()
scheduler.add_job(
run_daily_brief,
trigger="cron",
hour=settings.daily_brief_cron_hour,
minute=settings.daily_brief_cron_minute,
id="daily_brief_push",
replace_existing=True,
)
scheduler.add_job(
run_project_weekly,
trigger="cron",
day_of_week=settings.weekly_project_report_day_of_week,
hour=settings.weekly_project_report_cron_hour,
minute=settings.weekly_project_report_cron_minute,
id="project_weekly_push",
replace_existing=True,
)
@app.on_event("startup")
def start_scheduler() -> None:
scheduler.start()
@app.on_event("shutdown")
def stop_scheduler() -> None:
scheduler.shutdown(wait=False)

13
app/core/security.py Normal file
View File

@@ -0,0 +1,13 @@
from fastapi import Header, HTTPException, status
from app.core.config import get_settings
def require_api_key(x_api_key: str | None = Header(default=None)) -> None:
"""Validate the optional internal API key header."""
settings = get_settings()
if not settings.api_key:
return
if x_api_key != settings.api_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")

36
app/main.py Normal file
View File

@@ -0,0 +1,36 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
from app.core.scheduler import attach_scheduler
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
settings = get_settings()
app = FastAPI(
title=settings.app_name,
debug=settings.debug,
version="0.1.0",
description=(
"AI integration layer for company lifecycle management, existing MySQL "
"project systems, Feishu, OpenClaw, Hermes, and model providers."
),
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.api_prefix)
attach_scheduler(app)
return app
app = create_app()

View File

@@ -0,0 +1 @@
"""AI agent integration."""

View File

@@ -0,0 +1,123 @@
from abc import ABC, abstractmethod
from typing import Any
import httpx
from fastapi import HTTPException
from app.core.config import Settings, get_settings
class AIAdapter(ABC):
"""Interface for model provider adapters."""
provider_name: str
@abstractmethod
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
raise NotImplementedError
class NoopAdapter(AIAdapter):
"""Deterministic adapter used when no model provider is configured."""
provider_name = "noop"
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
return {
"answer": (
"AI provider is not configured yet. This is a deterministic placeholder. "
"Set MODEL_PROVIDER to openclaw, hermes, or direct_llm after credentials are ready."
),
"raw": {"prompt": prompt, "context": context or {}},
}
class OpenClawAdapter(AIAdapter):
"""Adapter for an OpenClaw-compatible agent endpoint."""
provider_name = "openclaw"
def __init__(self, settings: Settings):
self.settings = settings
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
url = f"{self.settings.openclaw_base_url.rstrip('/')}/api/v1/agent/ask"
headers = {}
if self.settings.openclaw_api_key:
headers["Authorization"] = f"Bearer {self.settings.openclaw_api_key}"
payload = {"prompt": prompt, "context": context or {}}
with httpx.Client(timeout=60) as client:
response = client.post(url, json=payload, headers=headers)
if response.status_code >= 400:
raise HTTPException(status_code=502, detail={"openclaw_error": response.text})
data = response.json()
return {"answer": data.get("answer") or data.get("content") or str(data), "raw": data}
class HermesAdapter(AIAdapter):
"""Adapter for a Hermes-compatible memory or agent endpoint."""
provider_name = "hermes"
def __init__(self, settings: Settings):
self.settings = settings
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
url = f"{self.settings.hermes_base_url.rstrip('/')}/api/v1/ask"
headers = {}
if self.settings.hermes_api_key:
headers["Authorization"] = f"Bearer {self.settings.hermes_api_key}"
payload = {"prompt": prompt, "context": context or {}}
with httpx.Client(timeout=60) as client:
response = client.post(url, json=payload, headers=headers)
if response.status_code >= 400:
raise HTTPException(status_code=502, detail={"hermes_error": response.text})
data = response.json()
return {"answer": data.get("answer") or data.get("content") or str(data), "raw": data}
class DirectLLMAdapter(AIAdapter):
"""Adapter for OpenAI-compatible chat completions APIs."""
provider_name = "direct_llm"
def __init__(self, settings: Settings):
self.settings = settings
def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.settings.direct_llm_api_key:
raise HTTPException(status_code=503, detail="DIRECT_LLM_API_KEY is not configured")
url = f"{self.settings.direct_llm_base_url.rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {self.settings.direct_llm_api_key}"}
messages = [
{
"role": "system",
"content": (
"You are a company management AI. Be concise, cite data from context, "
"and never approve payments, performance changes, or trades automatically."
),
},
{"role": "user", "content": f"Context:\n{context or {}}\n\nTask:\n{prompt}"},
]
payload = {"model": self.settings.direct_llm_model, "messages": messages}
with httpx.Client(timeout=60) as client:
response = client.post(url, json=payload, headers=headers)
if response.status_code >= 400:
raise HTTPException(status_code=502, detail={"llm_error": response.text})
data = response.json()
answer = data["choices"][0]["message"]["content"]
return {"answer": answer, "raw": data}
def get_adapter() -> AIAdapter:
"""Return the configured AI provider adapter."""
settings = get_settings()
provider = settings.model_provider.lower()
if provider == "openclaw":
return OpenClawAdapter(settings)
if provider == "hermes":
return HermesAdapter(settings)
if provider == "direct_llm":
return DirectLLMAdapter(settings)
return NoopAdapter()

View File

@@ -0,0 +1,38 @@
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.ai_agent.schemas import (
AIAskRequest,
AIAskResponse,
DraftPolicyRequest,
InvestmentResearchRequest,
)
from app.modules.ai_agent.service import AIService
router = APIRouter(dependencies=[Depends(require_api_key)])
@router.post("/ask", response_model=AIAskResponse)
def ask(payload: AIAskRequest, db: Session = Depends(get_db)) -> dict:
return AIService(db).ask(payload.prompt, payload.context, payload.actor, payload.source)
@router.post("/draft-policy", response_model=AIAskResponse)
def draft_policy(payload: DraftPolicyRequest, db: Session = Depends(get_db)) -> dict:
return AIService(db).draft_policy(
title=payload.title,
policy_type=payload.policy_type,
requirements=payload.requirements,
actor=payload.actor,
)
@router.post("/investment-research", response_model=AIAskResponse)
def investment_research(payload: InvestmentResearchRequest, db: Session = Depends(get_db)) -> dict:
return AIService(db).draft_investment_research(
symbol_or_topic=payload.symbol_or_topic,
risk_preference=payload.risk_preference,
actor=payload.actor,
)

View File

@@ -0,0 +1,29 @@
from typing import Any
from pydantic import BaseModel, Field
class AIAskRequest(BaseModel):
prompt: str
context: dict[str, Any] = Field(default_factory=dict)
actor: str = "api"
source: str = "api"
class AIAskResponse(BaseModel):
provider: str
answer: str
raw: dict[str, Any] = Field(default_factory=dict)
class DraftPolicyRequest(BaseModel):
title: str
policy_type: str
requirements: list[str]
actor: str = "api"
class InvestmentResearchRequest(BaseModel):
symbol_or_topic: str
risk_preference: str = "balanced"
actor: str = "api"

View File

@@ -0,0 +1,70 @@
from typing import Any
from sqlalchemy.orm import Session
from app.modules.ai_agent.adapters import get_adapter
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
class AIService:
"""Coordinate AI provider calls and audit logging."""
def __init__(self, db: Session):
self.db = db
self.audit = AuditService(db)
def ask(
self,
prompt: str,
context: dict[str, Any] | None = None,
actor: str = "api",
source: str = "api",
) -> dict[str, Any]:
adapter = get_adapter()
result = adapter.ask(prompt, context or {})
response = {
"provider": adapter.provider_name,
"answer": result["answer"],
"raw": result.get("raw", {}),
}
self.audit.log(
AuditLogCreate(
actor=actor,
source=source,
action="ai.ask",
target_type="ai",
risk_level="medium",
request_payload={"prompt": prompt, "context": context or {}},
response_payload=response,
)
)
return response
def draft_policy(
self,
title: str,
policy_type: str,
requirements: list[str],
actor: str,
) -> dict[str, Any]:
prompt = (
f"Draft a company policy in Chinese. Title: {title}. Type: {policy_type}. "
"Include purpose, scope, roles, process, approval rules, audit rules, and KPI linkage. "
f"Requirements: {requirements}"
)
return self.ask(prompt, actor=actor, source="policy")
def draft_investment_research(
self,
symbol_or_topic: str,
risk_preference: str,
actor: str,
) -> dict[str, Any]:
prompt = (
"Create an investment research memo in Chinese. Do not give direct trading "
"instructions. Include thesis, risks, data needed, position sizing constraints, "
"and human approval checklist. "
f"Topic: {symbol_or_topic}. Risk preference: {risk_preference}."
)
return self.ask(prompt, actor=actor, source="investment")

View File

@@ -0,0 +1 @@
"""Approval workflow module."""

View File

@@ -0,0 +1,29 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ApprovalRequest(Base):
__tablename__ = "approval_requests"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
ticket_id: 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)
applicant: Mapped[str] = mapped_column(String(128), default="api", index=True)
approver: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
payload: Mapped[str | None] = mapped_column(Text, nullable=True)
decision_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
)
decided_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View File

@@ -0,0 +1,38 @@
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.approvals.schemas import ApprovalCreate, ApprovalDecision, ApprovalRead
from app.modules.approvals.service import ApprovalService
router = APIRouter(dependencies=[Depends(require_api_key)])
@router.post("", response_model=ApprovalRead)
def create_approval(payload: ApprovalCreate, db: Session = Depends(get_db)):
return ApprovalService(db).create(payload)
@router.get("", response_model=list[ApprovalRead])
def list_approvals(
status: str | None = None,
limit: int = 100,
db: Session = Depends(get_db),
):
return ApprovalService(db).list(status_filter=status, limit=limit)
@router.get("/{ticket_id}", response_model=ApprovalRead)
def get_approval(ticket_id: str, db: Session = Depends(get_db)):
return ApprovalService(db).get_by_ticket(ticket_id)
@router.post("/{ticket_id}/approve", response_model=ApprovalRead)
def approve(ticket_id: str, payload: ApprovalDecision, db: Session = Depends(get_db)):
return ApprovalService(db).decide(ticket_id, payload.approver, True, payload.comment)
@router.post("/{ticket_id}/reject", response_model=ApprovalRead)
def reject(ticket_id: str, payload: ApprovalDecision, db: Session = Depends(get_db)):
return ApprovalService(db).decide(ticket_id, payload.approver, False, payload.comment)

View File

@@ -0,0 +1,37 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class ApprovalCreate(BaseModel):
domain: str
record_id: str | None = None
action: str
applicant: str = "api"
reason: str | None = None
payload: dict[str, Any] = Field(default_factory=dict)
class ApprovalDecision(BaseModel):
approver: str
comment: str | None = None
class ApprovalRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
ticket_id: str
domain: str
record_id: str | None
action: str
applicant: str
approver: str | None
status: str
reason: str | None
payload: str | None
decision_comment: str | None
created_at: datetime
updated_at: datetime
decided_at: datetime | None

View File

@@ -0,0 +1,111 @@
import json
import uuid
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.approvals.models import ApprovalRequest
from app.modules.approvals.schemas import ApprovalCreate
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
class ApprovalService:
"""Create, decide, and validate approval tickets for guarded actions."""
def __init__(self, db: Session):
self.db = db
self.audit = AuditService(db)
def create(self, payload: ApprovalCreate) -> ApprovalRequest:
ticket = ApprovalRequest(
ticket_id=f"APR-{uuid.uuid4().hex[:12].upper()}",
domain=payload.domain,
record_id=payload.record_id,
action=payload.action,
applicant=payload.applicant,
reason=payload.reason,
payload=json.dumps(payload.payload, ensure_ascii=False, default=str),
)
self.db.add(ticket)
self.db.commit()
self.db.refresh(ticket)
self.audit.log(
AuditLogCreate(
actor=payload.applicant,
source="approval",
action="approval.create",
target_type=payload.domain,
target_id=payload.record_id,
risk_level="medium",
request_payload=payload.model_dump(),
response_payload={"ticket_id": ticket.ticket_id, "status": ticket.status},
)
)
return ticket
def list(self, status_filter: str | None = None, limit: int = 100) -> list[ApprovalRequest]:
stmt = select(ApprovalRequest).order_by(ApprovalRequest.id.desc()).limit(min(limit, 500))
if status_filter:
stmt = stmt.where(ApprovalRequest.status == status_filter)
return list(self.db.execute(stmt).scalars())
def get_by_ticket(self, ticket_id: str) -> ApprovalRequest:
ticket = self.db.execute(
select(ApprovalRequest).where(ApprovalRequest.ticket_id == ticket_id)
).scalar_one_or_none()
if ticket is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Approval ticket not found",
)
return ticket
def decide(
self,
ticket_id: str,
approver: str,
approved: bool,
comment: str | None,
) -> ApprovalRequest:
ticket = self.get_by_ticket(ticket_id)
if ticket.status != "pending":
raise HTTPException(status_code=409, detail="Approval ticket already decided")
ticket.status = "approved" if approved else "rejected"
ticket.approver = approver
ticket.decision_comment = comment
from datetime import datetime
ticket.decided_at = datetime.utcnow()
self.db.commit()
self.db.refresh(ticket)
self.audit.log(
AuditLogCreate(
actor=approver,
source="approval",
action="approval.approve" if approved else "approval.reject",
target_type=ticket.domain,
target_id=ticket.record_id,
risk_level="high",
request_payload={"ticket_id": ticket_id, "comment": comment},
response_payload={"status": ticket.status},
)
)
return ticket
def is_approved_for(
self,
ticket_id: str,
domain: str,
record_id: str | int | None,
action: str,
) -> bool:
ticket = self.get_by_ticket(ticket_id)
if ticket.status != "approved":
return False
if ticket.domain != domain:
return False
if ticket.record_id and record_id is not None and str(ticket.record_id) != str(record_id):
return False
return ticket.action in {action, "update", f"update:{domain}", "*"}

View File

@@ -0,0 +1 @@
"""Audit module."""

View File

@@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
actor: Mapped[str] = mapped_column(String(128), default="system", index=True)
source: Mapped[str] = mapped_column(String(64), default="api", index=True)
action: Mapped[str] = mapped_column(String(128), index=True)
target_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
target_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
risk_level: Mapped[str] = mapped_column(String(32), default="low", index=True)
request_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
response_payload: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(32), default="success", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)

View File

@@ -0,0 +1,14 @@
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.audit.schemas import AuditLogRead
from app.modules.audit.service import AuditService
router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("/logs", response_model=list[AuditLogRead])
def list_audit_logs(limit: int = 100, db: Session = Depends(get_db)) -> list:
return AuditService(db).list_logs(limit=limit)

View File

@@ -0,0 +1,32 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
class AuditLogCreate(BaseModel):
actor: str = "system"
source: str = "api"
action: str
target_type: str | None = None
target_id: str | None = None
risk_level: str = "low"
request_payload: Any | None = None
response_payload: Any | None = None
status: str = "success"
class AuditLogRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
actor: str
source: str
action: str
target_type: str | None
target_id: str | None
risk_level: str
request_payload: str | None
response_payload: str | None
status: str
created_at: datetime

View File

@@ -0,0 +1,46 @@
import json
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.audit.models import AuditLog
from app.modules.audit.schemas import AuditLogCreate
def _dump(value: Any | None) -> str | None:
"""Serialize audit payloads while preserving existing strings."""
if value is None:
return None
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False, default=str)
class AuditService:
"""Persist and query audit log entries."""
def __init__(self, db: Session):
self.db = db
def log(self, payload: AuditLogCreate) -> AuditLog:
record = AuditLog(
actor=payload.actor,
source=payload.source,
action=payload.action,
target_type=payload.target_type,
target_id=payload.target_id,
risk_level=payload.risk_level,
request_payload=_dump(payload.request_payload),
response_payload=_dump(payload.response_payload),
status=payload.status,
)
self.db.add(record)
self.db.commit()
self.db.refresh(record)
return record
def list_logs(self, limit: int = 100) -> list[AuditLog]:
stmt = select(AuditLog).order_by(AuditLog.id.desc()).limit(min(limit, 500))
return list(self.db.execute(stmt).scalars())

View File

@@ -0,0 +1 @@
"""Business lifecycle module."""

View 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)

View 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]

View 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}

View 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]]

View 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

View File

@@ -0,0 +1 @@
"""Feishu integration."""

View File

@@ -0,0 +1,91 @@
import json
import time
from typing import Any
import httpx
from fastapi import HTTPException
from app.core.config import get_settings
class FeishuClient:
"""Small Feishu Open Platform client for tenant token and message APIs."""
def __init__(self) -> None:
self.settings = get_settings()
self._tenant_access_token: str | None = None
self._token_expires_at: float = 0
def _is_configured(self) -> bool:
return bool(self.settings.feishu_app_id and self.settings.feishu_app_secret)
def _get_tenant_access_token(self) -> str:
if not self._is_configured():
raise HTTPException(status_code=503, detail="Feishu app credentials are not configured")
if self._tenant_access_token and time.time() < self._token_expires_at:
return self._tenant_access_token
url = f"{self.settings.feishu_base_url}/auth/v3/tenant_access_token/internal"
payload = {
"app_id": self.settings.feishu_app_id,
"app_secret": self.settings.feishu_app_secret,
}
with httpx.Client(timeout=20) as client:
response = client.post(url, json=payload)
response.raise_for_status()
data = response.json()
if data.get("code") != 0:
raise HTTPException(status_code=502, detail={"feishu_error": data})
self._tenant_access_token = data["tenant_access_token"]
self._token_expires_at = time.time() + int(data.get("expire", 7200)) - 300
return self._tenant_access_token
def send_message(
self,
receive_id: str,
receive_id_type: str,
msg_type: str,
content: dict[str, Any],
) -> dict[str, Any]:
token = self._get_tenant_access_token()
url = f"{self.settings.feishu_base_url}/im/v1/messages"
headers = {"Authorization": f"Bearer {token}"}
params = {"receive_id_type": receive_id_type}
payload = {
"receive_id": receive_id,
"msg_type": msg_type,
"content": json.dumps(content, ensure_ascii=False),
}
with httpx.Client(timeout=20) as client:
response = client.post(url, headers=headers, params=params, json=payload)
response.raise_for_status()
data = response.json()
return data
def send_text(
self,
text: str,
receive_id: str | None = None,
receive_id_type: str = "chat_id",
) -> dict:
chat_id = receive_id or self.settings.feishu_default_chat_id
if not chat_id:
raise HTTPException(
status_code=400,
detail="receive_id or FEISHU_DEFAULT_CHAT_ID is required",
)
return self.send_message(chat_id, receive_id_type, "text", {"text": text})
def send_card(
self,
card: dict[str, Any],
receive_id: str | None = None,
receive_id_type: str = "chat_id",
) -> dict:
chat_id = receive_id or self.settings.feishu_default_chat_id
if not chat_id:
raise HTTPException(
status_code=400,
detail="receive_id or FEISHU_DEFAULT_CHAT_ID is required",
)
return self.send_message(chat_id, receive_id_type, "interactive", card)

View File

@@ -0,0 +1,176 @@
import json
import re
from typing import Any
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.modules.ai_agent.service import AIService
from app.modules.feishu.service import FeishuService
from app.modules.reports.service import ReportService
from app.modules.risk.service import RiskService
def _parse_content_text(content: Any) -> str:
"""Extract plain command text from a Feishu message content payload."""
if isinstance(content, dict):
return str(content.get("text") or content.get("content") or "")
if not isinstance(content, str):
return ""
try:
data = json.loads(content)
except json.JSONDecodeError:
return content
if isinstance(data, dict):
return str(data.get("text") or data.get("content") or "")
return content
def _clean_command_text(text: str) -> str:
"""Remove mentions and invisible characters from Feishu command text."""
text = re.sub(r"@\S+", "", text or "")
text = text.replace("\u200b", "")
return text.strip()
class FeishuCommandService:
"""Route Feishu text commands to reports, risk summaries, or AI replies."""
def __init__(self, db: Session):
self.db = db
self.feishu = FeishuService(db)
def extract_event_command(self, payload: dict[str, Any]) -> dict[str, Any] | None:
event = payload.get("event") or {}
message = event.get("message") or {}
if not message:
return None
text = _clean_command_text(_parse_content_text(message.get("content")))
if not text:
return None
sender = event.get("sender") or {}
sender_id = sender.get("sender_id") or {}
actor = sender_id.get("open_id") or sender_id.get("user_id") or "feishu"
return {
"text": text,
"chat_id": message.get("chat_id"),
"actor": actor,
}
def handle_text(
self,
text: str,
chat_id: str | None = None,
actor: str = "feishu",
auto_reply: bool = True,
) -> dict[str, Any]:
command_text = _clean_command_text(text)
lowered = command_text.lower()
provider_response: dict[str, Any] | None = None
if any(keyword in command_text for keyword in ["日报", "晨报", "经营日报", "经营晨报"]):
report = ReportService(self.db).daily_brief()
result = {
"command": "daily_brief",
"reply_type": "card",
"title": report["title"],
"content": report["content"],
"lines": report["lines"],
}
if auto_reply:
provider_response = self._send_card_if_configured(
chat_id,
report["title"],
report["lines"],
actor,
)
result["provider_response"] = provider_response
return result
if any(keyword in command_text for keyword in ["周报", "项目周报"]):
report = ReportService(self.db).project_weekly()
result = {
"command": "project_weekly",
"reply_type": "card",
"title": report["title"],
"content": report["content"],
"lines": report["lines"],
}
if auto_reply:
provider_response = self._send_card_if_configured(
chat_id,
report["title"],
report["lines"],
actor,
)
result["provider_response"] = provider_response
return result
if any(keyword in command_text for keyword in ["风险", "预警", "risk"]):
summary = RiskService(self.db).summary()
lines = [
f"- 综合风险等级:{summary['risk_level']}",
f"- 风险分:{summary['risk_score']}",
f"- 逾期任务:{len(summary['overdue_tasks'])}",
f"- 延期项目:{len(summary['delayed_projects'])}",
f"- 超预算项目:{len(summary['over_budget_projects'])}",
f"- 资金风险账户:{len(summary['fund_risks'])}",
]
result = {
"command": "risk_summary",
"reply_type": "card",
"title": "风险预警",
"content": "\n".join(lines),
"lines": lines,
}
if auto_reply:
provider_response = self._send_card_if_configured(chat_id, "风险预警", lines, actor)
result["provider_response"] = provider_response
return result
prompt = command_text
for prefix in ["", "ai ", "AI ", "/ask "]:
if command_text.startswith(prefix):
prompt = command_text[len(prefix) :].strip()
break
if not prompt:
prompt = "请说明你能做什么。"
ai_result = AIService(self.db).ask(prompt, context={}, actor=actor, source="feishu")
content = ai_result["answer"]
is_explicit_ai = lowered.startswith(("ai ", "/ask")) or command_text.startswith("")
result = {
"command": "ai_ask" if is_explicit_ai else "fallback_ai",
"reply_type": "text",
"title": "AI 回复",
"content": content,
}
if auto_reply:
provider_response = self._send_text_if_configured(chat_id, content, actor)
result["provider_response"] = provider_response
return result
def _send_card_if_configured(
self,
chat_id: str | None,
title: str,
lines: list[str],
actor: str,
) -> dict[str, Any] | None:
settings = get_settings()
if not (settings.feishu_app_id and settings.feishu_app_secret):
return None
card = FeishuService.build_basic_card(title, lines)
return self.feishu.send_card(card, receive_id=chat_id, actor=actor)
def _send_text_if_configured(
self,
chat_id: str | None,
text: str,
actor: str,
) -> dict[str, Any] | None:
settings = get_settings()
if not (settings.feishu_app_id and settings.feishu_app_secret):
return None
return self.feishu.send_text(text, receive_id=chat_id, actor=actor)

View File

@@ -0,0 +1,83 @@
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_api_key
from app.modules.audit.schemas import AuditLogCreate
from app.modules.feishu.commands import FeishuCommandService
from app.modules.feishu.schemas import (
FeishuCardMessage,
FeishuCommandRequest,
FeishuCommandResult,
FeishuSendResult,
FeishuTextMessage,
)
from app.modules.feishu.service import FeishuService
router = APIRouter()
@router.post("/webhook")
async def feishu_webhook(request: Request, db: Session = Depends(get_db)) -> dict:
"""Handle Feishu webhook challenge and text command events."""
payload = await request.json()
service = FeishuService(db)
service.verify_event(payload)
if payload.get("challenge"):
return {"challenge": payload["challenge"]}
service.audit.log(
AuditLogCreate(
actor="feishu",
source="feishu",
action="webhook_event",
request_payload=payload,
response_payload={"accepted": True},
)
)
command = FeishuCommandService(db).extract_event_command(payload)
if not command:
return {"ok": True, "handled": False}
result = FeishuCommandService(db).handle_text(
command["text"],
chat_id=command["chat_id"],
actor=command["actor"],
auto_reply=True,
)
return {"ok": True, "handled": True, "result": result}
@router.post("/send-text", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
def send_text(payload: FeishuTextMessage, db: Session = Depends(get_db)) -> dict:
result = FeishuService(db).send_text(
payload.text,
receive_id=payload.receive_id,
receive_id_type=payload.receive_id_type,
)
return {"ok": result.get("code") == 0, "provider_response": result}
@router.post("/send-card", response_model=FeishuSendResult, dependencies=[Depends(require_api_key)])
def send_card(payload: FeishuCardMessage, db: Session = Depends(get_db)) -> dict:
result = FeishuService(db).send_card(
payload.card,
receive_id=payload.receive_id,
receive_id_type=payload.receive_id_type,
)
return {"ok": result.get("code") == 0, "provider_response": result}
@router.post(
"/commands/preview",
response_model=FeishuCommandResult,
dependencies=[Depends(require_api_key)],
)
def preview_command(payload: FeishuCommandRequest, db: Session = Depends(get_db)) -> dict:
"""Preview local Feishu command routing without requiring webhook delivery."""
return FeishuCommandService(db).handle_text(
payload.text,
chat_id=payload.chat_id,
actor=payload.actor,
auto_reply=payload.auto_reply,
)

View File

@@ -0,0 +1,47 @@
from typing import Any
from pydantic import BaseModel, Field
class FeishuTextMessage(BaseModel):
receive_id: str | None = Field(
default=None,
description="chat_id or open_id depending on type.",
)
receive_id_type: str = "chat_id"
text: str
class FeishuCardMessage(BaseModel):
receive_id: str | None = None
receive_id_type: str = "chat_id"
card: dict[str, Any]
class FeishuWebhookEvent(BaseModel):
type: str | None = None
challenge: str | None = None
token: str | None = None
schema_: str | None = Field(default=None, alias="schema")
header: dict[str, Any] | None = None
event: dict[str, Any] | None = None
class FeishuSendResult(BaseModel):
ok: bool
provider_response: dict[str, Any]
class FeishuCommandRequest(BaseModel):
text: str
chat_id: str | None = None
actor: str = "api"
auto_reply: bool = False
class FeishuCommandResult(BaseModel):
command: str
reply_type: str
title: str
content: str
provider_response: dict[str, Any] | None = None

View File

@@ -0,0 +1,84 @@
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.feishu.client import FeishuClient
class FeishuService:
"""Send Feishu messages and record audit entries for outbound actions."""
def __init__(self, db: Session):
self.db = db
self.audit = AuditService(db)
self.client = FeishuClient()
def verify_event(self, payload: dict[str, Any]) -> None:
settings = get_settings()
expected = settings.feishu_verification_token
token = payload.get("token")
if expected and token and token != expected:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Feishu token",
)
def send_text(
self,
text: str,
receive_id: str | None = None,
receive_id_type: str = "chat_id",
actor: str = "system",
) -> dict[str, Any]:
result = self.client.send_text(text, receive_id, receive_id_type)
self.audit.log(
AuditLogCreate(
actor=actor,
source="feishu",
action="send_text",
request_payload={
"receive_id": receive_id,
"receive_id_type": receive_id_type,
"text": text,
},
response_payload=result,
)
)
return result
def send_card(
self,
card: dict[str, Any],
receive_id: str | None = None,
receive_id_type: str = "chat_id",
actor: str = "system",
) -> dict[str, Any]:
result = self.client.send_card(card, receive_id, receive_id_type)
self.audit.log(
AuditLogCreate(
actor=actor,
source="feishu",
action="send_card",
request_payload={
"receive_id": receive_id,
"receive_id_type": receive_id_type,
"card": card,
},
response_payload=result,
)
)
return result
@staticmethod
def build_basic_card(title: str, lines: list[str]) -> dict[str, Any]:
return {
"config": {"wide_screen_mode": True},
"header": {"title": {"tag": "plain_text", "content": title}},
"elements": [
{"tag": "div", "text": {"tag": "lark_md", "content": "\n".join(lines) or "暂无数据"}}
],
}

View File

@@ -0,0 +1 @@
"""Existing MySQL integration."""

View File

@@ -0,0 +1,50 @@
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.legacy_mysql.schemas import (
LegacyProjectSyncRequest,
LegacyProjectSyncResult,
QueryResult,
ReadonlyQueryRequest,
)
from app.modules.legacy_mysql.service import LegacyMySQLService
router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("/health")
def mysql_health(db: Session = Depends(get_db)) -> dict[str, str]:
return LegacyMySQLService(db).health()
@router.get("/tables")
def list_tables(db: Session = Depends(get_db)) -> dict[str, list[str]]:
return {"tables": LegacyMySQLService(db).list_tables()}
@router.get("/tables/{table_name}")
def describe_table(table_name: str, db: Session = Depends(get_db)) -> dict:
return {"table": table_name, "columns": LegacyMySQLService(db).describe_table(table_name)}
@router.post("/query", response_model=QueryResult)
def readonly_query(payload: ReadonlyQueryRequest, db: Session = Depends(get_db)) -> dict:
return LegacyMySQLService(db).execute_readonly(payload.sql, payload.params, payload.limit)
@router.get("/projects", response_model=QueryResult)
def default_project_query(limit: int = 100, db: Session = Depends(get_db)) -> dict:
return LegacyMySQLService(db).fetch_default_projects(limit=limit)
@router.post("/projects/sync", response_model=LegacyProjectSyncResult)
def sync_projects(payload: LegacyProjectSyncRequest, db: Session = Depends(get_db)) -> dict:
return LegacyMySQLService(db).sync_projects(
source_query=payload.source_query,
field_map=payload.field_map,
limit=payload.limit,
dry_run=payload.dry_run,
actor=payload.actor,
)

View File

@@ -0,0 +1,54 @@
from typing import Any
from pydantic import BaseModel, Field
class ReadonlyQueryRequest(BaseModel):
"""Readonly SQL query request for the legacy MySQL connection."""
sql: str = Field(..., description="Readonly SELECT statement.")
params: dict[str, Any] = Field(default_factory=dict)
limit: int = Field(default=100, ge=1, le=500)
class QueryResult(BaseModel):
"""Tabular query result returned as JSON rows."""
columns: list[str]
rows: list[dict[str, Any]]
row_count: int
class LegacyProjectRecord(BaseModel):
"""Raw project row from the legacy project system."""
data: dict[str, Any]
class LegacyProjectSyncRequest(BaseModel):
"""Request body for syncing legacy projects into the internal ledger."""
source_query: str | None = Field(
default=None,
description="Optional SELECT query for project sync.",
)
field_map: dict[str, str] = Field(
default_factory=dict,
description=(
"Map internal project fields to legacy row fields, "
"e.g. {'name': 'project_name'}."
),
)
limit: int = Field(default=100, ge=1, le=500)
dry_run: bool = True
actor: str = "api"
class LegacyProjectSyncResult(BaseModel):
"""Summary of a legacy project sync operation."""
dry_run: bool
created: int
updated: int
skipped: int
items: list[dict[str, Any]]

View File

@@ -0,0 +1,277 @@
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import inspect, text
from sqlalchemy.engine import Engine, RowMapping
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import legacy_engine
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.models import Project
from app.modules.business.service import serialize_model
FORBIDDEN_SQL_TOKENS = {
"insert",
"update",
"delete",
"drop",
"alter",
"truncate",
"create",
"replace",
"grant",
"revoke",
}
def _jsonable(value: Any) -> Any:
"""Convert database scalar values into JSON-friendly values."""
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
return value
def _row_to_dict(row: RowMapping) -> dict[str, Any]:
"""Convert a SQLAlchemy row mapping to a serializable dictionary."""
return {key: _jsonable(value) for key, value in row.items()}
class LegacyMySQLService:
"""Read legacy MySQL data and sync projects into the internal ledger."""
def __init__(self, db: Session | None):
self.db = db
@staticmethod
def _ensure_engine() -> Engine:
if legacy_engine is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="LEGACY_DATABASE_URL is not configured",
)
return legacy_engine
@staticmethod
def _ensure_readonly(sql: str) -> None:
stripped = sql.strip().lower()
if not stripped.startswith("select"):
raise HTTPException(status_code=400, detail="Only SELECT statements are allowed")
tokens = {token.strip(" ;,\n\t") for token in stripped.replace("(", " ").split()}
if tokens & FORBIDDEN_SQL_TOKENS:
raise HTTPException(status_code=400, detail="Forbidden SQL token in readonly query")
def health(self) -> dict[str, str]:
engine = self._ensure_engine()
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
except SQLAlchemyError as exc:
raise HTTPException(status_code=503, detail=f"MySQL connection failed: {exc}") from exc
return {"status": "ok"}
def list_tables(self) -> list[str]:
engine = self._ensure_engine()
return sorted(inspect(engine).get_table_names())
def describe_table(self, table_name: str) -> list[dict[str, Any]]:
engine = self._ensure_engine()
inspector = inspect(engine)
if table_name not in inspector.get_table_names():
raise HTTPException(status_code=404, detail="Table not found")
columns = []
for column in inspector.get_columns(table_name):
columns.append(
{
"name": column["name"],
"type": str(column["type"]),
"nullable": column.get("nullable", True),
"default": (
str(column.get("default"))
if column.get("default") is not None
else None
),
}
)
return columns
def execute_readonly(
self,
sql: str,
params: dict[str, Any] | None = None,
limit: int = 100,
) -> dict[str, Any]:
self._ensure_readonly(sql)
engine = self._ensure_engine()
params = dict(params or {})
params.setdefault("limit", min(limit, 500))
limited_sql = sql
if " limit " not in sql.lower():
limited_sql = f"{sql.rstrip(';')} LIMIT :limit"
with engine.connect() as conn:
result = conn.execute(text(limited_sql), params)
rows = [_row_to_dict(row) for row in result.mappings().all()]
columns = list(rows[0].keys()) if rows else []
return {"columns": columns, "rows": rows, "row_count": len(rows)}
def fetch_default_projects(self, limit: int = 100) -> dict[str, Any]:
settings = get_settings()
if not settings.legacy_project_query:
raise HTTPException(
status_code=400,
detail="LEGACY_PROJECT_QUERY is not configured. Configure it in .env first.",
)
return self.execute_readonly(settings.legacy_project_query, {"limit": limit}, limit=limit)
@staticmethod
def _value(
row: dict[str, Any],
field_map: dict[str, str],
internal_name: str,
fallback: Any = None,
) -> Any:
source_name = field_map.get(internal_name, internal_name)
if source_name in row:
return row[source_name]
return fallback
def _project_payload(self, row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]:
settings = get_settings()
external_id = self._value(row, field_map, "external_id", row.get("id"))
raw_code = self._value(row, field_map, "code", None)
code = str(raw_code) if raw_code else f"{settings.legacy_project_code_prefix}-{external_id}"
return {
"code": code,
"external_id": str(external_id) if external_id is not None else code,
"source_system": "legacy_mysql",
"name": self._value(row, field_map, "name", "未命名项目"),
"owner": self._value(row, field_map, "owner", None),
"status": self._value(row, field_map, "status", "未知"),
"progress_percent": int(
self._value(
row,
field_map,
"progress_percent",
row.get("progress") or 0,
)
or 0
),
"start_date": self._value(row, field_map, "start_date", None),
"due_date": self._value(row, field_map, "due_date", None),
"budget_amount": (
self._value(row, field_map, "budget_amount", row.get("budget") or 0) or 0
),
"actual_amount": (
self._value(row, field_map, "actual_amount", row.get("actual_cost") or 0) or 0
),
"description": self._value(row, field_map, "description", None),
}
def sync_projects(
self,
source_query: str | None = None,
field_map: dict[str, str] | None = None,
limit: int = 100,
dry_run: bool = True,
actor: str = "api",
) -> dict[str, Any]:
if self.db is None:
raise HTTPException(
status_code=503,
detail="Application database session is not available",
)
settings = get_settings()
query = source_query or settings.legacy_project_query
if not query:
raise HTTPException(status_code=400, detail="Project sync query is not configured")
rows = self.execute_readonly(query, {"limit": limit}, limit=limit)["rows"]
field_map = field_map or {}
created = 0
updated = 0
skipped = 0
items: list[dict[str, Any]] = []
for row in rows:
payload = self._project_payload(row, field_map)
if not payload["external_id"] and not payload["code"]:
skipped += 1
items.append(
{
"action": "skipped",
"reason": "missing external_id/code",
"source": row,
}
)
continue
stmt = select(Project).where(
Project.source_system == "legacy_mysql",
Project.external_id == payload["external_id"],
)
record = self.db.execute(stmt).scalar_one_or_none()
if record is None:
record = self.db.execute(
select(Project).where(Project.code == payload["code"])
).scalar_one_or_none()
if record is None:
created += 1
action = "create"
result = payload
if not dry_run:
record = Project(**payload)
self.db.add(record)
self.db.flush()
result = serialize_model(record)
else:
updated += 1
action = "update"
if not dry_run:
for key, value in payload.items():
setattr(record, key, value)
self.db.flush()
result = serialize_model(record)
else:
result = payload
items.append({"action": action, "project": result, "source": row})
if not dry_run:
self.db.commit()
result = {
"dry_run": dry_run,
"created": created,
"updated": updated,
"skipped": skipped,
"items": items,
}
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source="legacy_mysql",
action="sync_projects",
target_type="projects",
risk_level="medium",
request_payload={
"source_query": source_query or "LEGACY_PROJECT_QUERY",
"field_map": field_map,
"limit": limit,
"dry_run": dry_run,
},
response_payload={
key: result[key] for key in ["dry_run", "created", "updated", "skipped"]
},
)
)
return result

View File

@@ -0,0 +1 @@
"""Report module."""

View File

@@ -0,0 +1,41 @@
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.reports.schemas import PushReportRequest, ReportResponse
from app.modules.reports.service import ReportService
router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("/daily-brief", response_model=ReportResponse)
def daily_brief(db: Session = Depends(get_db)) -> dict:
return ReportService(db).daily_brief()
@router.get("/project-weekly", response_model=ReportResponse)
def project_weekly(db: Session = Depends(get_db)) -> dict:
return ReportService(db).project_weekly()
@router.post("/daily-brief/push")
def push_daily_brief(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict:
report = ReportService(db).daily_brief()
return ReportService(db).push_report(
report,
payload.receive_id,
payload.receive_id_type,
payload.actor,
)
@router.post("/project-weekly/push")
def push_project_weekly(payload: PushReportRequest, db: Session = Depends(get_db)) -> dict:
report = ReportService(db).project_weekly()
return ReportService(db).push_report(
report,
payload.receive_id,
payload.receive_id_type,
payload.actor,
)

View File

@@ -0,0 +1,13 @@
from pydantic import BaseModel
class ReportResponse(BaseModel):
title: str
content: str
lines: list[str]
class PushReportRequest(BaseModel):
receive_id: str | None = None
receive_id_type: str = "chat_id"
actor: str = "system"

View File

@@ -0,0 +1,96 @@
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.modules.business.models import Expense, FundAccount, Procurement, Project, WorkTask
from app.modules.feishu.service import FeishuService
from app.modules.risk.service import RiskService
def _money(value: Decimal | int | float | None) -> str:
"""Format a numeric value as a two-decimal money string."""
amount = Decimal(value or 0)
return f"{amount:,.2f}"
class ReportService:
"""Build operational reports and push them through Feishu."""
def __init__(self, db: Session):
self.db = db
self.risks = RiskService(db)
def daily_brief(self) -> dict:
project_count = int(
self.db.execute(select(func.count()).select_from(Project)).scalar() or 0
)
task_count = int(self.db.execute(select(func.count()).select_from(WorkTask)).scalar() or 0)
procurement_pending = int(
self.db.execute(
select(func.count())
.select_from(Procurement)
.where(Procurement.approval_status.in_(["草稿", "审批中", "待审批"]))
).scalar()
or 0
)
expense_pending = int(
self.db.execute(
select(func.count())
.select_from(Expense)
.where(Expense.approval_status.in_(["草稿", "审批中", "待审批"]))
).scalar()
or 0
)
fund_total = (
self.db.execute(select(func.sum(FundAccount.current_balance))).scalar()
or Decimal("0")
)
risk_summary = self.risks.summary()
lines = [
f"- 项目总数:{project_count}",
f"- 任务总数:{task_count}",
f"- 待处理采购:{procurement_pending}",
f"- 待处理费用:{expense_pending}",
f"- 当前账户总余额:{_money(fund_total)}",
f"- 逾期任务:{len(risk_summary['overdue_tasks'])}",
f"- 延期项目:{len(risk_summary['delayed_projects'])}",
f"- 超预算项目:{len(risk_summary['over_budget_projects'])}",
f"- 资金风险账户:{len(risk_summary['fund_risks'])}",
f"- 综合风险等级:{risk_summary['risk_level']}",
]
return {"title": "每日经营晨报", "lines": lines, "content": "\n".join(lines)}
def project_weekly(self) -> dict:
active = int(
self.db.execute(
select(func.count())
.select_from(Project)
.where(Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭"]))
).scalar()
or 0
)
delayed = self.risks.delayed_projects()
over_budget = self.risks.over_budget_projects()
lines = [
f"- 活跃项目:{active}",
f"- 延期项目:{len(delayed)}",
f"- 超预算项目:{len(over_budget)}",
"- 需要管理层关注:",
]
for item in delayed[:10]:
lines.append(f" - 延期:{item.get('code')} {item.get('name')},负责人 {item.get('owner')}")
for item in over_budget[:10]:
lines.append(f" - 超预算:{item.get('code')} {item.get('name')}")
return {"title": "项目周报", "lines": lines, "content": "\n".join(lines)}
def push_report(
self,
report: dict,
receive_id: str | None,
receive_id_type: str,
actor: str,
) -> dict:
card = FeishuService.build_basic_card(report["title"], report["lines"])
return FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)

View File

@@ -0,0 +1 @@
"""Risk detection module."""

View File

@@ -0,0 +1,33 @@
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.risk.service import RiskService
router = APIRouter(dependencies=[Depends(require_api_key)])
@router.get("/summary")
def risk_summary(db: Session = Depends(get_db)) -> dict:
return RiskService(db).summary()
@router.get("/overdue-tasks")
def overdue_tasks(db: Session = Depends(get_db)) -> dict:
return {"items": RiskService(db).overdue_tasks()}
@router.get("/delayed-projects")
def delayed_projects(db: Session = Depends(get_db)) -> dict:
return {"items": RiskService(db).delayed_projects()}
@router.get("/over-budget-projects")
def over_budget_projects(db: Session = Depends(get_db)) -> dict:
return {"items": RiskService(db).over_budget_projects()}
@router.get("/funds")
def fund_risks(db: Session = Depends(get_db)) -> dict:
return {"items": RiskService(db).fund_risks()}

View File

@@ -0,0 +1,69 @@
from datetime import date
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.business.models import FundAccount, Project, WorkTask
from app.modules.business.service import serialize_model
class RiskService:
"""Evaluate rule-based business risk signals from internal ledgers."""
def __init__(self, db: Session):
self.db = db
def overdue_tasks(self) -> list[dict[str, Any]]:
stmt = select(WorkTask).where(
WorkTask.due_date.is_not(None),
WorkTask.due_date < date.today(),
WorkTask.status.notin_(["完成", "已完成", "关闭"]),
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def delayed_projects(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.due_date.is_not(None),
Project.due_date < date.today(),
Project.status.notin_(["验收", "已完成", "复盘", "归档", "关闭"]),
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def over_budget_projects(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.budget_amount > 0,
Project.actual_amount > Project.budget_amount,
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def fund_risks(self) -> list[dict[str, Any]]:
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def summary(self) -> dict[str, Any]:
overdue_tasks = self.overdue_tasks()
delayed_projects = self.delayed_projects()
over_budget_projects = self.over_budget_projects()
fund_risks = self.fund_risks()
risk_score = (
len(overdue_tasks) * 1
+ len(delayed_projects) * 3
+ len(over_budget_projects) * 4
+ len(fund_risks) * 5
)
if risk_score >= 15:
level = "high"
elif risk_score >= 5:
level = "medium"
else:
level = "low"
return {
"risk_level": level,
"risk_score": Decimal(risk_score),
"overdue_tasks": overdue_tasks,
"delayed_projects": delayed_projects,
"over_budget_projects": over_budget_projects,
"fund_risks": fund_risks,
}

1
app/tools/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Operational scripts."""

37
app/tools/init_db.py Normal file
View File

@@ -0,0 +1,37 @@
from app.core.database import Base, engine
from app.modules.approvals.models import ApprovalRequest
from app.modules.audit.models import AuditLog
from app.modules.business.models import (
Expense,
FundAccount,
PerformanceMetric,
Policy,
Procurement,
Project,
Standard,
Supplier,
WorkTask,
)
_MODELS = [
ApprovalRequest,
AuditLog,
Project,
WorkTask,
Procurement,
Expense,
FundAccount,
Policy,
Standard,
PerformanceMetric,
Supplier,
]
def main() -> None:
Base.metadata.create_all(bind=engine)
print("Database schema initialized.")
if __name__ == "__main__":
main()