feat: 商机跟单新增跟进记录子表(后端+API模块)

- 新增 mini_business_logs 表(business_id/log_date/method/content/created_by)
- MiniBusiness 模型添加 logs relationship(cascade delete)
- 5个端点: GET列表/POST新增/PUT编辑/DELETE删除
- 跟进方式: 电话/微信/上门/邮件/其他
- lifespan 自动建表+索引

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-12 18:51:04 +08:00
parent 13f8b9c512
commit 17a0218b32
7 changed files with 248 additions and 2 deletions
+143
View File
@@ -0,0 +1,143 @@
"""MiniBusinessLog CRUD API — follow-up log entries for mini business opportunities."""
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.models.mini_business import MiniBusiness
from app.models.mini_business_log import MiniBusinessLog
from app.models.user import User
from app.schemas.mini_business_log import (
MiniBusinessLogCreate, MiniBusinessLogUpdate, MiniBusinessLogOut,
)
router = APIRouter(prefix="/mini-business", tags=["MiniBusiness Logs"])
async def _get_log(db: AsyncSession, log_id: str) -> MiniBusinessLog:
result = await db.execute(select(MiniBusinessLog).where(MiniBusinessLog.id == log_id))
log = result.scalar_one_or_none()
if not log:
raise HTTPException(status_code=404, detail="跟进记录不存在")
return log
@router.get("/{business_id}/logs")
async def list_logs(
business_id: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""List follow-up logs for a mini business, newest first."""
# Verify business exists and user has access
mb = await db.get(MiniBusiness, business_id)
if not mb:
raise HTTPException(status_code=404, detail="商机不存在")
if current_user["role"] == "manager" and str(mb.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
result = await db.execute(
select(MiniBusinessLog)
.where(MiniBusinessLog.business_id == business_id)
.order_by(MiniBusinessLog.log_date.desc(), MiniBusinessLog.created_at.desc())
)
logs = result.scalars().all()
# Resolve creator names
user_ids = {log.created_by for log in logs}
users = (await db.execute(select(User).where(User.id.in_(user_ids)))).scalars().all()
user_map = {u.id: u.name for u in users}
return [
{
"id": log.id,
"business_id": log.business_id,
"log_date": str(log.log_date),
"method": log.method,
"content": log.content,
"created_by": log.created_by,
"created_by_name": user_map.get(log.created_by, ""),
"created_at": str(log.created_at) if log.created_at else None,
}
for log in logs
]
@router.post("/{business_id}/logs")
async def create_log(
business_id: str,
data: MiniBusinessLogCreate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
"""Add a follow-up log entry."""
mb = await db.get(MiniBusiness, business_id)
if not mb:
raise HTTPException(status_code=404, detail="商机不存在")
if current_user["role"] == "manager" and str(mb.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
log = MiniBusinessLog(
business_id=uuid.UUID(business_id),
log_date=data.log_date,
method=data.method,
content=data.content,
created_by=uuid.UUID(current_user["user_id"]),
)
db.add(log)
await db.commit()
await db.refresh(log)
creator = await db.get(User, log.created_by)
return {
"id": log.id,
"business_id": log.business_id,
"log_date": str(log.log_date),
"method": log.method,
"content": log.content,
"created_by": log.created_by,
"created_by_name": creator.name if creator else "",
"created_at": str(log.created_at) if log.created_at else None,
}
@router.put("/logs/{log_id}")
async def update_log(
log_id: str,
data: MiniBusinessLogUpdate,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
"""Update a follow-up log entry."""
log = await _get_log(db, log_id)
# Managers can only edit their own logs; directors/leaders can edit any
if current_user["role"] == "manager" and str(log.created_by) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
update_data = data.model_dump(exclude_unset=True)
for k, v in update_data.items():
setattr(log, k, v)
await db.commit()
await db.refresh(log)
return {"id": log.id, "log_date": str(log.log_date), "method": log.method, "content": log.content}
@router.delete("/logs/{log_id}")
async def delete_log(
log_id: str,
current_user: dict = Depends(require_any_role),
db: AsyncSession = Depends(get_db),
):
"""Delete a follow-up log entry."""
log = await _get_log(db, log_id)
if current_user["role"] == "manager" and str(log.created_by) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
await db.delete(log)
await db.commit()
return {"status": "deleted"}
+20 -1
View File
@@ -5,7 +5,7 @@ from sqlalchemy import select
from app.config import settings from app.config import settings
from app.database import engine, Base, async_session from app.database import engine, Base, async_session
from app.api import router as api_router from app.api import router as api_router
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits from app.api import auth, users, customers, visits, work_plans, mini_business, mini_business_logs, key_visits
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
from app.services.holidays import refresh_holidays from app.services.holidays import refresh_holidays
@@ -74,6 +74,24 @@ async def lifespan(app: FastAPI):
await conn.run_sync(lambda c: c.exec_driver_sql( await conn.run_sync(lambda c: c.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_leaves_dates ON leaves(start_date, end_date)" "CREATE INDEX IF NOT EXISTS idx_leaves_dates ON leaves(start_date, end_date)"
)) ))
# mini_business_logs table for v0.8
await conn.run_sync(lambda c: c.exec_driver_sql(
"CREATE TABLE IF NOT EXISTS mini_business_logs ("
" id UUID PRIMARY KEY DEFAULT gen_random_uuid(),"
" business_id UUID NOT NULL REFERENCES mini_business(id) ON DELETE CASCADE,"
" log_date DATE NOT NULL,"
" method VARCHAR(20) NOT NULL DEFAULT '电话',"
" content TEXT NOT NULL DEFAULT '',"
" created_by UUID NOT NULL REFERENCES users(id),"
" created_at TIMESTAMPTZ DEFAULT now()"
")"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_mbl_business_id ON mini_business_logs(business_id)"
))
await conn.run_sync(lambda c: c.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS idx_mbl_log_date ON mini_business_logs(log_date)"
))
# Read notification_time from DB (or use default 17:30) # Read notification_time from DB (or use default 17:30)
notification_hour, notification_minute = 17, 30 notification_hour, notification_minute = 17, 30
@@ -123,6 +141,7 @@ app.include_router(customers.router, prefix="/api")
app.include_router(visits.router, prefix="/api") app.include_router(visits.router, prefix="/api")
app.include_router(work_plans.router, prefix="/api") app.include_router(work_plans.router, prefix="/api")
app.include_router(mini_business.router, prefix="/api") app.include_router(mini_business.router, prefix="/api")
app.include_router(mini_business_logs.router, prefix="/api")
app.include_router(key_visits.router, prefix="/api") app.include_router(key_visits.router, prefix="/api")
app.include_router(dashboard.router, prefix="/api") app.include_router(dashboard.router, prefix="/api")
app.include_router(leaves.router, prefix="/api") app.include_router(leaves.router, prefix="/api")
+2
View File
@@ -10,6 +10,7 @@ from app.models.daily_note import DailyNote
from app.models.ai_summary import AISummary from app.models.ai_summary import AISummary
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
from app.models.leave import Leave from app.models.leave import Leave
from app.models.mini_business_log import MiniBusinessLog
__all__ = [ __all__ = [
"User", "User",
@@ -19,6 +20,7 @@ __all__ = [
"Visit", "Visit",
"WorkPlan", "WorkPlan",
"MiniBusiness", "MiniBusiness",
"MiniBusinessLog",
"KeyVisit", "KeyVisit",
"DailyNote", "DailyNote",
"AISummary", "AISummary",
+7 -1
View File
@@ -1,9 +1,13 @@
import uuid import uuid
from typing import TYPE_CHECKING
from sqlalchemy import String, Text, ForeignKey from sqlalchemy import String, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base from app.database import Base
if TYPE_CHECKING:
from app.models.mini_business_log import MiniBusinessLog
class MiniBusiness(Base): class MiniBusiness(Base):
__tablename__ = "mini_business" __tablename__ = "mini_business"
@@ -17,3 +21,5 @@ class MiniBusiness(Base):
manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True) manager_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), index=True)
expected_revenue_date: Mapped[str] = mapped_column(String(50), default="") expected_revenue_date: Mapped[str] = mapped_column(String(50), default="")
edit_log: Mapped[list] = mapped_column(JSONB, default=list) edit_log: Mapped[list] = mapped_column(JSONB, default=list)
logs: Mapped[list["MiniBusinessLog"]] = relationship(back_populates="business", cascade="all, delete-orphan")
+30
View File
@@ -0,0 +1,30 @@
"""MiniBusinessLog — follow-up log entries for mini business opportunities."""
import uuid
from datetime import date, datetime
from sqlalchemy import String, Date, DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class MiniBusinessLog(Base):
__tablename__ = "mini_business_logs"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
business_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mini_business.id", ondelete="CASCADE"), index=True
)
log_date: Mapped[date] = mapped_column(Date)
method: Mapped[str] = mapped_column(String(20), default="电话")
content: Mapped[str] = mapped_column(Text, default="")
created_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
business: Mapped["MiniBusiness"] = relationship(back_populates="logs")
+33
View File
@@ -0,0 +1,33 @@
"""MiniBusinessLog Pydantic schemas."""
from datetime import date, datetime
from uuid import UUID
from typing import Optional
from pydantic import BaseModel, Field
FOLLOW_UP_METHODS = ["电话", "微信", "上门", "邮件", "其他"]
class MiniBusinessLogCreate(BaseModel):
log_date: date
method: str = Field(default="电话", pattern="^(电话|微信|上门|邮件|其他)$")
content: str = ""
class MiniBusinessLogUpdate(BaseModel):
log_date: Optional[date] = None
method: Optional[str] = Field(default=None, pattern="^(电话|微信|上门|邮件|其他)$")
content: Optional[str] = None
class MiniBusinessLogOut(BaseModel):
id: UUID
business_id: UUID
log_date: date
method: str
content: str
created_by: UUID
created_by_name: str = ""
created_at: datetime | None = None
model_config = {"from_attributes": True}
+13
View File
@@ -13,4 +13,17 @@ export const miniBusinessApi = {
delete(id: string) { delete(id: string) {
return api.delete(`/mini-business/${id}`) return api.delete(`/mini-business/${id}`)
}, },
// Follow-up logs
getLogs(businessId: string) {
return api.get(`/mini-business/${businessId}/logs`)
},
createLog(businessId: string, data: any) {
return api.post(`/mini-business/${businessId}/logs`, data)
},
updateLog(logId: string, data: any) {
return api.put(`/mini-business/logs/${logId}`, data)
},
deleteLog(logId: string) {
return api.delete(`/mini-business/logs/${logId}`)
},
} }