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:
@@ -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
@@ -5,7 +5,7 @@ from sqlalchemy import select
|
||||
from app.config import settings
|
||||
from app.database import engine, Base, async_session
|
||||
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.models.system_config import SystemConfig
|
||||
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(
|
||||
"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)
|
||||
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(work_plans.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(dashboard.router, prefix="/api")
|
||||
app.include_router(leaves.router, prefix="/api")
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.models.daily_note import DailyNote
|
||||
from app.models.ai_summary import AISummary
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.leave import Leave
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"Visit",
|
||||
"WorkPlan",
|
||||
"MiniBusiness",
|
||||
"MiniBusinessLog",
|
||||
"KeyVisit",
|
||||
"DailyNote",
|
||||
"AISummary",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
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 app.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.mini_business_log import MiniBusinessLog
|
||||
|
||||
|
||||
class MiniBusiness(Base):
|
||||
__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)
|
||||
expected_revenue_date: Mapped[str] = mapped_column(String(50), default="")
|
||||
edit_log: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
|
||||
logs: Mapped[list["MiniBusinessLog"]] = relationship(back_populates="business", cascade="all, delete-orphan")
|
||||
|
||||
@@ -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")
|
||||
@@ -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,4 +13,17 @@ export const miniBusinessApi = {
|
||||
delete(id: string) {
|
||||
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}`)
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user