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
+150
View File
@@ -0,0 +1,150 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
# database URL — 由 env.py 从 .env 动态读取
# sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+93
View File
@@ -0,0 +1,93 @@
"""
Alembic env.py — 异步 PostgreSQL 迁移环境
从 .env 读取 DATABASE_URL,自动发现项目所有 ORM 模型
"""
import asyncio
import os
import sys
from logging.config import fileConfig
from alembic import context
from dotenv import load_dotenv
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
# 确保项目根目录在 sys.path 中
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# 加载 .env
load_dotenv(os.path.join(os.path.dirname(os.path.dirname(__file__)), ".env"))
# Alembic Config
config = context.config
# 从 .env 动态设置 sqlalchemy.urlasyncpg → psycopg2 用于迁移)
db_url = os.getenv("DATABASE_URL", "")
# Alembic 需要同步驱动,将 asyncpg 替换为 psycopg2
sync_url = db_url.replace("+asyncpg", "")
config.set_main_option("sqlalchemy.url", sync_url)
# 日志配置
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# 导入所有模型,确保 Alembic 能检测到所有表
from app.models.base import Base
from app.models import crm, erp, order, shipping, finance, ai, sys as sys_models
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""离线迁移(仅生成 SQL 脚本)"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
"""在线迁移(异步引擎)"""
from sqlalchemy.ext.asyncio import create_async_engine
connectable = create_async_engine(
os.getenv("DATABASE_URL", ""),
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""在线迁移入口"""
# 如果是 asyncpg 则走异步迁移
db_url = os.getenv("DATABASE_URL", "")
if "asyncpg" in db_url:
asyncio.run(run_async_migrations())
else:
from sqlalchemy import engine_from_config
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
do_run_migrations(connection)
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
File diff suppressed because it is too large Load Diff
View File
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="密码已重置")
View File
+26
View File
@@ -0,0 +1,26 @@
"""
Action Card 队列 —— Dify 工具端点 → SSE 生成器
使用全局队列(不区分用户),因为 Dify 工具调用发生在 SSE 流的生命周期内。
"""
from __future__ import annotations
import asyncio
from typing import Any
_pending_cards: list[dict[str, Any]] = []
_lock = asyncio.Lock()
async def push_card(card_data: dict[str, Any]) -> None:
"""工具端点调用:推入一个 action_card"""
async with _lock:
_pending_cards.append(card_data)
print(f"[ActionCardQueue] pushed card: {card_data.get('card_type', 'unknown')}")
async def pop_cards() -> list[dict[str, Any]]:
"""SSE 生成器调用:取出并清空所有 pending 的 action_card"""
async with _lock:
cards = list(_pending_cards)
_pending_cards.clear()
return cards
+43
View File
@@ -0,0 +1,43 @@
"""
应用全局配置 - 从 .env 读取环境变量
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Database
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/crm_erp"
# JWT
JWT_SECRET_KEY: str = "super-secret-key-change-in-production-please"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24h
# App
APP_NAME: str = "润滑油CRM/ERP系统"
APP_VERSION: str = "0.1.0"
DEBUG: bool = True
# Dify AI 中枢配置
DIFY_API_BASE_URL: str = "" # 例如 http://192.168.1.88
DIFY_API_KEY: str = ""
DIFY_APP_ID: str = ""
DIFY_TIMEOUT_MS: int = 30000 # 30s
DIFY_WORKFLOW_PERSONA_KEY: str = "" # 销售日志→客户画像 Workflow
DIFY_WORKFLOW_REPORT_KEY: str = "" # 周报自动生成 Workflow
# Ollama / vLLM 算力节点
OLLAMA_4060_BASE_URL: str = "http://192.168.1.88:11435" # 4060: Qwen3.5-4B + BGE-M3
OLLAMA_4060_MODEL: str = "qwen3.5:4b" # 意图分类用
OLLAMA_3090_BASE_URL: str = "http://192.168.1.88:11434" # 3090: Qwen3.5-27B (with vision)
OLLAMA_3090_MODEL: str = "qwen3.5:27b" # OCR / 视觉理解用
settings = Settings()
+78
View File
@@ -0,0 +1,78 @@
"""
自定义异常 + 全局异常处理器
统一输出格式: { "code": int, "data": Any, "message": str }
"""
from __future__ import annotations
from typing import Any
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
# ── 自定义业务异常 ─────────────────────────────────────────
class BizException(Exception):
"""通用业务异常,code 对应 HTTP 状态码"""
def __init__(self, code: int = 400, message: str = "业务异常", data: Any = None):
self.code = code
self.message = message
self.data = data
class UnauthorizedException(BizException):
def __init__(self, message: str = "未登录或 Token 已失效"):
super().__init__(code=401, message=message)
class ForbiddenException(BizException):
def __init__(self, message: str = "无权访问"):
super().__init__(code=403, message=message)
class NotFoundException(BizException):
def __init__(self, message: str = "资源不存在"):
super().__init__(code=404, message=message)
# ── 统一响应构造 ──────────────────────────────────────────
def _make_response(code: int, message: str, data: Any = None) -> JSONResponse:
return JSONResponse(
status_code=code,
content={"code": code, "data": data, "message": message},
)
# ── 注册全局异常处理器 ────────────────────────────────────
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(BizException)
async def biz_exception_handler(_req: Request, exc: BizException) -> JSONResponse:
return _make_response(exc.code, exc.message, exc.data)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(
_req: Request, exc: StarletteHTTPException
) -> JSONResponse:
return _make_response(exc.status_code, str(exc.detail))
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
_req: Request, exc: RequestValidationError
) -> JSONResponse:
# 把 Pydantic 校验错误打平为可读字符串
errors = []
for e in exc.errors():
loc = " -> ".join(str(x) for x in e.get("loc", []))
errors.append(f"{loc}: {e.get('msg', '')}")
return _make_response(422, "请求参数校验失败", errors)
@app.exception_handler(Exception)
async def global_exception_handler(
_req: Request, exc: Exception
) -> JSONResponse:
# 兜底:未知异常统一 500
return _make_response(500, f"服务器内部错误: {exc!s}")
+48
View File
@@ -0,0 +1,48 @@
"""
JWT 签发与校验 + 密码哈希工具
"""
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
# ── 密码哈希 ──────────────────────────────────────────────
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
# ── JWT Token ─────────────────────────────────────────────
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
settings.JWT_SECRET_KEY,
algorithm=settings.JWT_ALGORITHM,
)
def decode_access_token(token: str) -> dict | None:
"""解析 JWT,失败返回 None"""
try:
payload = jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM],
)
return payload
except JWTError:
return None
View File
+37
View File
@@ -0,0 +1,37 @@
"""
异步数据库引擎 & Session 工厂
"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.core.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
pool_recycle=3600,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI Dependency —— 每个请求一个 Session,自动关闭"""
async with async_session_factory() as session:
try:
yield session
finally:
await session.close()
+89
View File
@@ -0,0 +1,89 @@
"""
FastAPI 应用主入口
启动命令: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
"""
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.core.exceptions import register_exception_handlers
from app.api.auth import router as auth_router
from app.api.customers import router as customers_router
from app.api.products import router as products_router
from app.api.orders import router as orders_router
from app.api.shipping import router as shipping_router
from app.api.finance import router as finance_router
from app.api.sys_settings import router as sys_settings_router
from app.api.chat import router as chat_router
from app.api.dify_tools import router as dify_tools_router
from app.api.sales_logs import router as sales_logs_router
from app.api.import_export import router as import_export_router
from app.api.sales_invoice import router as sales_invoice_router
from app.api.reports import router as reports_router
from app.api.contacts import router as contacts_router
from app.api.dashboard import router as dashboard_router
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""应用生命周期:启动/关闭时的钩子"""
# ── startup ──
print(f"🚀 {settings.APP_NAME} v{settings.APP_VERSION} 启动中...")
yield
# ── shutdown ──
print("👋 服务正在关闭...")
from fastapi.staticfiles import StaticFiles
import os
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
)
# 挂载上传文件目录
os.makedirs("uploads", exist_ok=True)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
# ── CORS(仅允许内网生产地址) ──
app.add_middleware(
CORSMiddleware,
allow_origins=["http://192.168.1.100"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── 注册全局异常处理器 ──
register_exception_handlers(app)
# ── 注册路由 ──
app.include_router(auth_router, prefix="/api")
app.include_router(customers_router, prefix="/api")
app.include_router(products_router, prefix="/api")
app.include_router(orders_router, prefix="/api")
app.include_router(shipping_router, prefix="/api")
app.include_router(finance_router, prefix="/api")
app.include_router(sys_settings_router, prefix="/api")
app.include_router(chat_router, prefix="/api")
app.include_router(dify_tools_router, prefix="/api")
app.include_router(sales_logs_router, prefix="/api")
app.include_router(import_export_router, prefix="/api")
app.include_router(sales_invoice_router, prefix="/api")
app.include_router(reports_router, prefix="/api")
app.include_router(contacts_router, prefix="/api")
app.include_router(dashboard_router, prefix="/api")
# ── 健康检查 ──
@app.get("/health", tags=["系统"])
async def health_check() -> dict:
return {"status": "ok", "version": settings.APP_VERSION}
+1
View File
@@ -0,0 +1 @@
# MCP 工具层 — Dify Agent function_call 工具注册与执行
+81
View File
@@ -0,0 +1,81 @@
"""
MCP 工具注册中心
提供 @register_tool 装饰器和全局 TOOL_REGISTRY
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Coroutine
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.auth import CurrentUserPayload
@dataclass
class MCPToolMeta:
"""MCP 工具元信息"""
name: str
description: str
parameters: dict[str, Any] # JSON Schema 描述
handler: Callable[
[AsyncSession, CurrentUserPayload, dict[str, Any]],
Coroutine[Any, Any, "MCPToolResult"]
] | None = None
@dataclass
class MCPToolResult:
"""MCP 工具执行结果"""
success: bool = True
# 返回给 AI/前端的类型:text 或 action_card
response_type: str = "text" # "text" | "action_card"
data: dict[str, Any] = field(default_factory=dict)
message: str = ""
# ── 全局工具注册表 ────────────────────────────────────────
TOOL_REGISTRY: dict[str, MCPToolMeta] = {}
def register_tool(
name: str,
description: str,
parameters: dict[str, Any] | None = None,
):
"""装饰器:注册 MCP 工具到全局注册表"""
def decorator(fn: Callable):
meta = MCPToolMeta(
name=name,
description=description,
parameters=parameters or {},
handler=fn,
)
TOOL_REGISTRY[name] = meta
return fn
return decorator
def get_tools_manifest() -> list[dict[str, Any]]:
"""返回所有已注册工具的清单(供 Dify Agent 读取配置)"""
return [
{
"name": meta.name,
"description": meta.description,
"parameters": meta.parameters,
}
for meta in TOOL_REGISTRY.values()
]
async def execute_tool(
tool_name: str,
db: AsyncSession,
user: CurrentUserPayload,
params: dict[str, Any],
) -> MCPToolResult:
"""根据工具名称执行对应 handler"""
meta = TOOL_REGISTRY.get(tool_name)
if meta is None or meta.handler is None:
return MCPToolResult(
success=False,
message=f"工具 '{tool_name}' 未注册或无 handler",
)
return await meta.handler(db, user, params)
+197
View File
@@ -0,0 +1,197 @@
"""
MCP 工具注册 — 首批业务工具
每个工具函数签名统一为: async def tool_fn(db, user, params) -> MCPToolResult
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.auth import CurrentUserPayload
from app.mcp.registry import MCPToolResult, register_tool
from app.services import customer_service, order_service
@register_tool(
name="search_customers",
description="搜索客户列表,支持按名称模糊搜索和等级过滤",
parameters={
"type": "object",
"properties": {
"keyword": {"type": "string", "description": "客户名称关键词"},
"level": {"type": "string", "enum": ["A", "B", "C"], "description": "客户等级"},
"page": {"type": "integer", "default": 1},
"size": {"type": "integer", "default": 10},
},
},
)
async def search_customers(
db: AsyncSession, user: CurrentUserPayload, params: dict[str, Any],
) -> MCPToolResult:
result = await customer_service.list_customers(
db, user,
page=params.get("page", 1),
size=params.get("size", 10),
keyword=params.get("keyword"),
level=params.get("level"),
)
return MCPToolResult(
success=True, response_type="text",
data=result.model_dump(mode="json"),
message=f"找到 {result.total} 个客户",
)
@register_tool(
name="create_customer",
description="创建新客户(返回确认卡片,需用户确认后执行)",
parameters={
"type": "object",
"properties": {
"name": {"type": "string", "description": "客户名称"},
"level": {"type": "string", "enum": ["A", "B", "C"]},
"contact": {"type": "string", "description": "联系人"},
"phone": {"type": "string", "description": "电话"},
},
"required": ["name"],
},
)
async def create_customer_tool(
db: AsyncSession, user: CurrentUserPayload, params: dict[str, Any],
) -> MCPToolResult:
# 写操作 → 返回 action_card,由前端确认后再真正执行
return MCPToolResult(
success=True, response_type="action_card",
data={
"card_type": "create_customer",
"title": "新建客户确认",
"summary": f"即将创建客户: {params.get('name', '未知')}",
"fields": [
{"label": "客户名称", "value": params.get("name", ""), "editable": True},
{"label": "客户等级", "value": params.get("level", "C"), "editable": True},
{"label": "联系人", "value": params.get("contact", ""), "editable": True},
{"label": "电话", "value": params.get("phone", ""), "editable": True},
],
"actions": [
{"key": "confirm", "label": "确认创建", "style": "primary"},
{"key": "cancel", "label": "取消", "style": "default"},
],
"params": params, # 原始参数,回调时用
},
message="请确认以下客户信息",
)
@register_tool(
name="calculate_price",
description="查询客户专属报价(历史成交价追溯 → 标准价兜底)",
parameters={
"type": "object",
"properties": {
"customer_id": {"type": "string", "description": "客户 UUID"},
"sku_id": {"type": "string", "description": "产品 SKU UUID"},
},
"required": ["customer_id", "sku_id"],
},
)
async def calculate_price_tool(
db: AsyncSession, user: CurrentUserPayload, params: dict[str, Any],
) -> MCPToolResult:
result = await order_service.calculate_price(
db,
customer_id=uuid.UUID(params["customer_id"]),
sku_id=uuid.UUID(params["sku_id"]),
)
return MCPToolResult(
success=True, response_type="text",
data=result.model_dump(mode="json"),
message=f"SKU {result.sku_code} 报价: ¥{result.unit_price} (来源: {result.price_source})",
)
@register_tool(
name="create_order",
description="创建销售订单(返回确认卡片,需用户确认后执行)",
parameters={
"type": "object",
"properties": {
"customer_id": {"type": "string", "description": "客户 UUID"},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sku_id": {"type": "string"},
"qty": {"type": "number"},
"unit_price": {"type": "number"},
},
},
},
"remark": {"type": "string"},
},
"required": ["customer_id", "items"],
},
)
async def create_order_tool(
db: AsyncSession, user: CurrentUserPayload, params: dict[str, Any],
) -> MCPToolResult:
items = params.get("items", [])
total = sum(i.get("qty", 0) * i.get("unit_price", 0) for i in items)
return MCPToolResult(
success=True, response_type="action_card",
data={
"card_type": "create_order",
"title": "创建订单确认",
"summary": f"{len(items)} 项商品,总金额 ¥{total:.2f}",
"fields": [
{"label": "客户ID", "value": params.get("customer_id", ""), "editable": False},
{"label": "商品数", "value": str(len(items)), "editable": False},
{"label": "总金额", "value": f"¥{total:.2f}", "editable": False},
{"label": "备注", "value": params.get("remark", ""), "editable": True},
],
"actions": [
{"key": "confirm", "label": "确认建单", "style": "primary"},
{"key": "cancel", "label": "取消", "style": "default"},
],
"params": params,
},
message="请确认订单信息",
)
@register_tool(
name="search_orders",
description="搜索订单列表,支持按客户名称、订单号、发货/付款状态筛选",
parameters={
"type": "object",
"properties": {
"keyword": {"type": "string", "description": "客户名称关键词"},
"order_no": {"type": "string", "description": "订单号模糊搜索"},
"shipping_state": {
"type": "string",
"enum": ["pending", "partial", "shipped"],
"description": "发货状态",
},
"payment_state": {
"type": "string",
"enum": ["unpaid", "partial", "cleared"],
"description": "付款状态",
},
"page": {"type": "integer", "default": 1},
"size": {"type": "integer", "default": 10},
},
},
)
async def search_orders_tool(
db: AsyncSession, user: CurrentUserPayload, params: dict[str, Any],
) -> MCPToolResult:
result = await order_service.list_orders(
db, user,
page=params.get("page", 1),
size=params.get("size", 10),
keyword=params.get("keyword"),
)
return MCPToolResult(
success=True, response_type="text",
data=result.model_dump(mode="json"),
message=f"找到 {result.total} 个订单",
)
View File
+55
View File
@@ -0,0 +1,55 @@
"""
新增 ORM 模型 — ai_chat_sessions / sales_logs / ai_report_drafts
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, SmallInteger, String, Text, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class AiChatSession(Base):
__tablename__ = "ai_chat_sessions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=False)
role: Mapped[str] = mapped_column(String(10), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
msg_type: Mapped[str] = mapped_column(String(20), default="text")
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
class SalesLog(Base):
__tablename__ = "sales_logs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
salesperson_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=False)
customer_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("crm_customers.id"), nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
log_date: Mapped[date] = mapped_column(Date, default=date.today)
contact_ids: Mapped[list | None] = mapped_column(JSONB, default=list, nullable=True)
ai_processed: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
class AiReportDraft(Base):
__tablename__ = "ai_report_drafts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
author_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=False)
report_type: Mapped[str] = mapped_column(String(20), nullable=False)
period_start: Mapped[date] = mapped_column(Date, nullable=False)
period_end: Mapped[date] = mapped_column(Date, nullable=False)
content_md: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), default="draft")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
+10
View File
@@ -0,0 +1,10 @@
"""
SQLAlchemy 声明式基类
"""
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""所有 ORM Model 的共同父类"""
pass
+68
View File
@@ -0,0 +1,68 @@
"""
CRM 客户域 ORM 模型 —— 映射 crm_customers 表
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, SmallInteger, String, Text, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class CrmCustomer(Base):
__tablename__ = "crm_customers"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
level: Mapped[str] = mapped_column(String(1), default="C")
industry: Mapped[str | None] = mapped_column(String(100), nullable=True)
contact: Mapped[str | None] = mapped_column(String(50), nullable=True)
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
email: Mapped[str | None] = mapped_column(String(100), nullable=True)
address: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_score: Mapped[float] = mapped_column(Numeric(5, 2), default=0)
ai_persona: Mapped[dict | None] = mapped_column(JSONB, default=dict, nullable=True)
owner_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
)
status: Mapped[int] = mapped_column(SmallInteger, default=1)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
# 关系
owner: Mapped["SysUser | None"] = relationship("SysUser", lazy="selectin") # noqa: F821
contacts: Mapped[list["CrmContact"]] = relationship("CrmContact", back_populates="customer", lazy="selectin")
class CrmContact(Base):
"""客户联系人子表 (V5.0) — 映射 crm_contacts"""
__tablename__ = "crm_contacts"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
customer_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("crm_customers.id"), nullable=False
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
title: Mapped[str | None] = mapped_column(String(100), nullable=True)
ai_buyer_persona: Mapped[dict | None] = mapped_column(JSONB, default=dict, nullable=True)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
# 关系
customer: Mapped["CrmCustomer"] = relationship("CrmCustomer", back_populates="contacts")
+96
View File
@@ -0,0 +1,96 @@
"""
ERP 供应链域 ORM 模型
映射: erp_product_categories / erp_product_skus / erp_inventory_flows
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
Numeric,
SmallInteger,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class ProductCategory(Base):
__tablename__ = "erp_product_categories"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("erp_product_categories.id"), nullable=True
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
class ProductSku(Base):
__tablename__ = "erp_product_skus"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
category_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("erp_product_categories.id"), nullable=True
)
sku_code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
spec: Mapped[str | None] = mapped_column(String(100), nullable=True)
standard_price: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
stock_qty: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
warning_threshold: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
unit: Mapped[str] = mapped_column(String(20), default="")
status: Mapped[int] = mapped_column(SmallInteger, default=1)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
category: Mapped[ProductCategory | None] = relationship(
"ProductCategory", lazy="selectin"
)
class InventoryFlow(Base):
__tablename__ = "erp_inventory_flows"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
sku_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("erp_product_skus.id"), nullable=False
)
change_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
reason: Mapped[str] = mapped_column(String(50), nullable=False)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
operator_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
sku: Mapped[ProductSku | None] = relationship("ProductSku", lazy="selectin")
operator: Mapped["SysUser | None"] = relationship("SysUser", lazy="selectin") # noqa: F821
+152
View File
@@ -0,0 +1,152 @@
"""
财务票据域 ORM 模型
映射: fin_invoice_pool / fin_expense_records / fin_expense_details
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
Numeric,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class FinInvoicePool(Base):
__tablename__ = "fin_invoice_pool"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
uploader_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
)
file_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
merchant_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
invoice_date: Mapped[date | None] = mapped_column(Date, nullable=True)
type: Mapped[str] = mapped_column(String(30), nullable=False, default="expense")
ai_extracted_data: Mapped[dict] = mapped_column(JSONB, default=dict)
is_used: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
uploader: Mapped["SysUser | None"] = relationship("SysUser", lazy="selectin") # noqa: F821
class FinExpenseRecord(Base):
__tablename__ = "fin_expense_records"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
system_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
applicant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=False
)
total_amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
approved_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
)
approved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
applicant: Mapped["SysUser"] = relationship( # noqa: F821
"SysUser", foreign_keys=[applicant_id], lazy="selectin"
)
approver: Mapped["SysUser | None"] = relationship( # noqa: F821
"SysUser", foreign_keys=[approved_by], lazy="selectin"
)
details: Mapped[list[FinExpenseDetail]] = relationship(
"FinExpenseDetail", back_populates="expense_record", lazy="selectin"
)
class FinExpenseDetail(Base):
__tablename__ = "fin_expense_details"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
expense_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("fin_expense_records.id"), nullable=False
)
invoice_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("fin_invoice_pool.id"), nullable=True
)
expense_desc: Mapped[str | None] = mapped_column(String(500), nullable=True)
expense_date: Mapped[date | None] = mapped_column(Date, nullable=True)
original_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
offset_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
expense_record: Mapped[FinExpenseRecord] = relationship(
"FinExpenseRecord", back_populates="details"
)
invoice: Mapped[FinInvoicePool | None] = relationship(
"FinInvoicePool", lazy="selectin"
)
class FinSalesInvoice(Base):
"""销项发票表(AR 应收账款核心)"""
__tablename__ = "finance_sales_invoices"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
issuer: Mapped[str] = mapped_column(String(200), nullable=False)
receiver_customer_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("crm_customers.id"), nullable=False
)
invoice_number: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
billing_date: Mapped[date] = mapped_column(Date, nullable=False)
payment_status: Mapped[str] = mapped_column(
String(20), nullable=False, default="未回款"
)
payment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
payment_amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
receiver_customer: Mapped["CrmCustomer"] = relationship( # noqa: F821
"CrmCustomer", lazy="selectin"
)
creator: Mapped["SysUser | None"] = relationship( # noqa: F821
"SysUser", lazy="selectin"
)
+88
View File
@@ -0,0 +1,88 @@
"""
ERP 交易域 ORM 模型
映射: erp_orders / erp_order_items
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
Numeric,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class ErpOrder(Base):
__tablename__ = "erp_orders"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
order_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
customer_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("crm_customers.id"), nullable=False
)
salesperson_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
)
total_amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
shipping_state: Mapped[str] = mapped_column(
String(20), nullable=False, default="pending"
)
payment_state: Mapped[str] = mapped_column(
String(20), nullable=False, default="unpaid"
)
paid_amount: Mapped[float] = mapped_column(Numeric(14, 2), default=0)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
order_date: Mapped[date] = mapped_column(Date, default=date.today)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
# 关系
customer: Mapped["CrmCustomer"] = relationship("CrmCustomer", lazy="selectin") # noqa: F821
salesperson: Mapped["SysUser | None"] = relationship("SysUser", lazy="selectin") # noqa: F821
items: Mapped[list[ErpOrderItem]] = relationship(
"ErpOrderItem", back_populates="order", lazy="selectin"
)
class ErpOrderItem(Base):
__tablename__ = "erp_order_items"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
order_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("erp_orders.id"), nullable=False
)
sku_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("erp_product_skus.id"), nullable=False
)
qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
unit_price: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
sub_total: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
shipped_qty: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
# 关系
order: Mapped[ErpOrder] = relationship("ErpOrder", back_populates="items")
sku: Mapped["ProductSku"] = relationship("ProductSku", lazy="selectin") # noqa: F821
+86
View File
@@ -0,0 +1,86 @@
"""
ERP 物流域 ORM 模型
映射: erp_shipping_records / erp_shipping_items
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import (
Boolean,
Date,
DateTime,
ForeignKey,
Numeric,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class ErpShippingRecord(Base):
__tablename__ = "erp_shipping_records"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
shipping_no: Mapped[str] = mapped_column(String(30), unique=True, nullable=False)
order_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("erp_orders.id"), nullable=False
)
carrier: Mapped[str | None] = mapped_column(String(100), nullable=True)
tracking_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="transit")
ship_date: Mapped[date] = mapped_column(Date, default=date.today)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
operator_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sys_users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
# 关系
order: Mapped["ErpOrder"] = relationship("ErpOrder", lazy="selectin") # noqa: F821
operator: Mapped["SysUser | None"] = relationship("SysUser", lazy="selectin") # noqa: F821
items: Mapped[list[ErpShippingItem]] = relationship(
"ErpShippingItem", back_populates="shipping_record", lazy="selectin"
)
class ErpShippingItem(Base):
__tablename__ = "erp_shipping_items"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
shipping_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("erp_shipping_records.id"), nullable=False
)
order_item_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("erp_order_items.id"), nullable=False
)
sku_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("erp_product_skus.id"), nullable=False
)
shipped_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
# 关系
shipping_record: Mapped[ErpShippingRecord] = relationship(
"ErpShippingRecord", back_populates="items"
)
order_item: Mapped["ErpOrderItem"] = relationship("ErpOrderItem", lazy="selectin") # noqa: F821
sku: Mapped["ProductSku"] = relationship("ProductSku", lazy="selectin") # noqa: F821
+99
View File
@@ -0,0 +1,99 @@
"""
RBAC 权限域 ORM 模型 —— 映射到已有的 sys_departments / sys_roles / sys_users 表
注意: 表已由 schema.sql 创建,这里仅做 ORM 映射,不使用 metadata.create_all
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, SmallInteger, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class SysDepartment(Base):
__tablename__ = "sys_departments"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sys_departments.id"), nullable=True
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
sort_order: Mapped[int] = mapped_column(SmallInteger, default=0)
status: Mapped[int] = mapped_column(SmallInteger, default=1)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
# 自引用关系
children: Mapped[list[SysDepartment]] = relationship(
"SysDepartment", back_populates="parent", lazy="selectin"
)
parent: Mapped[SysDepartment | None] = relationship(
"SysDepartment", back_populates="children", remote_side=[id], lazy="selectin"
)
class SysRole(Base):
__tablename__ = "sys_roles"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
role_name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
data_scope: Mapped[str] = mapped_column(String(20), nullable=False, default="self")
menu_keys: Mapped[dict] = mapped_column(JSONB, default=list)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[int] = mapped_column(SmallInteger, default=1)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
class SysUser(Base):
__tablename__ = "sys_users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
dept_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sys_departments.id"), nullable=True
)
role_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("sys_roles.id"), nullable=True
)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
real_name: Mapped[str | None] = mapped_column(String(50), nullable=True)
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
email: Mapped[str | None] = mapped_column(String(100), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
status: Mapped[int] = mapped_column(SmallInteger, default=1)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
# 关系
department: Mapped[SysDepartment | None] = relationship(
"SysDepartment", lazy="selectin"
)
role: Mapped[SysRole | None] = relationship("SysRole", lazy="selectin")
View File
+35
View File
@@ -0,0 +1,35 @@
"""
Action Card 协议 v1 Schema & 回调处理
"""
from __future__ import annotations
from pydantic import BaseModel
class ActionCardField(BaseModel):
label: str
value: str
editable: bool = False
class ActionButton(BaseModel):
key: str # "confirm" | "cancel" | "edit"
label: str # "确认建单" | "取消"
style: str = "primary" # "primary" | "danger" | "default"
class ActionCardPayload(BaseModel):
card_id: str # 前端生成的唯一 ID
card_type: str # "create_order" | "create_customer" | "create_shipping" ...
title: str
summary: str
fields: list[ActionCardField] = []
actions: list[ActionButton] = []
params: dict = {} # 原始工具参数,回调时传回
class ActionCardCallback(BaseModel):
"""前端点击确认/取消后的回调请求"""
card_id: str | None = None
card_type: str
action_key: str # "confirm" | "cancel"
params: dict = {} # 可能被用户在卡片上修改过的参数
+41
View File
@@ -0,0 +1,41 @@
"""
Auth 相关 Pydantic V2 Schema
"""
from __future__ import annotations
import uuid
from pydantic import BaseModel, Field
# ── 登录请求 ──────────────────────────────────────────────
class LoginRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=50, examples=["admin"])
password: str = Field(..., min_length=1, max_length=128, examples=["123456"])
# ── Token 响应 ────────────────────────────────────────────
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
# ── 当前用户信息(从 JWT 解析 + DB 查表组合而来)──────────
class CurrentUserPayload(BaseModel):
"""注入到 Dependency 中的用户权限上下文"""
user_id: uuid.UUID
username: str
real_name: str | None = None
dept_id: uuid.UUID | None = None
dept_name: str | None = None
role_id: uuid.UUID | None = None
role_name: str | None = None
data_scope: str = "self" # all / dept_and_sub / self
menu_keys: list[str] = Field(default_factory=list)
# ── 修改密码请求 ────────────────────────────────────
class UpdatePasswordRequest(BaseModel):
old_password: str = Field(..., min_length=1, max_length=128)
new_password: str = Field(..., min_length=6, max_length=128, description="新密码至少6位")
+66
View File
@@ -0,0 +1,66 @@
"""
CRM 客户域 Pydantic V2 Schemas
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# ── 创建 ──────────────────────────────────────────────────
class CustomerCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200, examples=["中石化润滑油公司"])
level: str = Field(default="C", pattern=r"^[ABC]$")
industry: str | None = Field(default=None, max_length=100)
contact: str | None = Field(default=None, max_length=50)
phone: str | None = Field(default=None, max_length=30)
email: str | None = Field(default=None, max_length=100)
address: str | None = None
status: int = Field(default=1, ge=0, le=1)
# ── 更新 ──────────────────────────────────────────────────
class CustomerUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
level: str | None = Field(default=None, pattern=r"^[ABC]$")
industry: str | None = Field(default=None, max_length=100)
contact: str | None = Field(default=None, max_length=50)
phone: str | None = Field(default=None, max_length=30)
email: str | None = Field(default=None, max_length=100)
address: str | None = None
status: int | None = Field(default=None, ge=0, le=1)
# ── 响应 ──────────────────────────────────────────────────
class CustomerResponse(BaseModel):
id: uuid.UUID
name: str
level: str
industry: str | None = None
contact: str | None = None
phone: str | None = None
email: str | None = None
address: str | None = None
ai_score: float = 0
ai_persona: dict[str, Any] | None = None
owner_id: uuid.UUID | None = None
owner_name: str | None = None
status: int = 1
is_deleted: bool = False
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# ── 分页列表 ─────────────────────────────────────────────
class CustomerListResponse(BaseModel):
total: int
items: list[CustomerResponse]
page: int
size: int
+121
View File
@@ -0,0 +1,121 @@
"""
ERP 供应链域 Pydantic V2 Schemas
分类树 / 产品 SKU / 库存流水
"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 分类树
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class CategoryNode(BaseModel):
"""递归树节点,适配前端 el-tree"""
id: uuid.UUID
parent_id: uuid.UUID | None = None
name: str
sort_order: int = 0
children: list[CategoryNode] = Field(default_factory=list)
model_config = {"from_attributes": True}
class CategoryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
parent_id: uuid.UUID | None = None
sort_order: int = Field(default=0)
class CategoryUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
parent_id: uuid.UUID | None = None
sort_order: int | None = None
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 产品 SKU
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class SkuCreate(BaseModel):
sku_code: str = Field(..., min_length=1, max_length=50, examples=["SKU-LUB-001"])
name: str = Field(..., min_length=1, max_length=200, examples=["昆仑天润 KR9"])
category_id: uuid.UUID | None = None
spec: str | None = Field(default=None, max_length=100, examples=["200L/桶"])
standard_price: float = Field(default=0, ge=0)
stock_qty: float = Field(default=0, ge=0, description="初始库存(仅创建时可设,后续只能通过流水变更)")
warning_threshold: float = Field(default=0, ge=0)
unit: str = Field(default="", max_length=20)
status: int = Field(default=1, ge=0, le=1)
class SkuUpdate(BaseModel):
"""
⚠️ 架构师红线:不包含 stock_qty 字段!
库存只能通过 InventoryFlow 事务接口变更。
"""
name: str | None = Field(default=None, min_length=1, max_length=200)
category_id: uuid.UUID | None = None
spec: str | None = Field(default=None, max_length=100)
standard_price: float | None = Field(default=None, ge=0)
warning_threshold: float | None = Field(default=None, ge=0)
unit: str | None = Field(default=None, max_length=20)
status: int | None = Field(default=None, ge=0, le=1)
class SkuResponse(BaseModel):
id: uuid.UUID
sku_code: str
name: str
category_id: uuid.UUID | None = None
category_name: str | None = None
spec: str | None = None
standard_price: float = 0
stock_qty: float = 0
warning_threshold: float = 0
unit: str = ""
status: int = 1
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class SkuListResponse(BaseModel):
total: int
items: list[SkuResponse]
page: int
size: int
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 库存流水
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class InventoryFlowCreate(BaseModel):
"""库存变更请求"""
sku_id: uuid.UUID = Field(..., description="产品 SKU ID")
change_qty: float = Field(..., description="变更数量,正=入库 负=出库")
reason: str = Field(
..., max_length=50,
description="变动原因: purchase/shipment/loss/adjust",
examples=["purchase"],
)
remark: str | None = Field(default=None, description="备注")
class InventoryFlowResponse(BaseModel):
id: uuid.UUID
sku_id: uuid.UUID
sku_code: str | None = None
sku_name: str | None = None
change_qty: float
reason: str
remark: str | None = None
operator_id: uuid.UUID | None = None
operator_name: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
+139
View File
@@ -0,0 +1,139 @@
"""
财务票据域 Pydantic V2 Schemas
票据池 / 报销单(嵌套明细)/ 审批状态变更
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from typing import Any
from pydantic import BaseModel, Field
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 票据池
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class InvoiceCreate(BaseModel):
file_url: str | None = Field(default=None, max_length=500)
merchant_name: str | None = Field(default=None, max_length=200)
amount: float = Field(default=0, ge=0)
invoice_date: date | None = None
type: str = Field(default="expense", pattern=r"^(expense|customer)$")
ai_extracted_data: dict[str, Any] = Field(
default_factory=dict,
description="OCR/AI 解析的结构化数据 (JSONB)",
examples=[{"merchant": "中国石化", "amount": 580.00, "date": "2026-02-20"}],
)
class InvoiceResponse(BaseModel):
id: uuid.UUID
uploader_id: uuid.UUID | None = None
uploader_name: str | None = None
file_url: str | None = None
merchant_name: str | None = None
amount: float = 0
invoice_date: date | None = None
type: str
ai_extracted_data: dict[str, Any] = Field(default_factory=dict)
is_used: bool = False
created_at: datetime
model_config = {"from_attributes": True}
class InvoiceListResponse(BaseModel):
total: int
items: list[InvoiceResponse]
page: int
size: int
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 报销单创建(嵌套明细)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ExpenseDetailCreate(BaseModel):
invoice_id: uuid.UUID = Field(..., description="关联发票 ID")
expense_desc: str | None = Field(default=None, max_length=500)
expense_date: date | None = Field(default=None, description="费用发生日期")
original_type: str | None = Field(
default=None, max_length=50, examples=["fuel", "entertainment", "travel", "office"]
)
offset_type: str | None = Field(default=None, max_length=50)
amount: float = Field(..., gt=0, description="本行报销金额")
class ExpenseCreate(BaseModel):
total_amount: float = Field(..., gt=0)
remark: str | None = None
items: list[ExpenseDetailCreate] = Field(..., min_length=1)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 报销明细响应
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ExpenseDetailResponse(BaseModel):
id: uuid.UUID
invoice_id: uuid.UUID | None = None
invoice_merchant: str | None = None
invoice_amount: float | None = None
expense_desc: str | None = None
expense_date: date | None = None
original_type: str | None = None
offset_type: str | None = None
amount: float = 0
model_config = {"from_attributes": True}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 报销单响应
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ExpenseResponse(BaseModel):
id: uuid.UUID
system_no: str
applicant_id: uuid.UUID
applicant_name: str | None = None
total_amount: float
status: str
remark: str | None = None
approved_by: uuid.UUID | None = None
approver_name: str | None = None
approved_at: datetime | None = None
details: list[ExpenseDetailResponse] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ExpenseBriefResponse(BaseModel):
id: uuid.UUID
system_no: str
applicant_id: uuid.UUID
applicant_name: str | None = None
total_amount: float
status: str
created_at: datetime
model_config = {"from_attributes": True}
class ExpenseListResponse(BaseModel):
total: int
items: list[ExpenseBriefResponse]
page: int
size: int
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 审批状态变更
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ExpenseStatusUpdate(BaseModel):
action: str = Field(
..., pattern=r"^(withdraw|approve|reject)$",
description="状态变更动作: withdraw/approve/reject",
)
reason: str | None = Field(default=None, description="审批意见或驳回原因")
+111
View File
@@ -0,0 +1,111 @@
"""
ERP 交易域 Pydantic V2 Schemas
订单创建(嵌套明细)/ 详情响应 / 动态定价
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from pydantic import BaseModel, Field
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# B2B 动态定价
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class PriceCalculateRequest(BaseModel):
customer_id: uuid.UUID
sku_id: uuid.UUID
class PriceCalculateResponse(BaseModel):
sku_id: uuid.UUID
sku_code: str
sku_name: str
unit_price: float
price_source: str # "history" | "standard"
last_order_no: str | None = None
last_order_date: date | None = None
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 订单创建(嵌套明细)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class OrderItemCreate(BaseModel):
sku_id: uuid.UUID
qty: float = Field(..., gt=0, description="订购数量")
unit_price: float = Field(..., ge=0, description="成交单价")
class OrderCreate(BaseModel):
customer_id: uuid.UUID
remark: str | None = None
order_date: date | None = None
items: list[OrderItemCreate] = Field(..., min_length=1, description="至少一个明细行")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 订单明细响应
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class OrderItemResponse(BaseModel):
id: uuid.UUID
sku_id: uuid.UUID
sku_code: str | None = None
sku_name: str | None = None
spec: str | None = None
qty: float
unit_price: float
sub_total: float
shipped_qty: float = 0
model_config = {"from_attributes": True}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 订单主表响应
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class OrderResponse(BaseModel):
id: uuid.UUID
order_no: str
customer_id: uuid.UUID
customer_name: str | None = None
salesperson_id: uuid.UUID | None = None
salesperson_name: str | None = None
total_amount: float
shipping_state: str
payment_state: str
paid_amount: float = 0
remark: str | None = None
order_date: date
items: list[OrderItemResponse] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 订单列表(不含 items 明细,减少传输体积)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class OrderBriefResponse(BaseModel):
id: uuid.UUID
order_no: str
customer_id: uuid.UUID
customer_name: str | None = None
salesperson_name: str | None = None
total_amount: float
shipping_state: str
payment_state: str
paid_amount: float = 0
order_date: date
created_at: datetime
model_config = {"from_attributes": True}
class OrderListResponse(BaseModel):
total: int
items: list[OrderBriefResponse]
page: int
size: int
+81
View File
@@ -0,0 +1,81 @@
"""
V5.0 AI 画像强约束 Pydantic Schema
- CompanyPersona: 企业级画像 (crm_customers.ai_persona)
- BuyerPersona: 联系人级画像 (crm_contacts.ai_buyer_persona)
"""
from __future__ import annotations
from pydantic import BaseModel, Field
# ═══════════════════════════════════════════════════════
# 企业画像 (crm_customers.ai_persona)
# ═══════════════════════════════════════════════════════
class Firmographics(BaseModel):
"""企业属性"""
industry: str | None = None
scale: str | None = None
business_model: str | None = None
class DynamicStatus(BaseModel):
"""时序动态"""
pain_points: list[str] = Field(default_factory=list)
purchase_intent: str | None = None
recent_events: list[str] = Field(default_factory=list)
class CompanyPersona(BaseModel):
"""企业级 AI 画像 Schema"""
firmographics: Firmographics = Field(default_factory=Firmographics)
dynamic_status: DynamicStatus = Field(default_factory=DynamicStatus)
summary: str | None = None
# ═══════════════════════════════════════════════════════
# 联系人画像 (crm_contacts.ai_buyer_persona)
# ═══════════════════════════════════════════════════════
class BuyerRole(BaseModel):
"""决策角色"""
decision_role: str | None = None # 决策者 / 影响者 / 执行者
authority_level: str | None = None # 高 / 中 / 低
class BuyerKPI(BaseModel):
"""核心痛点与目标"""
core_goals: list[str] = Field(default_factory=list)
pain_points: list[str] = Field(default_factory=list)
class BuyerPreference(BaseModel):
"""交互偏好"""
comm_style: str | None = None
meeting_preference: str | None = None
topics_of_interest: list[str] = Field(default_factory=list)
class BuyerPersona(BaseModel):
"""联系人级 AI 画像 Schema"""
role: BuyerRole = Field(default_factory=BuyerRole)
kpi: BuyerKPI = Field(default_factory=BuyerKPI)
preference: BuyerPreference = Field(default_factory=BuyerPreference)
# ═══════════════════════════════════════════════════════
# 双轨画像回写 PayloadDify Workflow 回调用)
# ═══════════════════════════════════════════════════════
class ContactPersonaUpdate(BaseModel):
"""单个联系人画像增量"""
contact_id: str
role: dict | None = None
kpi: dict | None = None
preference: dict | None = None
class PersonaUpdatePayload(BaseModel):
"""双轨画像回写请求体"""
company_updates: dict | None = None
contact_updates: list[ContactPersonaUpdate] | None = None
+22
View File
@@ -0,0 +1,22 @@
"""
统一响应 Schema
"""
from __future__ import annotations
from typing import Any, Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
code: int = 200
data: T | None = None
message: str = "ok"
def ok(data: Any = None, message: str = "ok") -> dict:
"""快捷返回成功响应"""
return {"code": 200, "data": data, "message": message}
+55
View File
@@ -0,0 +1,55 @@
"""
销项发票 Pydantic V2 Schemas
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from pydantic import BaseModel, Field
class SalesInvoiceCreate(BaseModel):
issuer: str = Field(..., min_length=1, max_length=200, examples=["XX润滑油有限公司"])
receiver_customer_id: uuid.UUID = Field(..., description="受票方客户 ID")
invoice_number: str = Field(..., min_length=1, max_length=100, examples=["INV-20260312-001"])
amount: float = Field(..., gt=0, description="票面金额")
billing_date: date = Field(..., description="开票日期")
remark: str | None = None
class SalesInvoiceUpdate(BaseModel):
payment_status: str | None = Field(
default=None, pattern=r"^(未回款|部分回款|已结清)$",
description="回款状态",
)
payment_date: date | None = None
payment_amount: float | None = Field(default=None, ge=0, description="已回款金额")
remark: str | None = None
class SalesInvoiceResponse(BaseModel):
id: uuid.UUID
issuer: str
receiver_customer_id: uuid.UUID
customer_name: str | None = None
invoice_number: str
amount: float
billing_date: date
payment_status: str = "未回款"
payment_date: date | None = None
payment_amount: float = 0
remark: str | None = None
created_by: uuid.UUID | None = None
creator_name: str | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class SalesInvoiceListResponse(BaseModel):
total: int
items: list[SalesInvoiceResponse]
page: int
size: int
+95
View File
@@ -0,0 +1,95 @@
"""
ERP 物流域 Pydantic V2 Schemas
发货单创建(嵌套明细)/ 列表 / 详情
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from pydantic import BaseModel, Field
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 发货明细创建
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ShippingItemCreate(BaseModel):
order_item_id: uuid.UUID = Field(..., description="关联的订单明细行 ID")
sku_id: uuid.UUID = Field(..., description="产品 SKU ID")
shipped_qty: float = Field(..., gt=0, description="本次发货数量,必须 > 0")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 发货单创建
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ShippingCreate(BaseModel):
order_id: uuid.UUID = Field(..., description="关联订单 ID")
carrier: str | None = Field(default=None, max_length=100, examples=["德邦物流"])
tracking_no: str | None = Field(default=None, max_length=100, examples=["DB202602270001"])
ship_date: date | None = None
remark: str | None = None
items: list[ShippingItemCreate] = Field(..., min_length=1, description="至少一个发货明细行")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 发货明细响应
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ShippingItemResponse(BaseModel):
id: uuid.UUID
order_item_id: uuid.UUID
sku_id: uuid.UUID
sku_code: str | None = None
sku_name: str | None = None
spec: str | None = None
unit: str | None = None
shipped_qty: float
model_config = {"from_attributes": True}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 发货单响应
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ShippingResponse(BaseModel):
id: uuid.UUID
shipping_no: str
order_id: uuid.UUID
order_no: str | None = None
customer_name: str | None = None
carrier: str | None = None
tracking_no: str | None = None
status: str
ship_date: date
remark: str | None = None
operator_name: str | None = None
items: list[ShippingItemResponse] = Field(default_factory=list)
created_at: datetime
model_config = {"from_attributes": True}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 发货列表(Brief,不带明细减少体积)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class ShippingBriefResponse(BaseModel):
id: uuid.UUID
shipping_no: str
order_id: uuid.UUID
order_no: str | None = None
customer_name: str | None = None
carrier: str | None = None
tracking_no: str | None = None
status: str
ship_date: date
operator_name: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
class ShippingListResponse(BaseModel):
total: int
items: list[ShippingBriefResponse]
page: int
size: int
+114
View File
@@ -0,0 +1,114 @@
"""
系统设置域 Pydantic V2 Schemas
部门树 / 角色(含 JSONB menu_keys/ 用户管理
"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 部门树
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class DeptNode(BaseModel):
id: uuid.UUID
parent_id: uuid.UUID | None = None
name: str
sort_order: int = 0
status: int = 1
children: list[DeptNode] = Field(default_factory=list)
model_config = {"from_attributes": True}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 角色
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class RoleCreate(BaseModel):
role_name: str = Field(..., min_length=1, max_length=50)
data_scope: str = Field(
default="self", pattern=r"^(all|dept_and_sub|self)$"
)
menu_keys: list[str] = Field(
default_factory=list,
description="前端路由 name 数组,JSONB 存储",
examples=[["CustomerList", "OrderList", "ProductList"]],
)
description: str | None = Field(default=None, max_length=255)
status: int = Field(default=1, ge=0, le=1)
class RoleUpdate(BaseModel):
role_name: str | None = Field(default=None, min_length=1, max_length=50)
data_scope: str | None = Field(default=None, pattern=r"^(all|dept_and_sub|self)$")
menu_keys: list[str] | None = None
description: str | None = Field(default=None, max_length=255)
status: int | None = Field(default=None, ge=0, le=1)
class RoleResponse(BaseModel):
id: uuid.UUID
role_name: str
data_scope: str
menu_keys: list[str] = Field(default_factory=list)
description: str | None = None
status: int = 1
created_at: datetime
model_config = {"from_attributes": True}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 用户/员工
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class UserCreate(BaseModel):
username: str = Field(..., min_length=2, max_length=50)
password: str = Field(..., min_length=6, max_length=128, description="明文密码,后端 bcrypt 哈希后存储")
real_name: str | None = Field(default=None, max_length=50)
phone: str | None = Field(default=None, max_length=20)
email: str | None = Field(default=None, max_length=100)
dept_id: uuid.UUID | None = None
role_id: uuid.UUID | None = None
status: int = Field(default=1, ge=0, le=1)
class UserUpdate(BaseModel):
real_name: str | None = Field(default=None, max_length=50)
phone: str | None = Field(default=None, max_length=20)
email: str | None = Field(default=None, max_length=100)
dept_id: uuid.UUID | None = None
role_id: uuid.UUID | None = None
status: int | None = Field(default=None, ge=0, le=1)
class UserResetPassword(BaseModel):
new_password: str = Field(..., min_length=6, max_length=128)
class UserResponse(BaseModel):
id: uuid.UUID
username: str
real_name: str | None = None
phone: str | None = None
email: str | None = None
dept_id: uuid.UUID | None = None
dept_name: str | None = None
role_id: uuid.UUID | None = None
role_name: str | None = None
data_scope: str | None = None
status: int = 1
last_login_at: datetime | None = None
created_at: datetime
model_config = {"from_attributes": True}
class UserListResponse(BaseModel):
total: int
items: list[UserResponse]
page: int
size: int
+2
View File
@@ -0,0 +1,2 @@
# Service Layer — 业务逻辑集中层
# REST API 路由和 MCP 工具共用此层函数
+58
View File
@@ -0,0 +1,58 @@
"""
AI 对话历史服务 — 持久化 + 查询
"""
from __future__ import annotations
import uuid
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ai import AiChatSession
async def save_message(
db: AsyncSession,
user_id: uuid.UUID,
role: str,
content: str,
msg_type: str = "text",
) -> AiChatSession:
"""保存一条对话消息"""
msg = AiChatSession(
user_id=user_id,
role=role,
content=content,
msg_type=msg_type,
)
db.add(msg)
await db.commit()
await db.refresh(msg)
return msg
async def load_history(
db: AsyncSession,
user_id: uuid.UUID,
limit: int = 50,
) -> list[dict]:
"""加载用户最近 N 条对话(时间正序返回,方便前端渲染)"""
stmt = (
select(AiChatSession)
.where(AiChatSession.user_id == user_id)
.order_by(desc(AiChatSession.created_at))
.limit(limit)
)
result = await db.execute(stmt)
rows = result.scalars().all()
# 反转为时间正序
rows = list(reversed(rows))
return [
{
"id": str(r.id),
"role": r.role,
"content": r.content,
"type": r.msg_type,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
+87
View File
@@ -0,0 +1,87 @@
"""
联系人服务 — CRUD (V5.0)
"""
from __future__ import annotations
import uuid
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.crm import CrmContact
async def list_contacts(
db: AsyncSession,
customer_id: uuid.UUID,
) -> list[dict]:
"""列出某客户下所有未删除的联系人"""
stmt = (
select(CrmContact)
.where(
CrmContact.customer_id == customer_id,
CrmContact.is_deleted.is_(False),
)
.order_by(CrmContact.created_at)
)
rows = (await db.execute(stmt)).scalars().all()
return [_to_dict(c) for c in rows]
async def create_contact(
db: AsyncSession,
customer_id: uuid.UUID,
data: dict,
) -> dict:
"""新增联系人"""
contact = CrmContact(
customer_id=customer_id,
name=data["name"],
phone=data.get("phone"),
title=data.get("title"),
)
db.add(contact)
await db.commit()
await db.refresh(contact)
return _to_dict(contact)
async def update_contact(
db: AsyncSession,
contact_id: uuid.UUID,
data: dict,
) -> dict:
"""编辑联系人"""
contact = await db.get(CrmContact, contact_id)
if not contact or contact.is_deleted:
raise ValueError("联系人不存在")
for field in ("name", "phone", "title"):
if field in data:
setattr(contact, field, data[field])
await db.commit()
await db.refresh(contact)
return _to_dict(contact)
async def delete_contact(
db: AsyncSession,
contact_id: uuid.UUID,
) -> None:
"""软删除联系人"""
contact = await db.get(CrmContact, contact_id)
if not contact or contact.is_deleted:
raise ValueError("联系人不存在")
contact.is_deleted = True
await db.commit()
def _to_dict(c: CrmContact) -> dict:
return {
"id": str(c.id),
"customer_id": str(c.customer_id),
"name": c.name,
"phone": c.phone,
"title": c.title,
"ai_buyer_persona": c.ai_buyer_persona or {},
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
+336
View File
@@ -0,0 +1,336 @@
"""
客户管理 Service 层
REST API 路由 和 MCP 工具 共用此层函数
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
from app.models.crm import CrmCustomer
from app.schemas.auth import CurrentUserPayload
from app.schemas.crm import (
CustomerCreate,
CustomerListResponse,
CustomerResponse,
CustomerUpdate,
)
# ── ORM → Response ───────────────────────────────────────
def _to_response(c: CrmCustomer) -> CustomerResponse:
return CustomerResponse(
id=c.id,
name=c.name,
level=c.level,
industry=c.industry,
contact=c.contact,
phone=c.phone,
email=c.email,
address=c.address,
ai_score=float(c.ai_score or 0),
ai_persona=c.ai_persona,
owner_id=c.owner_id,
owner_name=c.owner.real_name if c.owner else None,
status=c.status,
is_deleted=c.is_deleted,
created_at=c.created_at,
updated_at=c.updated_at,
)
# ── 权限校验 ─────────────────────────────────────────────
def _check_access(customer: CrmCustomer, user: CurrentUserPayload) -> None:
if user.data_scope == "all":
return
if user.data_scope == "dept_and_sub":
return # 简化版:放通本部门
# data_scope == 'self'
if customer.owner_id != user.user_id:
raise ForbiddenException("无权访问该客户(数据权限:仅本人)")
# ── Service Functions ────────────────────────────────────
async def create_customer(
db: AsyncSession,
user: CurrentUserPayload,
body: CustomerCreate,
) -> CustomerResponse:
customer = CrmCustomer(
name=body.name,
level=body.level,
industry=body.industry,
contact=body.contact,
phone=body.phone,
email=body.email,
address=body.address,
status=body.status,
owner_id=user.user_id,
)
db.add(customer)
await db.commit()
await db.refresh(customer)
return _to_response(customer)
async def list_customers(
db: AsyncSession,
user: CurrentUserPayload,
page: int = 1,
size: int = 20,
keyword: str | None = None,
level: str | None = None,
include_archived: bool = False,
) -> CustomerListResponse:
if include_archived:
base_where = [] # 不过滤 is_deleted
else:
base_where = [CrmCustomer.is_deleted.is_(False)]
# ── 数据域隔离 ──
if user.data_scope == "self":
base_where.append(CrmCustomer.owner_id == user.user_id)
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
from app.models.sys import SysUser
sub = select(SysUser.id).where(
SysUser.dept_id == user.dept_id,
SysUser.is_deleted.is_(False),
)
base_where.append(CrmCustomer.owner_id.in_(sub))
if keyword:
base_where.append(CrmCustomer.name.ilike(f"%{keyword}%"))
if level:
base_where.append(CrmCustomer.level == level)
total = (
await db.execute(select(func.count()).select_from(CrmCustomer).where(*base_where))
).scalar() or 0
stmt = (
select(CrmCustomer)
.where(*base_where)
.order_by(CrmCustomer.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
customers = (await db.execute(stmt)).scalars().all()
return CustomerListResponse(
total=total,
items=[_to_response(c) for c in customers],
page=page,
size=size,
)
async def get_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
) -> CustomerResponse:
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(False),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或已被删除")
_check_access(customer, user)
return _to_response(customer)
async def update_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
body: CustomerUpdate,
) -> CustomerResponse:
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(False),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或已被删除")
_check_access(customer, user)
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(CrmCustomer).where(CrmCustomer.id == customer_id).values(**update_data)
)
await db.commit()
updated = (
await db.execute(select(CrmCustomer).where(CrmCustomer.id == customer_id))
).scalar_one()
return _to_response(updated)
async def delete_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
) -> None:
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(False),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或已被删除")
_check_access(customer, user)
await db.execute(
update(CrmCustomer)
.where(CrmCustomer.id == customer_id)
.values(is_deleted=True, updated_at=datetime.utcnow())
)
await db.commit()
async def restore_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
) -> None:
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(True),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或未被归档")
_check_access(customer, user)
await db.execute(
update(CrmCustomer)
.where(CrmCustomer.id == customer_id)
.values(is_deleted=False, updated_at=datetime.utcnow())
)
await db.commit()
async def get_customer_products(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
) -> list[dict]:
"""通过订单反查客户关联的产品 SKU(去重聚合)"""
from app.models.order import ErpOrder, ErpOrderItem
from app.models.erp import ProductSku
from sqlalchemy import desc as sa_desc
# 确认客户存在
stmt = select(CrmCustomer).where(CrmCustomer.id == customer_id)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在")
_check_access(customer, user)
# 聚合: 该客户所有订单中的 SKU,含总数量、最近下单时间
agg_stmt = (
select(
ErpOrderItem.sku_id,
ProductSku.sku_code,
ProductSku.name.label("sku_name"),
ProductSku.spec,
func.sum(ErpOrderItem.qty).label("total_qty"),
func.max(ErpOrder.order_date).label("last_order_date"),
func.count(func.distinct(ErpOrder.id)).label("order_count"),
)
.join(ErpOrder, ErpOrderItem.order_id == ErpOrder.id)
.join(ProductSku, ErpOrderItem.sku_id == ProductSku.id)
.where(
ErpOrder.customer_id == customer_id,
ErpOrder.is_deleted.is_(False),
ErpOrderItem.is_deleted.is_(False),
)
.group_by(
ErpOrderItem.sku_id,
ProductSku.sku_code,
ProductSku.name,
ProductSku.spec,
)
.order_by(sa_desc("last_order_date"))
)
rows = (await db.execute(agg_stmt)).all()
return [
{
"sku_id": str(r.sku_id),
"sku_code": r.sku_code,
"sku_name": r.sku_name,
"spec": r.spec,
"total_qty": float(r.total_qty),
"order_count": r.order_count,
"last_order_date": r.last_order_date.isoformat() if r.last_order_date else None,
}
for r in rows
]
async def search_customers(
db: AsyncSession,
user: CurrentUserPayload,
q: str,
limit: int = 20,
) -> list[dict]:
"""模糊搜索客户,返回精简列表(供远程选择器用)"""
base_where = [CrmCustomer.is_deleted.is_(False)]
# 数据域隔离
if user.data_scope == "self":
base_where.append(CrmCustomer.owner_id == user.user_id)
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
from app.models.sys import SysUser
sub = select(SysUser.id).where(
SysUser.dept_id == user.dept_id,
SysUser.is_deleted.is_(False),
)
base_where.append(CrmCustomer.owner_id.in_(sub))
# 模糊搜索(名称 / 联系人 / 电话)
from sqlalchemy import or_
base_where.append(
or_(
CrmCustomer.name.ilike(f"%{q}%"),
CrmCustomer.contact.ilike(f"%{q}%"),
CrmCustomer.phone.ilike(f"%{q}%"),
)
)
stmt = (
select(CrmCustomer)
.where(*base_where)
.order_by(CrmCustomer.name)
.limit(limit)
)
customers = (await db.execute(stmt)).scalars().all()
return [
{
"id": str(c.id),
"name": c.name,
"level": c.level,
"contact": c.contact,
"phone": c.phone,
}
for c in customers
]
+278
View File
@@ -0,0 +1,278 @@
"""
财务票据 Service 层
REST API 路由 和 MCP 工具 共用此层函数
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
from app.models.finance import FinExpenseDetail, FinExpenseRecord, FinInvoicePool
from app.schemas.auth import CurrentUserPayload
from app.schemas.finance import (
ExpenseBriefResponse, ExpenseCreate, ExpenseDetailResponse,
ExpenseListResponse, ExpenseResponse, ExpenseStatusUpdate,
InvoiceCreate, InvoiceListResponse, InvoiceResponse,
)
def _inv_to_resp(inv: FinInvoicePool) -> InvoiceResponse:
return InvoiceResponse(
id=inv.id, uploader_id=inv.uploader_id,
uploader_name=inv.uploader.real_name if inv.uploader else None,
file_url=inv.file_url, merchant_name=inv.merchant_name,
amount=float(inv.amount or 0), invoice_date=inv.invoice_date,
type=inv.type, ai_extracted_data=inv.ai_extracted_data or {},
is_used=inv.is_used, created_at=inv.created_at,
)
def _detail_to_resp(d: FinExpenseDetail) -> ExpenseDetailResponse:
return ExpenseDetailResponse(
id=d.id, invoice_id=d.invoice_id,
invoice_merchant=d.invoice.merchant_name if d.invoice else None,
invoice_amount=float(d.invoice.amount) if d.invoice else None,
expense_desc=d.expense_desc, original_type=d.original_type,
offset_type=d.offset_type, amount=float(d.amount or 0),
)
def _exp_to_resp(exp: FinExpenseRecord, with_details: bool = True) -> ExpenseResponse:
return ExpenseResponse(
id=exp.id, system_no=exp.system_no, applicant_id=exp.applicant_id,
applicant_name=exp.applicant.real_name if exp.applicant else None,
total_amount=float(exp.total_amount or 0), status=exp.status,
remark=exp.remark, approved_by=exp.approved_by,
approver_name=exp.approver.real_name if exp.approver else None,
approved_at=exp.approved_at,
details=[_detail_to_resp(d) for d in exp.details] if with_details else [],
created_at=exp.created_at, updated_at=exp.updated_at,
)
def _exp_to_brief(exp: FinExpenseRecord) -> ExpenseBriefResponse:
return ExpenseBriefResponse(
id=exp.id, system_no=exp.system_no, applicant_id=exp.applicant_id,
applicant_name=exp.applicant.real_name if exp.applicant else None,
total_amount=float(exp.total_amount or 0), status=exp.status,
created_at=exp.created_at,
)
async def _generate_expense_no(db: AsyncSession) -> str:
today = date.today().strftime("%Y%m%d")
prefix = f"EXP-{today}-"
count = (await db.execute(
select(func.count()).select_from(FinExpenseRecord)
.where(FinExpenseRecord.system_no.like(f"{prefix}%"))
)).scalar() or 0
return f"{prefix}{count + 1:03d}"
async def _release_invoices(db: AsyncSession, expense_id: uuid.UUID, now: datetime) -> None:
detail_stmt = select(FinExpenseDetail.invoice_id).where(
FinExpenseDetail.expense_id == expense_id, FinExpenseDetail.invoice_id.is_not(None),
)
inv_ids = (await db.execute(detail_stmt)).scalars().all()
if inv_ids:
await db.execute(
update(FinInvoicePool).where(FinInvoicePool.id.in_(inv_ids))
.values(is_used=False, updated_at=now)
)
# ── Service Functions ────────────────────────────────────
async def create_invoice(db: AsyncSession, user: CurrentUserPayload, body: InvoiceCreate) -> InvoiceResponse:
invoice = FinInvoicePool(
uploader_id=user.user_id, file_url=body.file_url,
merchant_name=body.merchant_name, amount=body.amount,
invoice_date=body.invoice_date, type=body.type,
ai_extracted_data=body.ai_extracted_data, is_used=False,
)
db.add(invoice)
await db.commit()
await db.refresh(invoice)
return _inv_to_resp(invoice)
async def list_invoices(
db: AsyncSession, user: CurrentUserPayload,
page: int = 1, size: int = 20,
inv_type: str | None = None, is_used: bool | None = None,
) -> InvoiceListResponse:
where = [FinInvoicePool.is_deleted.is_(False)]
if user.data_scope == "self":
where.append(FinInvoicePool.uploader_id == user.user_id)
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
from app.models.sys import SysUser
dept_users = select(SysUser.id).where(SysUser.dept_id == user.dept_id, SysUser.is_deleted.is_(False))
where.append(FinInvoicePool.uploader_id.in_(dept_users))
if inv_type:
where.append(FinInvoicePool.type == inv_type)
if is_used is not None:
where.append(FinInvoicePool.is_used == is_used)
total = (await db.execute(select(func.count()).select_from(FinInvoicePool).where(*where))).scalar() or 0
stmt = select(FinInvoicePool).where(*where).order_by(FinInvoicePool.created_at.desc()).offset((page - 1) * size).limit(size)
invoices = (await db.execute(stmt)).scalars().all()
return InvoiceListResponse(total=total, items=[_inv_to_resp(i) for i in invoices], page=page, size=size)
async def void_invoice(db: AsyncSession, user: CurrentUserPayload, invoice_id: uuid.UUID) -> None:
inv = (await db.execute(
select(FinInvoicePool).where(FinInvoicePool.id == invoice_id, FinInvoicePool.is_deleted.is_(False))
)).scalar_one_or_none()
if inv is None:
raise NotFoundException("票据不存在或已作废")
if user.data_scope != "all" and inv.uploader_id != user.user_id:
raise ForbiddenException("无权作废他人上传的票据")
if inv.is_used:
raise BizException(message="该票据已关联报销单,无法作废。请先撤回对应报销单。")
await db.execute(update(FinInvoicePool).where(FinInvoicePool.id == invoice_id).values(is_deleted=True, updated_at=datetime.utcnow()))
await db.commit()
async def create_expense(db: AsyncSession, user: CurrentUserPayload, body: ExpenseCreate) -> ExpenseResponse:
invoice_ids = [item.invoice_id for item in body.items]
try:
async with db.begin_nested():
lock_stmt = select(FinInvoicePool).where(
FinInvoicePool.id.in_(invoice_ids), FinInvoicePool.is_deleted.is_(False),
).with_for_update()
locked_invs = (await db.execute(lock_stmt)).scalars().all()
locked_map = {inv.id: inv for inv in locked_invs}
for item in body.items:
inv = locked_map.get(item.invoice_id)
if inv is None:
raise BizException(message=f"发票 {item.invoice_id} 不存在或已被作废")
if inv.is_used:
raise BizException(message=f"发票 {inv.merchant_name or inv.id}{inv.amount}) 已被其他报销单使用,禁止重复报销")
system_no = await _generate_expense_no(db)
expense = FinExpenseRecord(
system_no=system_no, applicant_id=user.user_id,
total_amount=body.total_amount, status="submitted", remark=body.remark,
)
db.add(expense)
await db.flush()
for item in body.items:
db.add(FinExpenseDetail(
expense_id=expense.id, invoice_id=item.invoice_id,
expense_desc=item.expense_desc, original_type=item.original_type,
offset_type=item.offset_type, amount=item.amount,
))
await db.execute(
update(FinInvoicePool).where(FinInvoicePool.id.in_(invoice_ids))
.values(is_used=True, updated_at=datetime.utcnow())
)
await db.commit()
except BizException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
raise BizException(code=500, message=f"报销单创建事务失败: {e!s}") from e
refreshed = (await db.execute(select(FinExpenseRecord).where(FinExpenseRecord.id == expense.id))).scalar_one()
return _exp_to_resp(refreshed)
async def list_expenses(
db: AsyncSession, user: CurrentUserPayload,
page: int = 1, size: int = 20,
status: str | None = None, applicant_id: uuid.UUID | None = None,
) -> ExpenseListResponse:
where = [FinExpenseRecord.is_deleted.is_(False)]
if user.data_scope == "self":
where.append(FinExpenseRecord.applicant_id == user.user_id)
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
from app.models.sys import SysUser
dept_users = select(SysUser.id).where(SysUser.dept_id == user.dept_id, SysUser.is_deleted.is_(False))
where.append(FinExpenseRecord.applicant_id.in_(dept_users))
if status:
where.append(FinExpenseRecord.status == status)
if applicant_id and user.data_scope == "all":
where.append(FinExpenseRecord.applicant_id == applicant_id)
total = (await db.execute(select(func.count()).select_from(FinExpenseRecord).where(*where))).scalar() or 0
stmt = select(FinExpenseRecord).where(*where).order_by(FinExpenseRecord.created_at.desc()).offset((page - 1) * size).limit(size)
expenses = (await db.execute(stmt)).scalars().all()
return ExpenseListResponse(total=total, items=[_exp_to_brief(e) for e in expenses], page=page, size=size)
async def get_expense(db: AsyncSession, user: CurrentUserPayload, expense_id: uuid.UUID) -> ExpenseResponse:
exp = (await db.execute(
select(FinExpenseRecord).where(FinExpenseRecord.id == expense_id, FinExpenseRecord.is_deleted.is_(False))
)).scalar_one_or_none()
if exp is None:
raise NotFoundException("报销单不存在")
if user.data_scope == "self" and exp.applicant_id != user.user_id:
raise ForbiddenException("无权查看他人的报销单")
return _exp_to_resp(exp)
async def update_expense_status(
db: AsyncSession, user: CurrentUserPayload,
expense_id: uuid.UUID, body: ExpenseStatusUpdate,
) -> str:
"""返回操作结果消息"""
exp = (await db.execute(
select(FinExpenseRecord).where(FinExpenseRecord.id == expense_id, FinExpenseRecord.is_deleted.is_(False))
)).scalar_one_or_none()
if exp is None:
raise NotFoundException("报销单不存在")
now = datetime.utcnow()
if body.action == "withdraw":
if exp.applicant_id != user.user_id:
raise ForbiddenException("只能撤回自己的报销单")
if exp.status != "submitted":
raise BizException(message=f"当前状态 [{exp.status}] 不允许撤回,仅 submitted 状态可撤回")
try:
async with db.begin_nested():
await db.execute(update(FinExpenseRecord).where(FinExpenseRecord.id == expense_id).values(status="voided", updated_at=now))
await _release_invoices(db, expense_id, now)
await db.commit()
except BizException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
raise BizException(code=500, message=f"撤回事务失败: {e!s}") from e
return "报销单已撤回,关联发票已释放"
elif body.action == "approve":
if user.data_scope != "all":
raise ForbiddenException("仅管理员/财务可审批")
if exp.status != "submitted":
raise BizException(message=f"当前状态 [{exp.status}] 不允许审批")
await db.execute(update(FinExpenseRecord).where(FinExpenseRecord.id == expense_id).values(
status="approved", approved_by=user.user_id, approved_at=now, updated_at=now,
))
await db.commit()
return "报销单已审批通过"
elif body.action == "reject":
if user.data_scope != "all":
raise ForbiddenException("仅管理员/财务可驳回")
if exp.status != "submitted":
raise BizException(message=f"当前状态 [{exp.status}] 不允许驳回")
try:
async with db.begin_nested():
await db.execute(update(FinExpenseRecord).where(FinExpenseRecord.id == expense_id).values(
status="rejected", approved_by=user.user_id, approved_at=now, updated_at=now,
))
await _release_invoices(db, expense_id, now)
await db.commit()
except BizException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
raise BizException(code=500, message=f"驳回事务失败: {e!s}") from e
return "报销单已驳回,关联发票已释放"
raise BizException(message=f"未知操作: {body.action}")
+108
View File
@@ -0,0 +1,108 @@
"""
意图分类服务 — 基于 4060 节点 Qwen3.5-4B
将用户输入快速分类为不同的意图类型,用于路由到对应的处理逻辑。
"""
from __future__ import annotations
import json
import re
import httpx
from app.core.config import settings
# 意图 → Dify App 路由映射(可扩展)
INTENT_ROUTES = {
"crm": "dify_agent", # 客户、订单、产品、发货等 CRM 操作
"finance": "dify_agent", # 财务、报销、票据操作
"knowledge": "dify_agent", # 知识库问答(未来可以指向独立的 RAG App)
"general": "dify_agent", # 通用闲聊
"report": "dify_workflow_report", # 周报/月报生成
}
SYSTEM_PROMPT = """你是一个意图分类器。根据用户输入,判断它属于以下哪个意图类别。只返回 JSON 格式 {"intent": "xxx", "confidence": 0.xx}。
意图类别:
- crm: 客户管理(查询/新建客户)、订单管理(查询/下单)、产品/库存、发货物流
- finance: 报销、票据、发票、财务审批
- report: 生成周报、月报、工作汇报
- knowledge: 产品知识、技术问答、公司规章制度
- general: 日常闲聊、问候、与业务无关的问题
只输出 JSON,不要解释。"""
async def classify_intent(message: str) -> dict:
"""
调用 4060 上的 Qwen3.5-4B 做快速意图分类。
返回 {"intent": "crm", "confidence": 0.95, "route": "dify_agent"}
如果分类失败,默认路由到 dify_agent。
"""
fallback = {"intent": "general", "confidence": 0.0, "route": "dify_agent"}
if not settings.OLLAMA_4060_BASE_URL:
return fallback
url = f"{settings.OLLAMA_4060_BASE_URL}/api/chat"
payload = {
"model": settings.OLLAMA_4060_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message},
],
"stream": False,
"options": {
"temperature": 0.1,
"num_predict": 500, # Qwen3.5 的 CoT thinking 会消耗较多 token
},
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, json=payload)
if resp.status_code != 200:
print(f"[IntentGateway] 4060 返回 {resp.status_code}: {resp.text[:200]}")
return fallback
data = resp.json()
# Qwen3.5 的 CoT 推理放在 message.thinking 字段,最终结果在 message.content
content = data.get("message", {}).get("content", "")
thinking = data.get("message", {}).get("thinking", "")
# 优先从 content 提取 JSON,回退到 thinking
for text_source in [content, thinking]:
if not text_source:
continue
# 去掉 <think>...</think> 块
cleaned = re.sub(r'<think>.*?</think>', '', text_source, flags=re.DOTALL).strip()
json_match = re.search(r'\{[^}]+\}', cleaned)
if json_match:
try:
result = json.loads(json_match.group())
intent = result.get("intent", "general")
confidence = float(result.get("confidence", 0.0))
route = INTENT_ROUTES.get(intent, "dify_agent")
print(f"[IntentGateway] intent={intent}, confidence={confidence:.2f}, route={route}")
return {"intent": intent, "confidence": confidence, "route": route}
except json.JSONDecodeError:
continue
# 从 thinking 内容中启发式推断意图(当 JSON 未生成完成时)
combined = (thinking + " " + content).lower()
if any(kw in combined for kw in ["crm", "customer", "客户", "订单", "order"]):
print(f"[IntentGateway] 启发式推断: crm")
return {"intent": "crm", "confidence": 0.7, "route": "dify_agent"}
if any(kw in combined for kw in ["finance", "报销", "发票", "票据", "财务"]):
print(f"[IntentGateway] 启发式推断: finance")
return {"intent": "finance", "confidence": 0.7, "route": "dify_agent"}
if any(kw in combined for kw in ["report", "周报", "月报", "汇报"]):
print(f"[IntentGateway] 启发式推断: report")
return {"intent": "report", "confidence": 0.7, "route": "dify_workflow_report"}
print(f"[IntentGateway] JSON 解析失败, 内容长度: content={len(content)}, thinking={len(thinking)}")
return fallback
except httpx.TimeoutException:
print("[IntentGateway] 4060 超时,降级到默认路由")
return fallback
except Exception as e:
print(f"[IntentGateway] 错误: {e}")
return fallback
+220
View File
@@ -0,0 +1,220 @@
"""
OCR 服务 — 基于 3090 节点 Qwen3.5-27B (Vision)
对发票/名片图片做 AI 视觉理解,提取结构化数据。
"""
from __future__ import annotations
import base64
import json
import re
import httpx
from app.core.config import settings
INVOICE_PROMPT = """你是一个专业的发票OCR解析器。请分析图片中的发票/票据,提取以下结构化信息,以 JSON 格式返回:
{
"merchant": "开票方/销售方名称",
"amount": 金额数字(不带货币符号),
"date": "YYYY-MM-DD 格式的开票日期",
"invoice_code": "发票代码(如有)",
"invoice_number": "发票号码(如有)",
"tax_rate": "税率(如有)",
"tax_amount": 税额数字(如有),
"items": "发票上的商品/服务名称",
"buyer": "购买方/抬头(如有)",
"remark": "备注信息(如有)"
}
只输出 JSON,不需要解释。如果某个字段无法识别,设为 null。"""
BUSINESS_CARD_PROMPT = """你是一个名片OCR解析器。请分析图片中的名片,提取以下信息并以 JSON 返回:
{
"name": "姓名",
"company": "公司名称",
"title": "职位",
"phone": "电话号码",
"email": "邮箱",
"address": "地址",
"other": "其他信息"
}
只输出 JSON。无法识别的字段设为 null。"""
async def ocr_image(
image_base64: str,
scene: str = "invoice",
) -> dict:
"""
调用 3090 Qwen-VL 对图片做视觉理解/OCR。
Args:
image_base64: base64 编码的图片数据
scene: "invoice" | "business_card" | "general"
Returns:
{"success": True, "data": {...提取的结构化数据...}}
"""
fallback = {"success": False, "data": {}, "error": "OCR 服务不可用"}
if not settings.OLLAMA_3090_BASE_URL:
return fallback
prompt = INVOICE_PROMPT if scene == "invoice" else (
BUSINESS_CARD_PROMPT if scene == "business_card" else
"请详细描述图片中的所有文字内容,以 JSON 格式输出。"
)
url = f"{settings.OLLAMA_3090_BASE_URL}/api/chat"
payload = {
"model": settings.OLLAMA_3090_MODEL,
"messages": [
{
"role": "user",
"content": "/no_think\n" + prompt,
"images": [image_base64], # Ollama vision 格式
},
],
"stream": False,
"options": {
"temperature": 0.1,
"num_predict": 2000,
},
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(url, json=payload)
if resp.status_code != 200:
print(f"[OCR] 3090 返回 {resp.status_code}: {resp.text[:200]}")
return {"success": False, "data": {}, "error": f"VL 模型返回 {resp.status_code}"}
data = resp.json()
# Qwen3.5 的 CoT 推理放在 message.thinking,最终结果在 message.content
content = data.get("message", {}).get("content", "")
thinking = data.get("message", {}).get("thinking", "")
# 优先从 content 提取 JSON,回退到 thinking
for text_source in [content, thinking]:
if not text_source:
continue
cleaned = re.sub(r'<think>.*?</think>', '', text_source, flags=re.DOTALL).strip()
json_match = re.search(r'\{[\s\S]*\}', cleaned)
if json_match:
try:
result = json.loads(json_match.group())
print(f"[OCR] 解析成功: {list(result.keys())}")
return {"success": True, "data": result}
except json.JSONDecodeError:
continue
# 没有提取到 JSON,返回原始文本
raw = content or thinking
print(f"[OCR] 未能提取 JSON, 内容长度: content={len(content)}, thinking={len(thinking)}")
return {"success": True, "data": {"raw_text": raw[:2000]}}
except httpx.TimeoutException:
print("[OCR] 3090 超时(60s")
return {"success": False, "data": {}, "error": "VL 模型响应超时"}
except json.JSONDecodeError as e:
print(f"[OCR] JSON 解析失败: {e}")
return {"success": False, "data": {}, "error": f"JSON 解析失败: {e}"}
except Exception as e:
print(f"[OCR] 错误: {e}")
return {"success": False, "data": {}, "error": str(e)}
TEXT_INVOICE_PROMPT = """你是一个专业的发票数据提取器。以下是一份发票/票据的文本内容(来自 PDF 转换后的 Markdown 或纯文本)。
请从中提取以下结构化信息,以 JSON 格式返回:
{
"merchant": "开票方/销售方名称",
"amount": 金额数字(不带货币符号),
"date": "YYYY-MM-DD 格式的开票日期",
"invoice_code": "发票代码(如有)",
"invoice_number": "发票号码(如有)",
"tax_rate": "税率(如有)",
"tax_amount": 税额数字(如有),
"items": "发票上的商品/服务名称",
"buyer": "购买方/抬头(如有)",
"remark": "备注信息(如有)"
}
只输出 JSON,不需要解释。如果某个字段无法识别,设为 null。
注意:文本可能是从 PDF 转换而来,格式可能不规整,请智能识别。"""
async def extract_invoice_from_text(
text: str,
scene: str = "invoice",
) -> dict:
"""
用 LLM 从纯文本(MD/TXT)中提取发票结构化数据。
不走视觉模型,纯文本理解,更快更准。
"""
fallback = {"success": False, "data": {}, "error": "AI 文本提取服务不可用"}
if not settings.OLLAMA_3090_BASE_URL:
return fallback
prompt = TEXT_INVOICE_PROMPT if scene == "invoice" else (
BUSINESS_CARD_PROMPT if scene == "business_card" else
"请从以下文本中提取所有关键信息,以 JSON 格式输出。"
)
# 限制文本长度,避免 token 爆炸
truncated = text[:8000] if len(text) > 8000 else text
url = f"{settings.OLLAMA_3090_BASE_URL}/api/chat"
payload = {
"model": settings.OLLAMA_3090_MODEL,
"messages": [
{
"role": "user",
"content": f"/no_think\n{prompt}\n\n--- 以下是发票文本内容 ---\n\n{truncated}",
# 不传 images —— 纯文本模式
},
],
"stream": False,
"options": {
"temperature": 0.1,
"num_predict": 2000,
},
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(url, json=payload)
if resp.status_code != 200:
print(f"[TextExtract] 3090 返回 {resp.status_code}: {resp.text[:200]}")
return {"success": False, "data": {}, "error": f"LLM 返回 {resp.status_code}"}
data = resp.json()
content = data.get("message", {}).get("content", "")
thinking = data.get("message", {}).get("thinking", "")
for text_source in [content, thinking]:
if not text_source:
continue
cleaned = re.sub(r'<think>.*?</think>', '', text_source, flags=re.DOTALL).strip()
json_match = re.search(r'\{[\s\S]*\}', cleaned)
if json_match:
try:
result = json.loads(json_match.group())
print(f"[TextExtract] AI 提取成功: {list(result.keys())}")
return {"success": True, "data": result}
except json.JSONDecodeError:
continue
raw = content or thinking
print(f"[TextExtract] 未能提取 JSON, 内容: {raw[:200]}")
return {"success": True, "data": {"raw_text": raw[:2000]}}
except httpx.TimeoutException:
print("[TextExtract] 3090 超时")
return {"success": False, "data": {}, "error": "LLM 响应超时"}
except Exception as e:
print(f"[TextExtract] 错误: {e}")
return {"success": False, "data": {}, "error": str(e)}
+300
View File
@@ -0,0 +1,300 @@
"""
订单管理 Service 层
REST API 路由 和 MCP 工具 共用此层函数
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
from app.models.crm import CrmCustomer
from app.models.erp import ProductSku
from app.models.order import ErpOrder, ErpOrderItem
from app.schemas.auth import CurrentUserPayload
from app.schemas.order import (
OrderBriefResponse,
OrderCreate,
OrderItemResponse,
OrderListResponse,
OrderResponse,
PriceCalculateResponse,
)
# ── 工具函数 ─────────────────────────────────────────────
async def _generate_order_no(db: AsyncSession) -> str:
today = date.today().strftime("%Y%m%d")
prefix = f"ORD-{today}-"
stmt = (
select(func.count())
.select_from(ErpOrder)
.where(ErpOrder.order_no.like(f"{prefix}%"))
)
count = (await db.execute(stmt)).scalar() or 0
return f"{prefix}{count + 1:03d}"
def _item_to_response(item: ErpOrderItem) -> OrderItemResponse:
return OrderItemResponse(
id=item.id,
sku_id=item.sku_id,
sku_code=item.sku.sku_code if item.sku else None,
sku_name=item.sku.name if item.sku else None,
spec=item.sku.spec if item.sku else None,
qty=float(item.qty),
unit_price=float(item.unit_price),
sub_total=float(item.sub_total),
shipped_qty=float(item.shipped_qty or 0),
)
def _order_to_response(o: ErpOrder, with_items: bool = True) -> OrderResponse:
return OrderResponse(
id=o.id,
order_no=o.order_no,
customer_id=o.customer_id,
customer_name=o.customer.name if o.customer else None,
salesperson_id=o.salesperson_id,
salesperson_name=o.salesperson.real_name if o.salesperson else None,
total_amount=float(o.total_amount or 0),
shipping_state=o.shipping_state,
payment_state=o.payment_state,
paid_amount=float(o.paid_amount or 0),
remark=o.remark,
order_date=o.order_date,
items=[_item_to_response(i) for i in o.items] if with_items else [],
created_at=o.created_at,
updated_at=o.updated_at,
)
def _order_to_brief(o: ErpOrder) -> OrderBriefResponse:
return OrderBriefResponse(
id=o.id,
order_no=o.order_no,
customer_id=o.customer_id,
customer_name=o.customer.name if o.customer else None,
salesperson_name=o.salesperson.real_name if o.salesperson else None,
total_amount=float(o.total_amount or 0),
shipping_state=o.shipping_state,
payment_state=o.payment_state,
paid_amount=float(o.paid_amount or 0),
order_date=o.order_date,
created_at=o.created_at,
)
def _check_order_access(order: ErpOrder, user: CurrentUserPayload) -> None:
if user.data_scope == "all":
return
if user.data_scope == "self":
if order.salesperson_id != user.user_id:
raise ForbiddenException("无权访问该订单(数据权限:仅本人)")
# ── Service Functions ────────────────────────────────────
async def calculate_price(
db: AsyncSession,
customer_id: uuid.UUID,
sku_id: uuid.UUID,
) -> PriceCalculateResponse:
sku = (
await db.execute(
select(ProductSku).where(
ProductSku.id == sku_id,
ProductSku.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if sku is None:
raise NotFoundException("产品 SKU 不存在")
# 历史成交价追溯
history_stmt = (
select(ErpOrderItem.unit_price, ErpOrder.order_no, ErpOrder.order_date)
.join(ErpOrder, ErpOrderItem.order_id == ErpOrder.id)
.where(
ErpOrder.customer_id == customer_id,
ErpOrderItem.sku_id == sku_id,
ErpOrder.is_deleted.is_(False),
ErpOrderItem.is_deleted.is_(False),
)
.order_by(ErpOrder.created_at.desc())
.limit(1)
)
history = (await db.execute(history_stmt)).first()
if history:
return PriceCalculateResponse(
sku_id=sku.id,
sku_code=sku.sku_code,
sku_name=sku.name,
unit_price=float(history.unit_price),
price_source="history",
last_order_no=history.order_no,
last_order_date=history.order_date,
)
return PriceCalculateResponse(
sku_id=sku.id,
sku_code=sku.sku_code,
sku_name=sku.name,
unit_price=float(sku.standard_price or 0),
price_source="standard",
)
async def create_order(
db: AsyncSession,
user: CurrentUserPayload,
body: OrderCreate,
) -> OrderResponse:
# 校验客户存在
cust = (
await db.execute(
select(CrmCustomer).where(
CrmCustomer.id == body.customer_id,
CrmCustomer.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if cust is None:
raise NotFoundException("客户不存在")
# 校验所有 SKU 存在
sku_ids = [item.sku_id for item in body.items]
skus = (
await db.execute(
select(ProductSku).where(
ProductSku.id.in_(sku_ids),
ProductSku.is_deleted.is_(False),
)
)
).scalars().all()
found_ids = {s.id for s in skus}
missing = [str(sid) for sid in sku_ids if sid not in found_ids]
if missing:
raise BizException(message=f"以下 SKU 不存在: {', '.join(missing)}")
try:
async with db.begin_nested():
order_no = await _generate_order_no(db)
total = sum(item.qty * item.unit_price for item in body.items)
order = ErpOrder(
order_no=order_no,
customer_id=body.customer_id,
salesperson_id=user.user_id,
total_amount=total,
shipping_state="pending",
payment_state="unpaid",
paid_amount=0,
remark=body.remark,
order_date=body.order_date or date.today(),
)
db.add(order)
await db.flush()
for item in body.items:
order_item = ErpOrderItem(
order_id=order.id,
sku_id=item.sku_id,
qty=item.qty,
unit_price=item.unit_price,
sub_total=round(item.qty * item.unit_price, 2),
shipped_qty=0,
)
db.add(order_item)
await db.commit()
except BizException:
raise
except Exception as e:
await db.rollback()
raise BizException(code=500, message=f"订单创建事务失败: {e!s}") from e
refreshed = (
await db.execute(select(ErpOrder).where(ErpOrder.id == order.id))
).scalar_one()
return _order_to_response(refreshed)
async def list_orders(
db: AsyncSession,
user: CurrentUserPayload,
page: int = 1,
size: int = 20,
customer_id: uuid.UUID | None = None,
shipping_state: str | None = None,
payment_state: str | None = None,
keyword: str | None = None,
) -> OrderListResponse:
where: list[Any] = [ErpOrder.is_deleted.is_(False)]
if user.data_scope == "self":
where.append(ErpOrder.salesperson_id == user.user_id)
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
from app.models.sys import SysUser
sub = select(SysUser.id).where(
SysUser.dept_id == user.dept_id,
SysUser.is_deleted.is_(False),
)
where.append(ErpOrder.salesperson_id.in_(sub))
if customer_id:
where.append(ErpOrder.customer_id == customer_id)
if shipping_state:
where.append(ErpOrder.shipping_state == shipping_state)
if payment_state:
where.append(ErpOrder.payment_state == payment_state)
if keyword:
where.append(ErpOrder.order_no.ilike(f"%{keyword}%"))
total = (
await db.execute(select(func.count()).select_from(ErpOrder).where(*where))
).scalar() or 0
stmt = (
select(ErpOrder)
.where(*where)
.order_by(ErpOrder.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
orders = (await db.execute(stmt)).scalars().all()
return OrderListResponse(
total=total,
items=[_order_to_brief(o) for o in orders],
page=page,
size=size,
)
async def get_order(
db: AsyncSession,
user: CurrentUserPayload,
order_id: uuid.UUID,
) -> OrderResponse:
order = (
await db.execute(
select(ErpOrder).where(
ErpOrder.id == order_id,
ErpOrder.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if order is None:
raise NotFoundException("订单不存在或已被删除")
_check_order_access(order, user)
return _order_to_response(order)
+396
View File
@@ -0,0 +1,396 @@
"""
产品与库存 Service 层
REST API 路由 和 MCP 工具 共用此层函数
"""
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, NotFoundException
from app.models.erp import InventoryFlow, ProductCategory, ProductSku
from app.schemas.auth import CurrentUserPayload
from app.schemas.erp import (
CategoryCreate,
CategoryNode,
CategoryUpdate,
InventoryFlowCreate,
InventoryFlowResponse,
SkuCreate,
SkuListResponse,
SkuResponse,
SkuUpdate,
)
# ── ORM → Response ───────────────────────────────────────
def _sku_to_response(s: ProductSku) -> SkuResponse:
return SkuResponse(
id=s.id,
sku_code=s.sku_code,
name=s.name,
category_id=s.category_id,
category_name=s.category.name if s.category else None,
spec=s.spec,
standard_price=float(s.standard_price or 0),
stock_qty=float(s.stock_qty or 0),
warning_threshold=float(s.warning_threshold or 0),
unit=s.unit,
status=s.status,
created_at=s.created_at,
updated_at=s.updated_at,
)
def _flow_to_response(f: InventoryFlow) -> InventoryFlowResponse:
return InventoryFlowResponse(
id=f.id,
sku_id=f.sku_id,
sku_code=f.sku.sku_code if f.sku else None,
sku_name=f.sku.name if f.sku else None,
change_qty=float(f.change_qty),
reason=f.reason,
remark=f.remark,
operator_id=f.operator_id,
operator_name=f.operator.real_name if f.operator else None,
created_at=f.created_at,
)
def _build_tree(
items: list[ProductCategory],
parent_id: uuid.UUID | None = None,
) -> list[CategoryNode]:
nodes: list[CategoryNode] = []
for item in items:
if item.parent_id == parent_id:
children = _build_tree(items, item.id)
nodes.append(
CategoryNode(
id=item.id,
parent_id=item.parent_id,
name=item.name,
sort_order=item.sort_order,
children=children,
)
)
nodes.sort(key=lambda n: n.sort_order)
return nodes
# ── Service Functions ────────────────────────────────────
async def get_category_tree(db: AsyncSession) -> list[dict[str, Any]]:
stmt = (
select(ProductCategory)
.where(ProductCategory.is_deleted.is_(False))
.order_by(ProductCategory.sort_order)
)
categories = list((await db.execute(stmt)).scalars().all())
tree = _build_tree(categories, parent_id=None)
return [n.model_dump(mode="json") for n in tree]
async def create_category(
db: AsyncSession,
body: CategoryCreate,
) -> dict[str, Any]:
if body.parent_id:
parent = (
await db.execute(
select(ProductCategory).where(
ProductCategory.id == body.parent_id,
ProductCategory.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if parent is None:
raise NotFoundException("父级分类不存在")
cat = ProductCategory(
name=body.name,
parent_id=body.parent_id,
sort_order=body.sort_order,
)
db.add(cat)
await db.commit()
await db.refresh(cat)
return {
"id": str(cat.id),
"name": cat.name,
"parent_id": str(cat.parent_id) if cat.parent_id else None,
}
async def update_category(
db: AsyncSession,
cat_id: uuid.UUID,
body: CategoryUpdate,
) -> None:
cat = (
await db.execute(
select(ProductCategory).where(
ProductCategory.id == cat_id,
ProductCategory.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if cat 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(ProductCategory).where(ProductCategory.id == cat_id).values(**update_data)
)
await db.commit()
async def delete_category(db: AsyncSession, cat_id: uuid.UUID) -> None:
cat = (
await db.execute(
select(ProductCategory).where(
ProductCategory.id == cat_id,
ProductCategory.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if cat is None:
raise NotFoundException("分类不存在或已被删除")
child_count = (
await db.execute(
select(func.count()).select_from(ProductCategory).where(
ProductCategory.parent_id == cat_id,
ProductCategory.is_deleted.is_(False),
)
)
).scalar() or 0
if child_count > 0:
raise BizException(message=f"该分类下有 {child_count} 个子分类,无法删除")
sku_count = (
await db.execute(
select(func.count()).select_from(ProductSku).where(
ProductSku.category_id == cat_id,
ProductSku.is_deleted.is_(False),
)
)
).scalar() or 0
if sku_count > 0:
raise BizException(message=f"该分类下有 {sku_count} 个产品 SKU,无法删除")
await db.execute(
update(ProductCategory)
.where(ProductCategory.id == cat_id)
.values(is_deleted=True, updated_at=datetime.utcnow())
)
await db.commit()
async def list_skus(
db: AsyncSession,
page: int = 1,
size: int = 20,
category_id: uuid.UUID | None = None,
keyword: str | None = None,
) -> SkuListResponse:
where: list[Any] = [ProductSku.is_deleted.is_(False)]
if category_id:
where.append(ProductSku.category_id == category_id)
if keyword:
where.append(
ProductSku.name.ilike(f"%{keyword}%")
| ProductSku.sku_code.ilike(f"%{keyword}%")
)
total = (
await db.execute(select(func.count()).select_from(ProductSku).where(*where))
).scalar() or 0
stmt = (
select(ProductSku)
.where(*where)
.order_by(ProductSku.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
rows = (await db.execute(stmt)).scalars().all()
return SkuListResponse(
total=total,
items=[_sku_to_response(s) for s in rows],
page=page,
size=size,
)
async def create_sku(db: AsyncSession, body: SkuCreate) -> SkuResponse:
exists = (
await db.execute(
select(ProductSku.id).where(
ProductSku.sku_code == body.sku_code,
ProductSku.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if exists:
raise BizException(message=f"SKU 编码 '{body.sku_code}' 已存在")
sku = ProductSku(
sku_code=body.sku_code,
name=body.name,
category_id=body.category_id,
spec=body.spec,
standard_price=body.standard_price,
stock_qty=body.stock_qty,
warning_threshold=body.warning_threshold,
unit=body.unit,
status=body.status,
)
db.add(sku)
await db.commit()
await db.refresh(sku)
return _sku_to_response(sku)
async def update_sku(
db: AsyncSession,
sku_id: uuid.UUID,
body: SkuUpdate,
) -> SkuResponse:
sku = (
await db.execute(
select(ProductSku).where(
ProductSku.id == sku_id, ProductSku.is_deleted.is_(False)
)
)
).scalar_one_or_none()
if sku 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(ProductSku).where(ProductSku.id == sku_id).values(**update_data)
)
await db.commit()
refreshed = (
await db.execute(select(ProductSku).where(ProductSku.id == sku_id))
).scalar_one()
return _sku_to_response(refreshed)
async def create_inventory_flow(
db: AsyncSession,
user: CurrentUserPayload,
body: InventoryFlowCreate,
) -> InventoryFlowResponse:
sku = (
await db.execute(
select(ProductSku).where(
ProductSku.id == body.sku_id, ProductSku.is_deleted.is_(False)
)
)
).scalar_one_or_none()
if sku is None:
raise NotFoundException("产品 SKU 不存在")
if body.change_qty < 0:
current_stock = float(sku.stock_qty or 0)
if current_stock + body.change_qty < 0:
raise BizException(
message=f"库存不足:当前库存 {current_stock},请求出库 {abs(body.change_qty)}"
)
try:
async with db.begin_nested():
flow = InventoryFlow(
sku_id=body.sku_id,
change_qty=body.change_qty,
reason=body.reason,
remark=body.remark,
operator_id=user.user_id,
)
db.add(flow)
await db.flush()
await db.execute(
update(ProductSku)
.where(ProductSku.id == body.sku_id)
.values(
stock_qty=ProductSku.stock_qty + Decimal(str(body.change_qty)),
updated_at=datetime.utcnow(),
)
)
await db.commit()
except Exception as e:
await db.rollback()
raise BizException(code=500, message=f"库存变更事务失败: {e!s}") from e
refreshed = (
await db.execute(select(InventoryFlow).where(InventoryFlow.id == flow.id))
).scalar_one()
return _flow_to_response(refreshed)
async def get_inventory_flows(
db: AsyncSession,
sku_id: uuid.UUID,
page: int = 1,
size: int = 50,
) -> dict[str, Any]:
sku = (
await db.execute(
select(ProductSku).where(
ProductSku.id == sku_id, ProductSku.is_deleted.is_(False)
)
)
).scalar_one_or_none()
if sku is None:
raise NotFoundException("产品 SKU 不存在")
where: list[Any] = [
InventoryFlow.sku_id == sku_id,
InventoryFlow.is_deleted.is_(False),
]
total = (
await db.execute(
select(func.count()).select_from(InventoryFlow).where(*where)
)
).scalar() or 0
stmt = (
select(InventoryFlow)
.where(*where)
.order_by(InventoryFlow.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
flows = (await db.execute(stmt)).scalars().all()
return {
"total": total,
"sku_code": sku.sku_code,
"sku_name": sku.name,
"current_stock": float(sku.stock_qty or 0),
"items": [_flow_to_response(f).model_dump(mode="json") for f in flows],
"page": page,
"size": size,
}
@@ -0,0 +1,220 @@
"""
销项发票 Service 层 — CRUD + 多条件查询 + 导出
"""
from __future__ import annotations
import io
import uuid
from datetime import date, datetime
from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, NotFoundException
from app.models.finance import FinSalesInvoice
from app.schemas.auth import CurrentUserPayload
from app.schemas.sales_invoice import (
SalesInvoiceCreate,
SalesInvoiceListResponse,
SalesInvoiceResponse,
SalesInvoiceUpdate,
)
def _to_response(inv: FinSalesInvoice) -> SalesInvoiceResponse:
return SalesInvoiceResponse(
id=inv.id,
issuer=inv.issuer,
receiver_customer_id=inv.receiver_customer_id,
customer_name=inv.receiver_customer.name if inv.receiver_customer else None,
invoice_number=inv.invoice_number,
amount=float(inv.amount or 0),
billing_date=inv.billing_date,
payment_status=inv.payment_status,
payment_date=inv.payment_date,
payment_amount=float(inv.payment_amount or 0),
remark=inv.remark,
created_by=inv.created_by,
creator_name=inv.creator.real_name if inv.creator else None,
created_at=inv.created_at,
updated_at=inv.updated_at,
)
async def create_invoice(
db: AsyncSession,
user: CurrentUserPayload,
body: SalesInvoiceCreate,
) -> SalesInvoiceResponse:
# 检查发票号唯一性
existing = (await db.execute(
select(func.count()).select_from(FinSalesInvoice).where(
FinSalesInvoice.invoice_number == body.invoice_number,
FinSalesInvoice.is_deleted.is_(False),
)
)).scalar()
if existing:
raise BizException(message=f"发票号 {body.invoice_number} 已存在")
inv = FinSalesInvoice(
issuer=body.issuer,
receiver_customer_id=body.receiver_customer_id,
invoice_number=body.invoice_number,
amount=body.amount,
billing_date=body.billing_date,
remark=body.remark,
created_by=user.user_id,
)
db.add(inv)
await db.commit()
await db.refresh(inv)
return _to_response(inv)
async def list_invoices(
db: AsyncSession,
page: int = 1,
size: int = 20,
customer_name: str | None = None,
invoice_number: str | None = None,
payment_status: str | None = None,
start_date: date | None = None,
end_date: date | None = None,
) -> SalesInvoiceListResponse:
conditions = [FinSalesInvoice.is_deleted.is_(False)]
if invoice_number:
conditions.append(FinSalesInvoice.invoice_number.ilike(f"%{invoice_number}%"))
if payment_status:
conditions.append(FinSalesInvoice.payment_status == payment_status)
if start_date:
conditions.append(FinSalesInvoice.billing_date >= start_date)
if end_date:
conditions.append(FinSalesInvoice.billing_date <= end_date)
where = and_(*conditions) if conditions else True
total = (await db.execute(
select(func.count()).select_from(FinSalesInvoice).where(where)
)).scalar() or 0
stmt = (
select(FinSalesInvoice)
.where(where)
.order_by(FinSalesInvoice.billing_date.desc())
.offset((page - 1) * size)
.limit(size)
)
invoices = (await db.execute(stmt)).scalars().all()
items = [_to_response(inv) for inv in invoices]
# 如果有客户名称筛选,在 Python 层过滤(因为是 join 字段)
if customer_name:
items = [i for i in items if customer_name.lower() in (i.customer_name or "").lower()]
total = len(items)
return SalesInvoiceListResponse(
total=total,
items=items,
page=page,
size=size,
)
async def get_invoice(
db: AsyncSession,
invoice_id: uuid.UUID,
) -> SalesInvoiceResponse:
stmt = select(FinSalesInvoice).where(
FinSalesInvoice.id == invoice_id,
FinSalesInvoice.is_deleted.is_(False),
)
inv = (await db.execute(stmt)).scalar_one_or_none()
if inv is None:
raise NotFoundException("发票不存在或已被删除")
return _to_response(inv)
async def update_invoice(
db: AsyncSession,
invoice_id: uuid.UUID,
body: SalesInvoiceUpdate,
) -> SalesInvoiceResponse:
stmt = select(FinSalesInvoice).where(
FinSalesInvoice.id == invoice_id,
FinSalesInvoice.is_deleted.is_(False),
)
inv = (await db.execute(stmt)).scalar_one_or_none()
if inv 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(FinSalesInvoice)
.where(FinSalesInvoice.id == invoice_id)
.values(**update_data)
)
await db.commit()
updated = (await db.execute(
select(FinSalesInvoice).where(FinSalesInvoice.id == invoice_id)
)).scalar_one()
return _to_response(updated)
async def export_invoices(
db: AsyncSession,
start_date: date | None = None,
end_date: date | None = None,
) -> io.BytesIO:
"""导出指定时间段的发票汇总及回款追踪表"""
from openpyxl import Workbook
conditions = [FinSalesInvoice.is_deleted.is_(False)]
if start_date:
conditions.append(FinSalesInvoice.billing_date >= start_date)
if end_date:
conditions.append(FinSalesInvoice.billing_date <= end_date)
stmt = (
select(FinSalesInvoice)
.where(and_(*conditions))
.order_by(FinSalesInvoice.billing_date.desc())
)
invoices = (await db.execute(stmt)).scalars().all()
wb = Workbook()
ws = wb.active
ws.title = "发票汇总及回款追踪"
ws.append([
"发票号", "开票方", "受票客户", "票面金额",
"开票日期", "回款状态", "已回款金额", "回款日期", "备注"
])
for inv in invoices:
ws.append([
inv.invoice_number,
inv.issuer,
inv.receiver_customer.name if inv.receiver_customer else "",
float(inv.amount or 0),
inv.billing_date.isoformat() if inv.billing_date else "",
inv.payment_status,
float(inv.payment_amount or 0),
inv.payment_date.isoformat() if inv.payment_date else "",
inv.remark or "",
])
# 列宽
col_widths = [20, 25, 25, 15, 15, 12, 15, 15, 30]
for i, w in enumerate(col_widths, 1):
ws.column_dimensions[chr(64 + i)].width = w
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
return buffer
+164
View File
@@ -0,0 +1,164 @@
"""
销售日志服务 — CRUD + Dify 工作流异步触发
"""
from __future__ import annotations
import uuid
from datetime import date
from typing import Any
import httpx
from sqlalchemy import select, func, desc, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ai import SalesLog
from app.schemas.auth import CurrentUserPayload
async def create_log(
db: AsyncSession,
user: CurrentUserPayload,
content: str,
customer_id: str | None = None,
contact_ids: list[str] | None = None,
log_date: date | None = None,
) -> dict:
"""创建销售日志"""
log = SalesLog(
salesperson_id=user.user_id,
customer_id=uuid.UUID(customer_id) if customer_id else None,
contact_ids=contact_ids or [],
content=content,
log_date=log_date or date.today(),
)
db.add(log)
await db.commit()
await db.refresh(log)
return _to_dict(log)
async def list_logs(
db: AsyncSession,
user: CurrentUserPayload,
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,
) -> dict:
"""查询销售日志列表"""
conditions = [SalesLog.is_deleted.is_(False)]
# 数据权限
if user.data_scope == "self":
conditions.append(SalesLog.salesperson_id == user.user_id)
elif user_id:
conditions.append(SalesLog.salesperson_id == uuid.UUID(user_id))
if start_date:
conditions.append(SalesLog.log_date >= start_date)
if end_date:
conditions.append(SalesLog.log_date <= end_date)
if customer_id:
conditions.append(SalesLog.customer_id == uuid.UUID(customer_id))
where = and_(*conditions)
# count
count_stmt = select(func.count()).select_from(SalesLog).where(where)
total = (await db.execute(count_stmt)).scalar() or 0
# data
stmt = (
select(SalesLog)
.where(where)
.order_by(desc(SalesLog.created_at))
.offset((page - 1) * size)
.limit(size)
)
rows = (await db.execute(stmt)).scalars().all()
return {
"total": total,
"page": page,
"size": size,
"items": [_to_dict(r) for r in rows],
}
async def trigger_persona_workflow(
log_id: uuid.UUID,
customer_id: uuid.UUID,
content: str,
salesperson_name: str = "",
contact_ids: list[str] | None = None,
) -> None:
"""异步触发 Dify 画像提取 Workflowfire-and-forget"""
from app.core.config import settings
if not settings.DIFY_WORKFLOW_PERSONA_KEY or not settings.DIFY_API_BASE_URL:
print("[Workflow] 画像提取 Workflow 未配置,跳过")
return
url = f"{settings.DIFY_API_BASE_URL}/v1/workflows/run"
headers = {
"Authorization": f"Bearer {settings.DIFY_WORKFLOW_PERSONA_KEY}",
"Content-Type": "application/json",
}
payload = {
"inputs": {
"customer_id": str(customer_id),
"content": content,
"salesperson_name": salesperson_name,
"contact_ids": ",".join(contact_ids) if contact_ids else "",
},
"response_mode": "blocking",
"user": str(customer_id),
}
max_retries = 3
for attempt in range(max_retries):
try:
async with httpx.AsyncClient(timeout=300) as client:
resp = await client.post(url, json=payload, headers=headers)
print(f"[Workflow] 画像提取触发 status={resp.status_code}, body={resp.text[:200]}")
# 成功后回写 ai_processed
if resp.status_code == 200:
try:
from app.db.database import async_session_factory
from sqlalchemy import update as sa_update
async with async_session_factory() as session:
await session.execute(
sa_update(SalesLog)
.where(SalesLog.id == log_id)
.values(ai_processed=True)
)
await session.commit()
print(f"[Workflow] ai_processed 已更新 log_id={log_id}")
except Exception as db_err:
print(f"[Workflow] ai_processed 回写失败: {db_err}")
return # 成功,退出重试循环
else:
print(f"[Workflow] HTTP {resp.status_code},第 {attempt+1}/{max_retries}")
except Exception as e:
print(f"[Workflow] 画像提取触发失败 (第 {attempt+1}/{max_retries} 次): {e}")
# 重试前等待
if attempt < max_retries - 1:
import asyncio
await asyncio.sleep(10 * (attempt + 1))
def _to_dict(log: SalesLog) -> dict:
return {
"id": str(log.id),
"salesperson_id": str(log.salesperson_id),
"customer_id": str(log.customer_id) if log.customer_id else None,
"contact_ids": log.contact_ids or [],
"content": log.content,
"log_date": log.log_date.isoformat() if log.log_date else None,
"ai_processed": log.ai_processed,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
+221
View File
@@ -0,0 +1,221 @@
"""
物流发货 Service 层
REST API 路由 和 MCP 工具 共用此层函数
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
from app.models.erp import InventoryFlow, ProductSku
from app.models.order import ErpOrder, ErpOrderItem
from app.models.shipping import ErpShippingItem, ErpShippingRecord
from app.schemas.auth import CurrentUserPayload
from app.schemas.shipping import (
ShippingBriefResponse, ShippingCreate, ShippingItemResponse,
ShippingListResponse, ShippingResponse,
)
async def _generate_shipping_no(db: AsyncSession) -> str:
today = date.today().strftime("%Y%m%d")
prefix = f"SHP-{today}-"
count = (await db.execute(
select(func.count()).select_from(ErpShippingRecord)
.where(ErpShippingRecord.shipping_no.like(f"{prefix}%"))
)).scalar() or 0
return f"{prefix}{count + 1:03d}"
def _ship_item_to_resp(si: ErpShippingItem) -> ShippingItemResponse:
return ShippingItemResponse(
id=si.id, order_item_id=si.order_item_id, sku_id=si.sku_id,
sku_code=si.sku.sku_code if si.sku else None,
sku_name=si.sku.name if si.sku else None,
spec=si.sku.spec if si.sku else None,
unit=si.sku.unit if si.sku else None,
shipped_qty=float(si.shipped_qty),
)
def _ship_to_resp(sr: ErpShippingRecord, with_items: bool = True) -> ShippingResponse:
return ShippingResponse(
id=sr.id, shipping_no=sr.shipping_no, order_id=sr.order_id,
order_no=sr.order.order_no if sr.order else None,
customer_name=sr.order.customer.name if sr.order and sr.order.customer else None,
carrier=sr.carrier, tracking_no=sr.tracking_no, status=sr.status,
ship_date=sr.ship_date, remark=sr.remark,
operator_name=sr.operator.real_name if sr.operator else None,
items=[_ship_item_to_resp(i) for i in sr.items] if with_items else [],
created_at=sr.created_at,
)
def _ship_to_brief(sr: ErpShippingRecord) -> ShippingBriefResponse:
return ShippingBriefResponse(
id=sr.id, shipping_no=sr.shipping_no, order_id=sr.order_id,
order_no=sr.order.order_no if sr.order else None,
customer_name=sr.order.customer.name if sr.order and sr.order.customer else None,
carrier=sr.carrier, tracking_no=sr.tracking_no, status=sr.status,
ship_date=sr.ship_date,
operator_name=sr.operator.real_name if sr.operator else None,
created_at=sr.created_at,
)
def _check_shipping_access(order: ErpOrder, user: CurrentUserPayload) -> None:
if user.data_scope == "all":
return
if user.data_scope == "self" and order.salesperson_id != user.user_id:
raise ForbiddenException("无权访问该订单的发货记录(数据权限:仅本人)")
async def create_shipping(
db: AsyncSession, user: CurrentUserPayload, body: ShippingCreate,
) -> tuple[ShippingResponse, str]:
"""返回 (response, new_shipping_state)"""
order = (await db.execute(
select(ErpOrder).where(ErpOrder.id == body.order_id, ErpOrder.is_deleted.is_(False))
)).scalar_one_or_none()
if order is None:
raise NotFoundException("订单不存在")
if order.shipping_state == "shipped":
raise BizException(message="该订单已全部发完,无法再次发货")
_check_shipping_access(order, user)
order_item_ids = [item.order_item_id for item in body.items]
oi_rows = (await db.execute(
select(ErpOrderItem).where(
ErpOrderItem.id.in_(order_item_ids),
ErpOrderItem.order_id == body.order_id,
ErpOrderItem.is_deleted.is_(False),
)
)).scalars().all()
oi_map: dict[uuid.UUID, ErpOrderItem] = {oi.id: oi for oi in oi_rows}
for item in body.items:
oi = oi_map.get(item.order_item_id)
if oi is None:
raise BizException(message=f"订单明细行 {item.order_item_id} 不存在或不属于该订单")
remaining = float(oi.qty) - float(oi.shipped_qty or 0)
if item.shipped_qty > remaining:
raise BizException(message=f"SKU {item.sku_id} 发货数量超出未发余量:本次 {item.shipped_qty},剩余可发 {remaining}")
new_state = "partial"
try:
async with db.begin_nested():
now = datetime.utcnow()
shipping_no = await _generate_shipping_no(db)
record = ErpShippingRecord(
shipping_no=shipping_no, order_id=body.order_id,
carrier=body.carrier, tracking_no=body.tracking_no,
status="transit", ship_date=body.ship_date or date.today(),
remark=body.remark, operator_id=user.user_id,
)
db.add(record)
await db.flush()
for item in body.items:
si = ErpShippingItem(
shipping_id=record.id, order_item_id=item.order_item_id,
sku_id=item.sku_id, shipped_qty=item.shipped_qty,
)
db.add(si)
result = await db.execute(
update(ProductSku).where(
ProductSku.id == item.sku_id,
ProductSku.stock_qty >= item.shipped_qty,
).values(
stock_qty=ProductSku.stock_qty - Decimal(str(item.shipped_qty)),
updated_at=now,
)
)
if result.rowcount == 0:
sku = (await db.execute(select(ProductSku).where(ProductSku.id == item.sku_id))).scalar_one_or_none()
current_stock = float(sku.stock_qty) if sku else 0
raise BizException(message=f"库存不足无法发货: SKU {item.sku_id},当前库存 {current_stock},请求出库 {item.shipped_qty}")
db.add(InventoryFlow(
sku_id=item.sku_id, change_qty=-item.shipped_qty,
reason="shipment", remark=f"订单发货出库 - 发货单 {shipping_no}",
operator_id=user.user_id,
))
await db.execute(
update(ErpOrderItem).where(ErpOrderItem.id == item.order_item_id)
.values(shipped_qty=ErpOrderItem.shipped_qty + Decimal(str(item.shipped_qty)), updated_at=now)
)
await db.flush()
all_items = (await db.execute(
select(ErpOrderItem).where(ErpOrderItem.order_id == body.order_id, ErpOrderItem.is_deleted.is_(False))
)).scalars().all()
all_shipped = all(float(i.shipped_qty or 0) >= float(i.qty) for i in all_items)
new_state = "shipped" if all_shipped else "partial"
await db.execute(
update(ErpOrder).where(ErpOrder.id == body.order_id)
.values(shipping_state=new_state, updated_at=now)
)
await db.commit()
except BizException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
raise BizException(code=500, message=f"发货事务失败: {e!s}") from e
refreshed = (await db.execute(
select(ErpShippingRecord).where(ErpShippingRecord.id == record.id)
)).scalar_one()
return _ship_to_resp(refreshed), new_state
async def list_shipping(
db: AsyncSession, user: CurrentUserPayload,
page: int = 1, size: int = 20,
order_no: str | None = None, tracking_no: str | None = None,
) -> ShippingListResponse:
where: list[Any] = [ErpShippingRecord.is_deleted.is_(False)]
if user.data_scope == "self":
my_orders = select(ErpOrder.id).where(ErpOrder.salesperson_id == user.user_id, ErpOrder.is_deleted.is_(False))
where.append(ErpShippingRecord.order_id.in_(my_orders))
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
from app.models.sys import SysUser
dept_users = select(SysUser.id).where(SysUser.dept_id == user.dept_id, SysUser.is_deleted.is_(False))
dept_orders = select(ErpOrder.id).where(ErpOrder.salesperson_id.in_(dept_users), ErpOrder.is_deleted.is_(False))
where.append(ErpShippingRecord.order_id.in_(dept_orders))
if order_no:
matched = select(ErpOrder.id).where(ErpOrder.order_no.ilike(f"%{order_no}%"))
where.append(ErpShippingRecord.order_id.in_(matched))
if tracking_no:
where.append(ErpShippingRecord.tracking_no.ilike(f"%{tracking_no}%"))
total = (await db.execute(select(func.count()).select_from(ErpShippingRecord).where(*where))).scalar() or 0
stmt = select(ErpShippingRecord).where(*where).order_by(ErpShippingRecord.created_at.desc()).offset((page - 1) * size).limit(size)
records = (await db.execute(stmt)).scalars().all()
return ShippingListResponse(total=total, items=[_ship_to_brief(r) for r in records], page=page, size=size)
async def get_shipping_by_order(
db: AsyncSession, user: CurrentUserPayload, order_id: uuid.UUID,
) -> dict[str, Any]:
order = (await db.execute(
select(ErpOrder).where(ErpOrder.id == order_id, ErpOrder.is_deleted.is_(False))
)).scalar_one_or_none()
if order is None:
raise NotFoundException("订单不存在")
_check_shipping_access(order, user)
stmt = select(ErpShippingRecord).where(
ErpShippingRecord.order_id == order_id, ErpShippingRecord.is_deleted.is_(False),
).order_by(ErpShippingRecord.created_at.desc())
records = (await db.execute(stmt)).scalars().all()
return {
"order_id": str(order_id), "order_no": order.order_no,
"shipping_state": order.shipping_state, "total_shipments": len(records),
"shipments": [_ship_to_resp(r).model_dump(mode="json") for r in records],
}
+78
View File
@@ -0,0 +1,78 @@
-- ============================================================
-- Phase 3 数据库迁移脚本
-- 执行方式: psql -U postgres -d crm_erp -f migration_phase3.sql
-- ============================================================
-- ────── 1. AI 对话持久化 ──────
CREATE TABLE IF NOT EXISTS ai_chat_sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES sys_users(id),
role VARCHAR(10) NOT NULL CHECK (role IN ('user', 'assistant')),
content TEXT NOT NULL,
msg_type VARCHAR(20) DEFAULT 'text',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE ai_chat_sessions IS 'AI 悬浮球对话历史(按用户分区)';
CREATE INDEX IF NOT EXISTS idx_chat_user_time ON ai_chat_sessions(user_id, created_at DESC);
-- ────── 2. 客户 AI 画像字段 ──────
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'crm_customers' AND column_name = 'ai_persona'
) THEN
ALTER TABLE crm_customers ADD COLUMN ai_persona JSONB DEFAULT '{}';
COMMENT ON COLUMN crm_customers.ai_persona IS 'AI 提炼的客户画像(痛点/偏好/购买习惯等)';
END IF;
END $$;
-- ────── 3. 销售日志表 ──────
CREATE TABLE IF NOT EXISTS sales_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
salesperson_id UUID NOT NULL REFERENCES sys_users(id),
customer_id UUID REFERENCES crm_customers(id),
content TEXT NOT NULL,
log_date DATE DEFAULT CURRENT_DATE,
ai_processed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE sales_logs IS '销售日志表(一源多用数据源)';
CREATE INDEX IF NOT EXISTS idx_slog_sales ON sales_logs(salesperson_id) WHERE is_deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_slog_cust ON sales_logs(customer_id) WHERE is_deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_slog_date ON sales_logs(log_date) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_slog_updated
BEFORE UPDATE ON sales_logs
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ────── 4. AI 报告草稿表 ──────
CREATE TABLE IF NOT EXISTS ai_report_drafts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
author_id UUID NOT NULL REFERENCES sys_users(id),
report_type VARCHAR(20) NOT NULL CHECK (report_type IN ('weekly', 'monthly')),
period_start DATE NOT NULL,
period_end DATE NOT NULL,
content_md TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'confirmed', 'archived')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE ai_report_drafts IS 'AI 生成的报告草稿(周报/月报)';
CREATE INDEX IF NOT EXISTS idx_report_author ON ai_report_drafts(author_id) WHERE is_deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_report_period ON ai_report_drafts(period_start, period_end);
CREATE TRIGGER trg_report_updated
BEFORE UPDATE ON ai_report_drafts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ────── 完成 ──────
-- 新增: ai_chat_sessions, sales_logs, ai_report_drafts (3 张表)
-- 修改: crm_customers 增加 ai_persona JSONB 字段
+53
View File
@@ -0,0 +1,53 @@
-- ============================================================
-- Phase 4 数据库迁移脚本
-- 执行方式: psql -U postgres -d crm_erp -f migration_phase4.sql
-- ============================================================
-- ────── 1. pg_trgm 扩展(客户模糊搜索) ──────
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- 为客户名称建 pg_trgm GIN 索引
CREATE INDEX IF NOT EXISTS idx_cust_name_trgm
ON crm_customers USING gin (name gin_trgm_ops);
-- ────── 2. 销项发票表 finance_sales_invoices ──────
CREATE TABLE IF NOT EXISTS finance_sales_invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
issuer VARCHAR(200) NOT NULL, -- 开票方/我方主体
receiver_customer_id UUID NOT NULL REFERENCES crm_customers(id), -- 受票方
invoice_number VARCHAR(100) NOT NULL UNIQUE, -- 发票号
amount NUMERIC(14,2) NOT NULL DEFAULT 0, -- 票面金额
billing_date DATE NOT NULL, -- 开票时间
payment_status VARCHAR(20) NOT NULL DEFAULT '未回款'
CHECK (payment_status IN ('未回款', '部分回款', '已结清')),
payment_date DATE, -- 回款时间
payment_amount NUMERIC(14,2) DEFAULT 0, -- 已回款金额
remark TEXT,
created_by UUID REFERENCES sys_users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE finance_sales_invoices IS '销项发票表(AR 应收账款核心)';
COMMENT ON COLUMN finance_sales_invoices.issuer IS '开票方/我方主体名称';
COMMENT ON COLUMN finance_sales_invoices.receiver_customer_id IS '受票方,关联 crm_customers';
COMMENT ON COLUMN finance_sales_invoices.invoice_number IS '发票号码(全局唯一)';
COMMENT ON COLUMN finance_sales_invoices.amount IS '票面金额';
COMMENT ON COLUMN finance_sales_invoices.billing_date IS '开票日期';
COMMENT ON COLUMN finance_sales_invoices.payment_status IS '回款状态: 未回款/部分回款/已结清';
COMMENT ON COLUMN finance_sales_invoices.payment_date IS '回款日期';
COMMENT ON COLUMN finance_sales_invoices.payment_amount IS '已回款金额(用于部分回款场景)';
CREATE INDEX IF NOT EXISTS idx_fsi_receiver ON finance_sales_invoices(receiver_customer_id) WHERE is_deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_fsi_number ON finance_sales_invoices(invoice_number) WHERE is_deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_fsi_billing_date ON finance_sales_invoices(billing_date) WHERE is_deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_fsi_payment_status ON finance_sales_invoices(payment_status) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_fsi_updated
BEFORE UPDATE ON finance_sales_invoices
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ────── 完成 ──────
-- 新增: finance_sales_invoices (1 张表)
-- 新增: pg_trgm 扩展 + crm_customers.name GIN 索引
+42
View File
@@ -0,0 +1,42 @@
-- ═══════════════════════════════════════════════════════════════
-- V5.0 ERP 智能化升级 — 底层数据物理基座重构
-- ═══════════════════════════════════════════════════════════════
-- 1. 创建 crm_contacts 子表 (联系人维度,1:N 映射到 crm_customers)
CREATE TABLE IF NOT EXISTS crm_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES crm_customers(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
phone VARCHAR(30),
title VARCHAR(100), -- 职位/头衔
ai_buyer_persona JSONB DEFAULT '{}'::jsonb, -- 联系人级 AI 画像
is_deleted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
COMMENT ON TABLE crm_contacts IS '客户联系人子表 (V5.0)';
COMMENT ON COLUMN crm_contacts.customer_id IS '关联的客户 UUID';
COMMENT ON COLUMN crm_contacts.name IS '联系人姓名';
COMMENT ON COLUMN crm_contacts.phone IS '联系人电话';
COMMENT ON COLUMN crm_contacts.title IS '职位/头衔';
COMMENT ON COLUMN crm_contacts.ai_buyer_persona IS '联系人个体画像 JSONBrole / kpi / preference';
-- 索引
CREATE INDEX IF NOT EXISTS idx_contacts_customer ON crm_contacts(customer_id);
CREATE INDEX IF NOT EXISTS idx_contacts_not_deleted ON crm_contacts(customer_id) WHERE is_deleted = FALSE;
-- updated_at 自动更新触发器
CREATE OR REPLACE FUNCTION trg_contacts_updated() RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_crm_contacts_updated ON crm_contacts;
CREATE TRIGGER trg_crm_contacts_updated
BEFORE UPDATE ON crm_contacts
FOR EACH ROW EXECUTE FUNCTION trg_contacts_updated();
-- 2. sales_logs 新增 contact_ids 字段 (JSONB Array,允许一次沟通涉及多个联系人)
ALTER TABLE sales_logs ADD COLUMN IF NOT EXISTS contact_ids JSONB DEFAULT '[]'::jsonb;
COMMENT ON COLUMN sales_logs.contact_ids IS '本次沟通涉及的联系人 UUID 列表 (JSONB Array)';
+539
View File
@@ -0,0 +1,539 @@
-- ============================================================
-- 润滑油行业 B2B ERP/CRM 综合系统 - PostgreSQL 数据库建模
-- 技术栈: Python (FastAPI) + PostgreSQL
-- 生成时间: 2026-02-27
-- ============================================================
-- 启用 uuid-ossp 扩展(用于 UUID 主键生成)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================================
-- 通用函数:自动更新 updated_at 触发器
-- ============================================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ************************************************************
-- 1. RBAC 权限域
-- ************************************************************
-- ============================================================
-- 1.1 部门树表 sys_departments
-- ============================================================
CREATE TABLE sys_departments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
parent_id UUID REFERENCES sys_departments(id),
name VARCHAR(100) NOT NULL,
sort_order INT DEFAULT 0,
status SMALLINT DEFAULT 1, -- 1:启用 0:停用
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE sys_departments IS '部门树表';
COMMENT ON COLUMN sys_departments.id IS '部门主键 UUID';
COMMENT ON COLUMN sys_departments.parent_id IS '父级部门IDNULL表示顶级';
COMMENT ON COLUMN sys_departments.name IS '部门名称';
COMMENT ON COLUMN sys_departments.sort_order IS '排序序号';
COMMENT ON COLUMN sys_departments.status IS '状态 1:启用 0:停用';
CREATE INDEX idx_dept_parent ON sys_departments(parent_id) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_dept_updated
BEFORE UPDATE ON sys_departments
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- 1.2 角色表 sys_roles
-- ============================================================
CREATE TABLE sys_roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
role_name VARCHAR(50) NOT NULL UNIQUE,
data_scope VARCHAR(20) NOT NULL DEFAULT 'self', -- all / dept_and_sub / self
menu_keys JSONB DEFAULT '[]'::JSONB, -- 菜单与按钮权限键集合
description VARCHAR(255),
status SMALLINT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE sys_roles IS '角色表';
COMMENT ON COLUMN sys_roles.id IS '角色主键 UUID';
COMMENT ON COLUMN sys_roles.role_name IS '角色名称';
COMMENT ON COLUMN sys_roles.data_scope IS '数据权限范围: all=全部 / dept_and_sub=本部门及下属 / self=仅本人';
COMMENT ON COLUMN sys_roles.menu_keys IS '拥有的菜单/按钮权限键列表 (JSONB数组)';
CREATE TRIGGER trg_role_updated
BEFORE UPDATE ON sys_roles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- 1.3 员工/账号表 sys_users
-- ============================================================
CREATE TABLE sys_users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
dept_id UUID REFERENCES sys_departments(id),
role_id UUID REFERENCES sys_roles(id),
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
real_name VARCHAR(50),
phone VARCHAR(20),
email VARCHAR(100),
avatar_url VARCHAR(500),
status SMALLINT DEFAULT 1, -- 1:在职 0:离职
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE sys_users IS '员工/账号表';
COMMENT ON COLUMN sys_users.id IS '用户主键 UUID';
COMMENT ON COLUMN sys_users.dept_id IS '所属部门ID';
COMMENT ON COLUMN sys_users.role_id IS '所属角色ID';
COMMENT ON COLUMN sys_users.username IS '登录账号';
COMMENT ON COLUMN sys_users.password_hash IS '密码哈希值';
COMMENT ON COLUMN sys_users.real_name IS '真实姓名';
COMMENT ON COLUMN sys_users.status IS '状态 1:在职 0:离职';
CREATE INDEX idx_user_dept ON sys_users(dept_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_user_role ON sys_users(role_id) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_user_updated
BEFORE UPDATE ON sys_users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ************************************************************
-- 2. CRM 客户域
-- ************************************************************
-- ============================================================
-- 2.1 客户主表 crm_customers
-- ============================================================
CREATE TABLE crm_customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(200) NOT NULL,
level CHAR(1) DEFAULT 'C', -- A / B / C 三级
industry VARCHAR(100),
contact VARCHAR(50), -- 联系人姓名
phone VARCHAR(30),
email VARCHAR(100),
address TEXT,
ai_score NUMERIC(5,2) DEFAULT 0, -- AI 客情健康度评分 0~100
owner_id UUID REFERENCES sys_users(id), -- 负责销售
status SMALLINT DEFAULT 1, -- 1:活跃 0:冻结
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE crm_customers IS '客户主表';
COMMENT ON COLUMN crm_customers.id IS '客户主键 UUID';
COMMENT ON COLUMN crm_customers.name IS '客户/公司名称';
COMMENT ON COLUMN crm_customers.level IS '客户等级 A/B/C';
COMMENT ON COLUMN crm_customers.ai_score IS 'AI客情健康度评分 0~100';
COMMENT ON COLUMN crm_customers.owner_id IS '负责销售ID';
CREATE INDEX idx_cust_level ON crm_customers(level) WHERE is_deleted = FALSE;
CREATE INDEX idx_cust_owner ON crm_customers(owner_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_cust_name ON crm_customers(name) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_cust_updated
BEFORE UPDATE ON crm_customers
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- 2.2 客户跟进日志表 crm_follow_up_logs
-- ============================================================
CREATE TABLE crm_follow_up_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES crm_customers(id),
salesperson_id UUID NOT NULL REFERENCES sys_users(id),
content TEXT NOT NULL,
emotion_type VARCHAR(20), -- positive / neutral / negative
next_visit_time TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE crm_follow_up_logs IS '客户跟进日志表(AI简报数据源)';
COMMENT ON COLUMN crm_follow_up_logs.customer_id IS '关联客户ID';
COMMENT ON COLUMN crm_follow_up_logs.salesperson_id IS '操作销售人员ID';
COMMENT ON COLUMN crm_follow_up_logs.content IS '跟进内容';
COMMENT ON COLUMN crm_follow_up_logs.emotion_type IS '情感标记: positive/neutral/negative';
COMMENT ON COLUMN crm_follow_up_logs.next_visit_time IS '计划下次拜访时间';
CREATE INDEX idx_follow_cust ON crm_follow_up_logs(customer_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_follow_sales ON crm_follow_up_logs(salesperson_id) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_follow_updated
BEFORE UPDATE ON crm_follow_up_logs
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ************************************************************
-- 3. ERP 供应链域
-- ************************************************************
-- ============================================================
-- 3.1 产品分类树 erp_product_categories
-- ============================================================
CREATE TABLE erp_product_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
parent_id UUID REFERENCES erp_product_categories(id),
name VARCHAR(100) NOT NULL,
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE erp_product_categories IS '产品分类树表(左树结构支撑)';
COMMENT ON COLUMN erp_product_categories.parent_id IS '父级分类IDNULL为顶级分类';
CREATE INDEX idx_pcat_parent ON erp_product_categories(parent_id) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_pcat_updated
BEFORE UPDATE ON erp_product_categories
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- 3.2 产品 SKU 表 erp_product_skus
-- ============================================================
CREATE TABLE erp_product_skus (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
category_id UUID REFERENCES erp_product_categories(id),
sku_code VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
spec VARCHAR(100), -- 规格,如 200L/桶
standard_price NUMERIC(12,2) NOT NULL DEFAULT 0,
stock_qty NUMERIC(12,2) NOT NULL DEFAULT 0,
warning_threshold NUMERIC(12,2) DEFAULT 0, -- 库存预警阈值
unit VARCHAR(20) DEFAULT '',
status SMALLINT DEFAULT 1, -- 1:在售 0:停售
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE erp_product_skus IS '产品SKU主档表';
COMMENT ON COLUMN erp_product_skus.sku_code IS 'SKU编号,全局唯一';
COMMENT ON COLUMN erp_product_skus.spec IS '包装规格,如 200L/桶、18L/桶';
COMMENT ON COLUMN erp_product_skus.standard_price IS '标准售价';
COMMENT ON COLUMN erp_product_skus.stock_qty IS '当前库存数量';
COMMENT ON COLUMN erp_product_skus.warning_threshold IS '库存预警阈值,低于此值触发预警';
CREATE INDEX idx_sku_category ON erp_product_skus(category_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_sku_code ON erp_product_skus(sku_code) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_sku_updated
BEFORE UPDATE ON erp_product_skus
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- 3.3 出入库流水表 erp_inventory_flows
-- ============================================================
CREATE TABLE erp_inventory_flows (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
sku_id UUID NOT NULL REFERENCES erp_product_skus(id),
change_qty NUMERIC(12,2) NOT NULL, -- 正数=入库,负数=出库
reason VARCHAR(50) NOT NULL, -- purchase/shipment/loss/adjust
remark TEXT,
operator_id UUID REFERENCES sys_users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE erp_inventory_flows IS '出入库流水账表';
COMMENT ON COLUMN erp_inventory_flows.sku_id IS '关联SKU ID';
COMMENT ON COLUMN erp_inventory_flows.change_qty IS '变动数量:正=入库 负=出库';
COMMENT ON COLUMN erp_inventory_flows.reason IS '变动原因: purchase/shipment/loss/adjust';
COMMENT ON COLUMN erp_inventory_flows.operator_id IS '操作人ID';
CREATE INDEX idx_invflow_sku ON erp_inventory_flows(sku_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_invflow_operator ON erp_inventory_flows(operator_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_invflow_time ON erp_inventory_flows(created_at) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_invflow_updated
BEFORE UPDATE ON erp_inventory_flows
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ************************************************************
-- 4. ERP 交易域
-- ************************************************************
-- ============================================================
-- 4.1 订单主表 erp_orders
-- ============================================================
CREATE TABLE erp_orders (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
order_no VARCHAR(30) NOT NULL UNIQUE, -- 业务单号,如 ORD-20260227-001
customer_id UUID NOT NULL REFERENCES crm_customers(id),
salesperson_id UUID REFERENCES sys_users(id),
total_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
shipping_state VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending / partial / shipped
payment_state VARCHAR(20) NOT NULL DEFAULT 'unpaid', -- unpaid / partial / cleared
paid_amount NUMERIC(14,2) DEFAULT 0,
remark TEXT,
order_date DATE DEFAULT CURRENT_DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE erp_orders IS '订单主表';
COMMENT ON COLUMN erp_orders.order_no IS '业务订单号';
COMMENT ON COLUMN erp_orders.customer_id IS '下单客户ID';
COMMENT ON COLUMN erp_orders.salesperson_id IS '负责销售ID';
COMMENT ON COLUMN erp_orders.total_amount IS '订单总金额';
COMMENT ON COLUMN erp_orders.shipping_state IS '发货状态: pending/partial/shipped';
COMMENT ON COLUMN erp_orders.payment_state IS '付款状态: unpaid/partial/cleared';
COMMENT ON COLUMN erp_orders.paid_amount IS '已付金额';
CREATE INDEX idx_order_no ON erp_orders(order_no) WHERE is_deleted = FALSE;
CREATE INDEX idx_order_cust ON erp_orders(customer_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_order_sales ON erp_orders(salesperson_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_order_date ON erp_orders(order_date) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_order_updated
BEFORE UPDATE ON erp_orders
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- 4.2 订单明细表 erp_order_items
-- ============================================================
CREATE TABLE erp_order_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
order_id UUID NOT NULL REFERENCES erp_orders(id),
sku_id UUID NOT NULL REFERENCES erp_product_skus(id),
qty NUMERIC(12,2) NOT NULL,
unit_price NUMERIC(12,2) NOT NULL, -- 本次成交单价(可能是专属价)
sub_total NUMERIC(14,2) NOT NULL,
shipped_qty NUMERIC(12,2) DEFAULT 0, -- 已发货累计量(用于分批发货扣减)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE erp_order_items IS '订单明细行表';
COMMENT ON COLUMN erp_order_items.order_id IS '归属订单ID';
COMMENT ON COLUMN erp_order_items.sku_id IS '关联SKU ID';
COMMENT ON COLUMN erp_order_items.qty IS '下单数量';
COMMENT ON COLUMN erp_order_items.unit_price IS '成交单价(可能为客户专属价)';
COMMENT ON COLUMN erp_order_items.sub_total IS '小计金额 = qty * unit_price';
COMMENT ON COLUMN erp_order_items.shipped_qty IS '已发货累计数量,用于分批发货进度追踪';
CREATE INDEX idx_oitem_order ON erp_order_items(order_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_oitem_sku ON erp_order_items(sku_id) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_oitem_updated
BEFORE UPDATE ON erp_order_items
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ************************************************************
-- 5. ERP 物流域
-- ************************************************************
-- ============================================================
-- 5.1 发货主单表 erp_shipping_records
-- ============================================================
CREATE TABLE erp_shipping_records (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
shipping_no VARCHAR(30) NOT NULL UNIQUE, -- 发货单号 SHP-xxx
order_id UUID NOT NULL REFERENCES erp_orders(id),
carrier VARCHAR(100), -- 承运方,如德邦
tracking_no VARCHAR(100), -- 物流追踪号
status VARCHAR(20) NOT NULL DEFAULT 'transit', -- transit / delivered
ship_date DATE DEFAULT CURRENT_DATE,
remark TEXT,
operator_id UUID REFERENCES sys_users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE erp_shipping_records IS '发货主单表';
COMMENT ON COLUMN erp_shipping_records.shipping_no IS '发货单号';
COMMENT ON COLUMN erp_shipping_records.order_id IS '关联订单ID';
COMMENT ON COLUMN erp_shipping_records.carrier IS '承运方';
COMMENT ON COLUMN erp_shipping_records.tracking_no IS '物流追踪号';
COMMENT ON COLUMN erp_shipping_records.status IS '物流状态: transit=运输中 / delivered=已签收';
CREATE INDEX idx_ship_order ON erp_shipping_records(order_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_ship_no ON erp_shipping_records(shipping_no) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_ship_updated
BEFORE UPDATE ON erp_shipping_records
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- 5.2 发货明细表 erp_shipping_items
-- ============================================================
CREATE TABLE erp_shipping_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
shipping_id UUID NOT NULL REFERENCES erp_shipping_records(id),
order_item_id UUID NOT NULL REFERENCES erp_order_items(id),
sku_id UUID NOT NULL REFERENCES erp_product_skus(id),
shipped_qty NUMERIC(12,2) NOT NULL, -- 本次发货数量
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE erp_shipping_items IS '发货明细行表(承接分批发货扣减)';
COMMENT ON COLUMN erp_shipping_items.shipping_id IS '归属发货单ID';
COMMENT ON COLUMN erp_shipping_items.order_item_id IS '关联订单明细行ID(用于回写已发货量)';
COMMENT ON COLUMN erp_shipping_items.sku_id IS '关联SKU ID';
COMMENT ON COLUMN erp_shipping_items.shipped_qty IS '本次实际发货数量';
CREATE INDEX idx_sitem_shipping ON erp_shipping_items(shipping_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_sitem_oitem ON erp_shipping_items(order_item_id) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_sitem_updated
BEFORE UPDATE ON erp_shipping_items
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ************************************************************
-- 6. 财务票据域
-- ************************************************************
-- ============================================================
-- 6.1 统一发票池表 fin_invoice_pool
-- ============================================================
CREATE TABLE fin_invoice_pool (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
uploader_id UUID REFERENCES sys_users(id),
file_url VARCHAR(500),
merchant_name VARCHAR(200),
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
invoice_date DATE,
type VARCHAR(30) NOT NULL DEFAULT 'expense', -- customer=客户发票 / expense=报销发票
ai_extracted_data JSONB DEFAULT '{}'::JSONB, -- OCR + AI 解析的原始结构化数据
is_used BOOLEAN DEFAULT FALSE, -- 是否已被报销单引用
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE -- 软删除=作废
);
COMMENT ON TABLE fin_invoice_pool IS '统一发票池表(上传即OCR提取)';
COMMENT ON COLUMN fin_invoice_pool.uploader_id IS '上传人ID';
COMMENT ON COLUMN fin_invoice_pool.file_url IS '原始票据文件URL';
COMMENT ON COLUMN fin_invoice_pool.merchant_name IS '商户/开票方名称';
COMMENT ON COLUMN fin_invoice_pool.amount IS '票面金额';
COMMENT ON COLUMN fin_invoice_pool.type IS '票据类型: customer=客户发票 / expense=报销发票';
COMMENT ON COLUMN fin_invoice_pool.ai_extracted_data IS 'AI/OCR提取的结构化数据 (JSONB)';
COMMENT ON COLUMN fin_invoice_pool.is_used IS '是否已被报销单引用';
COMMENT ON COLUMN fin_invoice_pool.is_deleted IS '软删除标识(作废,防物理篡改)';
CREATE INDEX idx_inv_uploader ON fin_invoice_pool(uploader_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_inv_type ON fin_invoice_pool(type) WHERE is_deleted = FALSE;
CREATE INDEX idx_inv_date ON fin_invoice_pool(invoice_date) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_inv_updated
BEFORE UPDATE ON fin_invoice_pool
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- 6.2 报销单主表 fin_expense_records
-- ============================================================
CREATE TABLE fin_expense_records (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
system_no VARCHAR(30) NOT NULL UNIQUE, -- 系统单号 EXP-xxx
applicant_id UUID NOT NULL REFERENCES sys_users(id),
total_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft/pending/approved/rejected
remark TEXT,
approved_by UUID REFERENCES sys_users(id),
approved_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE fin_expense_records IS '报销单主表';
COMMENT ON COLUMN fin_expense_records.system_no IS '系统报销单号';
COMMENT ON COLUMN fin_expense_records.applicant_id IS '申请人ID';
COMMENT ON COLUMN fin_expense_records.total_amount IS '报销总金额';
COMMENT ON COLUMN fin_expense_records.status IS '报销状态: draft/pending/approved/rejected';
COMMENT ON COLUMN fin_expense_records.approved_by IS '审批人ID';
CREATE INDEX idx_exp_applicant ON fin_expense_records(applicant_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_exp_status ON fin_expense_records(status) WHERE is_deleted = FALSE;
CREATE INDEX idx_exp_no ON fin_expense_records(system_no) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_exp_updated
BEFORE UPDATE ON fin_expense_records
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- 6.3 报销明细行表 fin_expense_details
-- ============================================================
CREATE TABLE fin_expense_details (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
expense_id UUID NOT NULL REFERENCES fin_expense_records(id),
invoice_id UUID REFERENCES fin_invoice_pool(id),
expense_desc VARCHAR(500), -- 费用说明
original_type VARCHAR(50), -- 原始费用类型: fuel/entertainment/travel/office
offset_type VARCHAR(50), -- 冲顶类型(二级类型转换后)
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_deleted BOOLEAN DEFAULT FALSE
);
COMMENT ON TABLE fin_expense_details IS '报销明细行表';
COMMENT ON COLUMN fin_expense_details.expense_id IS '归属报销单ID';
COMMENT ON COLUMN fin_expense_details.invoice_id IS '关联发票ID';
COMMENT ON COLUMN fin_expense_details.expense_desc IS '费用说明描述';
COMMENT ON COLUMN fin_expense_details.original_type IS '原始费用类型: fuel/entertainment/travel/office';
COMMENT ON COLUMN fin_expense_details.offset_type IS '冲顶类型(二级票据类型转换后的类型)';
COMMENT ON COLUMN fin_expense_details.amount IS '本行金额';
CREATE INDEX idx_expd_expense ON fin_expense_details(expense_id) WHERE is_deleted = FALSE;
CREATE INDEX idx_expd_invoice ON fin_expense_details(invoice_id) WHERE is_deleted = FALSE;
CREATE TRIGGER trg_expd_updated
BEFORE UPDATE ON fin_expense_details
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- 完成:输出建表结果摘要
-- ============================================================
-- 共创建 13 张业务表:
-- RBAC 权限域: sys_departments, sys_roles, sys_users
-- CRM 客户域: crm_customers, crm_follow_up_logs
-- ERP 供应链域: erp_product_categories, erp_product_skus, erp_inventory_flows
-- ERP 交易域: erp_orders, erp_order_items
-- ERP 物流域: erp_shipping_records, erp_shipping_items
-- 财务票据域: fin_invoice_pool, fin_expense_records, fin_expense_details
--
-- 全局规范落地:
-- ✓ 主键统一 UUID (uuid_generate_v4)
-- ✓ 每表必带 created_at / updated_at / is_deleted 审计三件套
-- ✓ updated_at 自动触发器 (update_updated_at_column)
-- ✓ 灵活字段采用 JSONB (menu_keys, ai_extracted_data)
-- ✓ 外键关联完整,部分索引 (WHERE is_deleted=FALSE) 优化查询
+41
View File
@@ -0,0 +1,41 @@
-- ============================================================
-- 种子数据:用于测试登录的初始管理员账号
-- 密码: 123456 (bcrypt hash)
-- 执行方式: psql -U postgres -d crm_erp -f seed.sql
-- ============================================================
-- 1. 插入顶级部门
INSERT INTO sys_departments (id, parent_id, name, sort_order, status)
VALUES (
'a0000000-0000-0000-0000-000000000001',
NULL,
'总部',
1,
1
) ON CONFLICT DO NOTHING;
-- 2. 插入超级管理员角色 (data_scope = all)
INSERT INTO sys_roles (id, role_name, data_scope, menu_keys, description, status)
VALUES (
'b0000000-0000-0000-0000-000000000001',
'超级管理员',
'all',
'["dashboard","customers","customers:detail","products","orders","shipping","finance","settings"]'::JSONB,
'拥有系统全部权限',
1
) ON CONFLICT DO NOTHING;
-- 3. 插入管理员用户 (admin / 123456)
-- bcrypt hash of "123456" —— 直接用 Python 生成:
-- from passlib.context import CryptContext; print(CryptContext(schemes=["bcrypt"]).hash("123456"))
INSERT INTO sys_users (id, dept_id, role_id, username, password_hash, real_name, phone, status)
VALUES (
'c0000000-0000-0000-0000-000000000001',
'a0000000-0000-0000-0000-000000000001',
'b0000000-0000-0000-0000-000000000001',
'admin',
'$2b$12$N3aYJxXxEeUggclCvnVLgewUFIewfYhAB2fXLlWzI8lY4RoPjCbia',
'系统管理员',
'13800000000',
1
) ON CONFLICT DO NOTHING;
+144
View File
@@ -0,0 +1,144 @@
{
"openapi": "3.0.0",
"info": {
"title": "ERP MCP Tools for Dify",
"version": "1.1.0",
"description": "润滑油 CRM/ERP 系统的 AI 工具接口,供 Dify Agent 调用"
},
"servers": [
{
"url": "http://192.168.1.100:8000"
}
],
"paths": {
"/api/dify/tools/search_customers": {
"post": {
"operationId": "searchCustomers",
"summary": "搜索客户列表,支持按名称模糊搜索和等级过滤",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"keyword": { "type": "string", "description": "客户名称关键词" },
"level": { "type": "string", "description": "客户等级: A / B / C", "enum": ["A", "B", "C"] },
"page": { "type": "integer", "default": 1 },
"size": { "type": "integer", "default": 10 }
}
}
}
}
},
"responses": { "200": { "description": "客户列表查询结果" } }
}
},
"/api/dify/tools/create_customer": {
"post": {
"operationId": "createCustomer",
"summary": "创建新客户(返回确认卡片,需用户在前端确认后执行)",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "description": "客户名称" },
"level": { "type": "string", "description": "客户等级", "default": "C" },
"contact": { "type": "string", "description": "联系人" },
"phone": { "type": "string", "description": "电话" }
}
}
}
}
},
"responses": { "200": { "description": "返回 action_card 确认卡片" } }
}
},
"/api/dify/tools/calculate_price": {
"post": {
"operationId": "calculatePrice",
"summary": "查询客户专属报价:历史成交价追溯,无历史则标准价兜底",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["customer_id", "sku_id"],
"properties": {
"customer_id": { "type": "string", "description": "客户 UUID" },
"sku_id": { "type": "string", "description": "产品 SKU UUID" }
}
}
}
}
},
"responses": { "200": { "description": "报价查询结果" } }
}
},
"/api/dify/tools/create_order": {
"post": {
"operationId": "createOrder",
"summary": "创建销售订单(返回确认卡片,需用户确认后执行)",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["customer_id", "items"],
"properties": {
"customer_id": { "type": "string", "description": "客户 UUID" },
"items": {
"type": "array",
"description": "订单明细行列表",
"items": {
"type": "object",
"required": ["sku_id", "qty", "unit_price"],
"properties": {
"sku_id": { "type": "string", "description": "产品 SKU UUID" },
"qty": { "type": "number", "description": "数量" },
"unit_price": { "type": "number", "description": "单价" }
}
}
},
"remark": { "type": "string", "description": "备注" }
}
}
}
}
},
"responses": { "200": { "description": "返回 action_card 确认卡片" } }
}
},
"/api/dify/tools/search_orders": {
"post": {
"operationId": "searchOrders",
"summary": "搜索订单列表,支持按客户名称、订单号、发货/付款状态筛选",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"keyword": { "type": "string", "description": "客户名称关键词" },
"order_no": { "type": "string", "description": "订单号模糊搜索" },
"shipping_state": { "type": "string", "description": "发货状态", "enum": ["pending", "partial", "shipped"] },
"payment_state": { "type": "string", "description": "付款状态", "enum": ["unpaid", "partial", "cleared"] },
"page": { "type": "integer", "default": 1 },
"size": { "type": "integer", "default": 10 }
}
}
}
}
},
"responses": { "200": { "description": "订单列表查询结果" } }
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
# FastAPI & Server
fastapi==0.115.6
uvicorn[standard]==0.34.0
gunicorn==22.0.0
python-multipart==0.0.20
# Database (Async SQLAlchemy + asyncpg)
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
greenlet==3.1.1
# Database Migration
alembic==1.14.0
# Auth & Security
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.2.1
# Validation & Settings
pydantic==2.10.4
pydantic-settings==2.7.1
# Env
python-dotenv==1.0.1
# HTTP Client (Dify API 通信)
httpx==0.28.1
PyMuPDF>=1.24.0
Pillow>=10.0.0
# Excel 导入/导出
openpyxl>=3.1.0
Binary file not shown.
Binary file not shown.
+247
View File
@@ -0,0 +1,247 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
+70
View File
@@ -0,0 +1,70 @@
# This file must be used with "source bin/activate" *from bash*
# You cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
# on Windows, a path can contain colons and backslashes and has to be converted:
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
# transform D:\path\to\venv to /d/path/to/venv on MSYS
# and to /cygdrive/d/path/to/venv on Cygwin
export VIRTUAL_ENV=$(cygpath /home/hankin/crm_project/server/venv)
else
# use the path as-is
export VIRTUAL_ENV=/home/hankin/crm_project/server/venv
fi
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/"bin":$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1='(venv) '"${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT='(venv) '
export VIRTUAL_ENV_PROMPT
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null
+27
View File
@@ -0,0 +1,27 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV /home/hankin/crm_project/server/venv
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
set _OLD_VIRTUAL_PROMPT="$prompt"
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
set prompt = '(venv) '"$prompt"
setenv VIRTUAL_ENV_PROMPT '(venv) '
endif
alias pydoc python -m pydoc
rehash
+69
View File
@@ -0,0 +1,69 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/). You cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV /home/hankin/crm_project/server/venv
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT '(venv) '
end
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from alembic.config import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from dotenv.__main__ import cli
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(cli())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from fastapi.cli import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from httpx import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from mako.cmd import cmdline
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(cmdline())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from rsa.cli import decrypt
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(decrypt())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from rsa.cli import encrypt
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(encrypt())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from rsa.cli import keygen
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(keygen())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from rsa.util import private_to_public
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(private_to_public())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from rsa.cli import sign
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(sign())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from rsa.cli import verify
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(verify())
+1
View File
@@ -0,0 +1 @@
python3
+1
View File
@@ -0,0 +1 @@
/usr/bin/python3
+1
View File
@@ -0,0 +1 @@
python3
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from uvicorn.main import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from watchfiles.cli import cli
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(cli())
+6
View File
@@ -0,0 +1,6 @@
#!/home/hankin/crm_project/server/venv/bin/python3
import sys
from websockets.cli import main
if __name__ == '__main__':
sys.argv[0] = sys.argv[0].removesuffix('.exe')
sys.exit(main())

Some files were not shown because too many files have changed in this diff Show More