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:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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.url(asyncpg → 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()
|
||||
@@ -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
@@ -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. 签发 Token(sub 存 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="密码修改成功,请重新登录")
|
||||
|
||||
@@ -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-4B,5s 超时)
|
||||
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)
|
||||
@@ -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="联系人已删除")
|
||||
@@ -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
|
||||
|
||||
# 策略2:Dify 空 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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 [],
|
||||
)
|
||||
@@ -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="双轨画像回写成功")
|
||||
|
||||
@@ -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)
|
||||
@@ -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}"},
|
||||
)
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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 存档",
|
||||
)
|
||||
|
||||
@@ -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="回款状态更新成功")
|
||||
@@ -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="日志创建成功")
|
||||
@@ -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)
|
||||
@@ -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="密码已重置")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
# MCP 工具层 — Dify Agent function_call 工具注册与执行
|
||||
@@ -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)
|
||||
@@ -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} 个订单",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
SQLAlchemy 声明式基类
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""所有 ORM Model 的共同父类"""
|
||||
pass
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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 = {} # 可能被用户在卡片上修改过的参数
|
||||
@@ -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位")
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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="审批意见或驳回原因")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# 双轨画像回写 Payload(Dify 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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Service Layer — 业务逻辑集中层
|
||||
# REST API 路由和 MCP 工具共用此层函数
|
||||
@@ -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
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 画像提取 Workflow(fire-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,
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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 字段
|
||||
@@ -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 索引
|
||||
@@ -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 '联系人个体画像 JSONB:role / 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)';
|
||||
@@ -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 '父级部门ID,NULL表示顶级';
|
||||
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 '父级分类ID,NULL为顶级分类';
|
||||
|
||||
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) 优化查询
|
||||
@@ -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;
|
||||
@@ -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": "订单列表查询结果" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
python3
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/usr/bin/python3
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
python3
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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())
|
||||
Executable
+6
@@ -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
Reference in New Issue
Block a user