v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成

- Docker bridge 网络隔离(8000 端口封死)
- Gunicorn 4 Worker 多进程
- Alembic 数据库迁移基线
- 日志轮转 20m×3
- JWT 密钥 + DB 密码 + CORS 收紧
- 3-2-1 备份链路(NAS + R740-B 冷备)
- 连接池 pool_pre_ping + pool_recycle=3600
This commit is contained in:
hankin
2026-03-16 07:31:37 +00:00
commit 423baff73b
2578 changed files with 824643 additions and 0 deletions
View File
+100
View File
@@ -0,0 +1,100 @@
"""
Auth 路由 —— /api/auth/login & /api/auth/me
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.exceptions import BizException, UnauthorizedException
from app.core.security import create_access_token, hash_password, verify_password
from app.db.database import get_db
from app.models.sys import SysUser
from app.schemas.auth import (
CurrentUserPayload,
LoginRequest,
TokenResponse,
UpdatePasswordRequest,
)
from app.schemas.response import ok
router = APIRouter(prefix="/auth", tags=["鉴权"])
@router.post("/login", summary="账号密码登录,签发 JWT")
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)) -> dict:
# 1. 查询用户
stmt = select(SysUser).where(
SysUser.username == body.username,
SysUser.is_deleted.is_(False),
)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise BizException(code=401, message="用户名或密码错误")
# 2. 校验密码
if not verify_password(body.password, user.password_hash):
raise BizException(code=401, message="用户名或密码错误")
# 3. 检查账号状态
if user.status != 1:
raise BizException(code=403, message="账号已被禁用,请联系管理员")
# 4. 签发 Tokensub 存 user_id 字符串)
access_token = create_access_token(data={"sub": str(user.id)})
# 5. 刷新最后登录时间
await db.execute(
update(SysUser)
.where(SysUser.id == user.id)
.values(last_login_at=datetime.utcnow())
)
await db.commit()
return ok(
data=TokenResponse(access_token=access_token).model_dump(),
message="登录成功",
)
@router.get("/me", summary="获取当前登录用户信息(验证 Token 有效性)")
async def get_me(
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
return ok(data=current_user.model_dump(mode="json"))
@router.put("/password", summary="当前用户修改密码")
async def change_password(
body: UpdatePasswordRequest,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
# 1. 查出用户记录
stmt = select(SysUser).where(SysUser.id == current_user.user_id)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise BizException(code=404, message="用户不存在")
# 2. 校验旧密码
if not verify_password(body.old_password, user.password_hash):
raise BizException(code=400, message="旧密码错误")
# 3. 哈希新密码并更新
await db.execute(
update(SysUser)
.where(SysUser.id == current_user.user_id)
.values(password_hash=hash_password(body.new_password), updated_at=datetime.utcnow())
)
await db.commit()
return ok(message="密码修改成功,请重新登录")
+434
View File
@@ -0,0 +1,434 @@
"""
AI 智能助手路由 —— /api/chat
- POST /stream: 流式对话(SSE
- GET /mcp/tools: 返回已注册的 MCP 工具清单
- POST /mcp/execute: 执行指定 MCP 工具
- POST /action-card/callback: Action Card 确认/取消回调
"""
from __future__ import annotations
import asyncio
import json
import re
import uuid
from typing import Any
from fastapi import APIRouter, Body, Depends
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.action_card import ActionCardCallback
from app.schemas.response import ok
# 导入 MCP 注册中心(导入 tools 模块会触发 @register_tool 装饰器注册)
from app.mcp.registry import get_tools_manifest, execute_tool, MCPToolResult
import app.mcp.tools # noqa: F401 — 触发工具注册
from app.core.action_card_queue import pop_cards
# 导入 service 层(用于 action card 回调真正执行)
from app.services import customer_service, order_service
from app.schemas.crm import CustomerCreate
from app.schemas.order import OrderCreate
router = APIRouter(prefix="/chat", tags=["AI 智能助手"])
class ChatRequest(BaseModel):
message: str
conversation_id: str = "" # Dify 会话 ID,空则新建会话
# ── Dify 可用性探测 ──────────────────────────────────────
async def _check_dify_available() -> bool:
"""检测 Dify API 是否可用(2s 超时),探测根路径即可"""
from app.core.config import settings
if not settings.DIFY_API_BASE_URL or not settings.DIFY_API_KEY:
return False
try:
import httpx
async with httpx.AsyncClient(timeout=2.0) as client:
resp = await client.get(settings.DIFY_API_BASE_URL)
return resp.status_code < 500 # 2xx/3xx/4xx 都说明服务存活
except Exception:
return False
# ── <think> 标签流式过滤器 ────────────────────────────────
class _ThinkFilter:
"""
状态机:跨多个 SSE chunk 过滤 <think>...</think>。
流式场景下标签可能被拆分到不同 chunk 中到达。
"""
def __init__(self):
self._inside = False # 是否正处于 <think> 块内
self._buf = "" # 缓冲可能是不完整标签的尾部
def feed(self, text: str) -> str:
"""输入一段文本,返回过滤后应输出的部分"""
out = []
self._buf += text
while self._buf:
if self._inside:
# 在 think 块内,找 </think> 结束标签
end_pos = self._buf.lower().find("</think>")
if end_pos >= 0:
# 找到了,跳过 </think> 本身(8 字符)
self._buf = self._buf[end_pos + 8:]
self._inside = False
elif len(self._buf) > 8 and "</think>" not in self._buf.lower():
# 确认不含部分标签,整块吞掉
# 保留最后 8 字符以防 </think> 跨 chunk
self._buf = self._buf[-8:]
break
else:
# 缓冲区太短或可能含部分标签,等下一个 chunk
break
else:
# 不在 think 块内,找 <think> 开始标签
start_pos = self._buf.lower().find("<think>")
if start_pos >= 0:
# <think> 之前的内容输出
out.append(self._buf[:start_pos])
# 跳过 <think> 本身(7 字符),进入 inside 状态
self._buf = self._buf[start_pos + 7:]
self._inside = True
elif len(self._buf) > 7:
# 安全输出,保留最后 7 字符防跨 chunk 的 <think>
out.append(self._buf[:-7])
self._buf = self._buf[-7:]
break
else:
break
return "".join(out)
# ── Dify 流式转发生成器 ──────────────────────────────────
async def _dify_stream_generator(message: str, user_id: str, conversation_id: str = ""):
"""将用户消息转发到 Dify chat-messages API,流式返回"""
from app.core.config import settings
import httpx
url = f"{settings.DIFY_API_BASE_URL}/v1/chat-messages"
headers = {
"Authorization": f"Bearer {settings.DIFY_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"inputs": {},
"query": message,
"response_mode": "streaming",
"conversation_id": conversation_id,
"user": user_id,
}
try:
async with httpx.AsyncClient(timeout=settings.DIFY_TIMEOUT_MS / 1000) as client:
async with client.stream("POST", url, json=payload, headers=headers) as resp:
if resp.status_code != 200:
error_text = ""
async for chunk in resp.aiter_text():
error_text += chunk
yield f"data: {json.dumps({'type': 'text', 'content': f'Dify 返回错误 ({resp.status_code}): {error_text[:200]}'}, ensure_ascii=False)}\n\n"
return
# 用原始 text 做 SSE 解析,因为 aiter_lines 可能丢失 event 行
buf = ""
think_filter = _ThinkFilter() # 跨 chunk 过滤 <think> 标签
message_ended = False # 标记文本流是否结束
async for chunk in resp.aiter_text():
buf += chunk
# 按双换行分割完整 SSE 事件
while "\n\n" in buf:
event_block, buf = buf.split("\n\n", 1)
data_line = ""
for line in event_block.split("\n"):
if line.startswith("data: "):
data_line = line[6:]
if not data_line or data_line.strip() == "[DONE]":
continue
try:
event = json.loads(data_line)
except json.JSONDecodeError:
continue
event_type = event.get("event", "")
# ── Chatflow 模式:message 事件
if event_type == "message" and not message_ended:
answer = think_filter.feed(event.get("answer", ""))
if answer:
yield f"data: {json.dumps({'type': 'text', 'content': answer}, ensure_ascii=False)}\n\n"
# ── Agent 模式:agent_message 事件(最终文本回复)
elif event_type == "agent_message" and not message_ended:
answer = think_filter.feed(event.get("answer", ""))
if answer:
yield f"data: {json.dumps({'type': 'text', 'content': answer}, ensure_ascii=False)}\n\n"
# ── Agent 模式:agent_thought — 提取工具调用的 action_card
elif event_type == "agent_thought":
observation = event.get("observation", "")
if observation:
try:
obs_data = json.loads(observation)
for tool_key, tool_val in (obs_data.items() if isinstance(obs_data, dict) else []):
inner = tool_val
if isinstance(tool_val, str):
try:
inner = json.loads(tool_val)
except json.JSONDecodeError:
continue
if not isinstance(inner, dict):
continue
result_data = inner.get("data", inner)
if isinstance(result_data, dict):
resp_type = result_data.get("response_type", "")
result_inner = result_data.get("result", {})
if resp_type == "action_card" and isinstance(result_inner, dict):
yield f"data: {json.dumps({'type': 'action_card', 'content': result_inner.get('summary', '请确认操作'), 'card': result_inner}, ensure_ascii=False)}\n\n"
print(f"[Dify SSE] ✅ 注入 action_card: {result_inner.get('card_type', 'unknown')}")
except (json.JSONDecodeError, TypeError) as e:
print(f"[Dify SSE] observation 解析失败: {e}")
# ── message_end / 结束 — 不再 break,设置标记继续处理剩余事件
elif event_type in ("message_end", "agent_message_end"):
conv_id = event.get("conversation_id", "")
if conv_id:
yield f"data: {json.dumps({'type': 'conversation_id', 'conversation_id': conv_id}, ensure_ascii=False)}\n\n"
# 检查工具端点是否推送了 action_card
cards = await pop_cards()
for card in cards:
yield f"data: {json.dumps({'type': 'action_card', 'content': card.get('summary', '请确认操作'), 'card': card}, ensure_ascii=False)}\n\n"
print(f"[Dify SSE] ✅ 注入 action_card: {card.get('card_type', 'unknown')}")
message_ended = True
# ── 错误
elif event_type == "error":
err_msg = event.get("message", "未知错误")
yield f"data: {json.dumps({'type': 'text', 'content': f'\\n⚠️ Dify 错误: {err_msg}'}, ensure_ascii=False)}\n\n"
return # 出错直接返回
# ── 流结束后,刷新 buf 中可能剩余的事件(重要:agent_thought 可能在 message_end 之后)
if buf.strip():
for remaining_block in buf.split("\n\n"):
remaining_block = remaining_block.strip()
if not remaining_block:
continue
data_line = ""
for line in remaining_block.split("\n"):
if line.startswith("data: "):
data_line = line[6:]
if not data_line or data_line.strip() == "[DONE]":
continue
try:
event = json.loads(data_line)
if event.get("event") == "agent_thought":
observation = event.get("observation", "")
if observation:
obs_data = json.loads(observation)
for tool_key, tool_val in (obs_data.items() if isinstance(obs_data, dict) else []):
inner = tool_val
if isinstance(tool_val, str):
try:
inner = json.loads(tool_val)
except json.JSONDecodeError:
continue
if not isinstance(inner, dict):
continue
result_data = inner.get("data", inner)
if isinstance(result_data, dict) and result_data.get("response_type") == "action_card":
result_inner = result_data.get("result", {})
if isinstance(result_inner, dict):
yield f"data: {json.dumps({'type': 'action_card', 'content': result_inner.get('summary', '请确认操作'), 'card': result_inner}, ensure_ascii=False)}\n\n"
print(f"[Dify SSE] ✅ 注入 action_card (post-flush): {result_inner.get('card_type', 'unknown')}")
except (json.JSONDecodeError, TypeError):
pass
except httpx.TimeoutException:
yield f"data: {json.dumps({'type': 'text', 'content': '\n⚠️ Dify 响应超时,请稍后重试'}, ensure_ascii=False)}\n\n"
except Exception as e:
print(f"[Dify SSE] 异常: {e!s}")
yield f"data: {json.dumps({'type': 'text', 'content': f'\n⚠️ Dify 连接失败: {e!s}'}, ensure_ascii=False)}\n\n"
# ── Mock 流式生成器(降级用) ────────────────────────────
async def _mock_stream_generator(message: str, user_id: str):
"""Dify 不可用时的降级 mock 回复"""
await asyncio.sleep(0.3)
notice = "⚠️ AI 引擎暂不可用,当前为本地模拟回复。\n\n"
for char in notice:
yield f"data: {json.dumps({'type': 'text', 'content': char}, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.02)
reply = f"收到指令,正在解析:【{message}】...\n来自用户: {user_id}\n"
for char in reply:
yield f"data: {json.dumps({'type': 'text', 'content': char}, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.04)
# ── 1. POST /api/chat/stream — 流式对话(含意图网关) ────
@router.post("/stream", summary="流式对话接口(SSE")
async def chat_stream(
body: ChatRequest,
current_user: CurrentUserPayload = Depends(get_current_user),
):
user_id = str(current_user.user_id)
print(f"[AI Chat] user_id={user_id}, scope={current_user.data_scope}, msg='{body.message}'")
# Step 1: 意图分类(4060 Qwen3.5-4B5s 超时)
from app.services.intent_service import classify_intent
intent_result = await classify_intent(body.message)
intent = intent_result.get("intent", "general")
route = intent_result.get("route", "dify_agent")
print(f"[AI Chat] 意图网关: intent={intent}, route={route}")
# Step 2: 根据意图路由到不同后端
dify_available = await _check_dify_available()
if route == "dify_agent" and dify_available:
generator = _dify_stream_generator(body.message, user_id, body.conversation_id)
elif route == "dify_workflow_report" and dify_available:
# 周报走 Workflow,非流式,包装成 SSE
generator = _report_workflow_generator(body.message, user_id)
elif dify_available:
generator = _dify_stream_generator(body.message, user_id, body.conversation_id)
else:
generator = _mock_stream_generator(body.message, user_id)
return StreamingResponse(generator, media_type="text/event-stream")
# ── 周报 Workflow 生成器 ───────────────────────────
async def _report_workflow_generator(message: str, user_id: str):
"""调用周报 Workflow 并把结果包装成 SSE 流"""
from app.core.config import settings
import httpx
yield f"data: {json.dumps({'type': 'text', 'content': '📊 正在调用 AI 生成周报,请稍候...\n\n'}, ensure_ascii=False)}\n\n"
if not settings.DIFY_WORKFLOW_REPORT_KEY:
yield f"data: {json.dumps({'type': 'text', 'content': '⚠️ 周报 Workflow 未配置,请联系管理员。'}, ensure_ascii=False)}\n\n"
return
url = f"{settings.DIFY_API_BASE_URL}/v1/workflows/run"
headers = {
"Authorization": f"Bearer {settings.DIFY_WORKFLOW_REPORT_KEY}",
"Content-Type": "application/json",
}
payload = {
"inputs": {"user_id": user_id, "request": message},
"response_mode": "blocking",
"user": user_id,
}
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(url, json=payload, headers=headers)
if resp.status_code == 200:
data = resp.json()
output_text = data.get("data", {}).get("outputs", {}).get("text", "周报生成完成,但未获取到内容。")
yield f"data: {json.dumps({'type': 'text', 'content': output_text}, ensure_ascii=False)}\n\n"
else:
yield f"data: {json.dumps({'type': 'text', 'content': f'⚠️ 周报 Workflow 返回错误 ({resp.status_code})'}, ensure_ascii=False)}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'text', 'content': f'⚠️ 周报生成失败: {e}'}, ensure_ascii=False)}\n\n"
# ── 2. GET /api/chat/mcp/tools — 工具清单 ───────────────
@router.get("/mcp/tools", summary="获取已注册 MCP 工具列表(供 Dify 配置)")
async def list_mcp_tools(
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
return ok(data=get_tools_manifest())
# ── 3. POST /api/chat/mcp/execute — 执行工具 ────────────
class MCPExecuteRequest(BaseModel):
tool_name: str
params: dict[str, Any] = {}
@router.post("/mcp/execute", summary="执行指定 MCP 工具(Dify function_call 回调)")
async def execute_mcp_tool(
body: MCPExecuteRequest,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
print(f"[MCP Execute] user_id={current_user.user_id}, tool={body.tool_name}, params={body.params}")
result: MCPToolResult = await execute_tool(body.tool_name, db, current_user, body.params)
return ok(data={
"success": result.success,
"response_type": result.response_type,
"data": result.data,
"message": result.message,
})
# ── 4. POST /api/chat/action-card/callback — 卡片回调 ──
@router.post("/action-card/callback", summary="Action Card 确认/取消回调")
async def action_card_callback(
body: ActionCardCallback,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
print(f"[Action Card] user_id={current_user.user_id}, type={body.card_type}, action={body.action_key}")
if body.action_key == "cancel":
return ok(message="操作已取消")
# 根据 card_type 路由到具体 service
if body.card_type == "create_customer":
p = body.params
result = await customer_service.create_customer(
db, current_user,
CustomerCreate(
name=p.get("name", ""),
level=p.get("level", "C"),
contact=p.get("contact"),
phone=p.get("phone"),
),
)
return ok(data=result.model_dump(mode="json"), message="客户创建成功")
elif body.card_type == "create_order":
from datetime import date
p = body.params
items_raw = p.get("items", [])
from app.schemas.order import OrderItemCreate
items = [
OrderItemCreate(
sku_id=uuid.UUID(i["sku_id"]),
qty=i["qty"],
unit_price=i["unit_price"],
)
for i in items_raw
]
result = await order_service.create_order(
db, current_user,
OrderCreate(
customer_id=uuid.UUID(p["customer_id"]),
items=items,
remark=p.get("remark"),
order_date=date.today(),
),
)
return ok(data=result.model_dump(mode="json"), message=f"订单 {result.order_no} 创建成功")
return ok(message=f"未知的卡片类型: {body.card_type}")
# ── 5. GET /api/chat/history — 对话历史 ──────────────────
@router.get("/history", summary="获取当前用户的 AI 对话历史")
async def get_chat_history(
limit: int = 50,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
from app.services import chat_service
history = await chat_service.load_history(db, current_user.user_id, limit=limit)
return ok(data=history)
+72
View File
@@ -0,0 +1,72 @@
"""
联系人 API 路由 — /api/customers/{cid}/contacts & /api/contacts/{id}
V5.0: 实现客户下联系人的完整 CRUD
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Body
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.response import ok
from app.services import contact_service
router = APIRouter(tags=["联系人"])
@router.get("/customers/{customer_id}/contacts", summary="列出客户下所有联系人")
async def list_contacts(
customer_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
items = await contact_service.list_contacts(db, customer_id)
return ok(data=items)
@router.post("/customers/{customer_id}/contacts", summary="新增联系人")
async def create_contact(
customer_id: uuid.UUID,
name: str = Body(..., embed=True),
phone: str | None = Body(None, embed=True),
title: str | None = Body(None, embed=True),
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await contact_service.create_contact(
db, customer_id, {"name": name, "phone": phone, "title": title}
)
return ok(data=result, message="联系人创建成功")
@router.put("/contacts/{contact_id}", summary="编辑联系人")
async def update_contact(
contact_id: uuid.UUID,
name: str | None = Body(None, embed=True),
phone: str | None = Body(None, embed=True),
title: str | None = Body(None, embed=True),
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
data = {}
if name is not None:
data["name"] = name
if phone is not None:
data["phone"] = phone
if title is not None:
data["title"] = title
result = await contact_service.update_contact(db, contact_id, data)
return ok(data=result, message="联系人更新成功")
@router.delete("/contacts/{contact_id}", summary="删除联系人 (软删除)")
async def delete_contact(
contact_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
await contact_service.delete_contact(db, contact_id)
return ok(message="联系人已删除")
+286
View File
@@ -0,0 +1,286 @@
"""
CRM 客户模块路由 —— /api/customers
薄路由层:参数解析 + 调用 Service + 包装响应
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.crm import CustomerCreate, CustomerUpdate
from app.schemas.response import ok
from app.services import customer_service as svc
router = APIRouter(prefix="/customers", tags=["客户管理"])
@router.post("", summary="新增客户")
async def create_customer(
body: CustomerCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.create_customer(db, current_user, body)
return ok(data=result.model_dump(mode="json"), message="客户创建成功")
@router.get("", summary="分页获取客户列表(含数据权限隔离)")
async def list_customers(
page: int = Query(1, ge=1, description="页码"),
size: int = Query(20, ge=1, le=100, description="每页数量"),
keyword: str | None = Query(None, description="名称模糊搜索"),
level: str | None = Query(None, pattern=r"^[ABC]$", description="客户等级"),
include_archived: bool = Query(False, description="是否包含已归档客户"),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.list_customers(db, current_user, page, size, keyword, level, include_archived)
return ok(data=result.model_dump(mode="json"))
@router.get("/search", summary="模糊搜索客户(远程选择器用)")
async def search_customers(
q: str = Query(..., min_length=1, max_length=100, description="搜索关键词"),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.search_customers(db, current_user, q)
return ok(data=result)
@router.get("/{customer_id}", summary="获取客户详情")
async def get_customer(
customer_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.get_customer(db, current_user, customer_id)
return ok(data=result.model_dump(mode="json"))
@router.put("/{customer_id}", summary="修改客户信息")
async def update_customer(
customer_id: uuid.UUID,
body: CustomerUpdate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.update_customer(db, current_user, customer_id, body)
return ok(data=result.model_dump(mode="json"), message="客户信息已更新")
@router.delete("/{customer_id}", summary="软删除客户")
async def delete_customer(
customer_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
await svc.delete_customer(db, current_user, customer_id)
return ok(message="客户已归档")
@router.put("/{customer_id}/restore", summary="恢复已归档客户")
async def restore_customer(
customer_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
await svc.restore_customer(db, current_user, customer_id)
return ok(message="客户已恢复")
@router.get("/{customer_id}/products", summary="获取客户关联产品(通过订单反查)")
async def get_customer_products(
customer_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.get_customer_products(db, current_user, customer_id)
return ok(data=result)
@router.put("/{customer_id}/persona", summary="双轨画像回写(Dify Workflow 回调)")
async def update_customer_persona(
customer_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db),
) -> dict:
"""
V5.0 双轨画像回写 — 容错解析 Dify Workflow 回调 body。
支持以下格式:
1. 标准 JSON: {"company_updates": {...}, "contact_updates": [...]}
2. Dify 空 key 包装: {"": "JSON字符串"}
3. Qwen3.5 CoT 包裹: <think>...</think> + JSON
"""
import json as _json
import re
raw = await request.body()
raw_str = raw.decode("utf-8", errors="replace").strip()
# ── 统一预处理:去除 <think>...</think> 标签 ──
cleaned = re.sub(r'<think>[\s\S]*?</think>', '', raw_str, flags=re.DOTALL).strip()
# 也处理可能缺少闭合的 <think>
cleaned = re.sub(r'<think>[\s\S]*$', '', cleaned, flags=re.DOTALL).strip()
body = None
# 策略1:清理后直接 JSON 解析
try:
parsed = _json.loads(cleaned)
if isinstance(parsed, dict):
body = parsed
elif isinstance(parsed, list):
# 从数组中找到含画像 key 的 dict
for item in parsed:
if isinstance(item, dict) and len(item) > 1:
body = item
break
except _json.JSONDecodeError:
pass
# 策略2Dify 空 key 包装 {"": "...JSON..."}
if not body:
try:
wrapped = _json.loads(raw_str)
if isinstance(wrapped, dict):
values = list(wrapped.values())
if len(values) == 1 and isinstance(values[0], str):
inner = re.sub(r'<think>[\s\S]*?</think>', '', values[0], flags=re.DOTALL).strip()
try:
body = _json.loads(inner)
except _json.JSONDecodeError:
pass
except _json.JSONDecodeError:
pass
# 策略3:正则从文本中提取最外层 JSON 对象 {...}
if not body or not isinstance(body, dict):
# 找到第一个 { 到最后一个 } 之间的内容
m = re.search(r'\{', cleaned)
if m:
start = m.start()
# 用计数法匹配完整的 JSON 对象
depth = 0
end = start
for i in range(start, len(cleaned)):
if cleaned[i] == '{': depth += 1
elif cleaned[i] == '}': depth -= 1
if depth == 0:
end = i + 1
break
if depth == 0:
try:
body = _json.loads(cleaned[start:end])
except _json.JSONDecodeError:
pass
if not body or not isinstance(body, dict):
print(f"[Persona] 无法解析 body ({len(raw_str)} chars): {cleaned[:300]}")
return ok(message="画像回写失败:无法解析请求体")
print(f"[Persona] 解析成功,keys={list(body.keys())}")
from app.models.crm import CrmCustomer, CrmContact
from sqlalchemy import update as sa_update
# Key 模糊映射:LLM 可能不严格遵循 key name
_COMPANY_KEYS = {"company_updates", "company_info", "firmographics", "company", "企业画像"}
_CONTACT_KEYS = {"contact_updates", "contacts", "contact_info", "buyer_updates", "联系人画像"}
company_updates = None
contact_updates = None
for k, v in body.items():
kl = k.lower().strip()
print(f"[Persona] key='{k}' type={type(v).__name__}")
if kl in _COMPANY_KEYS or "company" in kl or "firm" in kl:
if isinstance(v, dict):
company_updates = v
elif isinstance(v, str):
try:
company_updates = _json.loads(v)
except _json.JSONDecodeError:
company_updates = {"summary": v}
elif kl in _CONTACT_KEYS or "contact" in kl:
if isinstance(v, list):
contact_updates = v
elif isinstance(v, dict):
contact_updates = [v]
elif isinstance(v, str):
try:
parsed_contacts = _json.loads(v)
contact_updates = parsed_contacts if isinstance(parsed_contacts, list) else [parsed_contacts]
except _json.JSONDecodeError:
pass
# 如果 company_updates 没有标准结构但有 firmographics/dynamic_status 在顶层
if not company_updates and ("firmographics" in body or "dynamic_status" in body):
company_updates = {
"firmographics": body.get("firmographics", {}),
"dynamic_status": body.get("dynamic_status", {}),
}
# 如果完全没匹配到 company key,但 body 本身看起来就是画像数据,直接用整个 body
if not company_updates and not contact_updates:
company_updates = body
print(f"[Persona] company={'' if company_updates else ''}, contacts={len(contact_updates) if contact_updates else 0}")
# ── 企业级画像合并 ──
if company_updates:
customer = await db.get(CrmCustomer, customer_id)
if customer:
merged = _deep_merge(customer.ai_persona or {}, company_updates)
stmt = (
sa_update(CrmCustomer)
.where(CrmCustomer.id == customer_id)
.values(ai_persona=merged)
)
await db.execute(stmt)
# ── 联系人级画像合并 ──
if contact_updates and isinstance(contact_updates, list):
for cu in contact_updates:
cid = cu.get("contact_id")
if not cid:
continue
try:
contact = await db.get(CrmContact, uuid.UUID(cid))
except (ValueError, TypeError):
continue
if not contact:
continue
updates = {k: v for k, v in cu.items() if k != "contact_id" and v is not None}
merged = _deep_merge(contact.ai_buyer_persona or {}, updates)
contact.ai_buyer_persona = merged
await db.commit()
return ok(message="画像更新成功")
def _deep_merge(base: dict, override: dict) -> dict:
"""递归深度合并两个 dict(增量模式)。
规则:
- dict + dict → 递归合并
- list + list → 追加去重
- 新值为空(空字符串/空列表/空dict/None)→ 保留旧值
- 新值非空 → 覆盖旧值
"""
result = base.copy()
for k, v in override.items():
old = result.get(k)
# 空值不覆盖已有数据
if old and (v is None or v == "" or v == [] or v == {}):
continue
if isinstance(old, dict) and isinstance(v, dict):
result[k] = _deep_merge(old, v)
elif isinstance(old, list) and isinstance(v, list):
# 列表去重追加
seen = set(str(x) for x in old)
result[k] = old + [x for x in v if str(x) not in seen]
else:
result[k] = v
return result
+74
View File
@@ -0,0 +1,74 @@
"""
Dashboard 统计 API — /api/dashboard
"""
from __future__ import annotations
from datetime import date, datetime
from fastapi import APIRouter, Depends
from sqlalchemy import func, select, and_, extract
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.response import ok
from app.models.order import ErpOrder
from app.models.shipping import ErpShippingRecord
from app.models.erp import ProductSku
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
@router.get("/stats", summary="工作台统计数据")
async def get_stats(
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
):
today = date.today()
month_start = today.replace(day=1)
# 本月新增订单数
orders_count_q = select(func.count()).select_from(ErpOrder).where(
and_(
ErpOrder.is_deleted.is_(False),
ErpOrder.order_date >= month_start,
)
)
orders_count = (await db.execute(orders_count_q)).scalar() or 0
# 待出库发货数(状态为 pending)
pending_shipping_q = select(func.count()).select_from(ErpOrder).where(
and_(
ErpOrder.is_deleted.is_(False),
ErpOrder.shipping_state == "pending",
)
)
pending_shipping = (await db.execute(pending_shipping_q)).scalar() or 0
# 库存预警 SKU 数(stock_qty <= warning_threshold 且 warning_threshold > 0
warning_skus_q = select(func.count()).select_from(ProductSku).where(
and_(
ProductSku.is_deleted.is_(False),
ProductSku.warning_threshold > 0,
ProductSku.stock_qty <= ProductSku.warning_threshold,
)
)
warning_skus = (await db.execute(warning_skus_q)).scalar() or 0
# 本月预计营收(本月订单总金额)
revenue_q = select(func.coalesce(func.sum(ErpOrder.total_amount), 0)).where(
and_(
ErpOrder.is_deleted.is_(False),
ErpOrder.order_date >= month_start,
)
)
monthly_revenue = float((await db.execute(revenue_q)).scalar() or 0)
return ok(data={
"orders_count": orders_count,
"pending_shipping": pending_shipping,
"warning_skus": warning_skus,
"monthly_revenue": monthly_revenue,
})
+67
View File
@@ -0,0 +1,67 @@
"""
FastAPI 依赖注入 —— 权限拦截核心
get_current_user: 解析 JWT → 查表获取完整权限上下文
"""
from __future__ import annotations
from fastapi import Depends, Header
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import UnauthorizedException
from app.core.security import decode_access_token
from app.db.database import get_db
from app.models.sys import SysUser
from app.schemas.auth import CurrentUserPayload
async def get_current_user(
authorization: str = Header(..., description="Bearer <token>"),
db: AsyncSession = Depends(get_db),
) -> CurrentUserPayload:
"""
核心鉴权依赖:
1. 从 Header 提取 Bearer Token
2. 解码 JWT 拿到 user_id
3. 查 sys_users + 联表 role/dept 拿到 data_scope 等完整上下文
4. 返回 CurrentUserPayload 供业务层使用
"""
# ── 解析 Bearer Token ──
if not authorization.startswith("Bearer "):
raise UnauthorizedException("Authorization 格式错误,需为 Bearer <token>")
token = authorization.removeprefix("Bearer ").strip()
payload = decode_access_token(token)
if payload is None:
raise UnauthorizedException("Token 无效或已过期")
user_id: str | None = payload.get("sub")
if user_id is None:
raise UnauthorizedException("Token 载荷缺少 sub 字段")
# ── 查库获取用户及关联角色 ──
stmt = (
select(SysUser)
.where(SysUser.id == user_id, SysUser.is_deleted.is_(False))
)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise UnauthorizedException("用户不存在或已被停用")
if user.status != 1:
raise UnauthorizedException("账号已被禁用")
# ── 组装权限上下文 ──
return CurrentUserPayload(
user_id=user.id,
username=user.username,
real_name=user.real_name,
dept_id=user.dept_id,
dept_name=user.department.name if user.department else None,
role_id=user.role_id,
role_name=user.role.role_name if user.role else None,
data_scope=user.role.data_scope if user.role else "self",
menu_keys=user.role.menu_keys if user.role else [],
)
+205
View File
@@ -0,0 +1,205 @@
"""
Dify 专用工具路由 —— /api/dify/tools
每个 MCP 工具对应一个独立 REST 端点,方便 Dify 通过 OpenAPI Schema 自动发现
注意:这些端点由 Dify Agent 内部调用,使用 API Key 认证而非 JWT
"""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.response import ok
from app.mcp.registry import execute_tool
import app.mcp.tools # noqa: F401
from app.core.action_card_queue import push_card
router = APIRouter(prefix="/dify/tools", tags=["Dify 工具接口"])
# ── Dify 工具专用认证:跳过 JWT,使用 Dify API Key 或直接放行 ──
async def get_dify_user(
authorization: str = Header("", description="可选的 Bearer <token>"),
db: AsyncSession = Depends(get_db),
) -> CurrentUserPayload:
"""
Dify 调用工具时不携带 JWT,这里提供一个管理员级上下文。
生产环境应增加 API Key 校验。
"""
# TODO: 生产环境加 Dify API Secret 校验
return CurrentUserPayload(
user_id=uuid.UUID("c0000000-0000-0000-0000-000000000001"), # admin
username="admin",
real_name="系统管理员",
dept_id=uuid.UUID("a0000000-0000-0000-0000-000000000001"),
dept_name="总部",
role_id=uuid.UUID("b0000000-0000-0000-0000-000000000001"),
role_name="超级管理员",
data_scope="all",
menu_keys=[],
)
# ── 1. 搜索客户 ──────────────────────────────────────────
class SearchCustomersInput(BaseModel):
keyword: str | None = Field(None, description="客户名称关键词")
level: str | None = Field(None, description="客户等级: A / B / C")
page: int = Field(1, description="页码")
size: int = Field(10, description="每页数量")
@router.post("/search_customers", summary="搜索客户列表,支持按名称模糊搜索和等级过滤")
async def dify_search_customers(
body: SearchCustomersInput,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_dify_user),
) -> dict:
result = await execute_tool("search_customers", db, current_user, body.model_dump(exclude_none=True))
return ok(data={"response_type": result.response_type, "result": result.data, "message": result.message})
# ── 2. 创建客户 ──────────────────────────────────────────
class CreateCustomerInput(BaseModel):
name: str = Field(..., description="客户名称")
level: str = Field("C", description="客户等级: A / B / C")
contact: str | None = Field(None, description="联系人")
phone: str | None = Field(None, description="电话")
@router.post("/create_customer", summary="创建新客户(返回确认卡片,需用户在前端确认后执行)")
async def dify_create_customer(
body: CreateCustomerInput,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_dify_user),
) -> dict:
result = await execute_tool("create_customer", db, current_user, body.model_dump(exclude_none=True))
# action_card 结果推入共享队列,供 SSE 生成器注入前端
if result.response_type == "action_card" and isinstance(result.data, dict):
await push_card(result.data)
return ok(data={"response_type": result.response_type, "result": result.data, "message": result.message})
# ── 3. 查询客户专属报价 ──────────────────────────────────
class CalculatePriceInput(BaseModel):
customer_id: str = Field(..., description="客户 UUID")
sku_id: str = Field(..., description="产品 SKU UUID")
@router.post("/calculate_price", summary="查询客户专属报价:历史成交价追溯,无历史则标准价兜底")
async def dify_calculate_price(
body: CalculatePriceInput,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_dify_user),
) -> dict:
result = await execute_tool("calculate_price", db, current_user, body.model_dump())
return ok(data={"response_type": result.response_type, "result": result.data, "message": result.message})
# ── 4. 创建销售订单 ──────────────────────────────────────
class OrderItemInput(BaseModel):
sku_id: str = Field(..., description="产品 SKU UUID")
qty: float = Field(..., description="数量")
unit_price: float = Field(..., description="单价")
class CreateOrderInput(BaseModel):
customer_id: str = Field(..., description="客户 UUID")
items: list[OrderItemInput] = Field(..., description="订单明细行列表")
remark: str | None = Field(None, description="备注")
@router.post("/create_order", summary="创建销售订单(返回确认卡片,需用户确认后执行)")
async def dify_create_order(
body: CreateOrderInput,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_dify_user),
) -> dict:
params = body.model_dump()
params["items"] = [i.model_dump() for i in body.items]
result = await execute_tool("create_order", db, current_user, params)
if result.response_type == "action_card" and isinstance(result.data, dict):
await push_card(result.data)
return ok(data={"response_type": result.response_type, "result": result.data, "message": result.message})
# ── 5. 搜索订单 ──────────────────────────────────────────
class SearchOrdersInput(BaseModel):
keyword: str | None = Field(None, description="客户名称关键词")
order_no: str | None = Field(None, description="订单号模糊搜索")
shipping_state: str | None = Field(None, description="发货状态: pending / partial / shipped")
payment_state: str | None = Field(None, description="付款状态: unpaid / partial / cleared")
page: int = Field(1, description="页码")
size: int = Field(10, description="每页数量")
@router.post("/search_orders", summary="搜索订单列表,支持按客户名称、订单号、发货/付款状态筛选")
async def dify_search_orders(
body: SearchOrdersInput,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_dify_user),
) -> dict:
result = await execute_tool("search_orders", db, current_user, body.model_dump(exclude_none=True))
return ok(data={"response_type": result.response_type, "result": result.data, "message": result.message})
# ── 6. 双轨画像回写 (V5.0) ──────────────────────────────
class ContactUpdate(BaseModel):
contact_id: str = Field(..., description="联系人 UUID")
role: dict | None = Field(None, description="决策角色更新 {decision_role, authority_level}")
kpi: dict | None = Field(None, description="KPI 更新 {core_goals, pain_points}")
preference: dict | None = Field(None, description="偏好更新 {comm_style, meeting_preference, topics_of_interest}")
class UpdatePersonaInput(BaseModel):
customer_id: str = Field(..., description="客户 UUID")
company_updates: dict | None = Field(None, description="企业级画像增量 {firmographics, dynamic_status}")
contact_updates: list[ContactUpdate] | None = Field(None, description="联系人级画像增量列表")
@router.post("/update_persona", summary="双轨画像回写:分别更新企业画像与联系人画像 (V5.0)")
async def dify_update_persona(
body: UpdatePersonaInput,
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_dify_user),
) -> dict:
"""Dify Agent 画像提取 Workflow 调用此工具,分类写入企业/联系人画像"""
import httpx
from app.core.config import settings
# 内部转调 PUT /api/customers/{id}/persona
payload: dict[str, Any] = {}
if body.company_updates:
payload["company_updates"] = body.company_updates
if body.contact_updates:
payload["contact_updates"] = [cu.model_dump(exclude_none=True) for cu in body.contact_updates]
from app.models.crm import CrmCustomer, CrmContact
from app.api.customers import _deep_merge
from sqlalchemy import update as sa_update
cid = uuid.UUID(body.customer_id)
if body.company_updates:
customer = await db.get(CrmCustomer, cid)
if customer:
merged = _deep_merge(customer.ai_persona or {}, body.company_updates)
stmt = sa_update(CrmCustomer).where(CrmCustomer.id == cid).values(ai_persona=merged)
await db.execute(stmt)
if body.contact_updates:
for cu in body.contact_updates:
try:
contact = await db.get(CrmContact, uuid.UUID(cu.contact_id))
except (ValueError, TypeError):
continue
if not contact:
continue
updates = cu.model_dump(exclude_none=True, exclude={"contact_id"})
merged = _deep_merge(contact.ai_buyer_persona or {}, updates)
contact.ai_buyer_persona = merged
await db.commit()
return ok(message="双轨画像回写成功")
+150
View File
@@ -0,0 +1,150 @@
"""
财务票据域路由 —— /api/finance
薄路由层:参数解析 + 调用 Service + 包装响应
"""
from __future__ import annotations
import uuid
import os
import time
import base64
from fastapi import APIRouter, Depends, Query, Body, File, UploadFile, Form
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.finance import ExpenseCreate, ExpenseStatusUpdate, InvoiceCreate
from app.schemas.response import ok
from app.core.exceptions import BizException
from app.services import finance_service as svc
router = APIRouter(prefix="/finance", tags=["财务票据"])
@router.post("/ocr", summary="上传票据图片并做 AI 发票/名片 OCR 识别")
async def ocr_recognize(
file: UploadFile = File(...),
scene: str = Form("invoice"),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
from app.services.ocr_service import ocr_image
# 读取并在本地保存原始文件
file_bytes = await file.read()
upload_dir = "uploads/finance"
os.makedirs(upload_dir, exist_ok=True)
ext = os.path.splitext(file.filename or "")[1].lower() or ".png"
ts = int(time.time())
safe_filename = f"{ts}_{current_user.user_id}{ext}"
file_path = os.path.join(upload_dir, safe_filename)
with open(file_path, "wb") as f:
f.write(file_bytes)
file_url = f"/uploads/finance/{safe_filename}"
# 仅支持图片(png/jpg/jpeg)和 PDF,不再支持 MD/TXT
supported = {".png", ".jpg", ".jpeg", ".pdf"}
if ext not in supported:
raise BizException(message=f"不支持的文件格式 {ext},仅支持: {', '.join(supported)}")
# 如果是 PDF,转成 PNG 再做 OCR
ocr_bytes = file_bytes
if ext == ".pdf":
try:
import fitz # PyMuPDF
doc = fitz.open(stream=file_bytes, filetype="pdf")
page = doc[0] # 取第一页
# 中等分辨率渲染(150 DPI,平衡质量与大小)
pix = page.get_pixmap(dpi=150)
ocr_bytes = pix.tobytes("png")
doc.close()
print(f"[OCR] PDF 转 PNG 成功: {len(ocr_bytes)} bytes")
except Exception as e:
print(f"[OCR] PDF 转换失败: {e}")
return ok(data={"ocr_data": {}, "file_url": file_url}, message=f"PDF 转换失败: {e}")
# 转换为纯 base64 传给 OCR
image_base64 = base64.b64encode(ocr_bytes).decode("utf-8")
result = await ocr_image(image_base64, scene)
if result.get("success"):
return ok(data={"ocr_data": result["data"], "file_url": file_url}, message="AI OCR 识别成功")
return ok(data={"ocr_data": result.get("data", {}), "file_url": file_url}, message=result.get("error", "OCR 识别失败"))
@router.post("/invoices", summary="上传票据入池(含 AI/OCR JSONB 数据)")
async def create_invoice(
body: InvoiceCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.create_invoice(db, current_user, body)
return ok(data=result.model_dump(mode="json"), message="票据入池成功")
@router.get("/invoices", summary="票据池列表(数据权限隔离)")
async def list_invoices(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
type: str | None = Query(None, alias="category", pattern=r"^(expense|customer)$"),
is_used: bool | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.list_invoices(db, current_user, page, size, type, is_used)
return ok(data=result.model_dump(mode="json"))
@router.delete("/invoices/{invoice_id}", summary="作废票据(软删除)")
async def void_invoice(
invoice_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
await svc.void_invoice(db, current_user, invoice_id)
return ok(message="票据已作废")
@router.post("/expenses", summary="生成报销单(防重锁定 + 强事务)")
async def create_expense(
body: ExpenseCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.create_expense(db, current_user, body)
return ok(data=result.model_dump(mode="json"), message=f"报销单 {result.system_no} 提交成功")
@router.get("/expenses", summary="报销单大盘(多角色数据权限)")
async def list_expenses(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
status: str | None = Query(None, pattern=r"^(submitted|approved|rejected|voided)$"),
applicant_id: uuid.UUID | None = Query(None, description="按申请人过滤(管理员用)"),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.list_expenses(db, current_user, page, size, status, applicant_id)
return ok(data=result.model_dump(mode="json"))
@router.get("/expenses/{expense_id}", summary="报销单详情(含明细行)")
async def get_expense(
expense_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.get_expense(db, current_user, expense_id)
return ok(data=result.model_dump(mode="json"))
@router.put("/expenses/{expense_id}/status", summary="审批/撤回报销单(含发票释放)")
async def update_expense_status(
expense_id: uuid.UUID,
body: ExpenseStatusUpdate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
msg = await svc.update_expense_status(db, current_user, expense_id, body)
return ok(message=msg)
+225
View File
@@ -0,0 +1,225 @@
"""
批量导入/导出路由 —— 产品导入 / 客户导入 / 客户导出 / 模板下载
"""
from __future__ import annotations
import io
import os
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.response import ok
from app.core.exceptions import BizException, ForbiddenException
router = APIRouter(tags=["批量导入导出"])
# ── 模板下载 ──────────────────────────────────────────
TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "templates")
@router.get("/templates/{name}", summary="下载 Excel 导入模板")
async def download_template(
name: str,
):
allowed = {
"product_import_template.xlsx",
"customer_import_template.xlsx",
}
if name not in allowed:
raise BizException(message=f"模板 {name} 不存在")
file_path = os.path.join(TEMPLATES_DIR, name)
if not os.path.exists(file_path):
raise BizException(message=f"模板文件 {name} 未找到")
return FileResponse(
path=file_path,
filename=name,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
# ── 产品批量导入 ──────────────────────────────────────
@router.post("/products/import", summary="Excel 批量导入产品 SKU")
async def import_products(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
):
from openpyxl import load_workbook
from app.models.erp import ErpProductSku
content = await file.read()
wb = load_workbook(io.BytesIO(content))
ws = wb.active
if ws is None:
raise BizException(message="Excel 文件无可用工作表")
rows = list(ws.iter_rows(min_row=2, values_only=True)) # 跳过表头
if not rows:
raise BizException(message="Excel 中无数据行")
created = 0
skipped = 0
errors = []
for i, row in enumerate(rows, start=2):
try:
sku_code = str(row[0] or "").strip()
name = str(row[1] or "").strip()
spec = str(row[2] or "").strip() or None
standard_price = float(row[3] or 0)
unit = str(row[4] or "").strip()
warning_threshold = float(row[5] or 0)
if not sku_code or not name:
skipped += 1
continue
# 检查 sku_code 是否已存在
exists = (await db.execute(
select(func.count()).select_from(ErpProductSku).where(
ErpProductSku.sku_code == sku_code,
ErpProductSku.is_deleted.is_(False),
)
)).scalar()
if exists:
skipped += 1
continue
sku = ErpProductSku(
sku_code=sku_code,
name=name,
spec=spec,
standard_price=standard_price,
unit=unit,
warning_threshold=warning_threshold,
)
db.add(sku)
created += 1
except Exception as e:
errors.append(f"{i}行: {e!s}")
await db.commit()
return ok(
data={"created": created, "skipped": skipped, "errors": errors},
message=f"导入完成:新增 {created} 条,跳过 {skipped}",
)
# ── 客户批量导入 ──────────────────────────────────────
@router.post("/crm/import", summary="Excel 批量导入客户")
async def import_customers(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
):
from openpyxl import load_workbook
from app.models.crm import CrmCustomer
content = await file.read()
wb = load_workbook(io.BytesIO(content))
ws = wb.active
if ws is None:
raise BizException(message="Excel 文件无可用工作表")
rows = list(ws.iter_rows(min_row=2, values_only=True))
if not rows:
raise BizException(message="Excel 中无数据行")
created = 0
skipped = 0
errors = []
for i, row in enumerate(rows, start=2):
try:
name = str(row[0] or "").strip()
level = str(row[1] or "C").strip().upper()
industry = str(row[2] or "").strip() or None
contact = str(row[3] or "").strip() or None
phone = str(row[4] or "").strip() or None
email = str(row[5] or "").strip() or None
address = str(row[6] or "").strip() or None
if not name:
skipped += 1
continue
if level not in ("A", "B", "C"):
level = "C"
customer = CrmCustomer(
name=name,
level=level,
industry=industry,
contact=contact,
phone=phone,
email=email,
address=address,
owner_id=current_user.user_id,
)
db.add(customer)
created += 1
except Exception as e:
errors.append(f"{i}行: {e!s}")
await db.commit()
return ok(
data={"created": created, "skipped": skipped, "errors": errors},
message=f"导入完成:新增 {created} 条,跳过 {skipped}",
)
# ── 客户导出(仅 admin) ─────────────────────────────
@router.get("/crm/export", summary="导出客户数据 (仅管理员)")
async def export_customers(
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
):
# 权限校验
if current_user.data_scope != "all" and (current_user.role_name or "").lower() != "admin":
raise ForbiddenException("仅管理员可导出客户数据")
from openpyxl import Workbook
from app.models.crm import CrmCustomer
stmt = select(CrmCustomer).where(CrmCustomer.is_deleted.is_(False)).order_by(CrmCustomer.created_at.desc())
customers = (await db.execute(stmt)).scalars().all()
wb = Workbook()
ws = wb.active
ws.title = "客户列表"
ws.append(["客户名称", "等级", "行业", "联系人", "电话", "邮箱", "地址", "AI评分", "创建时间"])
for c in customers:
ws.append([
c.name,
c.level,
c.industry or "",
c.contact or "",
c.phone or "",
c.email or "",
c.address or "",
float(c.ai_score or 0),
c.created_at.strftime("%Y-%m-%d %H:%M") if c.created_at else "",
])
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
filename = f"customers_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
+62
View File
@@ -0,0 +1,62 @@
"""
ERP 订单管理路由 —— /api/orders
薄路由层:参数解析 + 调用 Service + 包装响应
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.order import OrderCreate
from app.schemas.response import ok
from app.services import order_service as svc
router = APIRouter(prefix="/orders", tags=["订单管理"])
@router.get("/price/calculate", summary="B2B 动态定价:历史成交价追溯 → 标准价兜底")
async def calculate_price(
customer_id: uuid.UUID = Query(..., description="客户 ID"),
sku_id: uuid.UUID = Query(..., description="产品 SKU ID"),
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.calculate_price(db, customer_id, sku_id)
return ok(data=result.model_dump(mode="json"))
@router.post("", summary="创建订单(主子表事务)")
async def create_order(
body: OrderCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.create_order(db, current_user, body)
return ok(data=result.model_dump(mode="json"), message=f"订单 {result.order_no} 创建成功")
@router.get("", summary="订单大盘列表(含数据权限隔离)")
async def list_orders(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
customer_id: uuid.UUID | None = Query(None),
shipping_state: str | None = Query(None, pattern=r"^(pending|partial|shipped)$"),
payment_state: str | None = Query(None, pattern=r"^(unpaid|partial|cleared)$"),
keyword: str | None = Query(None, description="模糊搜索订单号"),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.list_orders(db, current_user, page, size, customer_id, shipping_state, payment_state, keyword)
return ok(data=result.model_dump(mode="json"))
@router.get("/{order_id}", summary="订单全景详情(关系预加载 items + customer")
async def get_order(
order_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.get_order(db, current_user, order_id)
return ok(data=result.model_dump(mode="json"))
+112
View File
@@ -0,0 +1,112 @@
"""
ERP 产品 & 库存模块路由 —— /api/products
薄路由层:参数解析 + 调用 Service + 包装响应
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.erp import CategoryCreate, CategoryUpdate, InventoryFlowCreate, SkuCreate, SkuUpdate
from app.schemas.response import ok
from app.services import product_service as svc
router = APIRouter(prefix="/products", tags=["产品与库存"])
@router.get("/categories/tree", summary="获取产品分类树(嵌套结构)")
async def get_category_tree(
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
tree = await svc.get_category_tree(db)
return ok(data=tree)
@router.post("/categories", summary="新增产品分类")
async def create_category(
body: CategoryCreate,
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.create_category(db, body)
return ok(data=result, message="分类创建成功")
@router.put("/categories/{cat_id}", summary="修改产品分类")
async def update_category(
cat_id: uuid.UUID,
body: CategoryUpdate,
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
await svc.update_category(db, cat_id, body)
return ok(message="分类信息已更新")
@router.delete("/categories/{cat_id}", summary="软删除产品分类")
async def delete_category(
cat_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
await svc.delete_category(db, cat_id)
return ok(message="分类已删除")
@router.get("/skus", summary="分页获取产品 SKU 列表")
async def list_skus(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
category_id: uuid.UUID | None = Query(None, description="按分类过滤"),
keyword: str | None = Query(None, description="模糊搜索 SKU 编码或名称"),
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.list_skus(db, page, size, category_id, keyword)
return ok(data=result.model_dump(mode="json"))
@router.post("/skus", summary="新增产品 SKU")
async def create_sku(
body: SkuCreate,
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.create_sku(db, body)
return ok(data=result.model_dump(mode="json"), message="产品创建成功")
@router.put("/skus/{sku_id}", summary="修改产品基础信息(不含库存)")
async def update_sku(
sku_id: uuid.UUID,
body: SkuUpdate,
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.update_sku(db, sku_id, body)
return ok(data=result.model_dump(mode="json"), message="产品信息已更新")
@router.post("/inventory/flow", summary="库存变更(事务级原子操作)")
async def create_inventory_flow(
body: InventoryFlowCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.create_inventory_flow(db, current_user, body)
return ok(data=result.model_dump(mode="json"), message="库存变更成功")
@router.get("/inventory/flows/{sku_id}", summary="获取单个 SKU 的库存流水(倒序)")
async def get_inventory_flows(
sku_id: uuid.UUID,
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
_: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.get_inventory_flows(db, sku_id, page, size)
return ok(data=result)
+354
View File
@@ -0,0 +1,354 @@
"""
AI 复盘报告路由 —— /api/reports
- POST /generate: SSE 流式生成复盘报告
- POST /confirm: 确认存档报告
"""
from __future__ import annotations
import json
import uuid
from datetime import date, datetime
from fastapi import APIRouter, Body, Depends, Header, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.response import ok
router = APIRouter(prefix="/reports", tags=["AI 复盘报告"])
@router.post("/generate", summary="SSE 流式生成复盘报告")
async def generate_report(
start_date: date = Body(..., embed=True),
end_date: date = Body(..., embed=True),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
authorization: str | None = Header(None),
):
"""
1. 聚合该用户在时间范围内的 sales_logs 内容
2. 调用 Dify Workflow (streaming) 生成复盘报告
3. SSE 流式返回给前端
"""
return StreamingResponse(
_report_sse_generator(db, current_user, start_date, end_date, authorization or ""),
media_type="text/event-stream",
)
async def _report_sse_generator(
db: AsyncSession,
user: CurrentUserPayload,
start_date: date,
end_date: date,
authorization: str = "",
):
import httpx
from app.core.config import settings
from app.models.ai import SalesLog
# 1. 聚合日志
stmt = (
select(SalesLog)
.where(
SalesLog.salesperson_id == user.user_id,
SalesLog.log_date >= start_date,
SalesLog.log_date <= end_date,
SalesLog.is_deleted.is_(False),
)
.order_by(SalesLog.log_date)
)
logs = (await db.execute(stmt)).scalars().all()
if not logs:
yield f"data: {json.dumps({'type': 'text', 'content': '⚠️ 该时间段内暂无销售日志数据,无法生成复盘报告。'}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
return
# 拼接日志摘要
log_summary = "\n".join([
f"[{log.log_date}] {log.content}" for log in logs
])
yield f"data: {json.dumps({'type': 'text', 'content': f'📊 找到 {len(logs)} 条日志,正在调用 AI 生成复盘报告...\\n\\n'}, ensure_ascii=False)}\n\n"
# 2. 调用 Dify Workflow
if not settings.DIFY_WORKFLOW_REPORT_KEY or not settings.DIFY_API_BASE_URL:
yield f"data: {json.dumps({'type': 'text', 'content': '⚠️ 周报 Workflow 未配置,请联系管理员设置 DIFY_WORKFLOW_REPORT_KEY。'}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
return
url = f"{settings.DIFY_API_BASE_URL}/v1/workflows/run"
headers = {
"Authorization": f"Bearer {settings.DIFY_WORKFLOW_REPORT_KEY}",
"Content-Type": "application/json",
}
payload = {
"inputs": {
"user_id": str(user.user_id),
"user_name": user.real_name or user.username,
"period_start": start_date.isoformat(),
"period_end": end_date.isoformat(),
"report_type": "monthly",
"sales_logs": log_summary,
"request": f"请基于以上 {len(logs)} 条销售日志,生成 {start_date}{end_date} 的复盘报告。",
"authorization": authorization,
},
"response_mode": "streaming",
"user": str(user.user_id),
}
print(f"[Report SSE] 开始调用 Dify: {url}")
print(f"[Report SSE] payload inputs keys: {list(payload['inputs'].keys())}")
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=30.0)) as client:
async with client.stream("POST", url, json=payload, headers=headers) as resp:
print(f"[Report SSE] Dify 响应状态: {resp.status_code}")
if resp.status_code != 200:
error_text = ""
async for chunk in resp.aiter_text():
error_text += chunk
print(f"[Report SSE] Dify 错误: {error_text[:500]}")
if resp.status_code in (401, 403):
yield f"data: {json.dumps({'type': 'text', 'content': '⚠️ Dify API Key 无效或已过期 (HTTP {}), 请在系统设置中检查 DIFY_WORKFLOW_REPORT_KEY 配置。'.format(resp.status_code)}, ensure_ascii=False)}\n\n"
else:
yield f"data: {json.dumps({'type': 'text', 'content': f'⚠️ Dify 返回错误 ({resp.status_code}): {error_text[:200]}'}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
return
buf = ""
chunk_count = 0
async for chunk in resp.aiter_text():
chunk_count += 1
buf += chunk
while "\n\n" in buf:
event_block, buf = buf.split("\n\n", 1)
data_line = ""
for line in event_block.split("\n"):
if line.startswith("data: "):
data_line = line[6:]
if not data_line or data_line.strip() == "[DONE]":
continue
try:
event = json.loads(data_line)
except json.JSONDecodeError:
print(f"[Report SSE] JSON 解析失败: {data_line[:100]}")
continue
event_type = event.get("event", "")
# 打印所有事件的概要信息
event_data = event.get("data", {})
node_id = event_data.get("node_id", "")
node_type = event_data.get("node_type", "")
status = event_data.get("status", "")
error_msg = event_data.get("error", "")
print(f"[Report SSE] event={event_type} node_type={node_type} status={status} error={str(error_msg)[:200]}")
if event_type == "text_chunk":
text = event.get("data", {}).get("text", "")
if text:
yield f"data: {json.dumps({'type': 'text', 'content': text}, ensure_ascii=False)}\n\n"
elif event_type == "node_finished":
node_data = event.get("data", {})
node_type = node_data.get("node_type", "")
print(f"[Report SSE] node_finished: node_type={node_type}")
# 捕获 LLM 节点的输出
if node_type == "llm":
llm_outputs = node_data.get("outputs", {})
llm_text = llm_outputs.get("text", "")
if llm_text:
print(f"[Report SSE] LLM 节点输出: {len(llm_text)} 字符")
yield f"data: {json.dumps({'type': 'text', 'content': llm_text}, ensure_ascii=False)}\n\n"
elif event_type == "workflow_finished":
outputs = event.get("data", {}).get("outputs", {})
print(f"[Report SSE] workflow_finished data keys: {list(event.get('data', {}).keys())}")
print(f"[Report SSE] workflow_finished outputs keys: {list(outputs.keys())}")
print(f"[Report SSE] workflow_finished outputs preview: {str(outputs)[:500]}")
output_text = outputs.get("text", "") or outputs.get("output", "") or outputs.get("result", "")
if not output_text:
# 尝试取第一个非空值
for v in outputs.values():
if isinstance(v, str) and len(v) > 20:
output_text = v
break
if output_text:
yield f"data: {json.dumps({'type': 'text', 'content': output_text}, ensure_ascii=False)}\n\n"
print(f"[Report SSE] Workflow 完成, output_text长度: {len(output_text)}")
print(f"[Report SSE] 流结束,共收到 {chunk_count} 个 chunk")
except httpx.TimeoutException:
print(f"[Report SSE] 超时!")
yield f"data: {json.dumps({'type': 'text', 'content': '\\n⚠️ Dify 响应超时(120秒),请稍后重试'}, ensure_ascii=False)}\n\n"
except Exception as e:
print(f"[Report SSE] 异常: {e!s}")
yield f"data: {json.dumps({'type': 'text', 'content': f'\\n⚠️ 报告生成失败: {e!s}'}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
print(f"[Report SSE] SSE 流完全结束")
@router.post("/confirm", summary="确认并存档复盘报告")
async def confirm_report(
start_date: date = Body(..., embed=True),
end_date: date = Body(..., embed=True),
content_md: str = Body(..., embed=True),
report_type: str = Body("monthly", embed=True),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
from app.models.ai import AiReportDraft
report = AiReportDraft(
author_id=current_user.user_id,
report_type=report_type,
period_start=start_date,
period_end=end_date,
content_md=content_md,
status="confirmed",
)
db.add(report)
await db.commit()
await db.refresh(report)
return ok(
data={"id": str(report.id), "status": report.status},
message="复盘报告已确认存档",
)
@router.get("/history", summary="查询复盘报告历史列表")
async def list_reports(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
from sqlalchemy import func as sa_func, desc
from app.models.ai import AiReportDraft
where = [
AiReportDraft.author_id == current_user.user_id,
AiReportDraft.is_deleted.is_(False),
]
total = (
await db.execute(select(sa_func.count()).select_from(AiReportDraft).where(*where))
).scalar() or 0
stmt = (
select(AiReportDraft)
.where(*where)
.order_by(desc(AiReportDraft.created_at))
.offset((page - 1) * size)
.limit(size)
)
rows = (await db.execute(stmt)).scalars().all()
items = [
{
"id": str(r.id),
"report_type": r.report_type,
"period_start": r.period_start.isoformat(),
"period_end": r.period_end.isoformat(),
"status": r.status,
"content_md": r.content_md,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rows
]
return ok(data={"total": total, "items": items, "page": page, "size": size})
@router.put("/{report_id}", summary="修改复盘报告内容")
async def update_report(
report_id: uuid.UUID,
content_md: str = Body(..., embed=True),
status: str = Body("confirmed", embed=True),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
from app.models.ai import AiReportDraft
report = (
await db.execute(
select(AiReportDraft).where(
AiReportDraft.id == report_id,
AiReportDraft.author_id == current_user.user_id,
AiReportDraft.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if report is None:
from app.core.exceptions import NotFoundException
raise NotFoundException("报告不存在")
report.content_md = content_md
report.status = status
await db.commit()
return ok(message="报告已更新")
@router.delete("/{report_id}", summary="删除复盘报告")
async def delete_report(
report_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
from app.models.ai import AiReportDraft
report = (
await db.execute(
select(AiReportDraft).where(
AiReportDraft.id == report_id,
AiReportDraft.author_id == current_user.user_id,
AiReportDraft.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if report is None:
from app.core.exceptions import NotFoundException
raise NotFoundException("报告不存在")
report.is_deleted = True
await db.commit()
return ok(message="报告已删除")
@router.post("/drafts", summary="Dify Workflow 回调 — 接收 LLM 生成的复盘报告")
async def receive_draft(
author_id: uuid.UUID = Body(..., embed=True),
report_type: str = Body("monthly", embed=True),
period_start: date = Body(..., embed=True),
period_end: date = Body(..., embed=True),
content_md: str = Body(..., embed=True),
db: AsyncSession = Depends(get_db),
) -> dict:
"""供 Dify Workflow HTTP 请求 2 回调使用,无需 CRM 用户认证。"""
from app.models.ai import AiReportDraft
report = AiReportDraft(
author_id=author_id,
report_type=report_type,
period_start=period_start,
period_end=period_end,
content_md=content_md,
status="confirmed",
)
db.add(report)
await db.commit()
await db.refresh(report)
print(f"[Report Drafts] Dify 回调存储成功: {report.id}, 内容长度: {len(content_md)}")
return ok(
data={"id": str(report.id), "status": report.status},
message="复盘报告已由 Dify Workflow 存档",
)
+90
View File
@@ -0,0 +1,90 @@
"""
销项发票路由 —— /api/finance/sales-invoices
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.sales_invoice import SalesInvoiceCreate, SalesInvoiceUpdate
from app.schemas.response import ok
from app.services import sales_invoice_service as svc
from app.core.exceptions import ForbiddenException
router = APIRouter(prefix="/finance/sales-invoices", tags=["销项发票(AR)"])
@router.post("", summary="创建销项发票")
async def create_invoice(
body: SalesInvoiceCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.create_invoice(db, current_user, body)
return ok(data=result.model_dump(mode="json"), message="销项发票创建成功")
@router.get("", summary="多条件查询销项发票列表")
async def list_invoices(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
customer_name: str | None = Query(None, description="客户名称模糊搜索"),
invoice_number: str | None = Query(None, description="发票号搜索"),
payment_status: str | None = Query(None, description="回款状态"),
start_date: date | None = Query(None, description="开票开始日期"),
end_date: date | None = Query(None, description="开票结束日期"),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.list_invoices(
db, page, size, customer_name, invoice_number,
payment_status, start_date, end_date,
)
return ok(data=result.model_dump(mode="json"))
@router.get("/export", summary="导出发票汇总及回款追踪表 (仅管理员)")
async def export_invoices(
start_date: date | None = Query(None),
end_date: date | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
):
if current_user.data_scope != "all" and (current_user.role_name or "").lower() != "admin":
raise ForbiddenException("仅管理员可导出发票数据")
buffer = await svc.export_invoices(db, start_date, end_date)
filename = f"sales_invoices_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.get("/{invoice_id}", summary="获取销项发票详情")
async def get_invoice(
invoice_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.get_invoice(db, invoice_id)
return ok(data=result.model_dump(mode="json"))
@router.put("/{invoice_id}", summary="更新回款状态")
async def update_invoice(
invoice_id: uuid.UUID,
body: SalesInvoiceUpdate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.update_invoice(db, invoice_id, body)
return ok(data=result.model_dump(mode="json"), message="回款状态更新成功")
+77
View File
@@ -0,0 +1,77 @@
"""
销售日志 API 路由 — /api/sales-logs
"""
from __future__ import annotations
import asyncio
from fastapi import APIRouter, Depends, Body
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.response import ok
from app.services import sales_log_service
router = APIRouter(prefix="/sales-logs", tags=["销售日志"])
@router.get("", summary="查询销售日志列表")
async def list_logs(
page: int = 1,
size: int = 20,
customer_id: str | None = None,
user_id: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
):
result = await sales_log_service.list_logs(
db, current_user,
page=page, size=size,
customer_id=customer_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
)
return ok(data=result)
@router.post("", summary="创建销售日志")
async def create_log(
content: str = Body(..., embed=True),
customer_id: str | None = Body(None, embed=True),
contact_ids: list[str] | None = Body(None, embed=True),
log_date: str | None = Body(None, embed=True),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
):
from datetime import date as date_type
parsed_date = None
if log_date:
parsed_date = date_type.fromisoformat(log_date)
result = await sales_log_service.create_log(
db, current_user,
content=content,
customer_id=customer_id,
contact_ids=contact_ids,
log_date=parsed_date,
)
# 异步触发 Dify 画像提取工作流(仅当关联了客户时)
if customer_id:
import uuid
asyncio.create_task(
sales_log_service.trigger_persona_workflow(
log_id=uuid.UUID(result["id"]),
customer_id=uuid.UUID(customer_id),
content=content,
salesperson_name=getattr(current_user, "real_name", ""),
contact_ids=contact_ids,
)
)
return ok(data=result, message="日志创建成功")
+49
View File
@@ -0,0 +1,49 @@
"""
ERP 物流发货路由 —— /api/shipping
薄路由层:参数解析 + 调用 Service + 包装响应
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.shipping import ShippingCreate
from app.schemas.response import ok
from app.services import shipping_service as svc
router = APIRouter(prefix="/shipping", tags=["物流发货"])
@router.post("", summary="执行分批发货(五步原子事务)")
async def create_shipping(
body: ShippingCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
resp, new_state = await svc.create_shipping(db, current_user, body)
return ok(data=resp.model_dump(mode="json"), message=f"发货单 {resp.shipping_no} 创建成功,订单状态已更新为 {new_state}")
@router.get("", summary="发货单大盘列表(数据权限与订单一致)")
async def list_shipping(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
order_no: str | None = Query(None, description="按订单号模糊搜索"),
tracking_no: str | None = Query(None, description="按物流单号搜索"),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.list_shipping(db, current_user, page, size, order_no, tracking_no)
return ok(data=result.model_dump(mode="json"))
@router.get("/order/{order_id}", summary="查询特定订单的全部发货轨迹")
async def get_shipping_by_order(
order_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.get_shipping_by_order(db, current_user, order_id)
return ok(data=result)
+431
View File
@@ -0,0 +1,431 @@
"""
系统设置与权限域路由 —— /api/settings
核心亮点:
1. 部门树递归组装
2. 角色 CRUD + JSONB menu_keys
3. 员工管理 + bcrypt 密码哈希
4. 全接口强制管理员权限校验
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
from app.core.security import hash_password
from app.db.database import get_db
from app.models.sys import SysDepartment, SysRole, SysUser
from app.schemas.auth import CurrentUserPayload
from app.schemas.sys import (
DeptNode,
RoleCreate,
RoleResponse,
RoleUpdate,
UserCreate,
UserListResponse,
UserResetPassword,
UserResponse,
UserUpdate,
)
from app.schemas.response import ok
router = APIRouter(prefix="/settings", tags=["系统设置"])
# ── 管理员权限守卫 ────────────────────────────────────────
def _require_admin(user: CurrentUserPayload) -> None:
"""全接口强制校验:必须 data_scope == 'all'"""
if user.data_scope != "all":
raise ForbiddenException("系统设置仅限管理员操作")
# ── 工具:部门树递归组装 ─────────────────────────────────
def _build_dept_tree(
items: list[SysDepartment], parent_id: uuid.UUID | None = None
) -> list[DeptNode]:
nodes: list[DeptNode] = []
for item in items:
if item.parent_id == parent_id:
children = _build_dept_tree(items, item.id)
nodes.append(
DeptNode(
id=item.id,
parent_id=item.parent_id,
name=item.name,
sort_order=item.sort_order,
status=item.status,
children=children,
)
)
nodes.sort(key=lambda n: n.sort_order)
return nodes
# ── 工具:收集部门及所有子部门 ID(递归) ────────────────
def _collect_dept_ids(
items: list[SysDepartment], root_id: uuid.UUID
) -> list[uuid.UUID]:
"""从扁平列表中递归收集某部门及其所有后代 ID"""
result = [root_id]
for item in items:
if item.parent_id == root_id:
result.extend(_collect_dept_ids(items, item.id))
return result
# ── 工具:User ORM → Response ────────────────────────────
def _user_to_resp(u: SysUser) -> UserResponse:
return UserResponse(
id=u.id,
username=u.username,
real_name=u.real_name,
phone=u.phone,
email=u.email,
dept_id=u.dept_id,
dept_name=u.department.name if u.department else None,
role_id=u.role_id,
role_name=u.role.role_name if u.role else None,
data_scope=u.role.data_scope if u.role else None,
status=u.status,
last_login_at=u.last_login_at,
created_at=u.created_at,
)
# ================================================================
# 1. GET /api/settings/departments/tree —— 部门树
# ================================================================
@router.get("/departments/tree", summary="获取组织架构树")
async def get_dept_tree(
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
stmt = (
select(SysDepartment)
.where(SysDepartment.is_deleted.is_(False))
.order_by(SysDepartment.sort_order)
)
depts = list((await db.execute(stmt)).scalars().all())
tree = _build_dept_tree(depts, parent_id=None)
return ok(data=[n.model_dump(mode="json") for n in tree])
# ================================================================
# 2. GET /api/settings/roles —— 角色列表
# ================================================================
@router.get("/roles", summary="角色列表")
async def list_roles(
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
stmt = (
select(SysRole)
.where(SysRole.is_deleted.is_(False))
.order_by(SysRole.created_at)
)
roles = (await db.execute(stmt)).scalars().all()
return ok(
data=[
RoleResponse(
id=r.id,
role_name=r.role_name,
data_scope=r.data_scope,
menu_keys=r.menu_keys or [],
description=r.description,
status=r.status,
created_at=r.created_at,
).model_dump(mode="json")
for r in roles
]
)
# ================================================================
# 3. POST /api/settings/roles —— 新增角色
# ================================================================
@router.post("/roles", summary="新增角色")
async def create_role(
body: RoleCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
# 校验名称唯一
exists = (
await db.execute(
select(SysRole.id).where(
SysRole.role_name == body.role_name,
SysRole.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if exists:
raise BizException(message=f"角色名称 '{body.role_name}' 已存在")
role = SysRole(
role_name=body.role_name,
data_scope=body.data_scope,
menu_keys=body.menu_keys,
description=body.description,
status=body.status,
)
db.add(role)
await db.commit()
await db.refresh(role)
return ok(
data=RoleResponse(
id=role.id,
role_name=role.role_name,
data_scope=role.data_scope,
menu_keys=role.menu_keys or [],
description=role.description,
status=role.status,
created_at=role.created_at,
).model_dump(mode="json"),
message="角色创建成功",
)
# ================================================================
# 4. PUT /api/settings/roles/{id} —— 修改角色
# ================================================================
@router.put("/roles/{role_id}", summary="修改角色(含 JSONB menu_keys")
async def update_role(
role_id: uuid.UUID,
body: RoleUpdate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
role = (
await db.execute(
select(SysRole).where(
SysRole.id == role_id, SysRole.is_deleted.is_(False)
)
)
).scalar_one_or_none()
if role is None:
raise NotFoundException("角色不存在")
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise BizException(message="未提供任何需要更新的字段")
# 名称唯一性校验(如果改了名)
if "role_name" in update_data and update_data["role_name"] != role.role_name:
dup = (
await db.execute(
select(SysRole.id).where(
SysRole.role_name == update_data["role_name"],
SysRole.id != role_id,
SysRole.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if dup:
raise BizException(message=f"角色名称 '{update_data['role_name']}' 已存在")
update_data["updated_at"] = datetime.utcnow()
await db.execute(
update(SysRole).where(SysRole.id == role_id).values(**update_data)
)
await db.commit()
refreshed = (
await db.execute(select(SysRole).where(SysRole.id == role_id))
).scalar_one()
return ok(
data=RoleResponse(
id=refreshed.id,
role_name=refreshed.role_name,
data_scope=refreshed.data_scope,
menu_keys=refreshed.menu_keys or [],
description=refreshed.description,
status=refreshed.status,
created_at=refreshed.created_at,
).model_dump(mode="json"),
message="角色信息已更新",
)
# ================================================================
# 5. GET /api/settings/users —— 员工分页列表
# ================================================================
@router.get("/users", summary="员工分页列表(支持部门树递归过滤)")
async def list_users(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
dept_id: uuid.UUID | None = Query(None, description="部门 ID(含所有子部门)"),
keyword: str | None = Query(None, description="姓名/手机号模糊搜索"),
status: int | None = Query(None, ge=0, le=1),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
where = [SysUser.is_deleted.is_(False)]
# 按部门过滤(递归收集子部门 ID
if dept_id:
all_depts = list(
(
await db.execute(
select(SysDepartment).where(SysDepartment.is_deleted.is_(False))
)
).scalars().all()
)
target_ids = _collect_dept_ids(all_depts, dept_id)
where.append(SysUser.dept_id.in_(target_ids))
if keyword:
where.append(
SysUser.real_name.ilike(f"%{keyword}%")
| SysUser.phone.ilike(f"%{keyword}%")
)
if status is not None:
where.append(SysUser.status == status)
total = (
await db.execute(select(func.count()).select_from(SysUser).where(*where))
).scalar() or 0
stmt = (
select(SysUser)
.where(*where)
.order_by(SysUser.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
users = (await db.execute(stmt)).scalars().all()
return ok(
data=UserListResponse(
total=total,
items=[_user_to_resp(u) for u in users],
page=page,
size=size,
).model_dump(mode="json")
)
# ================================================================
# 6. POST /api/settings/users —— 开通账号
# ================================================================
@router.post("/users", summary="开通员工账号(bcrypt 密码哈希)")
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
# 用户名唯一
exists = (
await db.execute(
select(SysUser.id).where(
SysUser.username == body.username,
SysUser.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if exists:
raise BizException(message=f"用户名 '{body.username}' 已被占用")
user = SysUser(
username=body.username,
password_hash=hash_password(body.password),
real_name=body.real_name,
phone=body.phone,
email=body.email,
dept_id=body.dept_id,
role_id=body.role_id,
status=body.status,
)
db.add(user)
await db.commit()
await db.refresh(user)
return ok(data=_user_to_resp(user).model_dump(mode="json"), message="账号创建成功")
# ================================================================
# 7. PUT /api/settings/users/{id} —— 编辑员工信息
# ================================================================
@router.put("/users/{user_id}", summary="编辑员工信息(不含密码)")
async def update_user(
user_id: uuid.UUID,
body: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
user = (
await db.execute(
select(SysUser).where(
SysUser.id == user_id, SysUser.is_deleted.is_(False)
)
)
).scalar_one_or_none()
if user is None:
raise NotFoundException("用户不存在")
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise BizException(message="未提供任何需要更新的字段")
update_data["updated_at"] = datetime.utcnow()
await db.execute(
update(SysUser).where(SysUser.id == user_id).values(**update_data)
)
await db.commit()
refreshed = (
await db.execute(select(SysUser).where(SysUser.id == user_id))
).scalar_one()
return ok(data=_user_to_resp(refreshed).model_dump(mode="json"), message="员工信息已更新")
# ================================================================
# 8. PUT /api/settings/users/{id}/reset-password —— 强制重置密码
# ================================================================
@router.put("/users/{user_id}/reset-password", summary="强制重置密码(bcrypt")
async def reset_password(
user_id: uuid.UUID,
body: UserResetPassword,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
_require_admin(current_user)
user = (
await db.execute(
select(SysUser).where(
SysUser.id == user_id, SysUser.is_deleted.is_(False)
)
)
).scalar_one_or_none()
if user is None:
raise NotFoundException("用户不存在")
await db.execute(
update(SysUser)
.where(SysUser.id == user_id)
.values(
password_hash=hash_password(body.new_password),
updated_at=datetime.utcnow(),
)
)
await db.commit()
return ok(message="密码已重置")