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.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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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) {
|
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}`)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user