This commit is contained in:
zlwu committed 2026-05-12 16:35:07 +08:00
1 parent d336fd0226
commit 2e9d020d50
3454 files changed
+878963 -319

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
# zkxw 工程 Cursor 规则
本仓库含 Web 管理端(`web-admin`)、小程序端(`miniprogram`)等子工程。实现或修改 UI 时,**以文档为权威来源**,本文件仅作快速索引与强约束摘要。
## 权威文档(必读路径)
- **Web 管理端(Fresh Mint)**:`docs/web-admin-ui-spec-fresh-mint.md`
适用范围:`web-admin`(Vue 3 + Element Plus + SCSS/Tailwind)。色值、布局、列表、弹窗、树形路径等均以该文档及其中引用的 `fresh-mint-green.html` 为准。
- **小程序端(三墙 Demo 体系)**:`docs/miniprogram-ui-spec-sanqiang.md`
适用范围:`miniprogram`(客户端 / 供应商端 / 配送员端)。视觉以该文档及 `mobile-ui/sanqiang-*.html` 为准。
修改对应子工程前,若涉及界面,应先检索上述文档中的章节(类名、Token、结构顺序)。
---
## Web 管理端(`web-admin`)摘要
### 技术栈与实现方式
- Vue 3 + Element Plus;主题与皮肤通过 **CSS 变量 + Element 主题变量 + 全局覆盖** 对齐规范,**勿随意改 Token 色值**。
- 树形主数据全路径:`web-admin/src/utils/tree-path.ts`(如 `buildTreePathLabelFromFlat`、`buildPathLabelMapFromTreeRoots`、`resolveTreePathDisplay`)。路径分隔符统一为 **` / `**(半角斜杠两侧各一空格)。
- 参考实现:商品查看弹窗 `web-admin/src/components/business/product-view-dialog/index.vue`;列表与分页对齐 `#/base/unit` 等已改造页。
### 视觉与 Token(与规范一致)
- 主色链:`--primary` `#10b981`、`--primary-dark` `#059669`、`--primary-bg` `#ecfdf5`;中性字与边框见规范第 2 节表格。
- 风格:清爽 B 端、薄荷绿 + Slate;**避免一屏多种高饱和色**;次要信息用 muted 与 `.tag.muted`。
- 字体:Inter + 系统栈;正文 14px / line-height 1.5。
### 布局与页面骨架
- 侧栏 220px、顶栏 56px、内容区 padding 24px;面板圆角 10px,输入/按钮等 8px 级圆角见规范。
- 列表页典型顺序:**筛选 `filter-block`(可选)→ 列表工具栏 → `list-body` / `list-wrap`(含 `el-table`)→ 分页**。
- **单标题**:列表面板仅一个 `panel-top` 主标题;筛选区内不要再加「筛选条件」等重复标题。
- **单分隔线**:工具栏与列表之间靠 `list-body` 顶边框;列表与分页之间保留列表底边与分页顶边规则,避免双线叠加(见规范列表与分页章节)。
### 筛选与表单
- 筛选栅格、label 11px、控件高度 36px、placeholder 规则等见规范第 5 节。
- 筛选区内部:**不要用横向分割线** 区分多行条件与展开区,仅用 gap / margin。
- 弹窗表单:**`label-position="top"`**;两列可用 `form-grid-2`;长字段 `grid-column: 1 / -1`。
- 弹窗底栏:取消/确定建议 **Close / Check** 图标,图标与文字间距 **6px**;footer 按钮高度与圆角等见规范 `el-dialog` 小节。
- 长表单:`el-form` 建议 **`scroll-to-error`** 与 **`scroll-into-view-options`**;校验失败可配合 `ElMessageBox.alert` 提示(与已有页一致)。
- 遮罩:**无背景模糊**(`backdrop-filter: none`、`modal-class` 等与现有 `order-module-dialog-mask` 等策略一致)。
### 列表工具栏与表格
- 工具栏:**「已选 N 项」在最前**,其后从左到右排列主按钮与批量操作;**禁止**用 `space-between` 把整组按钮推到行末。
- **同一行仅第一个主行动** 使用主色实心(`type="primary"` / `btn-main`);批量删除、导入导出等为中性描边。
- **操作列**:`fixed="right"`,预留足够宽度(建议 min-width ≥ 260px),避免按钮被裁切。
- 主表:`list-table` + `stripe`;与已对齐订单列表一致时 **勿随意加 `border`**(以规范及参考页为准)。
- 分页:表格与 **`el-pagination`** 同放在 **`list-body.list-wrap`** 内;外层 **`.pagination.list-pager-bar`**,`margin-top: 0`,上边框与 padding 见规范第 7.9 节。
### 树形选择器与查看态
- 下拉树节点文案:**本级名称**;选中后输入框、表格列、导出、查看弹窗:**全路径**(见规范树形与第 5.6 节)。
### 状态与业务色
- 进行中/主状态:浅绿底 + 深绿字;警告琥珀 `#d97706` 系;危险红系用于强提醒;与订单/配送枚举映射时保持语义一致(见规范第 12 节)。
---
## 小程序端(`miniprogram`)摘要
### Token 与适配
- 品牌绿:`#0a9f6d` / `#087f57` / `#e9f9f2`;大麦黄辅助:`#d6a343` / `#fff7e8`;中性背景与边框见规范第 2.1 节。
- 主基线宽度 **375px**,兼容 **414px**;间距节奏 8–16;卡片圆角 12–16px,大容器 18–22px。
### 三端统一骨架
- 顺序一般为:Header(绿渐变)→ 统计/提示(可选)→ 功能宫格 → 主列表/任务 → 底部固定区(客户端:结算条 + TabBar)。
- Header:绿色渐变、标题左对齐、操作右对齐、下缘大圆角(约 20–24px)。
### 组件与媒体(强约束)
- 列表缩略图:**1:1**,`aspectFill`;轮播:**4:3**,`aspectFill`;详情图:**widthFix**;视频建议 720p 内、尽量短(见规范第 7.0 节)。
- 状态标签语义:`warning` 琥珀待处理、`success` 绿色进行中或完成、`danger` 红色异常;避免同屏过多高饱和色。
- 底部结算条:数量 → 总价 → 主按钮;主按钮视觉优先级最高。
- 触达区域不小于 **44×44**;注意横滑与纵向滚动冲突(尤其一级分类横滑)。
### 从 Demo 到页面
- 规范第 11 节给出 Demo class 到 `pages/**/*.vue` 的建议类名;优先统一 Token 与骨架,再收敛公共样式到 `src/styles` 或公共组件。
- 新增页面类型应先在 `docs/miniprogram-ui-spec-sanqiang.md` 补充结构说明再落代码(见规范第 10 节)。
---
## 通用开发原则
- **最小必要改动**:不扩大范围、不做无关重构;新增代码与周边文件的命名、类型、导入风格保持一致。
- **跨端颜色不要混用**:管理端用 Fresh Mint 链(`#10b981` 等);小程序用三墙 Token(`#0a9f6d` 等);除非产品明确要求对齐,否则不要简单复制另一端的色值。
- **后端/API 变更**:若 UI 改动依赖接口字段(如树节点 id、分类全路径),需同步评估接口与类型定义,避免前端仅末级展示造成歧义。
- 提交信息使用完整句子,说明「改了什么、为何」,避免无意义前缀堆砌。
---
## 语言
- 与用户沟通时:若用户使用简体中文,助手回复使用**简体中文**。
- 代码注释与用户可见文案:遵循项目现有语言习惯(中文业务文案以产品为准)。
+45
View File
@@ -0,0 +1,45 @@
# 数据库连接配置示例
# DATABASE_URL 格式:mysql+pymysql://user:password@host:port/dbname
DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
# JWT配置示例
# SECRET_KEY=your-secret-key-change-in-production
# ALGORITHM=HS256
# ACCESS_TOKEN_EXPIRE_MINUTES=30
# 应用配置示例
# APP_NAME=中科兴皖一体化门户
# DEBUG=true
# 登录 RSA(Web/小程序加密登录;多实例须配置同一私钥 PEM)
# LOGIN_RSA_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
# 仅本地用 Swagger/Apifox 明文测登录时可临时打开(生产务必 false)
# LOGIN_ALLOW_PLAINTEXT_LOGIN=false
# LOGIN_ENCRYPT_TS_SKEW_SEC=300
# Redis 配置(用于会话/Token 吊销;需在后端运行时配置)
# REDIS_HOST=127.0.0.1
# REDIS_PORT=6379
# REDIS_DB=0
# REDIS_PASSWORD=
# REDIS_TIMEOUT_SEC=30
# REDIS_SSL_ENABLED=false
# MinIO 配置示例
# STORAGE_TYPE=minio
# MINIO_ENDPOINT=127.0.0.1:9000
# MINIO_ACCESS_KEY=CHANGEME
# MINIO_SECRET_KEY=CHANGEME
# MINIO_BUCKET=sanqiang
# MINIO_SECURE=false
# MINIO_BASE_URL=https://www.chengyantek.cn/sanqiang
# 微信支付配置示例
# 小程序支付使用 JSAPI(trade_type=JSAPI)
# WXPAY_MCHID=1900000109
# WXPAY_MINI_APPID=wx1234567890abcdef
# WXPAY_SECRET=your-miniapp-secret
# WXPAY_API_KEY=your-v2-api-key
# WXPAY_NOTIFY_URL=https://www.chengyantek.cn/api/payment/notify
# WXPAY_REFUND_NOTIFY_URL=https://www.chengyantek.cn/api/payment/refund-notify
# WXPAY_SANDBOX=false
+90
View File
@@ -0,0 +1,90 @@
# 中科兴皖一体化门户后端(空壳)
当前 `app.main` **不连接数据库、不注册任何业务路由**,仅提供根路径与 `/health`,便于占位或 CI。原 `app/api` 下 Python 文件已删除(请从 git 历史恢复)。`app/models`、`app/crud` 等目录仍保留为未引用代码,可按需继续清理。
## 技术栈
- FastAPI - 现代高性能异步 Web 框架
- SQLAlchemy - ORM 数据库映射
- Pydantic - 数据验证
- JWT - 用户认证
- MySQL/PostgreSQL - 数据库
## 项目结构
```
backend/
├── app/
│ ├── api/ # 包占位(路由实现已删除)
│ ├── core/ # 核心配置
│ ├── models/ # 数据库模型
│ ├── schemas/ # Pydantic schemas
│ ├── services/ # 业务逻辑
│ └── main.py # 应用入口
├── requirements.txt
└── README.md
```
## 快速开始
### 1. 创建虚拟环境
```bash
python -m venv venv
venv\Scripts\activate # Windows
# source venv/bin/activate # Linux/Mac
```
### 2. 安装依赖
```bash
pip install -r requirements.txt
```
### 3. 配置环境变量
复制 `.env.example` 为 `.env` 并修改配置:
```env
# 数据库配置
DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
# JWT配置
SECRET_KEY=your-secret-key-change-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# 应用配置
APP_NAME=中科兴皖一体化门户
DEBUG=true
```
### 4. 启动服务
```bash
uvicorn app.main:app --reload --port 8000
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
服务启动后,访问:
- API 文档:http://127.0.0.1:8000/docs
- ReDoc 文档:http://127.0.0.1:8000/redoc
## API 路由分组
| 前缀 | 说明 | 认证方式 |
|------|------|----------|
| `/api/admin` | 运营端接口 | JWT Token |
| `/api/supplier` | 供应商端接口 | JWT Token |
| `/api/client` | 客户端接口 | JWT Token |
| `/api/deliver` | 配送员端接口 | JWT Token |
| `/api/public` | 公共接口(登录等) | 无需认证 |
## 开发规范
- 使用 Pydantic 进行数据验证
- 使用 SQLAlchemy ORM 进行数据库操作
- 所有接口遵循 RESTful 规范
- 密码使用 bcrypt 加密存储
- 使用 JWT Token 进行用户认证
+1
View File
@@ -0,0 +1 @@
11632
+1
View File
@@ -0,0 +1 @@
# 中科兴皖一体化门户后端(骨架)
+3
View File
@@ -0,0 +1,3 @@
"""API 包:业务路由模块已删除,仅保留包占位。恢复历史实现请使用 git。"""
__all__: list[str] = []
+3
View File
@@ -0,0 +1,3 @@
from app.core.config import settings
__all__ = ["settings"]
+78
View File
@@ -0,0 +1,78 @@
"""运营 Web(/web/admin)JSON 请求体解析。
与 ApiCryptoMiddleware 配合:中间件未改写 body 时,在路由内解密 `{ enc, ts, nonce, edata }`
并剥 `{ data: ... }`,避免 FastAPI 声明式 Body 在校验阶段只看到密文信封而 422。
"""
from __future__ import annotations
import logging
from typing import Any, Optional, Tuple
from fastapi import Request
from app.core.request_payload import resolve_request_payload
_log = logging.getLogger("sqxp")
async def parse_admin_request_json(
request: Request,
current_user: dict,
*,
source: str,
) -> Tuple[Any, Optional[str]]:
"""
读取 JSON body;dict 时走 resolve_request_payload(明文 / 密文 / data 包装)。
Returns:
(payload, None) 成功;
(_, err_msg) 密文解密失败(此时 payload 为原始 raw,调用方可忽略)。
"""
dw = source.startswith("delivery_warning")
try:
raw: Any = await request.json()
except Exception as ex:
if dw:
_log.warning("parse_admin_request_json source=%s request.json() failed: %s", source, ex)
raw = {}
if isinstance(raw, dict):
if dw:
ed = raw.get("edata")
_log.info(
"parse_admin_request_json source=%s raw_keys=%s enc=%s edata_len=%s user_id=%s",
source,
sorted(raw.keys()),
int(raw.get("enc", 0) or 0),
len(ed) if isinstance(ed, str) else (len(str(ed)) if ed is not None else 0),
current_user.get("user_id"),
)
resolved = resolve_request_payload(raw, current_user, source=source)
if dw:
if isinstance(resolved, dict):
_log.info(
"parse_admin_request_json source=%s resolved_keys=%s",
source,
sorted(resolved.keys()),
)
else:
_log.info(
"parse_admin_request_json source=%s resolved_type=%s",
source,
type(resolved).__name__,
)
if resolved == {} and int(raw.get("enc", 0) or 0) == 1:
if dw:
_log.warning(
"parse_admin_request_json source=%s treating as decrypt failure (resolved empty, enc=1)",
source,
)
return raw, "密文解密失败"
return resolved, None
if dw:
_log.warning(
"parse_admin_request_json source=%s raw_not_dict type=%s",
source,
type(raw).__name__,
)
return raw, None
+65
View File
@@ -0,0 +1,65 @@
"""Web API 请求/响应加解密工具。"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import time
from typing import Any, Dict
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class ApiCryptoError(Exception):
"""API 加解密错误。"""
def derive_key(fixed_secret: str, user_id: str, ts: int, nonce: str) -> bytes:
seed = f"{fixed_secret}:{user_id}:{ts}:{nonce}"
return hashlib.sha256(seed.encode("utf-8")).digest()
def _b64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
def _b64url_decode(data: str) -> bytes:
pad = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode((data + pad).encode("utf-8"))
def encrypt_json(payload: Dict[str, Any], key: bytes) -> str:
iv = os.urandom(12)
aes = AESGCM(key)
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
cipher = aes.encrypt(iv, raw, None)
return _b64url_encode(iv + cipher)
def decrypt_json(cipher_text: str, key: bytes) -> Dict[str, Any]:
try:
data = _b64url_decode(cipher_text)
if len(data) < 13:
raise ApiCryptoError("密文长度非法")
iv = data[:12]
cipher = data[12:]
aes = AESGCM(key)
plain = aes.decrypt(iv, cipher, None)
obj = json.loads(plain.decode("utf-8"))
if not isinstance(obj, dict):
raise ApiCryptoError("解密后报文格式非法")
return obj
except ApiCryptoError:
raise
except Exception as exc:
raise ApiCryptoError("密文解析失败") from exc
def gen_nonce_hex() -> str:
return os.urandom(12).hex()
def is_ts_in_window(ts: int, skew_sec: int) -> bool:
now_ms = int(time.time() * 1000)
return abs(now_ms - int(ts)) <= max(30, int(skew_sec)) * 1000
+239
View File
@@ -0,0 +1,239 @@
"""Web API 加解密中间件。"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any, Optional, Tuple
from urllib.parse import urlencode
import redis.asyncio as redis
from jose import jwt
from redis import exceptions as redis_exceptions
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from app.core import api_crypto
from app.core.config import settings
from app.core.response import error_response
logger = logging.getLogger("sqxp")
_redis_client: Optional[redis.Redis] = None
_local_nonce_cache: dict[str, float] = {}
def _nonce_key(user_id: str, nonce: str) -> str:
return f"api-enc:nonce:{user_id}:{nonce}"
def _should_skip_path(path: str) -> bool:
excludes = [x.strip() for x in (settings.API_ENCRYPT_EXCLUDE_PATHS or "").split(",") if x.strip()]
return path in excludes
def _is_crypto_api(path: str) -> bool:
"""仅对运营端与小程序业务 API 应用统一密文协议。"""
prefix = settings.API_V1_PREFIX
return path.startswith(f"{prefix}/web/") or path.startswith(f"{prefix}/mini/")
def _claims_from_auth(auth_header: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
if not auth_header or not auth_header.startswith("Bearer "):
return None, None
token = auth_header[7:].strip()
if not token:
return None, None
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
options={"verify_signature": False, "verify_exp": False},
)
return payload.get("sub"), token
except Exception:
return None, token
def _get_redis_client() -> Optional[redis.Redis]:
global _redis_client
if not settings.REDIS_HOST:
return None
if _redis_client is None:
_redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
password=settings.REDIS_PASSWORD or None,
socket_timeout=settings.REDIS_TIMEOUT_SEC,
ssl=settings.REDIS_SSL_ENABLED,
decode_responses=True,
)
return _redis_client
async def _mark_nonce_once(user_id: str, nonce: str) -> bool:
ttl_sec = max(30, int(settings.API_ENCRYPT_NONCE_TTL_SEC))
key = _nonce_key(user_id, nonce)
client = _get_redis_client()
if client is not None:
try:
ok = await asyncio.wait_for(client.set(key, "1", ex=ttl_sec, nx=True), timeout=2.0)
return bool(ok)
except (asyncio.TimeoutError, redis_exceptions.RedisError):
logger.warning("api crypto nonce redis unavailable, fallback to local memory")
now = time.time()
expire_before = now - ttl_sec
stale_keys = [k for k, v in _local_nonce_cache.items() if v < expire_before]
for stale in stale_keys:
_local_nonce_cache.pop(stale, None)
if key in _local_nonce_cache:
return False
_local_nonce_cache[key] = now
return True
def _json_error_response(code: int, message: str) -> Response:
body = error_response(code, message)
return Response(content=json.dumps(body, ensure_ascii=False), media_type="application/json", status_code=200)
class ApiCryptoMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if not settings.API_ENCRYPT_ENABLED:
return await call_next(request)
if not settings.API_ENCRYPT_FIXED_SECRET:
if settings.API_ENCRYPT_FAIL_OPEN:
logger.warning("api crypto bypassed: API_ENCRYPT_FIXED_SECRET is empty")
return await call_next(request)
return _json_error_response(500, "服务端加密配置缺失")
path = request.url.path
if not _is_crypto_api(path) or _should_skip_path(path):
return await call_next(request)
content_type = (request.headers.get("content-type") or "").lower()
if "multipart/form-data" in content_type:
return await call_next(request)
body_bytes = await request.body()
req_json = {}
if body_bytes:
try:
req_json = json.loads(body_bytes.decode("utf-8"))
if not isinstance(req_json, dict):
req_json = {}
except Exception:
req_json = {}
is_encrypted_req = int(req_json.get("enc", 0) or 0) == 1
if not is_encrypted_req:
logger.info("api crypto bypassed plaintext request: %s", path)
return await call_next(request)
user_id, _ = _claims_from_auth(request.headers.get("authorization"))
if not user_id:
return _json_error_response(401, "未授权或 token 无效")
ts = req_json.get("ts")
nonce = req_json.get("nonce")
edata = req_json.get("edata")
if ts is None or not nonce or not isinstance(edata, str):
logger.warning("api crypto invalid envelope: path=%s", path)
return _json_error_response(400, "加密报文格式错误")
if not api_crypto.is_ts_in_window(int(ts), settings.API_ENCRYPT_TS_SKEW_SEC):
logger.warning("api crypto ts expired: path=%s user_id=%s", path, user_id)
return _json_error_response(400, "请求已过期,请重试")
if not await _mark_nonce_once(str(user_id), str(nonce)):
logger.warning("api crypto replay detected: path=%s user_id=%s nonce=%s", path, user_id, nonce)
return _json_error_response(409, "重复请求")
key = api_crypto.derive_key(
fixed_secret=settings.API_ENCRYPT_FIXED_SECRET,
user_id=str(user_id),
ts=int(ts),
nonce=str(nonce),
)
try:
plain = api_crypto.decrypt_json(edata, key)
except Exception as exc:
logger.warning("api crypto decrypt failed: path=%s user_id=%s err=%s", path, user_id, exc)
return _json_error_response(400, "密文解密失败")
override_method = request.headers.get("x-http-method-override", "").upper()
payload = plain.get("data", plain)
if override_method == "GET":
request.scope["method"] = "GET"
query_dict = payload if isinstance(payload, dict) else {}
request.scope["query_string"] = urlencode(query_dict, doseq=True).encode("utf-8")
body_for_route = b""
else:
# JSON body 可能是对象(多数 CRUD)或数组(如批量轮播图 POST)
if isinstance(payload, (dict, list)):
body_for_route = json.dumps(payload, ensure_ascii=False).encode("utf-8")
else:
body_for_route = json.dumps({}, ensure_ascii=False).encode("utf-8")
async def receive():
return {"type": "http.request", "body": body_for_route, "more_body": False}
request = Request(request.scope, receive)
response = await call_next(request)
content_type = (response.headers.get("content-type") or "").lower()
is_json_response = "application/json" in content_type or response.media_type == "application/json"
if not is_json_response:
return response
raw_resp = b""
if hasattr(response, "body_iterator"):
chunks: list[bytes] = []
async for chunk in response.body_iterator:
chunks.append(chunk)
raw_resp = b"".join(chunks)
elif getattr(response, "body", None) is not None:
raw_resp = response.body
if not raw_resp:
return response
try:
resp_obj = json.loads(raw_resp.decode("utf-8"))
if not isinstance(resp_obj, dict):
return response
except Exception:
return response
try:
resp_ts = int(time.time() * 1000)
resp_nonce = api_crypto.gen_nonce_hex()
resp_key = api_crypto.derive_key(
fixed_secret=settings.API_ENCRYPT_FIXED_SECRET,
user_id=str(user_id),
ts=resp_ts,
nonce=resp_nonce,
)
resp_edata = api_crypto.encrypt_json(resp_obj, resp_key)
wrapped = {
"code": resp_obj.get("code", 200),
"encrypted": True,
"ts": resp_ts,
"nonce": resp_nonce,
"edata": resp_edata,
}
except Exception as exc:
logger.warning("api crypto response encrypt failed: path=%s user_id=%s err=%s", path, user_id, exc)
if settings.API_ENCRYPT_FAIL_OPEN:
return response
return _json_error_response(500, "响应加密失败")
headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
return Response(
content=json.dumps(wrapped, ensure_ascii=False),
status_code=response.status_code,
headers=headers,
media_type="application/json",
background=response.background,
)
@@ -0,0 +1,276 @@
"""统一记录 API 请求/响应出入参的中间件"""
import asyncio
import json
import logging
import time
from typing import Any, Optional, Tuple
from fastapi import HTTPException
from jose import jwt
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from app.core.config import settings
from app.core.database import SessionLocal
logger = logging.getLogger("sqxp")
_SENSITIVE_KEYS = frozenset(
{
"password",
"oldpassword",
"newpassword",
"encryptedlogin",
"authorization",
"token",
"refreshtoken",
"apikey",
}
)
_OMIT_REQ_BYTES = 10 * 1024 * 1024
def _truncate(s: str, max_len: int) -> str:
if not s:
return s
if len(s) <= max_len:
return s
return s[:max_len] + f"\n... [truncated, total {len(s)} chars]"
def _redact_obj(obj: Any) -> Any:
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
lk = str(k).lower()
if (
lk in _SENSITIVE_KEYS
or "password" in lk
or lk in ("encryptedlogin", "refreshtoken")
):
out[k] = "***"
else:
out[k] = _redact_obj(v)
return out
if isinstance(obj, list):
return [_redact_obj(x) for x in obj[:200]] + (["..."] if len(obj) > 200 else [])
return obj
def _format_body_bytes(raw: bytes, content_type: str, max_bytes: int) -> str:
if not raw:
return ""
if len(raw) > _OMIT_REQ_BYTES:
return f"[body omitted: {len(raw)} bytes]"
if len(raw) > max_bytes:
raw = raw[:max_bytes]
ct = (content_type or "").lower()
if "multipart/form-data" in ct:
return "[multipart/form-data body omitted]"
if "application/json" in ct or ct == "" or "text/" in ct:
try:
text = raw.decode("utf-8", errors="replace")
try:
parsed = json.loads(text)
parsed = _redact_obj(parsed)
return _truncate(
json.dumps(parsed, ensure_ascii=False),
settings.API_REQUEST_LOG_MAX_BODY,
)
except json.JSONDecodeError:
return _truncate(text, settings.API_REQUEST_LOG_MAX_BODY)
except Exception:
return f"[decode error, {len(raw)} bytes]"
return _truncate(raw.decode("utf-8", errors="replace"), settings.API_REQUEST_LOG_MAX_BODY)
def _claims_from_auth(auth_header: Optional[str]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
if not auth_header or not auth_header.startswith("Bearer "):
return None, None, None
token = auth_header[7:].strip()
if not token:
return None, None, None
try:
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
options={"verify_signature": False, "verify_exp": False},
)
return payload.get("sub"), payload.get("username"), payload.get("role")
except Exception:
return None, None, None
def _should_skip(request: Request) -> bool:
p = request.url.path
if request.method == "OPTIONS":
return True
if p in ("/", "/health", "/docs", "/redoc", "/openapi.json"):
return True
if p.startswith("/docs") or p.startswith("/redoc"):
return True
return False
def _persist_log(
*,
http_method: str,
path: str,
query_string: Optional[str],
request_body: Optional[str],
response_body: Optional[str],
status_code: int,
duration_ms: int,
user_id: Optional[str],
username: Optional[str],
role: Optional[str],
ip_address: Optional[str],
user_agent: Optional[str],
) -> None:
from datetime import datetime
from app.core.utils import generate_id
from app.models.models import SysApiRequestLog
db = SessionLocal()
try:
row = SysApiRequestLog(
id=generate_id(),
http_method=http_method,
path=_truncate(path, 500),
query_string=_truncate(query_string or "", 2000) or None,
request_body=request_body,
response_body=response_body,
status_code=status_code,
duration_ms=duration_ms,
user_id=user_id,
username=_truncate(username or "", 100) if username else None,
role=role,
ip_address=ip_address,
user_agent=_truncate(user_agent or "", 500) if user_agent else None,
is_del=0,
create_at=datetime.now(),
create_by=None,
create_name=None,
)
db.add(row)
db.commit()
except Exception as e:
logger.warning("api_request_log persist failed: %s", e)
db.rollback()
finally:
db.close()
def _schedule_persist_log(**kwargs) -> None:
"""异步提交日志写入任务,不阻塞当前请求。"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# 无事件循环时(极少数场景)回退为同步写入,保证日志不丢。
try:
_persist_log(**kwargs)
except Exception as e:
logger.warning("api_request_log persist fallback failed: %s", e)
return
task = loop.create_task(asyncio.to_thread(_persist_log, **kwargs))
def _on_done(t: asyncio.Task) -> None:
try:
t.result()
except Exception as e:
logger.warning("api_request_log async task failed: %s", e)
task.add_done_callback(_on_done)
class ApiRequestLogMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if not settings.API_REQUEST_LOG_ENABLED or _should_skip(request):
return await call_next(request)
max_b = settings.API_REQUEST_LOG_MAX_BODY_BYTES
content_type = request.headers.get("content-type", "")
try:
body = await request.body()
except Exception as e:
body = b""
logger.warning("api_request_log read body failed: %s", e)
req_text = _format_body_bytes(body, content_type, max_b)
async def receive():
return {"type": "http.request", "body": body, "more_body": False}
request = Request(request.scope, receive)
uid, uname, role = _claims_from_auth(request.headers.get("authorization"))
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if not ip and request.client:
ip = request.client.host
ua = request.headers.get("user-agent", "")
start = time.perf_counter()
status_code = 500
resp_text = ""
raw_resp = b""
out: Optional[Response] = None
try:
response = await call_next(request)
status_code = response.status_code
if hasattr(response, "body_iterator"):
resp_chunks: list[bytes] = []
async for chunk in response.body_iterator:
resp_chunks.append(chunk)
raw_resp = b"".join(resp_chunks)
elif getattr(response, "body", None) is not None:
raw_resp = response.body
ct_out = response.headers.get("content-type", "")
resp_text = _format_body_bytes(raw_resp, ct_out, max_b)
headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
out = Response(
content=raw_resp,
status_code=response.status_code,
headers=headers,
media_type=response.media_type,
background=response.background,
)
except HTTPException as e:
status_code = e.status_code
d = e.detail
if not isinstance(d, str):
try:
d = json.dumps(d, ensure_ascii=False)
except Exception:
d = str(d)
resp_text = _truncate(d, settings.API_REQUEST_LOG_MAX_BODY)
raise
except Exception as e:
resp_text = _truncate(str(e), settings.API_REQUEST_LOG_MAX_BODY)
raise
finally:
elapsed_ms = int((time.perf_counter() - start) * 1000)
path = request.url.path
qs = request.url.query
_schedule_persist_log(
http_method=request.method,
path=path,
query_string=qs or None,
request_body=req_text or None,
response_body=resp_text or None,
status_code=status_code,
duration_ms=elapsed_ms,
user_id=uid,
username=uname,
role=role,
ip_address=ip,
user_agent=ua,
)
return out
+255
View File
@@ -0,0 +1,255 @@
"""认证模块"""
import asyncio
from datetime import datetime, timedelta
from typing import Optional
import redis.asyncio as redis
from redis import exceptions as redis_exceptions
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.core.security import verify_password
from app.models.models import SysUser
from app.crud.user import user_crud
from app.core.utils import generate_uuid
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/public/login")
REFRESH_TOKEN_EXPIRE_DAYS = 7
REFRESH_TOKEN_EXPIRE_SECONDS = int(timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS).total_seconds())
_redis_client: Optional[redis.Redis] = None
def _redis_session_enabled() -> bool:
# 只要配置了 REDIS_HOST,就启用“会话校验/refreshToken 吊销”
return bool(settings.REDIS_HOST)
def _get_redis_client() -> Optional[redis.Redis]:
global _redis_client
if not _redis_session_enabled():
return None
if _redis_client is None:
_redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
password=settings.REDIS_PASSWORD or None,
socket_timeout=settings.REDIS_TIMEOUT_SEC,
ssl=settings.REDIS_SSL_ENABLED,
decode_responses=True,
)
return _redis_client
def _session_key(session_id: str) -> str:
return f"auth:sessions:{session_id}"
def _refresh_key(refresh_jti: str) -> str:
return f"auth:refresh:{refresh_jti}"
async def store_session(session_id: str, user_id: str) -> None:
"""写入会话(用于强制失效)"""
r = _get_redis_client()
if r is None:
return
await r.set(_session_key(session_id), user_id, ex=REFRESH_TOKEN_EXPIRE_SECONDS)
async def revoke_session(session_id: str) -> None:
"""撤销会话(登出/强制失效)"""
r = _get_redis_client()
if r is None:
return
await r.delete(_session_key(session_id))
async def extend_session(session_id: str) -> None:
"""刷新会话 TTL(用于 refreshToken 延长会话)"""
r = _get_redis_client()
if r is None:
return
await r.expire(_session_key(session_id), REFRESH_TOKEN_EXPIRE_SECONDS)
async def is_session_valid(session_id: str) -> bool:
"""检查会话是否仍然有效"""
r = _get_redis_client()
if r is None:
return True
if not session_id:
return False
try:
# 添加连接超时保护,Redis 不可用时降级为验证通过
return await asyncio.wait_for(r.exists(_session_key(session_id)), timeout=3.0) > 0
except (asyncio.TimeoutError, redis_exceptions.TimeoutError, redis_exceptions.ConnectionError):
# Redis 连接失败时降级:只验证 JWT,不过滤会话吊销
return True
async def store_refresh_token(refresh_jti: str, user_id: str) -> None:
"""写入 refreshToken(用于 one-time 使用/吊销)"""
r = _get_redis_client()
if r is None:
return
await r.set(_refresh_key(refresh_jti), user_id, ex=REFRESH_TOKEN_EXPIRE_SECONDS)
async def is_refresh_token_valid(refresh_jti: str) -> bool:
r = _get_redis_client()
if r is None:
return True
if not refresh_jti:
return False
try:
return await asyncio.wait_for(r.exists(_refresh_key(refresh_jti)), timeout=3.0) > 0
except (asyncio.TimeoutError, redis_exceptions.TimeoutError, redis_exceptions.ConnectionError):
return True
async def revoke_refresh_token(refresh_jti: str) -> None:
r = _get_redis_client()
if r is None:
return
await r.delete(_refresh_key(refresh_jti))
def create_access_token(
data: dict,
session_id: str,
token_jti: str,
expires_delta: Optional[timedelta] = None,
) -> str:
"""创建JWT访问令牌"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update(
{
"exp": expire,
"type": "access",
"sid": session_id,
"jti": token_jti,
}
)
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict, session_id: str, refresh_jti: str) -> str:
"""创建刷新令牌"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) # 有效期与 Redis TTL 保持一致
to_encode.update(
{
"exp": expire,
"type": "refresh",
"sid": session_id,
"jti": refresh_jti,
}
)
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> dict:
"""解码JWT令牌"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user_by_token(
token: str,
db: Session,
) -> dict:
"""根据 token 获取当前用户(包含 Redis 会话校验)"""
payload = decode_token(token)
user_id: str = payload.get("sub")
session_id: str = payload.get("sid")
if not await is_session_valid(session_id):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token无效或已过期",
)
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
)
user = user_crud.get(db, user_id)
if not user or user.is_del == 1:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在或已删除",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户已被禁用",
)
return {
"user_id": user.id,
"username": user.username,
"real_name": user.real_name,
"role": user.role,
"region_id": user.region_id,
"phone": getattr(user, "phone", None),
"avatar_url": getattr(user, "avatar_url", None),
"is_active": getattr(user, "is_active", True),
}
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> dict:
"""获取当前用户"""
return await get_current_user_by_token(token, db)
async def get_current_user_simple(token: str = Depends(oauth2_scheme)) -> dict:
"""简化版获取当前用户(不查数据库)"""
payload = decode_token(token)
user_id: str = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
)
return {
"user_id": user_id,
"username": payload.get("username"),
"role": payload.get("role"),
"region_id": payload.get("region_id"),
"real_name": payload.get("real_name")
}
def get_client_ip(request: Request) -> str:
"""获取客户端IP"""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
+145
View File
@@ -0,0 +1,145 @@
from pydantic_settings import BaseSettings
from typing import Optional
from pathlib import Path
import os
class Settings(BaseSettings):
"""应用配置"""
# 应用基础配置
APP_NAME: str = "中科兴皖一体化门户"
DEBUG: bool = True
API_V1_PREFIX: str = "/api"
# 数据库配置
DATABASE_URL: str = "mysql+pymysql://root:password@localhost:3306/zkxw"
# JWT配置
SECRET_KEY: str = "your-secret-key-change-in-production-please-use-random-string"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 * 24 # 30天
# CORS配置
BACKEND_CORS_ORIGINS: list = [
"http://localhost:5173", # art-design-pro 开发服务器
"http://localhost:8080",
"http://localhost:8081", # uni-app H5 端口被占用时常用
"http://localhost:8082",
"http://127.0.0.1:5173",
"http://127.0.0.1:8080",
"http://127.0.0.1:8081",
"http://127.0.0.1:8082",
"http://localhost:3000", # Web开发
"http://localhost:3006", # Web开发
"http://127.0.0.1:3000",
"http://127.0.0.1:3006",
]
# 文件存储配置
STORAGE_TYPE: str = "local" # local, qiniu, oss, minio
# 本地存储配置
LOCAL_UPLOAD_DIR: str = "uploads"
LOCAL_STORAGE_URL: str = "/uploads"
# Redis 配置(用于登录会话与 refreshToken 吊销)
# 未配置 REDIS_HOST 时,后端会保持兼容:仍然只校验 JWT,不强制会话吊销。
REDIS_HOST: str = ""
REDIS_PORT: int = 6379
REDIS_DB: int = 0
REDIS_PASSWORD: str = ""
REDIS_TIMEOUT_SEC: int = 30
REDIS_SSL_ENABLED: bool = False
# 七牛云存储配置 (生产环境推荐)
QINIU_ACCESS_KEY: str = ""
QINIU_SECRET_KEY: str = ""
QINIU_BUCKET: str = ""
QINIU_DOMAIN: str = ""
# 阿里云OSS配置 (可选)
OSS_ACCESS_KEY_ID: str = ""
OSS_ACCESS_KEY_SECRET: str = ""
OSS_BUCKET: str = ""
OSS_ENDPOINT: str = ""
OSS_DOMAIN: str = ""
# MinIO配置
MINIO_ENDPOINT: str = ""
MINIO_ACCESS_KEY: str = ""
MINIO_SECRET_KEY: str = ""
MINIO_BUCKET: str = ""
MINIO_SECURE: bool = False
MINIO_BASE_URL: str = ""
# 微信支付配置 (生产环境必需)
WXPAY_MCHID: str = "" # 商户号
WXPAY_APPID: str = "" # 公众号AppID
WXPAY_MINI_APPID: str = "" # 小程序AppID
WXPAY_API_KEY: str = "" # API密钥 (V2)
WXPAY_SANDBOX: bool = False # 是否使用沙箱环境
WXPAY_NOTIFY_URL: str = "https://your-domain.com/api/payment/notify" # 支付回调地址
WXPAY_REFUND_NOTIFY_URL: str = "https://your-domain.com/api/payment/refund-notify" # 退款回调地址
# V3 API 证书配置
WXPAY_CERT_PATH: str = "" # API证书(apiclient_cert.pem)路径
WXPAY_PRIVATE_KEY_PATH: str = "" # API私钥(apiclient_key.pem)路径
WXPAY_SERIAL_NO: str = "" # 证书序列号
# SSL证书配置 (退款必需)
WXPAY_SSL_CERT: str = "" # SSL证书路径
WXPAY_SSL_KEY: str = "" # SSL私钥路径
# 微信小程序配置
WXPAY_SECRET: str = "" # 小程序Secret
# 微信模板消息ID配置
WX_TEMPLATE_ORDER_STATUS: str = "" # 订单状态通知模板ID
WX_TEMPLATE_DELIVERY_NOTICE: str = "" # 配送通知模板ID
WX_TEMPLATE_PAYMENT_SUCCESS: str = "" # 支付成功通知模板ID
WX_TEMPLATE_REFUND_RESULT: str = "" # 退款结果通知模板ID
WX_TEMPLATE_DELIVERY_WARNING: str = "" # 配送时效预警模板ID
# 登录 RSA:私钥 PEM(多实例/负载均衡须配置同一私钥;留空则进程启动时临时生成,仅适合单机开发)
LOGIN_RSA_PRIVATE_KEY_PEM: str = ""
# 是否允许明文 username/password 登录(仅建议本地排障临时开启;生产务必 false)
LOGIN_ALLOW_PLAINTEXT_LOGIN: bool = False
# 加密载荷内时间戳允许偏差(秒),防简单重放
LOGIN_ENCRYPT_TS_SKEW_SEC: int = 300
# 临时文件目录
TEMP_FILE_DIR: str = "temp_files"
# 全量 API 请求日志(中间件写入 sys_api_request_log)
API_REQUEST_LOG_ENABLED: bool = True
API_REQUEST_LOG_MAX_BODY: int = 65536
API_REQUEST_LOG_MAX_BODY_BYTES: int = 131072
# Web API 请求/响应加密配置
API_ENCRYPT_ENABLED: bool = False
API_ENCRYPT_FAIL_OPEN: bool = True
API_ENCRYPT_FIXED_SECRET: str = ""
API_ENCRYPT_TS_SKEW_SEC: int = 300
API_ENCRYPT_NONCE_TTL_SEC: int = 300
API_ENCRYPT_EXCLUDE_PATHS: str = (
"/,"
"/health,"
"/docs,"
"/openapi.json,"
"/api/web/auth/login-rsa-public-key,"
"/api/web/auth/login,"
"/api/web/admin/attachment/upload,"
"/api/web/admin/products/upload-image,"
"/api/web/admin/categories/upload-image,"
"/api/web/admin/users/upload-avatar,"
"/api/invoice/download"
)
class Config:
# 支持通过 ENV_FILE 指定环境文件;未指定时回退到 backend/.env
_default_env_file = Path(__file__).resolve().parents[2] / ".env"
env_file = os.getenv("ENV_FILE", str(_default_env_file))
case_sensitive = True
settings = Settings()
+26
View File
@@ -0,0 +1,26 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600,
echo=settings.DEBUG,
)
# 避免 commit 后 ORM 实例自动过期,导致读取字段时回表 SELECT(高频接口会显著拖慢)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, expire_on_commit=False, bind=engine)
Base = declarative_base()
def get_db():
"""获取数据库会话"""
db = SessionLocal()
try:
yield db
finally:
db.close()
+141
View File
@@ -0,0 +1,141 @@
"""错误处理和日志记录模块"""
import logging
import traceback
from typing import Optional, Any
from datetime import datetime
from functools import wraps
# 配置日志
logger = logging.getLogger("sqxp")
logger.setLevel(logging.INFO)
# 创建控制台处理器
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
# 创建格式化器
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
console_handler.setFormatter(formatter)
# 添加处理器
if not logger.handlers:
logger.addHandler(console_handler)
class BusinessException(Exception):
"""业务异常基类"""
def __init__(self, code: int = 400, message: str = "业务处理异常", data: Any = None):
self.code = code
self.message = message
self.data = data
super().__init__(message)
class ValidationException(BusinessException):
"""数据验证异常"""
def __init__(self, message: str = "数据验证失败", data: Any = None):
super().__init__(code=400, message=message, data=data)
class PermissionException(BusinessException):
"""权限不足异常"""
def __init__(self, message: str = "权限不足", data: Any = None):
super().__init__(code=403, message=message, data=data)
class ResourceNotFoundException(BusinessException):
"""资源不存在异常"""
def __init__(self, resource: str = "资源", data: Any = None):
super().__init__(code=404, message=f"{resource}不存在", data=data)
class ResourceConflictException(BusinessException):
"""资源冲突异常(如重复创建)"""
def __init__(self, message: str = "资源已存在", data: Any = None):
super().__init__(code=409, message=message, data=data)
class OrderLockedException(BusinessException):
"""订单已锁定异常"""
def __init__(self, message: str = "订单已被锁定", data: Any = None):
super().__init__(code=423, message=message, data=data)
class InvalidStatusTransitionException(BusinessException):
"""无效状态转换异常"""
def __init__(self, message: str = "无效的状态转换", data: Any = None):
super().__init__(code=400, message=message, data=data)
def log_operation(module: str, operation: str):
"""操作日志装饰器"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
start_time = datetime.now()
user_id = kwargs.get('current_user', {}).get('user_id') if kwargs.get('current_user') else 'anonymous'
logger.info(f"[{module}] 用户 {user_id} 开始执行 {operation}")
try:
result = func(*args, **kwargs)
elapsed = (datetime.now() - start_time).total_seconds()
logger.info(f"[{module}] 用户 {user_id} 执行 {operation} 成功,耗时 {elapsed:.2f}s")
return result
except BusinessException as e:
elapsed = (datetime.now() - start_time).total_seconds()
logger.warning(f"[{module}] 用户 {user_id} 执行 {operation} 业务异常: {e.message},耗时 {elapsed:.2f}s")
raise
except Exception as e:
elapsed = (datetime.now() - start_time).total_seconds()
logger.error(f"[{module}] 用户 {user_id} 执行 {operation} 系统异常: {str(e)},耗时 {elapsed:.2f}s")
logger.error(f"[{module}] 异常详情: {traceback.format_exc()}")
raise
return wrapper
return decorator
def log_api_request(endpoint: str):
"""API请求日志装饰器"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
start_time = datetime.now()
logger.info(f"[API] 请求开始: {endpoint}")
try:
result = await func(*args, **kwargs)
elapsed = (datetime.now() - start_time).total_seconds()
logger.info(f"[API] 请求完成: {endpoint},耗时 {elapsed:.3f}s")
return result
except Exception as e:
elapsed = (datetime.now() - start_time).total_seconds()
logger.error(f"[API] 请求异常: {endpoint},耗时 {elapsed:.3f}s,错误: {str(e)}")
raise
return wrapper
return decorator
def log_order_operation(operation: str):
"""订单操作日志装饰器"""
return log_operation("订单管理", operation)
def log_delivery_operation(operation: str):
"""配送操作日志装饰器"""
return log_operation("配送管理", operation)
def log_settlement_operation(operation: str):
"""结算操作日志装饰器"""
return log_operation("结算管理", operation)
def log_user_operation(operation: str):
"""用户操作日志装饰器"""
return log_operation("用户管理", operation)
+116
View File
@@ -0,0 +1,116 @@
"""登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。"""
from __future__ import annotations
import base64
import json
import logging
import time
from typing import Any, Dict, Optional, Tuple
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from app.core.config import settings
logger = logging.getLogger(__name__)
_private_key = None # type: ignore
_public_pem: Optional[str] = None
def ensure_initialized() -> None:
"""加载或生成 RSA 密钥对。"""
global _private_key, _public_pem
if _private_key is not None:
return
pem = (settings.LOGIN_RSA_PRIVATE_KEY_PEM or "").strip()
# 兼容 .env 中单行保存 PEM(以 "\n" 形式转义换行)的场景
if "\\n" in pem and "-----BEGIN" in pem:
pem = pem.replace("\\n", "\n")
if pem:
_private_key = serialization.load_pem_private_key(
pem.encode("utf-8"), password=None, backend=default_backend()
)
logger.info("登录 RSA:已自配置加载私钥")
else:
_private_key = rsa.generate_private_key(
public_exponent=65537, key_size=2048, backend=default_backend()
)
logger.warning(
"登录 RSA:未配置 LOGIN_RSA_PRIVATE_KEY_PEM,已生成进程内临时密钥;"
"多进程/负载均衡下请配置固定 PEM。"
)
_public_pem = (
_private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode("utf-8")
)
def get_public_pem() -> str:
ensure_initialized()
assert _public_pem is not None
return _public_pem
def decrypt_login_payload(b64_cipher: str) -> Dict[str, Any]:
"""Base64 密文 -> JSON 对象(须含 username、password、ts 毫秒时间戳)。"""
ensure_initialized()
raw = base64.b64decode(b64_cipher)
decrypted = _private_key.decrypt(raw, padding.PKCS1v15())
text = decrypted.decode("utf-8")
return json.loads(text)
def _validate_ts(ts_val: Any) -> bool:
if ts_val is None:
return False
try:
client_ms = int(ts_val)
except (TypeError, ValueError):
return False
now_ms = int(time.time() * 1000)
skew_ms = max(30, int(settings.LOGIN_ENCRYPT_TS_SKEW_SEC)) * 1000
return abs(now_ms - client_ms) <= skew_ms
def extract_login_credentials(body: dict) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""
从请求体解析用户名、密码。
返回 (username, password, error_message);error_message 非空时表示失败。
"""
enc = body.get("encryptedLogin")
if isinstance(enc, str) and enc.strip():
try:
plain = decrypt_login_payload(enc.strip())
except Exception:
return None, None, "登录凭证无效或已过期"
if not _validate_ts(plain.get("ts")):
return None, None, "登录凭证已过期,请重新登录"
username = (plain.get("username") or "").strip()
password = plain.get("password")
if password is None:
password = ""
elif not isinstance(password, str):
password = str(password)
if not username or not password:
return None, None, "用户名和密码不能为空"
return username, password, None
if settings.LOGIN_ALLOW_PLAINTEXT_LOGIN:
username = (body.get("username") or "").strip()
password = body.get("password") or ""
if not username or not password:
return None, None, "用户名和密码不能为空"
return username, password, None
return None, None, "请使用加密方式登录"
+54
View File
@@ -0,0 +1,54 @@
"""菜单 code 权限守卫"""
from typing import Callable, Set
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.auth import get_current_user
from app.core.database import get_db
from app.models.models import SysUserMenuScope
ADMIN_ROLES = {"admin", "super_admin"}
SUPER_ADMIN_ROLE = "super_admin"
def require_admin_or_super_admin(current_user: dict = Depends(get_current_user)):
"""运营端角色守卫:admin / super_admin 可访问。"""
role = current_user.get("role")
if role not in ADMIN_ROLES:
raise HTTPException(status_code=403, detail="需要运营人员权限")
return current_user
def get_user_menu_codes(db: Session, user_id: str) -> Set[str]:
"""读取用户授权的菜单 code 集合。"""
if not user_id:
return set()
rows = (
db.query(SysUserMenuScope.menu_code)
.filter(
SysUserMenuScope.user_id == user_id,
SysUserMenuScope.is_del == 0,
)
.all()
)
return {str(row[0]).strip() for row in rows if row and row[0]}
def require_menu_code(menu_code: str) -> Callable:
"""接口菜单权限守卫。super_admin 直接放行。"""
normalized = (menu_code or "").strip()
def _dep(
current_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
role = current_user.get("role")
if role == SUPER_ADMIN_ROLE:
return current_user
codes = get_user_menu_codes(db, current_user.get("user_id"))
if normalized not in codes:
raise HTTPException(status_code=403, detail="无菜单访问权限")
return current_user
return _dep
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Optional
@dataclass(frozen=True)
class ThreeStatus:
order_status: str
delivery_status: str
pay_status: str
def _to_decimal(v) -> Decimal:
try:
if v is None:
return Decimal("0")
if isinstance(v, Decimal):
return v
return Decimal(str(v))
except Exception:
return Decimal("0")
def derive_three_status_from_legacy(
legacy_status: Optional[str],
*,
paid_amount=None,
) -> ThreeStatus:
"""
阶段A兼容:从旧 status 推导三字段。
约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断;
若业务侧已有更精确来源(例如配送任务表/结算表),后续阶段再增强。
"""
s = (legacy_status or "").strip() or "pending_payment"
paid = _to_decimal(paid_amount)
# 默认:仅保留新主状态模型
order_status = "pending_confirm" if paid > 0 else "pending_payment"
delivery_status = "pending_dispatch"
pay_status = "paid" if paid > 0 else "pending_payment"
if s == "cancelled":
order_status = "cancelled"
elif s == "completed":
order_status = "completed"
delivery_status = "delivered"
elif s == "pending_payment":
order_status = "pending_payment"
pay_status = "pending_payment"
delivery_status = "pending_dispatch"
elif s == "delivering":
order_status = "pending_confirm"
delivery_status = "delivering"
elif s == "pending_confirm":
order_status = "pending_confirm"
delivery_status = "delivered"
elif s == "pending_review_cancel":
order_status = "pending_confirm"
delivery_status = "pending_dispatch"
pass
elif s == "refunding":
pay_status = "refunding"
return ThreeStatus(order_status=order_status, delivery_status=delivery_status, pay_status=pay_status)
def derive_legacy_status_from_three(
*,
order_status: Optional[str],
delivery_status: Optional[str],
pay_status: Optional[str],
) -> str:
"""
阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。
"""
o = (order_status or "").strip()
d = (delivery_status or "").strip()
p = (pay_status or "").strip()
if o == "cancelled":
return "cancelled"
if p == "refunding":
return "refunding"
if o == "completed":
return "completed"
if o == "pending_payment":
return "pending_payment"
if d == "pending_dispatch":
return "pending_confirm"
if d == "delivering":
return "delivering"
if d == "delivered":
return "pending_confirm"
return "pending_payment"
+71
View File
@@ -0,0 +1,71 @@
"""统一解析接口请求载荷(明文 / 密文信封)。"""
from __future__ import annotations
import logging
from typing import Any, Union
from app.core import api_crypto
from app.core.config import settings
def resolve_request_payload(
request_body: dict,
current_user: dict,
*,
source: str = "unknown",
) -> Union[dict, list]:
"""兼容明文与密文信封,返回业务层 payload(多为 dict;批量数组接口解密后为 list)。"""
logger = logging.getLogger("sqxp")
payload = request_body if isinstance(request_body, dict) else {}
if int(payload.get("enc", 0) or 0) == 1 and isinstance(payload.get("edata"), str):
logger.warning(
"%s received encrypted envelope in route handler; middleware may be bypassed",
source,
)
try:
user_id_for_crypto = str(current_user.get("user_id") or "")
ts = int(payload.get("ts"))
nonce = str(payload.get("nonce") or "")
if user_id_for_crypto and nonce and settings.API_ENCRYPT_FIXED_SECRET:
key = api_crypto.derive_key(
fixed_secret=settings.API_ENCRYPT_FIXED_SECRET,
user_id=user_id_for_crypto,
ts=ts,
nonce=nonce,
)
plain = api_crypto.decrypt_json(str(payload.get("edata")), key)
payload = plain if isinstance(plain, dict) else {}
logger.warning(
"%s decrypted payload via route fallback; please verify ApiCryptoMiddleware",
source,
)
except Exception as exc:
logger.warning("%s resolve_request_payload decrypt failed: err=%s", source, exc)
return {}
dw = source.startswith("delivery_warning")
if isinstance(payload, dict):
inner = payload.get("data")
if isinstance(inner, (dict, list)):
if dw:
if isinstance(inner, dict):
logger.info(
"%s unwrap: using inner dict keys=%s note_len=%s remark_len=%s",
source,
sorted(inner.keys()),
len(str(inner.get("note") or "")),
len(str(inner.get("remark") or "")),
)
else:
logger.info("%s unwrap: inner is list len=%s", source, len(inner))
return inner
if dw:
logger.info(
"%s no_inner_data: payload_keys=%s (no dict/list data wrapper)",
source,
sorted(payload.keys()),
)
elif dw:
logger.info("%s payload_not_dict type=%s", source, type(payload).__name__)
return payload if isinstance(payload, dict) else {}
+47
View File
@@ -0,0 +1,47 @@
"""统一响应格式"""
from typing import Any, Generic, Optional, TypeVar, List
from pydantic import BaseModel, Field
T = TypeVar("T")
class ResponseModel(BaseModel, Generic[T]):
"""通用响应格式"""
code: int = 200
message: str = "success"
data: Optional[T] = None
timestamp: int = 0
class Config:
from_attributes = True
class PageResult(BaseModel, Generic[T]):
"""分页响应格式"""
items: List[T] = Field(default_factory=list, serialization_alias="list")
total: int = 0
page: int = 1
pageSize: int = 10
totalPages: int = 0
def success_response(data: Any = None, message: str = "success") -> dict:
"""成功响应"""
from datetime import datetime
return {
"code": 200,
"message": message,
"data": data,
"timestamp": int(datetime.now().timestamp() * 1000)
}
def error_response(code: int = 400, message: str = "error", data: Any = None) -> dict:
"""错误响应"""
from datetime import datetime
return {
"code": code,
"message": message,
"data": data,
"timestamp": int(datetime.now().timestamp() * 1000)
}
+22
View File
@@ -0,0 +1,22 @@
import bcrypt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""验证密码(哈希非 bcrypt 或损坏时返回 False,避免抛错)"""
if not plain_password or not hashed_password:
return False
hp = hashed_password if isinstance(hashed_password, str) else str(hashed_password)
if not hp.startswith("$2"):
return False
try:
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hp.encode("utf-8"),
)
except (ValueError, TypeError):
return False
def get_password_hash(password: str) -> str:
"""生成密码哈希"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
+335
View File
@@ -0,0 +1,335 @@
"""文件存储工具模块
支持多种存储方式:
1. 本地存储 (local) - 默认
2. 七牛云存储 (qiniu) - 推荐生产环境使用
3. 阿里云OSS (oss) - 可选
使用方式:
from app.core.storage import storage
# 上传文件
url = storage.upload_file(file_content, "test.jpg", "images")
# 上传 Base64 图片
url = storage.upload_base64(base64_data, "avatar.png", "avatar")
# 删除文件
storage.delete_file("images/test.jpg")
"""
import os
import uuid
import base64
from io import BytesIO
from datetime import datetime
from typing import Optional, BinaryIO
from pathlib import Path
from app.core.config import settings
# HEIC 格式的 MIME 类型
HEIC_TYPES = {"image/heic", "image/heif"}
def convert_heic_to_jpeg(content: bytes) -> bytes:
"""将 HEIC/HEIF 格式转换为 JPEG 格式"""
try:
import pillow_heif
from PIL import Image
heif_file = pillow_heif.open(BytesIO(content))
image = heif_file.to_pillow()
output = BytesIO()
image.save(output, format="JPEG", quality=85)
return output.getvalue()
except ImportError:
raise Exception("请安装 pillow-heif: pip install pillow-heif")
def process_image_content(content: bytes, filename: str) -> tuple[bytes, str]:
"""处理图片内容,如果是 HEIC 格式则转换为 JPEG"""
import imghdr
img_type = imghdr.what(None, h=content[:32])
if img_type in ("heic", "heif"):
return convert_heic_to_jpeg(content), filename.rsplit(".", 1)[0] + ".jpg"
return content, filename
class BaseStorage:
"""存储基类"""
def upload_file(self, file_content: bytes, filename: str, folder: str = "") -> str:
"""上传文件"""
raise NotImplementedError
def upload_base64(self, base64_data: str, filename: str, folder: str = "") -> str:
"""上传 Base64 编码的图片"""
raise NotImplementedError
def delete_file(self, file_path: str) -> bool:
"""删除文件"""
raise NotImplementedError
def get_url(self, file_path: str) -> str:
"""获取文件访问URL"""
raise NotImplementedError
class LocalStorage(BaseStorage):
"""本地存储"""
def __init__(self):
self.base_url = settings.LOCAL_STORAGE_URL.rstrip("/")
self.upload_dir = settings.LOCAL_UPLOAD_DIR
def _ensure_dir(self, folder: str) -> Path:
"""确保目录存在"""
path = Path(self.upload_dir) / folder / datetime.now().strftime("%Y%m")
path.mkdir(parents=True, exist_ok=True)
return path
def _generate_filename(self, filename: str) -> str:
"""生成唯一文件名"""
ext = os.path.splitext(filename)[1] or ""
unique_name = f"{uuid.uuid4().hex}{ext}"
return unique_name
def upload_file(self, file_content: bytes, filename: str, folder: str = "") -> str:
"""上传文件到本地"""
# 处理 HEIC 格式图片
file_content, filename = process_image_content(file_content, filename)
folder_path = self._ensure_dir(folder)
unique_filename = self._generate_filename(filename)
file_path = folder_path / unique_filename
with open(file_path, "wb") as f:
f.write(file_content)
relative_path = f"{folder}/{datetime.now().strftime('%Y%m')}/{unique_filename}" if folder else f"{datetime.now().strftime('%Y%m')}/{unique_filename}"
return self.get_url(relative_path)
def upload_base64(self, base64_data: str, filename: str, folder: str = "") -> str:
"""上传 Base64 图片"""
# 去除 data:image/png;base64, 等前缀
if "," in base64_data:
base64_data = base64_data.split(",")[1]
file_content = base64.b64decode(base64_data)
return self.upload_file(file_content, filename, folder)
def delete_file(self, file_path: str) -> bool:
"""删除本地文件"""
try:
# 移除 URL 前缀获取相对路径
if file_path.startswith(self.base_url):
relative_path = file_path.replace(self.base_url, "").lstrip("/")
else:
relative_path = file_path.lstrip("/")
file_full_path = Path(self.upload_dir) / relative_path
if file_full_path.exists():
file_full_path.unlink()
return True
return False
except Exception:
return False
def get_url(self, file_path: str) -> str:
"""获取文件访问URL"""
return f"{self.base_url}/{file_path}"
class QiniuStorage(BaseStorage):
"""七牛云存储"""
def __init__(self):
try:
from qiniu import Auth, put_data, delete
self.qiniu = Auth(settings.QINIU_ACCESS_KEY, settings.QINIU_SECRET_KEY)
self.bucket = settings.QINIU_BUCKET
self.base_url = settings.QINIU_DOMAIN.rstrip("/")
except ImportError:
raise ImportError("请安装七牛云 SDK: pip install qiniu")
def _generate_filename(self, filename: str) -> str:
"""生成唯一文件名"""
ext = os.path.splitext(filename)[1] or ""
unique_name = f"{datetime.now().strftime('%Y%m/%d')}/{uuid.uuid4().hex}{ext}"
return unique_name
def upload_file(self, file_content: bytes, filename: str, folder: str = "") -> str:
"""上传文件到七牛云"""
from qiniu import put_data
# 处理 HEIC 格式图片
file_content, filename = process_image_content(file_content, filename)
key = self._generate_filename(filename)
token = self.qiniu.upload_token(self.bucket, key, 3600)
ret, info = put_data(token, key, file_content)
if ret is not None:
return self.get_url(ret["key"])
raise Exception(f"七牛云上传失败: {info}")
def upload_base64(self, base64_data: str, filename: str, folder: str = "") -> str:
"""上传 Base64 图片到七牛云"""
if "," in base64_data:
base64_data = base64_data.split(",")[1]
file_content = base64.b64decode(base64_data)
return self.upload_file(file_content, filename, folder)
def delete_file(self, file_path: str) -> bool:
"""删除七牛云文件"""
from qiniu import delete
try:
key = file_path.replace(f"{self.base_url}/", "")
ret, info = delete(self.bucket, key)
return ret is not None
except Exception:
return False
def get_url(self, file_path: str) -> str:
"""获取文件访问URL"""
return f"{self.base_url}/{file_path}"
class OSSStorage(BaseStorage):
"""阿里云OSS存储"""
def __init__(self):
try:
import oss2
self.auth = oss2.Auth(settings.OSS_ACCESS_KEY_ID, settings.OSS_ACCESS_KEY_SECRET)
self.bucket_name = settings.OSS_BUCKET
self.bucket = oss2.Bucket(self.auth, settings.OSS_ENDPOINT, self.bucket_name)
self.base_url = settings.OSS_DOMAIN.rstrip("/")
except ImportError:
raise ImportError("请安装阿里云 OSS SDK: pip install oss2")
def _generate_filename(self, filename: str) -> str:
"""生成唯一文件名"""
ext = os.path.splitext(filename)[1] or ""
unique_name = f"{datetime.now().strftime('%Y%m/%d')}/{uuid.uuid4().hex}{ext}"
return unique_name
def upload_file(self, file_content: bytes, filename: str, folder: str = "") -> str:
"""上传文件到OSS"""
# 处理 HEIC 格式图片
file_content, filename = process_image_content(file_content, filename)
key = self._generate_filename(filename)
result = self.bucket.put_object(key, file_content)
if result.status == 200:
return self.get_url(key)
raise Exception(f"OSS上传失败: {result.status}")
def upload_base64(self, base64_data: str, filename: str, folder: str = "") -> str:
"""上传 Base64 图片到OSS"""
if "," in base64_data:
base64_data = base64_data.split(",")[1]
file_content = base64.b64decode(base64_data)
return self.upload_file(file_content, filename, folder)
def delete_file(self, file_path: str) -> bool:
"""删除OSS文件"""
try:
key = file_path.replace(f"{self.base_url}/", "")
result = self.bucket.delete_object(key)
return result.status == 204
except Exception:
return False
def get_url(self, file_path: str) -> str:
"""获取文件访问URL"""
return f"{self.base_url}/{file_path}"
class MinIOStorage(BaseStorage):
"""MinIO存储"""
def __init__(self):
try:
from minio import Minio
self.client = Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_SECURE,
)
self.bucket_name = settings.MINIO_BUCKET
if settings.MINIO_BASE_URL:
self.base_url = settings.MINIO_BASE_URL.rstrip("/")
else:
scheme = "https" if settings.MINIO_SECURE else "http"
self.base_url = f"{scheme}://{settings.MINIO_ENDPOINT}/{self.bucket_name}"
except ImportError:
raise ImportError("请安装 MinIO SDK: pip install minio")
def _generate_filename(self, filename: str) -> str:
"""生成唯一文件名"""
ext = os.path.splitext(filename)[1] or ""
unique_name = f"{datetime.now().strftime('%Y%m/%d')}/{uuid.uuid4().hex}{ext}"
return unique_name
def upload_file(self, file_content: bytes, filename: str, folder: str = "") -> str:
"""上传文件到MinIO"""
# 处理 HEIC 格式图片
file_content, filename = process_image_content(file_content, filename)
key = self._generate_filename(filename)
if folder:
key = f"{folder.strip('/')}/{key}"
data = BytesIO(file_content)
self.client.put_object(
bucket_name=self.bucket_name,
object_name=key,
data=data,
length=len(file_content),
)
return self.get_url(key)
def upload_base64(self, base64_data: str, filename: str, folder: str = "") -> str:
"""上传 Base64 图片到MinIO"""
if "," in base64_data:
base64_data = base64_data.split(",")[1]
file_content = base64.b64decode(base64_data)
return self.upload_file(file_content, filename, folder)
def delete_file(self, file_path: str) -> bool:
"""删除MinIO文件"""
try:
key = file_path.replace(f"{self.base_url}/", "")
self.client.remove_object(self.bucket_name, key)
return True
except Exception:
return False
def get_url(self, file_path: str) -> str:
"""获取文件访问URL"""
return f"{self.base_url}/{file_path}"
def get_storage() -> BaseStorage:
"""获取存储实例"""
storage_type = settings.STORAGE_TYPE.lower()
if storage_type == "qiniu":
return QiniuStorage()
elif storage_type == "oss":
return OSSStorage()
elif storage_type == "minio":
return MinIOStorage()
else:
return LocalStorage()
# 全局存储实例
storage = get_storage()
+56
View File
@@ -0,0 +1,56 @@
"""统一工具函数"""
import uuid
import random
import string
from datetime import datetime
from typing import Optional
def generate_uuid() -> str:
"""生成UUID"""
return str(uuid.uuid4()).replace("-", "").upper()
def generate_id(prefix: str = "") -> str:
"""生成带前缀的ID"""
uid = str(uuid.uuid4()).replace("-", "").upper()
return f"{prefix}{uid}" if prefix else uid
def generate_random_password(length: int = 8) -> str:
"""生成随机密码"""
chars = string.ascii_letters + string.digits
return ''.join(random.choice(chars) for _ in range(length))
def get_current_time() -> datetime:
"""获取当前时间"""
return datetime.now()
def format_datetime(dt: Optional[datetime]) -> Optional[str]:
"""格式化时间为ISO 8601格式"""
if dt is None:
return None
return dt.strftime("%Y-%m-%d %H:%M:%S")
def parse_datetime(dt_str: str) -> Optional[datetime]:
"""解析ISO 8601格式时间"""
if not dt_str:
return None
try:
return datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return None
def get_file_size_display(size_bytes: int) -> str:
"""将字节数转换为可读的文件大小"""
if size_bytes is None:
return "0 B"
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size_bytes < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f} PB"
+354
View File
@@ -0,0 +1,354 @@
"""微信消息通知服务
支持微信小程序模板消息推送
使用方式:
from app.core.wechat_message import WechatMessageService
# 发送订单状态通知
WechatMessageService.send_order_status(
openid="user_openid",
order_no="ORD202603190001",
status="已支付",
amount=100.00
)
# 发送配送通知
WechatMessageService.send_delivery_notice(
openid="deliver_openid",
task_no="DLV20260319001",
address="深圳市南山区...",
time="尽快取货"
)
"""
import json
import logging
from typing import Optional, Dict, Any
from datetime import datetime
import requests
from app.core.config import settings
logger = logging.getLogger(__name__)
class WechatMessageService:
"""微信消息服务"""
# 模板消息ID配置
TEMPLATE_IDS = {
"order_status": "ORDER_STATUS_TEMPLATE_ID", # 订单状态通知
"delivery_notice": "DELIVERY_NOTICE_TEMPLATE_ID", # 配送通知
"payment_success": "PAYMENT_SUCCESS_TEMPLATE_ID", # 支付成功通知
"refund_result": "REFUND_RESULT_TEMPLATE_ID", # 退款结果通知
"delivery_warning": "DELIVERY_WARNING_TEMPLATE_ID", # 配送时效预警
}
def __init__(self):
self.appid = settings.WXPAY_MINI_APPID
self.secret = settings.WXPAY_SECRET
self.access_token = None
self.token_expires_at = None
def _get_access_token(self) -> Optional[str]:
"""获取Access Token"""
if self.access_token and self.token_expires_at:
if datetime.now() < self.token_expires_at:
return self.access_token
try:
url = "https://api.weixin.qq.com/cgi-bin/token"
params = {
"grant_type": "client_credential",
"appid": self.appid,
"secret": self.secret
}
response = requests.get(url, params=params, timeout=10)
result = response.json()
if "access_token" in result:
self.access_token = result["access_token"]
expires_in = result.get("expires_in", 7200)
self.token_expires_at = datetime.now().timestamp() + expires_in - 300
return self.access_token
else:
logger.error(f"获取Access Token失败: {result}")
return None
except Exception as e:
logger.error(f"请求Access Token异常: {str(e)}")
return None
def _send_template_message(self, openid: str, template_id: str, data: Dict, page: str = None, color: str = None) -> Dict:
"""发送模板消息
Args:
openid: 用户openid
template_id: 模板ID
data: 模板数据
page: 跳转页面
color: 字体颜色
Returns:
Dict: 发送结果
"""
access_token = self._get_access_token()
if not access_token:
return {"success": False, "error": "获取access_token失败"}
try:
url = f"https://api.weixin.qq.com/cgi-bin/message/template/send?access_token={access_token}"
payload = {
"touser": openid,
"template_id": template_id,
"data": data
}
if page:
payload["miniprogram"] = {
"appid": self.appid,
"pagepath": page
}
if color:
payload["color"] = color
response = requests.post(url, json=payload, timeout=10)
result = response.json()
if result.get("errcode") == 0:
logger.info(f"模板消息发送成功: {openid}, msgid: {result.get('msgid')}")
return {"success": True, "msgid": result.get("msgid")}
else:
logger.error(f"模板消息发送失败: {result}")
return {"success": False, "error": result.get("errmsg", "发送失败")}
except Exception as e:
logger.error(f"发送模板消息异常: {str(e)}")
return {"success": False, "error": str(e)}
@classmethod
def send_order_status(
cls,
openid: str,
order_no: str,
status: str,
amount: float,
remark: str = None
) -> Dict:
"""发送订单状态通知
Args:
openid: 用户openid
order_no: 订单号
status: 订单状态
amount: 订单金额
remark: 备注
"""
template_id = cls.TEMPLATE_IDS.get("order_status")
if not template_id:
return {"success": False, "error": "未配置订单状态模板ID"}
data = {
"order_no": {"value": order_no, "color": "#173177"},
"status": {"value": status, "color": "#07c160"},
"amount": {"value": f"¥{amount:.2f}", "color": "#ff6600"},
"remark": {"value": remark or "感谢您的使用", "color": "#888888"}
}
service = cls()
return service._send_template_message(
openid=openid,
template_id=template_id,
data=data,
page="pages/client/order/detail/detail"
)
@classmethod
def send_payment_success(
cls,
openid: str,
order_no: str,
amount: float,
payment_method: str = "微信支付"
) -> Dict:
"""发送支付成功通知
Args:
openid: 用户openid
order_no: 订单号
amount: 支付金额
payment_method: 支付方式
"""
template_id = cls.TEMPLATE_IDS.get("payment_success")
if not template_id:
return {"success": False, "error": "未配置支付成功模板ID"}
data = {
"order_no": {"value": order_no, "color": "#173177"},
"amount": {"value": f"¥{amount:.2f}", "color": "#ff6600"},
"payment_method": {"value": payment_method, "color": "#07c160"},
"time": {"value": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "color": "#888888"}
}
service = cls()
return service._send_template_message(
openid=openid,
template_id=template_id,
data=data,
page="pages/client/order/detail/detail"
)
@classmethod
def send_delivery_notice(
cls,
openid: str,
task_no: str,
supplier_name: str,
address: str,
remark: str = None
) -> Dict:
"""发送配送通知
Args:
openid: 配送员openid
task_no: 配送单号
supplier_name: 供应商名称
address: 取货地址
remark: 备注
"""
template_id = cls.TEMPLATE_IDS.get("delivery_notice")
if not template_id:
return {"success": False, "error": "未配置配送通知模板ID"}
data = {
"task_no": {"value": task_no, "color": "#173177"},
"supplier": {"value": supplier_name, "color": "#07c160"},
"address": {"value": address, "color": "#173177"},
"remark": {"value": remark or "请尽快前往取货", "color": "#ff6600"}
}
service = cls()
return service._send_template_message(
openid=openid,
template_id=template_id,
data=data,
page="pages/deliver/task/detail"
)
@classmethod
def send_delivery_warning(
cls,
openid: str,
task_no: str,
remaining_minutes: int,
urgency_level: str = "紧急"
) -> Dict:
"""发送配送时效预警
Args:
openid: 配送员openid
task_no: 配送单号
remaining_minutes: 剩余分钟数
urgency_level: 紧急程度
"""
template_id = cls.TEMPLATE_IDS.get("delivery_warning")
if not template_id:
return {"success": False, "error": "未配置配送预警模板ID"}
color = "#ff0000" if urgency_level == "紧急" else "#ff6600"
data = {
"task_no": {"value": task_no, "color": "#173177"},
"remaining": {"value": f"剩余{remaining_minutes}分钟", "color": color},
"level": {"value": urgency_level, "color": color},
"time": {"value": datetime.now().strftime("%H:%M"), "color": "#888888"}
}
service = cls()
return service._send_template_message(
openid=openid,
template_id=template_id,
data=data,
page="pages/deliver/task/detail"
)
@classmethod
def send_refund_result(
cls,
openid: str,
order_no: str,
refund_amount: float,
refund_time: str = None,
reason: str = None
) -> Dict:
"""发送退款结果通知
Args:
openid: 用户openid
order_no: 订单号
refund_amount: 退款金额
refund_time: 退款时间
reason: 退款原因
"""
template_id = cls.TEMPLATE_IDS.get("refund_result")
if not template_id:
return {"success": False, "error": "未配置退款结果模板ID"}
data = {
"order_no": {"value": order_no, "color": "#173177"},
"amount": {"value": f"¥{refund_amount:.2f}", "color": "#ff6600"},
"time": {"value": refund_time or datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "color": "#888888"},
"reason": {"value": reason or "原路退回", "color": "#07c160"}
}
service = cls()
return service._send_template_message(
openid=openid,
template_id=template_id,
data=data,
page="pages/client/refund/refund"
)
@classmethod
def send_urge_notification(
cls,
deliver_openid: str,
order_no: str,
client_name: str,
address: str
) -> Dict:
"""发送催单通知给配送员
Args:
deliver_openid: 配送员openid
order_no: 订单号
client_name: 客户名称
address: 配送地址
"""
template_id = cls.TEMPLATE_IDS.get("delivery_notice")
if not template_id:
return {"success": False, "error": "未配置配送通知模板ID"}
data = {
"task_no": {"value": order_no, "color": "#173177"},
"supplier": {"value": f"客户:{client_name}", "color": "#07c160"},
"address": {"value": address, "color": "#173177"},
"remark": {"value": "⚠️ 客户催单,请尽快处理", "color": "#ff0000"}
}
service = cls()
return service._send_template_message(
openid=deliver_openid,
template_id=template_id,
data=data,
page="pages/deliver/task/detail"
)
# 全局实例
wechat_message = WechatMessageService()
+627
View File
@@ -0,0 +1,627 @@
"""微信支付工具模块
支持微信支付 V3 API
文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.html
使用方式:
from app.core.wechat_pay import wechat_pay, WechatPayClient
# 统一下单
result = wechat_pay.unified_order(out_trade_no, total_amount, description, notify_url)
# 申请退款
result = wechat_pay.refund(transaction_id, refund_amount, total_amount)
"""
import hashlib
import time
import random
import string
import json
import base64
import xml.etree.ElementTree as ET
from typing import Optional, Dict, Any
from datetime import datetime
from urllib.parse import urljoin
import ssl
import urllib.request
import urllib.error
from app.core.config import settings
class WechatPayClient:
"""微信支付客户端"""
def __init__(self):
self.mchid = settings.WXPAY_MCHID
self.appid = settings.WXPAY_APPID
self.api_key = settings.WXPAY_API_KEY
self.cert_path = settings.WXPAY_CERT_PATH
self.private_key_path = settings.WXPAY_PRIVATE_KEY_PATH
self.sandbox = settings.WXPAY_SANDBOX
# API 版本
self.api_base = "https://api.mchpay.qpic.cn/v3" if self.sandbox else "https://api.mchpay.qpic.cn/v3"
def _generate_nonce_str(self, length: int = 32) -> str:
"""生成随机字符串"""
return ''.join(random.choices(string.ascii_letters + string.digits, k=length))
def _generate_sign(self, data: Dict[str, Any]) -> str:
"""生成签名 (V2 API)"""
# V2 签名算法
sorted_keys = sorted(data.keys())
sign_str = "&".join([f"{k}={data[k]}" for k in sorted_keys if data[k]])
sign_str += f"&key={self.api_key}"
return hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()
def _generate_sign_v3(self, method: str, url: str, timestamp: str, nonce: str, body: str = "") -> str:
"""生成签名 V3 (SHA256 with RSA)"""
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
from cryptography.x509 import load_pem_x509_certificate
with open(self.private_key_path, 'r') as f:
private_key = f.read()
# 构建签名串
sign_str = f"{method}\n{url}\n{timestamp}\n{nonce}\n{body}\n"
# 使用私钥签名
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
key = serialization.load_pem_private_key(
private_key.encode(),
password=None,
backend=default_backend()
)
signature = key.sign(
sign_str.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
return base64.b64encode(signature).decode('utf-8')
except ImportError:
raise ImportError("请安装 cryptography: pip install cryptography")
def _make_request(self, url: str, method: str = "GET", data: Dict = None, need_cert: bool = False) -> Dict:
"""发送HTTP请求"""
headers = {
'Content-Type': 'application/json',
'User-Agent': 'SQXP/1.0'
}
body = json.dumps(data) if data else ""
try:
if need_cert and self.cert_path and self.private_key_path:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.load_cert_chain(self.cert_path, self.private_key_path)
req = urllib.request.Request(url, data=body.encode(), headers=headers, method=method)
with urllib.request.urlopen(req, context=context) as response:
return json.loads(response.read().decode())
else:
req = urllib.request.Request(url, data=body.encode(), headers=headers, method=method)
with urllib.request.urlopen(req) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as e:
error_body = e.read().decode() if e.fp else ""
try:
return json.loads(error_body)
except:
return {"error": error_body, "code": e.code}
except Exception as e:
return {"error": str(e), "code": -1}
def unified_order(
self,
out_trade_no: str,
total_amount: int,
description: str,
notify_url: str,
openid: str = None
) -> Dict[str, Any]:
"""
统一下单接口 (V3)
Args:
out_trade_no: 商户订单号
total_amount: 订单金额(分)
description: 商品描述
notify_url: 回调通知地址
openid: 用户openid (JSAPI支付必需)
Returns:
返回支付参数,用于调起微信支付
"""
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce_str()
url = f"{self.api_base}/transactions/jsapi"
data = {
"appid": self.appid,
"mchid": self.mchid,
"description": description,
"out_trade_no": out_trade_no,
"time_expire": datetime.now().strftime("%Y-%m-%dT%H:%M:%S+08:00"),
"attach": "sqxp_order",
"notify_url": notify_url,
"amount": {
"total": total_amount,
"currency": "CNY"
},
"payer": {
"openid": openid
} if openid else None
}
# 移除None值
data = {k: v for k, v in data.items() if v is not None}
body = json.dumps(data)
sign = self._generate_sign_v3("POST", f"/v3/transactions/jsapi", timestamp, nonce_str, body)
headers = {
"Authorization": f'WECHATPAY2-SHA256-RSA2048 mchid="{self.mchid}",nonce_str="{nonce_str}",signature="{sign}",timestamp="{timestamp}",serial_no="{settings.WXPAY_SERIAL_NO}"',
"Content-Type": "application/json",
"Accept": "application/json"
}
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
if self.cert_path and self.private_key_path:
context.load_cert_chain(self.cert_path, self.private_key_path)
req = urllib.request.Request(url, data=body.encode(), headers=headers, method="POST")
with urllib.request.urlopen(req, context=context, timeout=30) as response:
result = json.loads(response.read().decode())
if "prepay_id" in result:
# 返回调起支付的参数
pay_sign = self._generate_pay_sign(result["prepay_id"])
return {
"success": True,
"prepay_id": result["prepay_id"],
"pay_sign": pay_sign,
"timestamp": timestamp,
"nonce_str": nonce_str
}
else:
return {"success": False, "error": result.get("message", "下单失败")}
except Exception as e:
return {"success": False, "error": str(e)}
def _generate_pay_sign(self, prepay_id: str) -> str:
"""生成支付签名 (小程序调起支付用)"""
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce_str()
sign_str = f"{self.appid}\n{timestamp}\n{nonce_str}\nprepay_id={prepay_id}\n"
return hashlib.sha256(sign_str.encode('utf-8')).hexdigest()
def query_order(self, out_trade_no: str = None, transaction_id: str = None) -> Dict:
"""查询订单"""
if not out_trade_no and not transaction_id:
return {"success": False, "error": "缺少订单号"}
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce_str()
if out_trade_no:
url = f"{self.api_base}/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={self.mchid}"
else:
url = f"{self.api_base}/v3/pay/transactions/id/{transaction_id}?mchid={self.mchid}"
headers = {
"Authorization": f'WECHATPAY2-SHA256-RSA2048 mchid="{self.mchid}",nonce_str="{nonce_str}",signature="",timestamp="{timestamp}",serial_no=""',
"Accept": "application/json"
}
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(req, context=context, timeout=30) as response:
result = json.loads(response.read().decode())
return {"success": True, "data": result}
except urllib.error.HTTPError as e:
return {"success": False, "error": f"HTTP {e.code}: {e.read().decode()}"}
except Exception as e:
return {"success": False, "error": str(e)}
def refund(
self,
out_trade_no: str,
total_amount: int,
refund_amount: int,
reason: str = None
) -> Dict:
"""
申请退款
Args:
out_trade_no: 商户订单号
total_amount: 订单总金额(分)
refund_amount: 退款金额(分)
reason: 退款原因
Returns:
退款结果
"""
if refund_amount > total_amount:
return {"success": False, "error": "退款金额不能大于订单金额"}
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce_str()
out_refund_no = f"REF{datetime.now().strftime('%Y%m%d%H%M%S')}{random.randint(1000, 9999)}"
url = f"{self.api_base}/v3/refund/domestic/refunds"
data = {
"out_trade_no": out_trade_no,
"out_refund_no": out_refund_no,
"reason": reason or "用户申请退款",
"notify_url": settings.WXPAY_REFUND_NOTIFY_URL,
"amount": {
"refund": refund_amount,
"total": total_amount,
"currency": "CNY"
}
}
body = json.dumps(data)
sign = self._generate_sign_v3("POST", "/v3/refund/domestic/refunds", timestamp, nonce_str, body)
headers = {
"Authorization": f'WECHATPAY2-SHA256-RSA2048 mchid="{self.mchid}",nonce_str="{nonce_str}",signature="{sign}",timestamp="{timestamp}",serial_no="{settings.WXPAY_SERIAL_NO}"',
"Content-Type": "application/json",
"Accept": "application/json"
}
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
if self.cert_path and self.private_key_path:
context.load_cert_chain(self.cert_path, self.private_key_path)
req = urllib.request.Request(url, data=body.encode(), headers=headers, method="POST")
with urllib.request.urlopen(req, context=context, timeout=30) as response:
result = json.loads(response.read().decode())
return {
"success": True,
"refund_id": result.get("refund_id"),
"out_refund_no": out_refund_no,
"refund_status": result.get("status")
}
except urllib.error.HTTPError as e:
error_body = e.read().decode()
return {"success": False, "error": f"HTTP {e.code}: {error_body}"}
except Exception as e:
return {"success": False, "error": str(e)}
def close_order(self, out_trade_no: str) -> Dict:
"""关闭订单"""
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce_str()
url = f"{self.api_base}/v3/transactions/out-trade-no/{out_trade_no}/close"
data = {"mchid": self.mchid}
body = json.dumps(data)
sign = self._generate_sign_v3("POST", f"/v3/transactions/out-trade-no/{out_trade_no}/close", timestamp, nonce_str, body)
headers = {
"Authorization": f'WECHATPAY2-SHA256-RSA2048 mchid="{self.mchid}",nonce_str="{nonce_str}",signature="{sign}",timestamp="{timestamp}",serial_no="{settings.WXPAY_SERIAL_NO}"',
"Content-Type": "application/json"
}
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
if self.cert_path and self.private_key_path:
context.load_cert_chain(self.cert_path, self.private_key_path)
req = urllib.request.Request(url, data=body.encode(), headers=headers, method="POST")
with urllib.request.urlopen(req, context=context, timeout=30) as response:
return {"success": True}
except urllib.error.HTTPError as e:
return {"success": False, "error": f"HTTP {e.code}: {e.read().decode()}"}
except Exception as e:
return {"success": False, "error": str(e)}
def get_wechat_pay_client() -> WechatPayClient:
"""获取微信支付客户端实例"""
return WechatPayClient()
# 全局实例 (延迟初始化)
_wechat_pay_client: Optional[WechatPayClient] = None
def wechat_pay() -> WechatPayClient:
"""获取微信支付客户端"""
global _wechat_pay_client
if _wechat_pay_client is None:
_wechat_pay_client = WechatPayClient()
return _wechat_pay_client
class WechatPayV2Client:
"""微信支付 V2 客户端 (兼容旧版)"""
def __init__(self):
self.mchid = settings.WXPAY_MCHID
self.appid = settings.WXPAY_APPID
self.api_key = settings.WXPAY_API_KEY
self.appid_mini = settings.WXPAY_MINI_APPID
self.notify_url = settings.WXPAY_NOTIFY_URL
if settings.WXPAY_SANDBOX:
self.pay_url = "https://sandbox.mch.weixin.qq.com/pay/unifiedorder"
else:
self.pay_url = "https://api.mch.weixin.qq.com/pay/unifiedorder"
def _generate_sign(self, params: Dict) -> str:
"""生成签名"""
sorted_keys = sorted(params.keys())
sign_str = "&".join([f"{k}={params[k]}" for k in sorted_keys if params[k]])
sign_str += f"&key={self.api_key}"
return hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()
def _dict_to_xml(self, params: Dict) -> str:
"""字典转XML"""
xml_str = "<xml>"
for k, v in params.items():
xml_str += f"<{k}><![CDATA[{v}]]></{k}>"
xml_str += "</xml>"
return xml_str
def _xml_to_dict(self, xml_str: str) -> Dict:
"""XML转字典"""
try:
root = ET.fromstring(xml_str)
return {child.tag: child.text for child in root}
except:
return {}
def build_miniprogram_pay_params(self, prepay_id: str) -> Dict[str, str]:
"""小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致)"""
appid = self.appid_mini or self.appid
time_stamp = str(int(time.time()))
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
package = f"prepay_id={prepay_id}"
sign_type = "MD5"
pay_fields = {
"appId": appid,
"nonceStr": nonce_str,
"package": package,
"signType": sign_type,
"timeStamp": time_stamp,
}
sign_str = "&".join([f"{k}={pay_fields[k]}" for k in sorted(pay_fields.keys())])
sign_str += f"&key={self.api_key}"
pay_sign = hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
return {
"appId": appid,
"timeStamp": time_stamp,
"nonceStr": nonce_str,
"package": package,
"signType": sign_type,
"paySign": pay_sign,
}
def unified_order(
self,
out_trade_no: str,
total_fee: int,
body: str,
openid: str = None,
trade_type: str = "JSAPI"
) -> Dict:
"""
统一下单 (V2 API)
Args:
out_trade_no: 商户订单号
total_fee: 订单金额(分)
body: 商品描述
openid: 用户openid
trade_type: 交易类型 JSAPI/NATIVE/APP
Returns:
返回支付参数
"""
params = {
"appid": self.appid,
"mch_id": self.mchid,
"nonce_str": ''.join(random.choices(string.ascii_letters + string.digits, k=32)),
"body": body,
"out_trade_no": out_trade_no,
"total_fee": total_fee,
"spbill_create_ip": "127.0.0.1",
"notify_url": self.notify_url,
"trade_type": trade_type
}
if openid:
params["openid"] = openid
# 小程序 JSAPI 必须使用小程序 AppID 与商户号绑定关系一致
if trade_type == "JSAPI" and self.appid_mini:
params["appid"] = self.appid_mini
# 生成签名
params["sign"] = self._generate_sign(params)
# 发送请求
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(
self.pay_url,
data=self._dict_to_xml(params).encode('utf-8'),
headers={"Content-Type": "text/xml"}
)
with urllib.request.urlopen(req, context=context, timeout=30) as response:
result = self._xml_to_dict(response.read().decode())
if result.get("return_code") == "SUCCESS" and result.get("result_code") == "SUCCESS":
if trade_type == "JSAPI":
prepay = result.get("prepay_id")
pay_extra = self.build_miniprogram_pay_params(prepay) if prepay else {}
return {
"success": True,
"prepay_id": prepay,
"package": pay_extra.get("package"),
"pay_params": pay_extra,
}
elif trade_type == "NATIVE":
return {
"success": True,
"code_url": result.get("code_url")
}
elif trade_type == "APP":
return {
"success": True,
"prepay_id": result.get("prepay_id")
}
else:
return {
"success": False,
"error": result.get("err_code_des", result.get("return_msg", "下单失败"))
}
except Exception as e:
return {"success": False, "error": str(e)}
def query_order(self, out_trade_no: str = None, transaction_id: str = None) -> Dict:
"""查询订单"""
if not out_trade_no and not transaction_id:
return {"success": False, "error": "缺少订单号"}
params = {
"appid": self.appid,
"mch_id": self.mchid,
"nonce_str": ''.join(random.choices(string.ascii_letters + string.digits, k=32))
}
if out_trade_no:
params["out_trade_no"] = out_trade_no
else:
params["transaction_id"] = transaction_id
params["sign"] = self._generate_sign(params)
query_url = "https://api.mch.weixin.qq.com/pay/orderquery"
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(
query_url,
data=self._dict_to_xml(params).encode('utf-8'),
headers={"Content-Type": "text/xml"}
)
with urllib.request.urlopen(req, context=context, timeout=30) as response:
result = self._xml_to_dict(response.read().decode())
return {"success": True, "data": result}
except Exception as e:
return {"success": False, "error": str(e)}
def refund(
self,
out_trade_no: str,
total_fee: int,
refund_fee: int,
refund_desc: str = None
) -> Dict:
"""申请退款 (V2)"""
out_refund_no = f"REF{datetime.now().strftime('%Y%m%d%H%M%S')}{random.randint(1000, 9999)}"
params = {
"appid": self.appid,
"mch_id": self.mchid,
"nonce_str": ''.join(random.choices(string.ascii_letters + string.digits, k=32)),
"out_trade_no": out_trade_no,
"out_refund_no": out_refund_no,
"total_fee": total_fee,
"refund_fee": refund_fee,
"refund_desc": refund_desc or "用户申请退款"
}
params["sign"] = self._generate_sign(params)
refund_url = "https://api.mch.weixin.qq.com/secapi/pay/refund"
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
# 需要双向证书
if settings.WXPAY_SSL_CERT and settings.WXPAY_SSL_KEY:
cert_file = settings.WXPAY_SSL_CERT
key_file = settings.WXPAY_SSL_KEY
req = urllib.request.Request(
refund_url,
data=self._dict_to_xml(params).encode('utf-8'),
headers={"Content-Type": "text/xml"}
)
with urllib.request.urlopen(req, context=context, timeout=30) as response:
result = self._xml_to_dict(response.read().decode())
if result.get("return_code") == "SUCCESS":
return {
"success": True,
"out_refund_no": out_refund_no,
"refund_id": result.get("refund_id")
}
return {"success": False, "error": result.get("return_msg", "退款失败")}
else:
return {"success": False, "error": "缺少SSL证书配置"}
except Exception as e:
return {"success": False, "error": str(e)}
def parse_notify(self, xml_data: str) -> Dict:
"""解析支付回调"""
result = self._xml_to_dict(xml_data)
if result.get("return_code") == "SUCCESS":
# 验证签名
sign = result.pop("sign", None)
if sign and sign == self._generate_sign(result):
return {"success": True, "data": result}
return {"success": False, "error": "签名验证失败"}
def get_wechat_pay_v2() -> WechatPayV2Client:
"""获取微信支付V2客户端"""
return WechatPayV2Client()
+73
View File
@@ -0,0 +1,73 @@
"""CRUD模块"""
from app.crud.base import CRUDBase, HistoryCRUD
from app.crud.region import region_crud
from app.crud.category import category_crud
from app.crud.unit import unit_crud
from app.crud.user import user_crud
from app.crud.password_history import password_history_crud
from app.crud.product import product_crud
from app.crud.address import address_crud
from app.crud.inquiry import inquiry_crud, inquiry_item_crud
from app.crud.quotation import quotation_crud, quotation_item_crud
from app.crud.price_list import price_list_crud, price_list_item_crud
from app.crud.order import order_crud, order_item_crud, order_status_log_crud
from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
from app.crud.settlement import settlement_crud, refund_crud
from app.crud.ord_settlement import (
ord_client_settlement_crud,
ord_client_settlement_item_crud,
ord_supplier_settlement_crud,
ord_supplier_settlement_item_crud,
)
from app.crud.deliverer_review import deliverer_review_crud
from app.crud.product_banner import product_banner, product_banner_his
from app.crud.product_detail_media import product_detail_media, product_detail_media_his
from app.crud.product_parameter import product_parameter, product_parameter_his
from app.crud.category_parameter import category_parameter, category_parameter_his
from app.crud.product_parameter_value import product_parameter_value, product_parameter_value_his
from app.crud.product_review import product_review, product_review_his
from app.crud.product_review_media import product_review_media, product_review_media_his
__all__ = [
"CRUDBase",
"HistoryCRUD",
"region_crud",
"category_crud",
"unit_crud",
"user_crud",
"password_history_crud",
"product_crud",
"address_crud",
"inquiry_crud",
"inquiry_item_crud",
"quotation_crud",
"quotation_item_crud",
"price_list_crud",
"price_list_item_crud",
"order_crud",
"order_item_crud",
"order_status_log_crud",
"delivery_task_crud",
"delivery_task_item_crud",
"settlement_crud",
"refund_crud",
"ord_client_settlement_crud",
"ord_client_settlement_item_crud",
"ord_supplier_settlement_crud",
"ord_supplier_settlement_item_crud",
"deliverer_review_crud",
"product_banner",
"product_banner_his",
"product_detail_media",
"product_detail_media_his",
"product_parameter",
"product_parameter_his",
"category_parameter",
"category_parameter_his",
"product_parameter_value",
"product_parameter_value_his",
"product_review",
"product_review_his",
"product_review_media",
"product_review_media_his",
]
+122
View File
@@ -0,0 +1,122 @@
"""地址CRUD"""
from typing import Optional, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.models.models import BasAddress, BasAddressHis
from app.crud.base import CRUDBase
class AddressCRUD(CRUDBase[BasAddress]):
"""地址CRUD操作"""
def get_by_user(self, db: Session, user_id: str) -> List[BasAddress]:
"""获取用户的所有地址"""
return db.query(BasAddress).filter(
and_(BasAddress.user_id == user_id, BasAddress.is_del == 0)
).order_by(BasAddress.create_at.desc(), BasAddress.id.desc()).all()
def get_by_user_paged(
self, db: Session, user_id: str, skip: int = 0, limit: int = 20
) -> Tuple[List[BasAddress], int]:
"""分页获取用户地址(按创建时间倒序)"""
q = db.query(BasAddress).filter(
and_(BasAddress.user_id == user_id, BasAddress.is_del == 0)
)
total = q.count()
rows = (
q.order_by(BasAddress.create_at.desc(), BasAddress.id.desc())
.offset(skip)
.limit(limit)
.all()
)
return rows, total
def get_by_user_and_type(self, db: Session, user_id: str, address_type: str) -> List[BasAddress]:
"""获取用户指定类型的地址"""
return db.query(BasAddress).filter(
and_(BasAddress.user_id == user_id, BasAddress.address_type == address_type, BasAddress.is_del == 0)
).order_by(BasAddress.create_at.desc(), BasAddress.id.desc()).all()
def get_default(self, db: Session, user_id: str, address_type: str = None) -> Optional[BasAddress]:
"""获取用户默认地址"""
query = db.query(BasAddress).filter(
and_(BasAddress.user_id == user_id, BasAddress.is_default == True, BasAddress.is_del == 0)
)
if address_type:
query = query.filter(BasAddress.address_type == address_type)
return query.first()
def set_default(self, db: Session, user_id: str, address_id: str) -> bool:
"""设置默认地址"""
# 先取消所有默认地址
db.query(BasAddress).filter(
and_(BasAddress.user_id == user_id, BasAddress.is_default == True)
).update({"is_default": False})
# 设置新的默认地址
address = self.get(db, address_id)
if address and address.user_id == user_id:
address.is_default = True
db.commit()
return True
return False
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BasAddress]:
"""更新地址并记录历史"""
original = self.get(db, id)
if not original:
return None
his_data = {
"id": original.id,
"user_id": original.user_id,
"address_type": original.address_type,
"contact_person": original.contact_person,
"contact_phone": original.contact_phone,
"province": original.province,
"city": original.city,
"district": original.district,
"detail_address": original.detail_address,
"is_default": original.is_default,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BasAddressHis(**his_data)
db.add(his)
return self.update(db, id, obj_in, updater)
def soft_delete_with_history(self, db: Session, id: str) -> bool:
"""删除地址并记录历史"""
original = self.get(db, id)
if not original:
return False
his_data = {
"id": original.id,
"user_id": original.user_id,
"address_type": original.address_type,
"contact_person": original.contact_person,
"contact_phone": original.contact_phone,
"province": original.province,
"city": original.city,
"district": original.district,
"detail_address": original.detail_address,
"is_default": original.is_default,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BasAddressHis(**his_data)
db.add(his)
return self.delete(db, id)
address_crud = AddressCRUD(BasAddress)
+122
View File
@@ -0,0 +1,122 @@
"""API 请求日志 CRUD"""
from datetime import datetime
from typing import List, Optional, Tuple
from sqlalchemy import and_, desc, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_id
from app.models.models import SysApiRequestLog, SysApiRequestLogHis
class ApiRequestLogCRUD:
@staticmethod
def get_multi_with_filters(
db: Session,
http_method: str = None,
status_code: int = None,
keyword: str = None,
path_prefix: str = None,
start_date: str = None,
end_date: str = None,
skip: int = 0,
limit: int = 10,
) -> Tuple[List[SysApiRequestLog], int]:
q = db.query(SysApiRequestLog).filter(SysApiRequestLog.is_del == 0)
if http_method:
q = q.filter(SysApiRequestLog.http_method == http_method.upper())
if status_code is not None:
q = q.filter(SysApiRequestLog.status_code == status_code)
if path_prefix:
q = q.filter(SysApiRequestLog.path.like(f"%{path_prefix}%"))
if keyword:
kw = f"%{keyword}%"
q = q.filter(
or_(
SysApiRequestLog.path.like(kw),
SysApiRequestLog.username.like(kw),
SysApiRequestLog.user_id.like(kw),
SysApiRequestLog.request_body.like(kw),
SysApiRequestLog.response_body.like(kw),
)
)
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
q = q.filter(SysApiRequestLog.create_at >= start_dt)
except Exception:
pass
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
end_dt = end_dt.replace(hour=23, minute=59, second=59)
q = q.filter(SysApiRequestLog.create_at <= end_dt)
except Exception:
pass
total = int(
q.with_entities(func.count(SysApiRequestLog.id)).order_by(None).scalar() or 0
)
rows = (
q.with_entities(
SysApiRequestLog.id,
SysApiRequestLog.http_method,
SysApiRequestLog.path,
SysApiRequestLog.query_string,
SysApiRequestLog.status_code,
SysApiRequestLog.duration_ms,
SysApiRequestLog.user_id,
SysApiRequestLog.username,
SysApiRequestLog.role,
SysApiRequestLog.ip_address,
SysApiRequestLog.user_agent,
func.substr(SysApiRequestLog.request_body, 1, 300).label("request_body"),
func.substr(SysApiRequestLog.response_body, 1, 300).label("response_body"),
SysApiRequestLog.create_at,
)
.order_by(desc(SysApiRequestLog.create_at))
.offset(skip)
.limit(limit)
.all()
)
return rows, total
@staticmethod
def get(db: Session, log_id: str) -> Optional[SysApiRequestLog]:
return db.query(SysApiRequestLog).filter(
and_(SysApiRequestLog.id == log_id, SysApiRequestLog.is_del == 0)
).first()
@staticmethod
def delete(db: Session, log_id: str) -> bool:
log = db.query(SysApiRequestLog).filter(SysApiRequestLog.id == log_id).first()
if not log:
return False
his = SysApiRequestLogHis(
id=log.id,
http_method=log.http_method,
path=log.path,
query_string=log.query_string,
request_body=log.request_body,
response_body=log.response_body,
status_code=log.status_code,
duration_ms=log.duration_ms,
user_id=log.user_id,
username=log.username,
role=log.role,
ip_address=log.ip_address,
user_agent=log.user_agent,
create_at=log.create_at,
create_by=log.create_by,
create_name=log.create_name,
update_at=log.update_at,
update_by=log.update_by,
his_create_at=datetime.now(),
)
db.add(his)
log.is_del = 1
db.commit()
return True
api_request_log_crud = ApiRequestLogCRUD()
+58
View File
@@ -0,0 +1,58 @@
"""附件CRUD"""
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.models.models import BizAttachment
from app.crud.base import CRUDBase
from app.core.utils import generate_id
class AttachmentCRUD(CRUDBase[BizAttachment]):
"""附件CRUD操作"""
def get_by_business(
self,
db: Session,
business_type: str,
business_id: str
) -> List[BizAttachment]:
"""根据业务类型和业务ID获取附件列表"""
return db.query(self.model).filter(
and_(
self.model.business_type == business_type,
self.model.business_id == business_id,
self.model.is_del == 0
)
).order_by(self.model.create_at.desc()).all()
def create_attachment(
self,
db: Session,
business_type: str,
business_id: str,
file_name: str,
file_url: str,
file_size: int = None,
file_type: str = None,
upload_by: str = None,
upload_by_name: str = None
) -> BizAttachment:
"""创建附件记录"""
data = {
"id": generate_id(),
"business_type": business_type,
"business_id": business_id,
"file_name": file_name,
"file_url": file_url,
"file_size": file_size,
"file_type": file_type,
"upload_by": upload_by,
"upload_by_name": upload_by_name,
"create_by": upload_by,
"create_name": upload_by_name
}
return self.create(db, data)
attachment_crud = AttachmentCRUD(BizAttachment)
+166
View File
@@ -0,0 +1,166 @@
"""CRUD基础类"""
from typing import TypeVar, Generic, Type, Optional, List, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
import re
from app.core.utils import generate_id
from app.core.database import Base
from sqlalchemy import func
ModelType = TypeVar("ModelType", bound=Base)
def camel_to_snake(name: str) -> str:
"""驼峰命名转蛇形命名"""
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
def get_model_attr(obj, key: str):
"""获取模型的属性名,支持驼峰转蛇形"""
# 先尝试直接匹配
if hasattr(obj, key):
return key
# 尝试驼峰转蛇形
snake_key = camel_to_snake(key)
if hasattr(obj, snake_key):
return snake_key
# 如果都没有,返回None
return None
class CRUDBase(Generic[ModelType]):
"""CRUD基类"""
def __init__(self, model: Type[ModelType]):
self.model = model
def get(self, db: Session, id: str) -> Optional[ModelType]:
"""根据ID获取单条记录"""
return db.query(self.model).filter(self.model.id == id).first()
def get_multi(
self,
db: Session,
skip: int = 0,
limit: int = 100,
filters: List[Any] = None,
order_by: str = None,
order_type: str = "desc"
) -> List[ModelType]:
"""获取多条记录"""
query = db.query(self.model)
if filters:
query = query.filter(*filters)
if order_by:
order_column = getattr(self.model, order_by, None)
if order_column:
if order_type == "asc":
query = query.order_by(order_column.asc())
else:
query = query.order_by(order_column.desc())
else:
query = query.order_by(self.model.create_at.desc())
return query.offset(skip).limit(limit).all()
def get_all(self, db: Session, filters: List[Any] = None) -> List[ModelType]:
"""获取所有记录"""
query = db.query(self.model)
if filters:
query = query.filter(*filters)
return query.all()
def create(self, db: Session, obj_in: dict, creator: dict = None) -> ModelType:
"""创建记录"""
obj = self.model()
for key, value in obj_in.items():
attr_name = get_model_attr(obj, key)
if attr_name:
setattr(obj, attr_name, value)
# 设置ID
if not obj.id:
obj.id = generate_id()
# 设置创建人信息
if creator:
if not obj.create_by and "user_id" in creator:
obj.create_by = creator["user_id"]
if not obj.create_name and "real_name" in creator:
obj.create_name = creator["real_name"]
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[ModelType]:
"""更新记录"""
obj = self.get(db, id)
if obj:
for key, value in obj_in.items():
attr_name = get_model_attr(obj, key)
if attr_name and key not in ["id", "create_at", "create_by", "create_name"]:
setattr(obj, attr_name, value)
# 设置更新人信息
if updater:
if "user_id" in updater:
obj.update_by = updater["user_id"]
if "real_name" in updater:
obj.update_name = updater.get("real_name", obj.create_name)
obj.update_at = func.now()
db.commit()
db.refresh(obj)
return obj
def delete(self, db: Session, id: str) -> bool:
"""删除记录(软删除)"""
obj = self.get(db, id)
if obj:
if hasattr(obj, "is_del"):
obj.is_del = 1
else:
db.delete(obj)
db.commit()
return True
return False
def hard_delete(self, db: Session, id: str) -> bool:
"""物理删除"""
obj = self.get(db, id)
if obj:
db.delete(obj)
db.commit()
return True
return False
def count(self, db: Session, filters: List[Any] = None) -> int:
"""统计数量"""
query = db.query(self.model)
if filters:
query = query.filter(*filters)
return query.count()
def exists(self, db: Session, filters: List[Any] = None) -> bool:
"""检查记录是否存在"""
query = db.query(self.model)
if filters:
query = query.filter(*filters)
return query.first() is not None
class HistoryCRUD(CRUDBase[ModelType]):
"""历史表CRUD类"""
def create_history(self, db: Session, original_data: dict) -> ModelType:
"""创建历史记录"""
obj = self.model()
# 复制原始数据到历史表
for key, value in original_data.items():
if hasattr(obj, key):
setattr(obj, key, value)
# 设置历史记录创建时间
if hasattr(obj, "his_create_at"):
obj.his_create_at = func.now()
db.add(obj)
db.commit()
db.refresh(obj)
return obj
+112
View File
@@ -0,0 +1,112 @@
"""商品分类CRUD"""
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.models.models import BasProductCategory, BasProductCategoryHis
from app.crud.base import CRUDBase
class CategoryCRUD(CRUDBase[BasProductCategory]):
"""商品分类CRUD操作"""
def get_by_name(self, db: Session, name: str, parent_id: str = None) -> Optional[BasProductCategory]:
"""根据名称获取分类"""
query = db.query(BasProductCategory).filter(
and_(BasProductCategory.name == name, BasProductCategory.is_del == 0)
)
if parent_id:
query = query.filter(BasProductCategory.parent_id == parent_id)
else:
query = query.filter(BasProductCategory.parent_id.is_(None))
return query.first()
def get_active_all(self, db: Session) -> List[BasProductCategory]:
"""获取所有启用的分类"""
return db.query(BasProductCategory).filter(
and_(BasProductCategory.is_del == 0, BasProductCategory.is_active == True)
).order_by(BasProductCategory.sort_order.asc()).all()
def get_children(self, db: Session, parent_id: str) -> List[BasProductCategory]:
"""获取子分类"""
return db.query(BasProductCategory).filter(
and_(BasProductCategory.parent_id == parent_id, BasProductCategory.is_del == 0)
).order_by(BasProductCategory.sort_order.asc()).all()
def get_tree(self, db: Session) -> List[dict]:
"""获取分类树"""
all_categories = self.get_active_all(db)
return self._build_tree(all_categories, None)
def _build_tree(self, categories: List[BasProductCategory], parent_id: str = None) -> List[dict]:
"""构建树形结构"""
tree = []
for cat in categories:
if cat.parent_id == parent_id:
pc = getattr(cat, "price_coefficient", None)
node = {
"id": cat.id,
"name": cat.name,
"parentId": cat.parent_id,
"sortOrder": cat.sort_order,
"priceCoefficient": float(pc) if pc is not None else 0.0,
"isActive": cat.is_active,
"children": self._build_tree(categories, cat.id)
}
tree.append(node)
return tree
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BasProductCategory]:
"""更新分类并记录历史"""
original = self.get(db, id)
if not original:
return None
pc_hist = getattr(original, "price_coefficient", None)
his_data = {
"id": original.id,
"name": original.name,
"image_url": getattr(original, "image_url", None),
"parent_id": original.parent_id,
"sort_order": original.sort_order,
"price_coefficient": pc_hist if pc_hist is not None else 0,
"is_active": original.is_active,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BasProductCategoryHis(**his_data)
db.add(his)
return self.update(db, id, obj_in, updater)
def soft_delete_with_history(self, db: Session, id: str) -> bool:
"""删除分类并记录历史"""
original = self.get(db, id)
if not original:
return False
pc_hist = getattr(original, "price_coefficient", None)
his_data = {
"id": original.id,
"name": original.name,
"image_url": getattr(original, "image_url", None),
"parent_id": original.parent_id,
"sort_order": original.sort_order,
"price_coefficient": pc_hist if pc_hist is not None else 0,
"is_active": original.is_active,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BasProductCategoryHis(**his_data)
db.add(his)
return self.delete(db, id)
category_crud = CategoryCRUD(BasProductCategory)
+116
View File
@@ -0,0 +1,116 @@
"""CRUD - 分类参数关联"""
from typing import List
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.crud.base import CRUDBase, HistoryCRUD
from app.models.category_parameter import BasCategoryParameter, BasCategoryParameterHis
from app.schemas.category_parameter import CategoryParameterBase
class CategoryParameterCRUD(CRUDBase[BasCategoryParameter]):
"""分类参数关联CRUD操作"""
def get_by_category(self, db: Session, category_id: str) -> List[BasCategoryParameter]:
"""获取分类的所有关联参数"""
return db.query(self.model).filter(
self.model.category_id == category_id,
self.model.is_del == 0
).order_by(self.model.sort_order.asc()).all()
def get_by_category_with_detail(self, db: Session, category_id: str) -> List[dict]:
"""获取分类的所有关联参数(带参数详情)"""
from app.models.product_parameter import BasProductParameter
results = db.query(
BasCategoryParameter,
BasProductParameter
).join(
BasProductParameter,
and_(
BasCategoryParameter.parameter_id == BasProductParameter.id,
BasProductParameter.is_del == 0
)
).filter(
BasCategoryParameter.category_id == category_id,
BasCategoryParameter.is_del == 0
).order_by(
BasCategoryParameter.sort_order.asc()
).all()
return [
{
"id": cp.id,
"parameterId": cp.parameter_id,
"parameterName": pp.name,
"parameterCode": pp.code,
"paramType": pp.param_type,
"options": pp.options,
"placeholder": pp.placeholder,
"isRequired": cp.is_required,
"defaultValue": cp.default_value,
"sortOrder": cp.sort_order,
}
for cp, pp in results
]
def batch_save(
self,
db: Session,
category_id: str,
items: List[dict],
creator: dict = None
) -> List[BasCategoryParameter]:
"""批量保存分类参数关联"""
# 先获取要删除的记录ID
old_records = db.query(self.model).filter(
self.model.category_id == category_id,
self.model.is_del == 0
).all()
# 物理删除旧记录(释放唯一索引约束)
for record in old_records:
db.delete(record)
db.commit()
# 创建新的关联
saved_items = []
for idx, item in enumerate(items):
item_data = {
"category_id": category_id,
"parameter_id": item.get("parameter_id"),
"is_required": item.get("is_required", 0),
"default_value": item.get("default_value"),
"sort_order": item.get("sort_order", idx),
}
cp = self.create(db, item_data, creator)
saved_items.append(cp)
db.commit()
return saved_items
def delete_by_category(self, db: Session, category_id: str) -> int:
"""删除分类的所有关联"""
count = db.query(self.model).filter(
self.model.category_id == category_id,
self.model.is_del == 0
).update({"is_del": 1})
db.commit()
return count
def exists_link(self, db: Session, category_id: str, parameter_id: str) -> bool:
"""检查是否已有关联"""
return db.query(self.model).filter(
self.model.category_id == category_id,
self.model.parameter_id == parameter_id,
self.model.is_del == 0
).first() is not None
class CategoryParameterHisCRUD(HistoryCRUD[BasCategoryParameterHis]):
"""分类参数关联历史CRUD操作"""
pass
category_parameter = CategoryParameterCRUD(BasCategoryParameter)
category_parameter_his = CategoryParameterHisCRUD(BasCategoryParameterHis)
+280
View File
@@ -0,0 +1,280 @@
"""客户购物车 CRUD"""
from decimal import Decimal
from typing import List, Optional, Dict, Any
from collections import defaultdict
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.models.models import BizClientCart, BizClientCartHis
from app.core.utils import generate_id
from app.crud.price_list import price_list_crud, price_list_item_crud
from app.crud.unit import unit_crud
from app.crud.product import product_crud
from app.crud.category import category_crud
class ClientCartCRUD:
def list_by_client(self, db: Session, client_id: str) -> List[BizClientCart]:
return (
db.query(BizClientCart)
.filter(and_(BizClientCart.client_id == client_id, BizClientCart.is_del == 0))
.order_by(BizClientCart.create_at.desc())
.all()
)
def sync_to_active_price_list(
self,
db: Session,
client_id: str,
region_id: str,
operator: dict,
rows: Optional[List[BizClientCart]] = None,
) -> bool:
"""
将购物车中仍属于当前生效价格清单的商品行,同步为区域最新生效清单的单价、单位与清单 ID。
同一商品多条行(历史清单 ID 不同)合并数量并保留一行。
无生效清单时返回 False,不修改数据。
"""
pl = price_list_crud.get_active(db, region_id)
if not pl:
return False
rows = rows if rows is not None else self.list_by_client(db, client_id)
if not rows:
return True
# 快速路径:如果购物车所有行已经属于当前生效价格清单,则无需做任何同步。
# 这能避免在“频繁加购/改数量”场景下对整车做 update + his insert,导致卡顿。
if all(getattr(r, "price_list_id", None) == pl.id for r in rows):
return True
price_items = price_list_item_crud.get_by_price_list(db, pl.id)
pmap: Dict[str, Any] = {i.product_id: i for i in price_items}
by_product: Dict[str, List[BizClientCart]] = defaultdict(list)
for r in rows:
if r.product_id in pmap:
by_product[r.product_id].append(r)
op_uid = operator.get("user_id")
now = datetime.now()
changed = False
for product_id, rel in by_product.items():
pi = pmap[product_id]
total_qty = sum(Decimal(str(r.quantity)) for r in rel)
if total_qty <= 0:
continue
new_unit_price = Decimal(str(pi.price))
new_amount = (total_qty * new_unit_price).quantize(Decimal("0.01"))
keep = next((r for r in rel if r.price_list_id == pl.id), rel[0])
for r in rel:
if r.id != keep.id:
self._copy_to_his(db, r)
r.is_del = 1
r.update_by = op_uid
r.update_at = now
changed = True
# 只有发生实际变化时才写历史并更新主表,避免“重复同步”导致的历史膨胀与写放大
product = product_crud.get(db, product_id)
unit = unit_crud.get(db, pi.unit_id) if pi.unit_id else None
cat_name = keep.category_name or ""
if product and product.category_id:
cat = category_crud.get(db, product.category_id)
cat_name = cat.name if cat else cat_name
need_update = (
keep.price_list_id != pl.id
or keep.unit_id != pi.unit_id
or Decimal(str(keep.quantity)) != total_qty
or Decimal(str(keep.unit_price)) != new_unit_price
or Decimal(str(keep.amount)) != new_amount
or (product and (keep.product_name != product.name or keep.image_url != product.image_url))
or keep.unit_name != (unit.name if unit else keep.unit_name)
or keep.category_name != (cat_name or keep.category_name)
)
if need_update:
self._copy_to_his(db, keep)
keep.price_list_id = pl.id
keep.unit_id = pi.unit_id
keep.quantity = total_qty
keep.unit_price = new_unit_price
keep.amount = new_amount
keep.product_name = product.name if product else keep.product_name
keep.unit_name = unit.name if unit else keep.unit_name
keep.category_name = cat_name or ""
keep.image_url = product.image_url if product else keep.image_url
keep.update_by = op_uid
keep.update_at = now
changed = True
if changed:
db.commit()
return True
def get(self, db: Session, cart_id: str) -> Optional[BizClientCart]:
return (
db.query(BizClientCart)
.filter(and_(BizClientCart.id == cart_id, BizClientCart.is_del == 0))
.first()
)
def _copy_to_his(self, db: Session, row: BizClientCart) -> None:
his = BizClientCartHis(
id=row.id,
client_id=row.client_id,
price_list_id=row.price_list_id,
product_id=row.product_id,
unit_id=row.unit_id,
quantity=row.quantity,
unit_price=row.unit_price,
amount=row.amount,
product_name=row.product_name,
unit_name=row.unit_name,
category_name=row.category_name,
image_url=row.image_url,
create_at=row.create_at,
create_by=row.create_by,
create_name=row.create_name,
update_at=row.update_at,
update_by=row.update_by,
his_create_at=datetime.now(),
)
db.add(his)
def add_or_update(
self,
db: Session,
client_id: str,
price_list_id: str,
product_id: str,
unit_id: str,
quantity: Decimal,
unit_price: Decimal,
product_name: str,
unit_name: str,
category_name: str,
image_url: str,
operator: dict,
) -> BizClientCart:
matched = (
db.query(BizClientCart)
.filter(
and_(
BizClientCart.client_id == client_id,
BizClientCart.product_id == product_id,
BizClientCart.price_list_id == price_list_id,
BizClientCart.is_del == 0,
)
)
.all()
)
op_uid = operator.get("user_id")
now = datetime.now()
if matched:
# 兼容历史脏数据:同客户+同价目表+同商品可能存在多行,先合并再做增量更新。
keep = matched[0]
merged_qty = sum(Decimal(str(r.quantity or 0)) for r in matched)
if merged_qty < 0:
merged_qty = Decimal("0")
for row in matched[1:]:
self._copy_to_his(db, row)
row.is_del = 1
row.update_by = op_uid
row.update_at = now
self._copy_to_his(db, keep)
next_qty = merged_qty + Decimal(str(quantity))
amount = (next_qty * unit_price).quantize(Decimal("0.01"))
keep.quantity = next_qty
keep.unit_price = unit_price
keep.amount = amount
keep.product_name = product_name
keep.unit_name = unit_name
keep.category_name = category_name
keep.image_url = image_url
keep.update_by = op_uid
keep.update_at = now
db.commit()
return keep
amount = (quantity * unit_price).quantize(Decimal("0.01"))
row = BizClientCart(
id=generate_id(),
client_id=client_id,
price_list_id=price_list_id,
product_id=product_id,
unit_id=unit_id,
quantity=quantity,
unit_price=unit_price,
amount=amount,
product_name=product_name,
unit_name=unit_name,
category_name=category_name or "",
image_url=image_url,
is_del=0,
create_by=op_uid,
create_name=operator.get("real_name"),
update_by=op_uid,
)
db.add(row)
db.commit()
return row
def update_quantity(
self,
db: Session,
cart_id: str,
client_id: str,
quantity: Decimal,
operator: dict,
current_row: Optional[BizClientCart] = None,
) -> Optional[BizClientCart]:
row = current_row or self.get(db, cart_id)
if not row or row.client_id != client_id:
return None
self._copy_to_his(db, row)
row.quantity = quantity
row.amount = (quantity * row.unit_price).quantize(Decimal("0.01"))
row.update_by = operator.get("user_id")
row.update_at = datetime.now()
db.commit()
return row
def soft_delete(self, db: Session, cart_id: str, client_id: str, operator: dict) -> bool:
row = self.get(db, cart_id)
if not row or row.client_id != client_id:
return False
self._copy_to_his(db, row)
row.is_del = 1
row.update_by = operator.get("user_id")
row.update_at = datetime.now()
db.commit()
return True
def clear(self, db: Session, client_id: str, operator: dict) -> int:
rows = self.list_by_client(db, client_id)
n = 0
for row in rows:
self._copy_to_his(db, row)
row.is_del = 1
row.update_by = operator.get("user_id")
row.update_at = datetime.now()
n += 1
if n:
db.commit()
return n
def batch_delete(self, db: Session, client_id: str, cart_ids: List[str], operator: dict) -> int:
n = 0
for cid in cart_ids:
if self.soft_delete(db, cid, client_id, operator):
n += 1
return n
client_cart_crud = ClientCartCRUD()
+66
View File
@@ -0,0 +1,66 @@
"""配送员评价 CRUD"""
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from app.crud.base import CRUDBase
from app.core.utils import generate_id
from app.models.models import BizDelivererReview
class DelivererReviewCRUD(CRUDBase[BizDelivererReview]):
"""配送员评价 CRUD"""
def __init__(self):
super().__init__(BizDelivererReview)
def get_by_order(self, db: Session, order_id: str) -> Optional[BizDelivererReview]:
return db.query(self.model).filter(
and_(
self.model.order_id == order_id,
self.model.is_del == 0
)
).first()
def create_review(
self,
db: Session,
order_id: str,
task_id: Optional[str],
client_id: str,
deliverer_id: str,
score: int,
tags: Optional[List[str]] = None,
content: Optional[str] = None,
is_anonymous: bool = False,
creator: Optional[dict] = None
) -> BizDelivererReview:
data = {
"id": generate_id(),
"order_id": order_id,
"task_id": task_id,
"client_id": client_id,
"deliverer_id": deliverer_id,
"score": score,
"tags": ",".join(tags) if tags else None,
"content": content,
"is_anonymous": is_anonymous,
"create_by": creator.get("user_id") if creator else None,
"create_name": creator.get("real_name") if creator else None
}
return self.create(db, data)
def get_deliverer_rating_stats(self, db: Session, deliverer_id: str) -> dict:
total = db.query(func.count(self.model.id)).filter(
and_(self.model.deliverer_id == deliverer_id, self.model.is_del == 0)
).scalar() or 0
avg_score = db.query(func.avg(self.model.score)).filter(
and_(self.model.deliverer_id == deliverer_id, self.model.is_del == 0)
).scalar()
return {
"total": int(total),
"avgScore": round(float(avg_score), 2) if avg_score is not None else 0.0
}
deliverer_review_crud = DelivererReviewCRUD()
+872
View File
@@ -0,0 +1,872 @@
"""配送任务CRUD"""
import json
from typing import Optional, List
from datetime import datetime, timedelta
from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import and_, func, or_, select, exists
from app.models.models import BizDeliveryTask, BizDeliveryTaskItem, BizDeliveryTaskHis, BizOrder
from app.crud.base import CRUDBase
from app.core.utils import generate_id
class DeliveryTaskCRUD(CRUDBase[BizDeliveryTask]):
"""配送任务CRUD操作"""
@staticmethod
def _sync_legacy_status_from_dual(task: BizDeliveryTask) -> None:
"""根据双状态回写兼容字段 status。"""
ds = (getattr(task, "deliverer_status", None) or "").strip()
ss = (getattr(task, "supplier_prepare_status", None) or "").strip()
if ds == "delivered":
task.status = "completed"
elif ds == "delivering":
task.status = "delivering"
elif ss == "stocking":
task.status = "supplier_stocking"
elif ss == "prepared":
task.status = "supplier_ready"
else:
task.status = "assigned" if task.deliver_id else "pending"
def get_by_order(self, db: Session, order_id: str) -> Optional[BizDeliveryTask]:
"""根据订单ID获取配送任务"""
return db.query(BizDeliveryTask).filter(
and_(
BizDeliveryTask.order_id == order_id,
BizDeliveryTask.is_del == 0
)
).first()
def get_multi_with_filters(
self,
db: Session,
region_id: str = None,
supplier_id: str = None,
status: str = None,
supplier_prepare_status: str = None,
order_no: str = None,
deliver_id: str = None,
keyword: str = None,
skip: int = 0,
limit: int = 10
) -> tuple[List[BizDeliveryTask], int]:
"""多条件查询配送任务"""
query = db.query(BizDeliveryTask).filter(BizDeliveryTask.is_del == 0)
if region_id:
query = query.filter(BizDeliveryTask.region_id == region_id)
if supplier_id:
query = query.filter(BizDeliveryTask.supplier_id == supplier_id)
if supplier_prepare_status:
parts = [s.strip() for s in supplier_prepare_status.split(",") if s.strip()]
if parts:
if len(parts) > 1:
query = query.filter(BizDeliveryTask.supplier_prepare_status.in_(parts))
else:
p0 = parts[0]
if p0 == "pending_prepare":
query = query.filter(
or_(
BizDeliveryTask.supplier_prepare_status == "pending_prepare",
BizDeliveryTask.supplier_prepare_status.is_(None),
)
)
else:
query = query.filter(BizDeliveryTask.supplier_prepare_status == p0)
elif status:
parts = [s.strip() for s in status.split(",") if s.strip()]
if parts:
if len(parts) > 1:
query = query.filter(BizDeliveryTask.status.in_(parts))
else:
query = query.filter(BizDeliveryTask.status == parts[0])
if order_no:
query = query.filter(BizDeliveryTask.task_no.like(f"%{order_no}%"))
if deliver_id:
query = query.filter(BizDeliveryTask.deliver_id == deliver_id)
if keyword:
kw = (keyword or "").strip()
if kw:
pattern = f"%{kw}%"
order_match = exists(
select(1).select_from(BizOrder).where(
and_(
BizOrder.id == BizDeliveryTask.order_id,
BizOrder.is_del == 0,
or_(
BizOrder.order_no.like(pattern),
BizOrder.contact_person.like(pattern),
BizOrder.contact_phone.like(pattern),
),
)
)
)
query = query.filter(
or_(
BizDeliveryTask.task_no.like(pattern),
BizDeliveryTask.order_id.like(pattern),
order_match,
)
)
total = query.count()
tasks = query.order_by(BizDeliveryTask.create_at.desc()).offset(skip).limit(limit).all()
return tasks, total
def get_by_deliver(
self,
db: Session,
deliver_id: str,
status: str = None,
deliverer_status: str = None,
skip: int = 0,
limit: int = 10
) -> tuple[List[BizDeliveryTask], int]:
"""根据配送员获取配送任务。优先 deliverer_status(小程序);否则按兼容字段 status。"""
query = db.query(BizDeliveryTask).filter(
and_(
BizDeliveryTask.deliver_id == deliver_id,
BizDeliveryTask.is_del == 0,
BizDeliveryTask.status != "cancelled",
)
)
if deliverer_status:
parts = [s.strip() for s in deliverer_status.split(",") if s.strip()]
if parts:
if len(parts) > 1:
query = query.filter(BizDeliveryTask.deliverer_status.in_(parts))
elif parts[0] == "pending_pickup":
query = query.filter(
or_(
BizDeliveryTask.deliverer_status == "pending_pickup",
BizDeliveryTask.deliverer_status.is_(None),
)
)
else:
query = query.filter(BizDeliveryTask.deliverer_status == parts[0])
elif status:
parts = [s.strip() for s in status.split(",") if s.strip()]
if parts:
if len(parts) > 1:
query = query.filter(BizDeliveryTask.status.in_(parts))
else:
query = query.filter(BizDeliveryTask.status == parts[0])
total = query.count()
tasks = query.order_by(BizDeliveryTask.create_at.desc()).offset(skip).limit(limit).all()
return tasks, total
def get_by_supplier(
self,
db: Session,
supplier_id: str,
status: str = None,
skip: int = 0,
limit: int = 10
) -> tuple[List[BizDeliveryTask], int]:
"""根据供应商获取配送任务"""
query = db.query(BizDeliveryTask).filter(
and_(
BizDeliveryTask.supplier_id == supplier_id,
BizDeliveryTask.is_del == 0
)
)
if status:
query = query.filter(BizDeliveryTask.status == status)
total = query.count()
tasks = query.order_by(BizDeliveryTask.create_at.desc()).offset(skip).limit(limit).all()
return tasks, total
def get_pending_list(
self,
db: Session,
region_id: str = None,
skip: int = 0,
limit: int = 10
) -> tuple[List[BizDeliveryTask], int]:
"""获取待分配配送的任务"""
query = db.query(BizDeliveryTask).filter(
and_(
BizDeliveryTask.status == "pending",
BizDeliveryTask.is_del == 0
)
)
if region_id:
query = query.filter(BizDeliveryTask.region_id == region_id)
total = query.count()
tasks = query.order_by(BizDeliveryTask.create_at.asc()).offset(skip).limit(limit).all()
return tasks, total
def assign_deliver(
self,
db: Session,
order_id: str,
deliver_id: str,
operator_id: str = None
) -> Optional[BizDeliveryTask]:
"""分配配送员"""
task = self.get_by_order(db, order_id)
if not task:
return None
if task.status != "pending":
return None
task.deliver_id = deliver_id
task.deliverer_status = "pending_pickup"
task.supplier_prepare_status = "pending_prepare"
self._sync_legacy_status_from_dual(task)
task.update_at = datetime.now()
db.commit()
db.refresh(task)
return task
def assign_deliver_batch(
self,
db: Session,
assignments: List[dict]
) -> List[BizDeliveryTask]:
"""批量分配配送员"""
results = []
for assignment in assignments:
order_id = assignment.get("orderId")
deliver_id = assignment.get("deliverId")
task = self.assign_deliver(db, order_id, deliver_id)
if task:
results.append(task)
return results
def update_status(
self,
db: Session,
task_id: str,
new_status: str,
operator_id: str = None,
remark: str = None,
image_urls: List[str] = None,
operator_real_name: str = None,
operator_role: str = "deliver",
) -> Optional[BizDeliveryTask]:
"""更新配送任务状态"""
task = self.get(db, task_id)
if not task:
return None
old_status = task.status
task.status = new_status
task.update_at = datetime.now()
if new_status == "picking":
# 配送员已取货:进入配送中(勿再保留 pending_pickup,否则列表/标题仍显示「待取货」)
task.deliverer_status = "delivering"
task.supplier_prepare_status = "picked_up"
task.pickup_time = datetime.now()
if remark:
task.pickup_remark = remark
if image_urls:
task.pickup_image_url = ",".join(image_urls) if len(image_urls) > 0 else None
elif new_status == "supplier_stocking":
task.supplier_prepare_status = "stocking"
elif new_status == "supplier_ready":
task.supplier_prepare_status = "prepared"
# 备货完成凭证(与配送员取货凭证共用字段,时间线区分为供应商操作)
if remark:
task.pickup_remark = remark
if image_urls:
task.pickup_image_url = ",".join(image_urls) if len(image_urls) > 0 else None
elif new_status == "delivering":
task.deliverer_status = "delivering"
if remark:
task.delivery_remark = remark
if image_urls:
task.delivery_image_url = ",".join(image_urls) if len(image_urls) > 0 else None
elif new_status == "completed":
task.deliverer_status = "delivered"
# 送达确认时间:与模型注释一致;小程序取货后直达 completed,必须在此写入
task.delivery_time = datetime.now()
if remark:
task.delivery_remark = remark
if image_urls:
task.delivery_image_url = ",".join(image_urls) if len(image_urls) > 0 else None
self._sync_legacy_status_from_dual(task)
# 写入订单「操作记录」:legacy task.status 若已为 picking,会与取货入参 new_status 相同而漏记;
# 配送员确认取货时必须仍写入一条。
if task.order_id:
from app.crud.order import order_crud
should_log_order = (old_status != new_status) or new_status == "picking"
if should_log_order:
order_crud._create_status_log(
db,
task.order_id,
f"dt_{old_status}" if old_status is not None else "",
f"dt_{new_status}",
operator_id or "system",
operator_role or "deliver",
reason=remark,
create_name=(operator_real_name or "").strip() or None,
)
db.commit()
db.refresh(task)
if new_status == "delivering":
from app.crud.delivery_warning_resolve import auto_resolve_open_warnings_for_task
auto_resolve_open_warnings_for_task(db, task_id)
return task
def cancel_task(
self,
db: Session,
task_id: str,
reason: str = None,
operator_id: str = None,
operator_real_name: str = None,
) -> Optional[BizDeliveryTask]:
"""取消配送任务"""
task = self.get(db, task_id)
if not task:
return None
if task.status in ["completed", "cancelled"]:
return None
old_status = task.status
task.status = "cancelled"
task.update_at = datetime.now()
if task.order_id:
from app.crud.order import order_crud
order_crud._create_status_log(
db,
task.order_id,
f"dt_{old_status}",
"dt_cancelled",
operator_id or "system",
"admin",
reason=reason,
create_name=(operator_real_name or "").strip() or None,
)
db.commit()
db.refresh(task)
from app.crud.delivery_warning_resolve import auto_resolve_open_warnings_for_task
auto_resolve_open_warnings_for_task(db, task_id)
return task
def reassign(
self,
db: Session,
task_id: str,
new_deliver_id: str,
operator_id: str = None,
operator_real_name: str = None,
) -> Optional[BizDeliveryTask]:
"""重新分配配送员"""
from app.crud.order import order_crud
from app.crud.user import user_crud
task = self.get(db, task_id)
if not task:
return None
if task.status in ["completed", "cancelled"]:
return None
old_deliver_id = task.deliver_id
if old_deliver_id == new_deliver_id:
return task
updater = None
if operator_id:
updater = {"user_id": operator_id, "real_name": operator_real_name}
self.update_with_history(
db,
task_id,
{"deliver_id": new_deliver_id},
updater=updater,
)
task = self.get(db, task_id)
if not task:
return None
def _dn(uid: Optional[str]) -> Optional[str]:
if not uid:
return None
u = user_crud.get(db, uid)
return (u.real_name if u else None) or uid
nu = user_crud.get(db, new_deliver_id)
order_crud._create_status_log(
db,
task.order_id,
"",
"delivery.reassign",
operator_id or "system",
"admin",
reason=json.dumps(
{
"fromDeliverId": old_deliver_id,
"toDeliverId": new_deliver_id,
"fromDeliverName": _dn(old_deliver_id),
"toDeliverName": nu.real_name if nu else new_deliver_id,
},
ensure_ascii=False,
),
create_name=(operator_real_name or "").strip() or None,
)
db.commit()
db.refresh(task)
return task
def get_warning_tasks(
self,
db: Session,
region_id: str = None,
within_minutes: int = 60,
skip: int = 0,
limit: int = 10
) -> tuple[List[BizDeliveryTask], int, int]:
"""获取时效预警任务"""
now = datetime.now()
expire_time_threshold = now + timedelta(minutes=within_minutes)
query = db.query(BizDeliveryTask).filter(
and_(
BizDeliveryTask.status.in_(["pending", "assigned", "picking"]),
BizDeliveryTask.is_del == 0,
BizDeliveryTask.effective_time <= expire_time_threshold
)
)
if region_id:
query = query.filter(BizDeliveryTask.region_id == region_id)
total = query.count()
warning_count = query.filter(
BizDeliveryTask.effective_time <= now + timedelta(minutes=30)
).count()
tasks = query.order_by(BizDeliveryTask.effective_time.asc()).offset(skip).limit(limit).all()
return tasks, total, warning_count
def _generate_task_no(self, db: Session) -> str:
"""生成配送单号"""
today = datetime.now().strftime("%Y%m%d")
prefix = f"DLV{today}"
last_task = db.query(BizDeliveryTask).filter(
BizDeliveryTask.task_no.like(f"{prefix}%")
).order_by(BizDeliveryTask.task_no.desc()).first()
if last_task:
last_no = int(last_task.task_no[-4:])
new_no = last_no + 1
else:
new_no = 1
return f"{prefix}{str(new_no).zfill(4)}"
def create_from_order(
self,
db: Session,
order_id: str,
supplier_id: str,
region_id: str,
creator: dict = None
) -> BizDeliveryTask:
"""从订单创建配送任务"""
task_no = self._generate_task_no(db)
# 设置配送截止时间(默认4小时)
effective_time = datetime.now() + timedelta(hours=4)
task = BizDeliveryTask(
id=generate_id(),
task_no=task_no,
order_id=order_id,
region_id=region_id,
supplier_id=supplier_id,
status="pending",
deliverer_status="pending_pickup",
supplier_prepare_status="pending_prepare",
effective_time=effective_time,
create_by=creator.get("user_id") if creator else None,
create_name=creator.get("real_name") if creator else None
)
db.add(task)
db.flush()
return task
def create_delivery_task_from_order(
self,
db: Session,
order_id: str,
order_items: List[dict],
supplier_id: str,
region_id: str,
creator: dict = None
) -> tuple:
"""从订单创建完整的配送任务(包含明细)"""
from app.crud.order import order_item_crud
# 创建配送任务
task = self.create_from_order(
db, order_id, supplier_id, region_id, creator
)
# 创建配送任务明细
task_items = []
for item_data in order_items:
# 获取订单项信息
order_item = order_item_crud.get(db, item_data.get("order_item_id"))
if not order_item:
continue
task_item = BizDeliveryTaskItem(
id=generate_id(),
task_id=task.id,
order_id=order_id,
order_item_id=item_data.get("order_item_id"),
product_id=item_data.get("product_id"),
client_id=item_data.get("client_id"),
delivery_address=item_data.get("delivery_address"),
contact_person=item_data.get("contact_person"),
contact_phone=item_data.get("contact_phone"),
quantity=Decimal(str(item_data.get("quantity", 0))),
status="pending",
create_by=creator.get("user_id") if creator else None,
create_name=creator.get("real_name") if creator else None
)
db.add(task_item)
task_items.append(task_item)
db.commit()
db.refresh(task)
for item in task_items:
db.refresh(item)
return task, task_items
def get_expiring_tasks(
self,
db: Session,
within_minutes: int = 60,
region_id: str = None
) -> List[BizDeliveryTask]:
"""获取即将过期的配送任务(用于定时任务)"""
now = datetime.now()
expire_threshold = now + timedelta(minutes=within_minutes)
query = db.query(BizDeliveryTask).filter(
and_(
BizDeliveryTask.status.in_(["pending", "assigned", "picking"]),
BizDeliveryTask.is_del == 0,
BizDeliveryTask.effective_time <= expire_threshold,
BizDeliveryTask.effective_time >= now
)
)
if region_id:
query = query.filter(BizDeliveryTask.region_id == region_id)
return query.order_by(BizDeliveryTask.effective_time.asc()).all()
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizDeliveryTask]:
"""更新配送任务并记录历史"""
original = self.get(db, id)
if not original:
return None
his_data = {
"id": generate_id(),
"task_no": original.task_no,
"order_id": original.order_id,
"region_id": original.region_id,
"supplier_id": original.supplier_id,
"deliver_id": original.deliver_id,
"status": original.status,
"deliverer_status": getattr(original, "deliverer_status", None),
"supplier_prepare_status": getattr(original, "supplier_prepare_status", None),
"pickup_time": original.pickup_time,
"pickup_remark": original.pickup_remark,
"pickup_image_url": original.pickup_image_url,
"delivery_time": original.delivery_time,
"delivery_remark": original.delivery_remark,
"delivery_image_url": original.delivery_image_url,
"expire_warning_sent": getattr(original, "expire_warning_sent", False),
"effective_time": original.effective_time,
"dispatch_remark": getattr(original, "dispatch_remark", None),
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BizDeliveryTaskHis(**his_data)
db.add(his)
return self.update(db, id, obj_in, updater)
# =========================================
# 抢单相关方法
# =========================================
def get_pending_snatch_tasks(
self,
db: Session,
skip: int = 0,
limit: int = 10
) -> tuple[List[BizDeliveryTask], int]:
"""获取待抢单的任务(状态为pending且未分配配送员)"""
query = db.query(BizDeliveryTask).filter(
and_(
BizDeliveryTask.status == "pending",
BizDeliveryTask.is_del == 0
)
)
total = query.count()
tasks = query.order_by(BizDeliveryTask.effective_time.asc()).offset(skip).limit(limit).all()
return tasks, total
def count_pending_snatch_tasks(self, db: Session) -> int:
"""统计待抢单的任务数量"""
return db.query(BizDeliveryTask).filter(
and_(
BizDeliveryTask.status == "pending",
BizDeliveryTask.is_del == 0
)
).count()
def assign_to_deliver(
self,
db: Session,
task_id: str,
deliver_id: str,
deliver_name: str = None
) -> bool:
"""分配任务给配送员(用于抢单)"""
task = self.get(db, task_id)
if not task:
return False
if task.status != "pending":
return False
if task.deliver_id is not None:
return False
task.deliver_id = deliver_id
task.deliver_name = deliver_name
task.deliverer_status = "pending_pickup"
task.supplier_prepare_status = "pending_prepare"
self._sync_legacy_status_from_dual(task)
task.update_at = datetime.now()
db.commit()
db.refresh(task)
return True
def get_snatched_by_me(
self,
db: Session,
deliver_id: str,
skip: int = 0,
limit: int = 10
) -> tuple[List[BizDeliveryTask], int]:
"""获取我抢到的任务"""
query = db.query(BizDeliveryTask).filter(
and_(
BizDeliveryTask.deliver_id == deliver_id,
BizDeliveryTask.status.in_(["pending", "assigned"]),
BizDeliveryTask.is_del == 0
)
)
total = query.count()
tasks = query.order_by(BizDeliveryTask.effective_time.asc()).offset(skip).limit(limit).all()
return tasks, total
def count_snatched_by_me(self, db: Session, deliver_id: str) -> int:
"""统计我抢到的任务数量"""
return db.query(BizDeliveryTask).filter(
and_(
BizDeliveryTask.deliver_id == deliver_id,
BizDeliveryTask.status.in_(["pending", "assigned"]),
BizDeliveryTask.is_del == 0
)
).count()
def count_today_snatched(self, db: Session, deliver_id: str, start_time: datetime) -> int:
"""统计今日抢单成功数量"""
return db.query(BizDeliveryTask).filter(
and_(
BizDeliveryTask.deliver_id == deliver_id,
BizDeliveryTask.status.in_(["pending", "assigned", "picking", "delivering", "completed"]),
BizDeliveryTask.is_del == 0,
BizDeliveryTask.update_at >= start_time
)
).count()
def cancel_snatch(self, db: Session, task_id: str) -> bool:
"""取消抢单"""
task = self.get(db, task_id)
if not task:
return False
if task.status not in ["pending", "assigned"]:
return False
task.deliver_id = None
task.deliver_name = None
task.status = "pending"
task.update_at = datetime.now()
db.commit()
db.refresh(task)
return True
def get_history_by_deliver(
self,
db: Session,
deliver_id: str,
start_date: str = None,
end_date: str = None,
skip: int = 0,
limit: int = 10
) -> tuple[List[BizDeliveryTask], int]:
"""获取配送员历史任务(已完成和已取消的)"""
query = db.query(BizDeliveryTask).filter(
and_(
BizDeliveryTask.deliver_id == deliver_id,
BizDeliveryTask.status.in_(["completed", "cancelled"]),
BizDeliveryTask.is_del == 0
)
)
if start_date:
query = query.filter(BizDeliveryTask.create_at >= start_date)
if end_date:
query = query.filter(BizDeliveryTask.create_at <= end_date + " 23:59:59")
total = query.count()
tasks = query.order_by(BizDeliveryTask.delivery_time.desc()).offset(skip).limit(limit).all()
return tasks, total
class DeliveryTaskItemCRUD(CRUDBase[BizDeliveryTaskItem]):
"""配送任务明细CRUD操作"""
def get_by_task(self, db: Session, task_id: str) -> List[BizDeliveryTaskItem]:
"""获取配送任务的所有明细"""
return db.query(BizDeliveryTaskItem).filter(
and_(
BizDeliveryTaskItem.task_id == task_id,
BizDeliveryTaskItem.is_del == 0
)
).all()
def get_delivered_quantity_by_order_item(
self, db: Session, order_id: str
) -> dict[str, float]:
"""按订单行汇总当前配送任务明细上的已送达数量(与 biz_order_item.delivered_quantity 对齐展示用)"""
task = db.query(BizDeliveryTask).filter(
and_(BizDeliveryTask.order_id == order_id, BizDeliveryTask.is_del == 0)
).first()
if not task:
return {}
out: dict[str, float] = {}
for ti in self.get_by_task(db, task.id):
oid = ti.order_item_id
if not oid:
continue
q = float(ti.delivered_quantity or 0)
prev = out.get(oid, 0.0)
if q > prev:
out[oid] = q
return out
def create_items(
self,
db: Session,
task_id: str,
items: List[dict],
creator: dict = None
) -> List[BizDeliveryTaskItem]:
"""批量创建配送任务明细"""
result = []
for item in items:
task_item = BizDeliveryTaskItem(
id=generate_id(),
task_id=task_id,
order_id=item.get("order_id"),
order_item_id=item.get("order_item_id"),
product_id=item.get("product_id"),
client_id=item.get("client_id"),
delivery_address=item.get("delivery_address"),
contact_person=item.get("contact_person"),
contact_phone=item.get("contact_phone"),
quantity=Decimal(str(item.get("quantity", 0))),
status="pending",
create_by=creator.get("user_id") if creator else None,
create_name=creator.get("real_name") if creator else None
)
db.add(task_item)
result.append(task_item)
db.commit()
for item in result:
db.refresh(item)
return result
def update_item_status(
self,
db: Session,
item_id: str,
delivered_quantity: Decimal,
status: str = "delivered"
) -> Optional[BizDeliveryTaskItem]:
"""更新配送明细状态"""
item = self.get(db, item_id)
if not item:
return None
item.delivered_quantity = delivered_quantity
item.status = status
if status == "delivered":
item.delivery_time = datetime.now()
if item.order_item_id:
from app.crud.order import order_item_crud
oi = order_item_crud.get(db, item.order_item_id)
if oi is not None:
oi.delivered_quantity = delivered_quantity
oi.update_at = datetime.now()
db.commit()
db.refresh(item)
return item
delivery_task_crud = DeliveryTaskCRUD(BizDeliveryTask)
delivery_task_item_crud = DeliveryTaskItemCRUD(BizDeliveryTaskItem)
@@ -0,0 +1,55 @@
"""配送预警:任务完成/取消时自动结案(写历史后更新主表)"""
from datetime import datetime
from sqlalchemy.orm import Session
from app.models.models import BizDeliveryWarning, BizDeliveryWarningHis
def auto_resolve_open_warnings_for_task(db: Session, task_id: str) -> int:
"""将某配送任务下未结案预警全部自动处理(配送完成等场景)。"""
warnings = (
db.query(BizDeliveryWarning)
.filter(
BizDeliveryWarning.task_id == task_id,
BizDeliveryWarning.is_del == 0,
BizDeliveryWarning.is_resolved == 0,
)
.all()
)
if not warnings:
return 0
now = datetime.now()
for warning in warnings:
his = BizDeliveryWarningHis(
id=warning.id,
task_id=warning.task_id,
order_id=warning.order_id,
warning_type=warning.warning_type,
urgency_level=warning.urgency_level,
remaining_minutes=warning.remaining_minutes,
message=warning.message,
is_sent=warning.is_sent,
sent_time=warning.sent_time,
is_resolved=warning.is_resolved,
resolved_time=warning.resolved_time,
resolved_by=warning.resolved_by,
resolved_note=warning.resolved_note,
create_at=warning.create_at,
create_by=warning.create_by,
create_name=warning.create_name,
update_at=warning.update_at,
update_by=warning.update_by,
his_create_at=now,
)
db.add(his)
warning.is_resolved = 1
warning.resolved_time = now
warning.resolved_by = "system"
warning.resolved_note = "auto:配送已完成"
warning.update_at = now
warning.update_by = "system"
db.commit()
return len(warnings)
+231
View File
@@ -0,0 +1,231 @@
"""询价单CRUD"""
from typing import Optional, List
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from app.models.models import BizInquiry, BizInquiryItem, BizInquiryItemHis, BizInquiryHis
from app.crud.base import CRUDBase
from app.core.utils import generate_id
class InquiryCRUD(CRUDBase[BizInquiry]):
"""询价单CRUD操作"""
def get_by_region(
self,
db: Session,
region_id: str,
status: str = None,
skip: int = 0,
limit: int = 20
) -> List[BizInquiry]:
"""根据区域获取询价单列表"""
query = db.query(BizInquiry).filter(
and_(
BizInquiry.region_id == region_id,
BizInquiry.is_del == 0
)
)
if status:
query = query.filter(BizInquiry.status == status)
return query.order_by(BizInquiry.create_at.desc()).offset(skip).limit(limit).all()
def get_published_by_region(
self,
db: Session,
region_id: str,
skip: int = 0,
limit: int = 20
) -> List[BizInquiry]:
"""获取区域已发布的询价单列表"""
return self.get_by_region(db, region_id, status="published", skip=skip, limit=limit)
def get_multi_with_filters(
self,
db: Session,
region_id: str = None,
status: str = None,
keyword: str = None,
skip: int = 0,
limit: int = 10
) -> tuple[List[BizInquiry], int]:
"""多条件查询询价单"""
query = db.query(BizInquiry).filter(BizInquiry.is_del == 0)
if region_id:
query = query.filter(BizInquiry.region_id == region_id)
if status:
query = query.filter(BizInquiry.status == status)
if keyword:
query = query.filter(BizInquiry.title.like(f"%{keyword}%"))
total = query.count()
inquiries = query.order_by(BizInquiry.create_at.desc()).offset(skip).limit(limit).all()
return inquiries, total
def publish(self, db: Session, id: str) -> Optional[BizInquiry]:
"""发布询价单"""
inquiry = self.get(db, id)
if inquiry and inquiry.status == "draft":
inquiry.status = "published"
inquiry.publish_time = datetime.now()
db.commit()
db.refresh(inquiry)
return inquiry
def unpublish(self, db: Session, id: str) -> Optional[BizInquiry]:
"""取消发布询价单(published -> draft)"""
inquiry = self.get(db, id)
if inquiry and inquiry.status == "published":
inquiry.status = "draft"
# 保留 publish_time 作为历史痕迹;如需清空可在这里置 None
db.commit()
db.refresh(inquiry)
return inquiry
def get_current_published_by_region(self, db: Session, region_id: str) -> Optional[BizInquiry]:
"""获取区域当前发布中的询价单(最多1条,业务上应保证互斥)"""
return db.query(BizInquiry).filter(
and_(
BizInquiry.region_id == region_id,
BizInquiry.status == "published",
BizInquiry.is_del == 0
)
).order_by(BizInquiry.publish_time.desc(), BizInquiry.create_at.desc()).first()
def close(self, db: Session, id: str) -> Optional[BizInquiry]:
"""关闭询价单"""
inquiry = self.get(db, id)
if inquiry and inquiry.status == "published":
inquiry.status = "closed"
inquiry.close_time = datetime.now()
db.commit()
db.refresh(inquiry)
return inquiry
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizInquiry]:
"""更新询价单并记录历史"""
original = self.get(db, id)
if not original:
return None
his_data = {
"id": original.id,
"region_id": original.region_id,
"title": original.title,
"status": original.status,
"publish_time": original.publish_time,
"close_time": original.close_time,
"remark": original.remark,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BizInquiryHis(**his_data)
db.add(his)
return self.update(db, id, obj_in, updater)
def soft_delete_with_history(self, db: Session, id: str) -> bool:
"""删除询价单并记录历史"""
original = self.get(db, id)
if not original:
return False
his_data = {
"id": original.id,
"region_id": original.region_id,
"title": original.title,
"status": original.status,
"publish_time": original.publish_time,
"close_time": original.close_time,
"remark": original.remark,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BizInquiryHis(**his_data)
db.add(his)
return self.delete(db, id)
class InquiryItemCRUD(CRUDBase[BizInquiryItem]):
"""询价单项CRUD操作"""
def get_by_inquiry(self, db: Session, inquiry_id: str) -> List[BizInquiryItem]:
"""获取询价单的所有询价项"""
return db.query(BizInquiryItem).filter(
and_(
BizInquiryItem.inquiry_id == inquiry_id,
BizInquiryItem.is_del == 0
)
).all()
def get_multi_with_inquiry_ids(
self,
db: Session,
inquiry_ids: List[str]
) -> List[BizInquiryItem]:
"""根据多个询价单ID获取询价项"""
return db.query(BizInquiryItem).filter(
and_(
BizInquiryItem.inquiry_id.in_(inquiry_ids),
BizInquiryItem.is_del == 0
)
).all()
def create_items(
self,
db: Session,
inquiry_id: str,
product_ids: List[str],
creator: dict = None
) -> List[BizInquiryItem]:
"""批量创建询价项"""
items = []
for product_id in product_ids:
item = BizInquiryItem(
id=generate_id(),
inquiry_id=inquiry_id,
product_id=product_id,
create_by=creator.get("user_id") if creator else None,
create_name=creator.get("real_name") if creator else None
)
db.add(item)
items.append(item)
db.commit()
for item in items:
db.refresh(item)
return items
def update_items(
self,
db: Session,
inquiry_id: str,
product_ids: List[str],
creator: dict = None
) -> List[BizInquiryItem]:
"""更新询价项(先删除旧的,再创建新的)"""
existing_items = self.get_by_inquiry(db, inquiry_id)
for item in existing_items:
item.is_del = 1
return self.create_items(db, inquiry_id, product_ids, creator)
def delete_by_inquiry(self, db: Session, inquiry_id: str) -> bool:
"""删除询价单的所有询价项"""
items = self.get_by_inquiry(db, inquiry_id)
for item in items:
item.is_del = 1
db.commit()
return True
inquiry_crud = InquiryCRUD(BizInquiry)
inquiry_item_crud = InquiryItemCRUD(BizInquiryItem)
+314
View File
@@ -0,0 +1,314 @@
"""客户/供应商结算单 CRUD(含 UPDATE 写历史表)"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.models import (
BizOrder,
OrdClientSettlement,
OrdClientSettlementHis,
OrdClientSettlementItem,
OrdClientSettlementItemHis,
OrdSupplierSettlement,
OrdSupplierSettlementHis,
OrdSupplierSettlementItem,
OrdSupplierSettlementItemHis,
)
class OrdClientSettlementCRUD(CRUDBase[OrdClientSettlement]):
def _snapshot_main(self, row: OrdClientSettlement) -> Dict[str, Any]:
return {
"id": row.id,
"settlement_no": row.settlement_no,
"client_id": row.client_id,
"client_name": row.client_name,
"order_count": row.order_count,
"total_amount": row.total_amount,
"voucher_urls": row.voucher_urls,
"remark": row.remark,
"settlement_at": row.settlement_at,
"status": row.status,
"create_at": row.create_at,
"create_by": row.create_by,
"create_name": row.create_name,
"update_at": row.update_at,
"update_by": row.update_by,
}
def get_multi_with_filters(
self,
db: Session,
*,
client_id: Optional[str] = None,
settlement_no: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
region_id: Optional[str] = None,
skip: int = 0,
limit: int = 10,
) -> Tuple[List[OrdClientSettlement], int]:
q = db.query(OrdClientSettlement).filter(OrdClientSettlement.is_del == 0)
if client_id:
q = q.filter(OrdClientSettlement.client_id == client_id)
if settlement_no:
q = q.filter(OrdClientSettlement.settlement_no.like(f"%{settlement_no}%"))
if start_date:
q = q.filter(OrdClientSettlement.settlement_at >= start_date)
if end_date:
q = q.filter(OrdClientSettlement.settlement_at <= end_date)
if region_id:
sub = (
db.query(OrdClientSettlementItem.settlement_id)
.join(BizOrder, BizOrder.id == OrdClientSettlementItem.order_id)
.filter(
OrdClientSettlementItem.is_del == 0,
BizOrder.is_del == 0,
BizOrder.region_id == region_id,
)
.distinct()
)
q = q.filter(OrdClientSettlement.id.in_(sub))
total = q.count()
rows = q.order_by(OrdClientSettlement.settlement_at.desc()).offset(skip).limit(limit).all()
return rows, total
def get_with_items(
self, db: Session, settlement_id: str
) -> Tuple[Optional[OrdClientSettlement], List[OrdClientSettlementItem]]:
main = self.get(db, settlement_id)
if not main or main.is_del == 1:
return None, []
items = (
db.query(OrdClientSettlementItem)
.filter(
OrdClientSettlementItem.settlement_id == settlement_id,
OrdClientSettlementItem.is_del == 0,
)
.order_by(OrdClientSettlementItem.create_at.asc())
.all()
)
return main, items
def belongs_to_region(self, db: Session, settlement_id: str, region_id: str) -> bool:
if not region_id:
return True
return (
db.query(OrdClientSettlementItem.id)
.join(BizOrder, BizOrder.id == OrdClientSettlementItem.order_id)
.filter(
OrdClientSettlementItem.settlement_id == settlement_id,
OrdClientSettlementItem.is_del == 0,
BizOrder.is_del == 0,
BizOrder.region_id == region_id,
)
.first()
is not None
)
def update_with_history(
self,
db: Session,
settlement_id: str,
obj_in: dict,
updater: Optional[dict] = None,
) -> Optional[OrdClientSettlement]:
original = self.get(db, settlement_id)
if not original or original.is_del == 1:
return None
his = OrdClientSettlementHis(**self._snapshot_main(original))
db.add(his)
return self.update(db, settlement_id, obj_in, updater)
class OrdClientSettlementItemCRUD(CRUDBase[OrdClientSettlementItem]):
def _snapshot_item(self, row: OrdClientSettlementItem) -> Dict[str, Any]:
return {
"id": row.id,
"settlement_id": row.settlement_id,
"order_id": row.order_id,
"order_no": row.order_no,
"client_id": row.client_id,
"order_amount": row.order_amount,
"create_at": row.create_at,
"create_by": row.create_by,
"create_name": row.create_name,
"update_at": row.update_at,
"update_by": row.update_by,
}
def update_with_history(
self,
db: Session,
item_id: str,
obj_in: dict,
updater: Optional[dict] = None,
) -> Optional[OrdClientSettlementItem]:
original = self.get(db, item_id)
if not original or original.is_del == 1:
return None
his = OrdClientSettlementItemHis(**self._snapshot_item(original))
db.add(his)
return self.update(db, item_id, obj_in, updater)
def soft_delete_with_history(self, db: Session, item_id: str, updater: Optional[dict] = None) -> bool:
original = self.get(db, item_id)
if not original or original.is_del == 1:
return False
his = OrdClientSettlementItemHis(**self._snapshot_item(original))
db.add(his)
return self.update(db, item_id, {"is_del": 1}, updater) is not None
class OrdSupplierSettlementCRUD(CRUDBase[OrdSupplierSettlement]):
def _snapshot_main(self, row: OrdSupplierSettlement) -> Dict[str, Any]:
return {
"id": row.id,
"settlement_no": row.settlement_no,
"supplier_id": row.supplier_id,
"supplier_name": row.supplier_name,
"order_count": row.order_count,
"total_amount": row.total_amount,
"voucher_urls": row.voucher_urls,
"remark": row.remark,
"settlement_at": row.settlement_at,
"status": row.status,
"create_at": row.create_at,
"create_by": row.create_by,
"create_name": row.create_name,
"update_at": row.update_at,
"update_by": row.update_by,
}
def get_multi_with_filters(
self,
db: Session,
*,
supplier_id: Optional[str] = None,
settlement_no: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
region_id: Optional[str] = None,
skip: int = 0,
limit: int = 10,
) -> Tuple[List[OrdSupplierSettlement], int]:
q = db.query(OrdSupplierSettlement).filter(OrdSupplierSettlement.is_del == 0)
if supplier_id:
q = q.filter(OrdSupplierSettlement.supplier_id == supplier_id)
if settlement_no:
q = q.filter(OrdSupplierSettlement.settlement_no.like(f"%{settlement_no}%"))
if start_date:
q = q.filter(OrdSupplierSettlement.settlement_at >= start_date)
if end_date:
q = q.filter(OrdSupplierSettlement.settlement_at <= end_date)
if region_id:
sub = (
db.query(OrdSupplierSettlementItem.settlement_id)
.join(BizOrder, BizOrder.id == OrdSupplierSettlementItem.order_id)
.filter(
OrdSupplierSettlementItem.is_del == 0,
BizOrder.is_del == 0,
BizOrder.region_id == region_id,
)
.distinct()
)
q = q.filter(OrdSupplierSettlement.id.in_(sub))
total = q.count()
rows = q.order_by(OrdSupplierSettlement.settlement_at.desc()).offset(skip).limit(limit).all()
return rows, total
def get_with_items(
self, db: Session, settlement_id: str
) -> Tuple[Optional[OrdSupplierSettlement], List[OrdSupplierSettlementItem]]:
main = self.get(db, settlement_id)
if not main or main.is_del == 1:
return None, []
items = (
db.query(OrdSupplierSettlementItem)
.filter(
OrdSupplierSettlementItem.settlement_id == settlement_id,
OrdSupplierSettlementItem.is_del == 0,
)
.order_by(OrdSupplierSettlementItem.create_at.asc())
.all()
)
return main, items
def belongs_to_region(self, db: Session, settlement_id: str, region_id: str) -> bool:
if not region_id:
return True
return (
db.query(OrdSupplierSettlementItem.id)
.join(BizOrder, BizOrder.id == OrdSupplierSettlementItem.order_id)
.filter(
OrdSupplierSettlementItem.settlement_id == settlement_id,
OrdSupplierSettlementItem.is_del == 0,
BizOrder.is_del == 0,
BizOrder.region_id == region_id,
)
.first()
is not None
)
def update_with_history(
self,
db: Session,
settlement_id: str,
obj_in: dict,
updater: Optional[dict] = None,
) -> Optional[OrdSupplierSettlement]:
original = self.get(db, settlement_id)
if not original or original.is_del == 1:
return None
his = OrdSupplierSettlementHis(**self._snapshot_main(original))
db.add(his)
return self.update(db, settlement_id, obj_in, updater)
class OrdSupplierSettlementItemCRUD(CRUDBase[OrdSupplierSettlementItem]):
def _snapshot_item(self, row: OrdSupplierSettlementItem) -> Dict[str, Any]:
return {
"id": row.id,
"settlement_id": row.settlement_id,
"order_id": row.order_id,
"order_no": row.order_no,
"supplier_id": row.supplier_id,
"order_amount": row.order_amount,
"create_at": row.create_at,
"create_by": row.create_by,
"create_name": row.create_name,
"update_at": row.update_at,
"update_by": row.update_by,
}
def update_with_history(
self,
db: Session,
item_id: str,
obj_in: dict,
updater: Optional[dict] = None,
) -> Optional[OrdSupplierSettlementItem]:
original = self.get(db, item_id)
if not original or original.is_del == 1:
return None
his = OrdSupplierSettlementItemHis(**self._snapshot_item(original))
db.add(his)
return self.update(db, item_id, obj_in, updater)
def soft_delete_with_history(self, db: Session, item_id: str, updater: Optional[dict] = None) -> bool:
original = self.get(db, item_id)
if not original or original.is_del == 1:
return False
his = OrdSupplierSettlementItemHis(**self._snapshot_item(original))
db.add(his)
return self.update(db, item_id, {"is_del": 1}, updater) is not None
ord_client_settlement_crud = OrdClientSettlementCRUD(OrdClientSettlement)
ord_client_settlement_item_crud = OrdClientSettlementItemCRUD(OrdClientSettlementItem)
ord_supplier_settlement_crud = OrdSupplierSettlementCRUD(OrdSupplierSettlement)
ord_supplier_settlement_item_crud = OrdSupplierSettlementItemCRUD(OrdSupplierSettlementItem)
File diff suppressed because it is too large. Load diff
+148
View File
@@ -0,0 +1,148 @@
"""订单调整记录 CRUD"""
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.core.utils import generate_id
from app.core.database import Base
from app.crud.base import CRUDBase
from app.models.models import BizOrderAdjust, BizOrderAdjustHis
class OrderAdjustCRUD(CRUDBase[BizOrderAdjust]):
"""订单调整记录CRUD"""
def __init__(self):
super().__init__(BizOrderAdjust)
def create_adjust(
self,
db: Session,
order_id: str,
order_item_id: str,
adjust_type: str,
before_quantity: float,
after_quantity: float,
before_amount: float,
after_amount: float,
reason: str,
operator_id: str,
operator_name: str,
substitute_product_id: str = None,
create_by: str = None,
create_name: str = None
) -> BizOrderAdjust:
"""创建订单调整记录"""
data = {
"id": generate_id(),
"order_id": order_id,
"order_item_id": order_item_id,
"adjust_type": adjust_type,
"before_quantity": before_quantity,
"after_quantity": after_quantity,
"before_amount": before_amount,
"after_amount": after_amount,
"difference_amount": before_amount - after_amount,
"reason": reason,
"operator_id": operator_id,
"operator_name": operator_name,
"substitute_product_id": substitute_product_id,
"create_by": create_by or operator_id,
"create_name": create_name or operator_name
}
return self.create(db, data)
def get_by_order(self, db: Session, order_id: str) -> List[BizOrderAdjust]:
"""获取订单的所有调整记录"""
return db.query(self.model).filter(
and_(
self.model.order_id == order_id,
self.model.is_del == 0
)
).order_by(self.model.create_at.desc()).all()
def get_by_order_item(self, db: Session, order_item_id: str) -> List[BizOrderAdjust]:
"""获取订单项的所有调整记录"""
return db.query(self.model).filter(
and_(
self.model.order_item_id == order_item_id,
self.model.is_del == 0
)
).order_by(self.model.create_at.desc()).all()
def get_total_adjust_amount(self, db: Session, order_id: str) -> float:
"""获取订单的总调整金额(负数表示退款)"""
records = self.get_by_order(db, order_id)
return sum(r.difference_amount or 0 for r in records)
def get_list(
self,
db: Session,
page: int = 1,
pageSize: int = 10,
keyword: Optional[str] = None,
adjust_type: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None
) -> tuple:
"""获取订单调整列表(支持分页和搜索)"""
query = db.query(self.model).filter(self.model.is_del == 0)
# 关键词搜索(搜索订单号)
if keyword:
query = query.filter(self.model.order_id.like(f"%{keyword}%"))
# 调整类型筛选
if adjust_type:
query = query.filter(self.model.adjust_type == adjust_type)
# 日期范围筛选
if start_date:
query = query.filter(self.model.create_at >= start_date)
if end_date:
query = query.filter(self.model.create_at <= f"{end_date} 23:59:59")
# 获取总数
total = query.count()
# 分页
offset = (page - 1) * pageSize
records = query.order_by(self.model.create_at.desc()).offset(offset).limit(pageSize).all()
return records, total
def create_adjust_history(
self,
db: Session,
original_record: BizOrderAdjust,
operator_id: str,
operator_name: str
) -> BizOrderAdjustHis:
"""创建订单调整历史记录"""
from app.crud.base import HistoryCRUD
history_crud = HistoryCRUD(BizOrderAdjustHis)
data = {
"id": generate_id(),
"order_id": original_record.order_id,
"order_item_id": original_record.order_item_id,
"adjust_type": original_record.adjust_type,
"before_quantity": original_record.before_quantity,
"after_quantity": original_record.after_quantity,
"before_amount": original_record.before_amount,
"after_amount": original_record.after_amount,
"difference_amount": original_record.difference_amount,
"reason": original_record.reason,
"substitute_product_id": original_record.substitute_product_id,
"operator_id": operator_id,
"operator_name": operator_name,
"create_by": original_record.create_by,
"create_name": original_record.create_name
}
return history_crud.create(db, data)
# 全局实例
order_adjust_crud = OrderAdjustCRUD()
+44
View File
@@ -0,0 +1,44 @@
"""密码历史CRUD"""
from typing import Optional, List
from sqlalchemy.orm import Session
from app.models.models import SysPasswordHistory
from app.crud.base import CRUDBase
from app.core.utils import generate_id
class PasswordHistoryCRUD(CRUDBase[SysPasswordHistory]):
"""密码历史CRUD操作"""
def create_password_history(
self,
db: Session,
user_id: str,
operation_type: str,
operator_id: str = None,
operator_name: str = None,
ip_address: str = None
) -> SysPasswordHistory:
"""创建密码修改历史"""
history = SysPasswordHistory(
id=generate_id(),
user_id=user_id,
operation_type=operation_type,
operator_id=operator_id,
operator_name=operator_name,
ip_address=ip_address
)
db.add(history)
db.commit()
db.refresh(history)
return history
def get_user_history(self, db: Session, user_id: str, limit: int = 5) -> List[SysPasswordHistory]:
"""获取用户密码修改历史"""
return db.query(SysPasswordHistory).filter(
SysPasswordHistory.user_id == user_id,
SysPasswordHistory.is_del == 0
).order_by(SysPasswordHistory.create_at.desc()).limit(limit).all()
password_history_crud = PasswordHistoryCRUD(SysPasswordHistory)
+387
View File
@@ -0,0 +1,387 @@
"""价格清单CRUD"""
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from app.models.models import BizPriceList, BizPriceListItem, BizPriceListHis
from app.crud.base import CRUDBase
from app.core.utils import generate_id
class PriceListCRUD(CRUDBase[BizPriceList]):
"""价格清单CRUD操作"""
def get_by_region(self, db: Session, region_id: str) -> List[BizPriceList]:
"""根据区域获取价格清单列表"""
return db.query(BizPriceList).filter(
and_(
BizPriceList.region_id == region_id,
BizPriceList.is_del == 0
)
).order_by(BizPriceList.create_at.desc()).all()
def get_active(self, db: Session, region_id: str) -> Optional[BizPriceList]:
"""获取区域当前生效的价格清单"""
return db.query(BizPriceList).filter(
and_(
BizPriceList.region_id == region_id,
BizPriceList.status == "active",
BizPriceList.is_del == 0
)
).first()
def get_multi_with_filters(
self,
db: Session,
region_id: str = None,
supplier_id: str = None,
status: str = None,
skip: int = 0,
limit: int = 10
) -> tuple[List[BizPriceList], int]:
"""多条件查询价格清单"""
query = db.query(BizPriceList).filter(BizPriceList.is_del == 0)
if region_id:
query = query.filter(BizPriceList.region_id == region_id)
if supplier_id:
query = query.filter(BizPriceList.supplier_id == supplier_id)
if status:
query = query.filter(BizPriceList.status == status)
total = query.count()
price_lists = query.order_by(BizPriceList.create_at.desc()).offset(skip).limit(limit).all()
return price_lists, total
def create_with_items(
self,
db: Session,
obj_in: dict,
items: List[dict],
creator: dict = None
) -> BizPriceList:
"""创建价格清单及其明细"""
# 计算版本号
version = 1
if obj_in.get("region_id"):
existing = db.query(BizPriceList).filter(
and_(
BizPriceList.region_id == obj_in["region_id"],
BizPriceList.is_del == 0
)
).order_by(BizPriceList.version.desc()).first()
if existing:
version = existing.version + 1
price_list = BizPriceList(
id=generate_id(),
region_id=obj_in["region_id"],
supplier_id=obj_in["supplier_id"],
version=version,
status="draft",
effective_time=obj_in.get("effective_time"),
remark=obj_in.get("remark"),
create_by=creator.get("user_id") if creator else None,
create_name=creator.get("real_name") if creator else None
)
db.add(price_list)
db.flush()
# 创建价格明细
for item in items:
price_item = BizPriceListItem(
id=generate_id(),
price_list_id=price_list.id,
product_id=item.get("product_id"),
unit_id=item.get("unit_id"),
price=Decimal(str(item.get("price", 0))),
create_by=creator.get("user_id") if creator else None,
create_name=creator.get("real_name") if creator else None
)
db.add(price_item)
db.commit()
db.refresh(price_list)
return price_list
def update_with_items(
self,
db: Session,
id: str,
obj_in: dict,
items: List[dict] = None,
updater: dict = None
) -> Optional[BizPriceList]:
"""更新价格清单及其明细(仅草稿状态可编辑)"""
price_list = self.get(db, id)
if not price_list:
return None
if price_list.status != "draft":
return None
# 更新主表
if obj_in.get("effective_time") is not None:
price_list.effective_time = obj_in["effective_time"]
if obj_in.get("remark") is not None:
price_list.remark = obj_in["remark"]
if updater:
price_list.update_by = updater.get("user_id")
# 如果有新的价格项,则替换
if items is not None:
# 删除旧的价格项
db.query(BizPriceListItem).filter(
BizPriceListItem.price_list_id == id
).delete()
# 添加新的价格项
for item in items:
price_item = BizPriceListItem(
id=generate_id(),
price_list_id=id,
product_id=item.get("product_id"),
unit_id=item.get("unit_id"),
price=Decimal(str(item.get("price", 0))),
create_by=updater.get("user_id") if updater else None,
create_name=updater.get("real_name") if updater else None
)
db.add(price_item)
db.commit()
db.refresh(price_list)
return price_list
def publish(
self,
db: Session,
id: str,
publisher_id: str,
effective_time: datetime = None,
effective_type: str = "immediate"
) -> Optional[BizPriceList]:
"""发布价格清单"""
price_list = self.get(db, id)
if not price_list:
return None
if price_list.status != "draft":
return None
# 如果是立即生效,设为active状态
if effective_type == "immediate":
# 先将其他生效中的价格清单设为失效
existing_active = db.query(BizPriceList).filter(
and_(
BizPriceList.region_id == price_list.region_id,
BizPriceList.status == "active",
BizPriceList.id != id,
BizPriceList.is_del == 0
)
).all()
for pl in existing_active:
pl.status = "inactive"
price_list.status = "active"
price_list.effective_time = datetime.now()
else:
price_list.status = "pending"
if effective_time:
price_list.effective_time = effective_time
price_list.published_by = publisher_id
price_list.published_time = datetime.now()
db.commit()
db.refresh(price_list)
return price_list
def invalidate(
self,
db: Session,
id: str
) -> Optional[BizPriceList]:
"""使价格清单失效"""
price_list = self.get(db, id)
if not price_list:
return None
if price_list.status in ["active", "pending"]:
price_list.status = "inactive"
db.commit()
db.refresh(price_list)
return price_list
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizPriceList]:
"""更新价格清单并记录历史"""
original = self.get(db, id)
if not original:
return None
his_data = {
"id": original.id,
"region_id": original.region_id,
"supplier_id": original.supplier_id,
"version": original.version,
"status": original.status,
"effective_time": original.effective_time,
"published_by": original.published_by,
"published_time": original.published_time,
"remark": original.remark,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BizPriceListHis(**his_data)
db.add(his)
return self.update(db, id, obj_in, updater)
def soft_delete_with_history(self, db: Session, id: str) -> bool:
"""删除价格清单并记录历史"""
original = self.get(db, id)
if not original:
return False
his_data = {
"id": original.id,
"region_id": original.region_id,
"supplier_id": original.supplier_id,
"version": original.version,
"status": original.status,
"effective_time": original.effective_time,
"published_by": original.published_by,
"published_time": original.published_time,
"remark": original.remark,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BizPriceListHis(**his_data)
db.add(his)
return self.delete(db, id)
class PriceListItemCRUD(CRUDBase[BizPriceListItem]):
"""价格清单项CRUD操作"""
def get_by_price_list(self, db: Session, price_list_id: str) -> List[BizPriceListItem]:
"""获取价格清单的所有明细"""
return db.query(BizPriceListItem).filter(
and_(
BizPriceListItem.price_list_id == price_list_id,
BizPriceListItem.is_del == 0
)
).all()
def get_multi_by_price_lists(
self,
db: Session,
price_list_ids: List[str]
) -> List[BizPriceListItem]:
"""根据多个价格清单ID获取明细"""
return db.query(BizPriceListItem).filter(
and_(
BizPriceListItem.price_list_id.in_(price_list_ids),
BizPriceListItem.is_del == 0
)
).all()
def get_product_price(
self,
db: Session,
region_id: str,
product_id: str
) -> Optional[BizPriceListItem]:
"""获取当前生效价格清单中指定商品的价格"""
# 先获取当前生效的价格清单
active_price_list = db.query(BizPriceList).filter(
and_(
BizPriceList.region_id == region_id,
BizPriceList.status == "active",
BizPriceList.is_del == 0
)
).first()
if not active_price_list:
return None
return db.query(BizPriceListItem).filter(
and_(
BizPriceListItem.price_list_id == active_price_list.id,
BizPriceListItem.product_id == product_id,
BizPriceListItem.is_del == 0
)
).first()
def get_product_price_history(
self,
db: Session,
region_id: str,
product_id: str,
skip: int = 0,
limit: int = 20
) -> tuple[List[dict], int]:
"""获取商品价格历史"""
# 获取所有生效过的价格清单及其明细
price_lists = db.query(BizPriceList).filter(
and_(
BizPriceList.region_id == region_id,
BizPriceList.status.in_(["active", "inactive"]),
BizPriceList.is_del == 0
)
).order_by(BizPriceList.published_time.desc()).all()
price_list_ids = [pl.id for pl in price_lists]
items = db.query(BizPriceListItem).filter(
and_(
BizPriceListItem.price_list_id.in_(price_list_ids),
BizPriceListItem.product_id == product_id,
BizPriceListItem.is_del == 0
)
).all()
# 关联价格清单信息
result = []
for item in items:
price_list = next((pl for pl in price_lists if pl.id == item.price_list_id), None)
if price_list:
result.append({
"price": item.price,
"unit_id": item.unit_id,
"price_list_id": item.price_list_id,
"version": price_list.version,
"published_time": price_list.published_time,
"status": price_list.status
})
total = len(result)
return result[skip:skip+limit], total
def get_by_product_and_price_list(
self,
db: Session,
product_id: str,
price_list_id: str
) -> Optional[BizPriceListItem]:
"""根据商品ID和价格清单ID获取价格项"""
return db.query(BizPriceListItem).filter(
and_(
BizPriceListItem.product_id == product_id,
BizPriceListItem.price_list_id == price_list_id,
BizPriceListItem.is_del == 0
)
).first()
price_list_crud = PriceListCRUD(BizPriceList)
price_list_item_crud = PriceListItemCRUD(BizPriceListItem)
+123
View File
@@ -0,0 +1,123 @@
"""商品CRUD"""
from typing import Optional, List
from sqlalchemy.orm import Session, defer
from sqlalchemy import and_, func, or_
from app.models.models import BasProduct, BasProductHis
from app.crud.base import CRUDBase
class ProductCRUD(CRUDBase[BasProduct]):
"""商品CRUD操作"""
def get_by_name(self, db: Session, name: str) -> Optional[BasProduct]:
"""根据名称获取商品"""
return db.query(BasProduct).filter(
and_(BasProduct.name == name, BasProduct.is_del == 0)
).first()
def get_active_all(self, db: Session) -> List[BasProduct]:
"""获取所有启用的商品"""
return db.query(BasProduct).filter(
and_(BasProduct.is_del == 0, BasProduct.is_active == True)
).order_by(BasProduct.create_at.desc()).all()
def get_multi_by_category(self, db: Session, category_id: str) -> List[BasProduct]:
"""根据分类获取商品"""
return db.query(BasProduct).filter(
and_(BasProduct.category_id == category_id, BasProduct.is_del == 0, BasProduct.is_active == True)
).order_by(BasProduct.create_at.desc()).all()
def get_multi_with_filters(
self,
db: Session,
category_id: str = None,
keyword: str = None,
is_active: bool = None,
skip: int = 0,
limit: int = 10,
*,
defer_detail_content: bool = False,
) -> tuple[List[BasProduct], int]:
"""多条件查询商品"""
filters = [BasProduct.is_del == 0]
if category_id:
filters.append(BasProduct.category_id == category_id)
if is_active is not None:
filters.append(BasProduct.is_active == is_active)
if keyword:
filters.append(
or_(
BasProduct.name.like(f"%{keyword}%"),
BasProduct.description.like(f"%{keyword}%"),
)
)
total = (
db.query(func.count(BasProduct.id))
.filter(*filters)
.order_by(None)
.scalar()
or 0
)
query = db.query(BasProduct).filter(*filters)
if defer_detail_content:
query = query.options(defer(BasProduct.detail_content))
products = query.order_by(BasProduct.create_at.desc()).offset(skip).limit(limit).all()
return products, total
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BasProduct]:
"""更新商品并记录历史"""
original = self.get(db, id)
if not original:
return None
his_data = {
"id": original.id,
"name": original.name,
"category_id": original.category_id,
"unit_id": original.unit_id,
"measure_value": getattr(original, "measure_value", None),
"description": original.description,
"image_url": original.image_url,
"is_active": original.is_active,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BasProductHis(**his_data)
db.add(his)
return self.update(db, id, obj_in, updater)
def soft_delete_with_history(self, db: Session, id: str) -> bool:
"""删除商品并记录历史"""
original = self.get(db, id)
if not original:
return False
his_data = {
"id": original.id,
"name": original.name,
"category_id": original.category_id,
"unit_id": original.unit_id,
"measure_value": getattr(original, "measure_value", None),
"description": original.description,
"image_url": original.image_url,
"is_active": original.is_active,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BasProductHis(**his_data)
db.add(his)
return self.delete(db, id)
product_crud = ProductCRUD(BasProduct)
+73
View File
@@ -0,0 +1,73 @@
"""CRUD - 商品轮播图"""
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase, HistoryCRUD
from app.models.product_banner import BasProductBanner, BasProductBannerHis
from app.schemas.product_banner import ProductBannerCreate, ProductBannerUpdate
class ProductBannerCRUD(CRUDBase[BasProductBanner]):
"""商品轮播图CRUD操作"""
def get_by_product(self, db: Session, product_id: str) -> List[BasProductBanner]:
"""获取商品的所有轮播图"""
return db.query(self.model).filter(
self.model.product_id == product_id,
self.model.is_del == 0
).order_by(self.model.sort_order.asc()).all()
def create_batch(
self,
db: Session,
product_id: str,
items: List[dict],
creator: dict = None
) -> List[BasProductBanner]:
"""批量创建轮播图"""
banners = []
for idx, item in enumerate(items):
item_data = {
"product_id": product_id,
"media_type": item.get("media_type", "image"),
"file_url": item.get("file_url", ""),
"thumbnail_url": item.get("thumbnail_url"),
"sort_order": item.get("sort_order", idx),
}
banner = self.create(db, item_data, creator)
banners.append(banner)
return banners
def update_sort(
self,
db: Session,
items: List[dict]
) -> bool:
"""批量更新排序"""
for item in items:
banner_id = item.get("id")
sort_order = item.get("sort_order")
if banner_id and sort_order is not None:
db.query(self.model).filter(self.model.id == banner_id).update({
"sort_order": sort_order
})
db.commit()
return True
def delete_by_product(self, db: Session, product_id: str) -> int:
"""删除商品的所有轮播图"""
count = db.query(self.model).filter(
self.model.product_id == product_id,
self.model.is_del == 0
).update({"is_del": 1})
db.commit()
return count
class ProductBannerHisCRUD(HistoryCRUD[BasProductBannerHis]):
"""商品轮播图历史CRUD操作"""
pass
product_banner = ProductBannerCRUD(BasProductBanner)
product_banner_his = ProductBannerHisCRUD(BasProductBannerHis)
+75
View File
@@ -0,0 +1,75 @@
"""CRUD - 商品详情媒体"""
from typing import List
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase, HistoryCRUD
from app.models.product_detail_media import BasProductDetailMedia, BasProductDetailMediaHis
from app.schemas.product_detail_media import ProductDetailMediaCreate, ProductDetailMediaUpdate
class ProductDetailMediaCRUD(CRUDBase[BasProductDetailMedia]):
"""商品详情媒体CRUD操作"""
def get_by_product(self, db: Session, product_id: str) -> List[BasProductDetailMedia]:
"""获取商品的所有详情媒体"""
return db.query(self.model).filter(
self.model.product_id == product_id,
self.model.is_del == 0
).order_by(self.model.sort_order.asc()).all()
def create_batch(
self,
db: Session,
product_id: str,
items: List[dict],
creator: dict = None
) -> List[BasProductDetailMedia]:
"""批量创建详情媒体"""
media_list = []
for idx, item in enumerate(items):
item_data = {
"product_id": product_id,
"content_type": item.get("content_type", "image"),
"content_text": item.get("content_text"),
"file_url": item.get("file_url"),
"thumbnail_url": item.get("thumbnail_url"),
"description": item.get("description"),
"sort_order": item.get("sort_order", idx),
}
media = self.create(db, item_data, creator)
media_list.append(media)
return media_list
def update_sort(
self,
db: Session,
items: List[dict]
) -> bool:
"""批量更新排序"""
for item in items:
media_id = item.get("id")
sort_order = item.get("sort_order")
if media_id and sort_order is not None:
db.query(self.model).filter(self.model.id == media_id).update({
"sort_order": sort_order
})
db.commit()
return True
def delete_by_product(self, db: Session, product_id: str) -> int:
"""删除商品的所有详情媒体"""
count = db.query(self.model).filter(
self.model.product_id == product_id,
self.model.is_del == 0
).update({"is_del": 1})
db.commit()
return count
class ProductDetailMediaHisCRUD(HistoryCRUD[BasProductDetailMediaHis]):
"""商品详情媒体历史CRUD操作"""
pass
product_detail_media = ProductDetailMediaCRUD(BasProductDetailMedia)
product_detail_media_his = ProductDetailMediaHisCRUD(BasProductDetailMediaHis)
+83
View File
@@ -0,0 +1,83 @@
"""CRUD - 商品参数模板"""
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import or_
from app.crud.base import CRUDBase, HistoryCRUD
from app.models.product_parameter import BasProductParameter, BasProductParameterHis
from app.schemas.product_parameter import ProductParameterCreate, ProductParameterUpdate
class ProductParameterCRUD(CRUDBase[BasProductParameter]):
"""商品参数模板CRUD操作"""
def get_multi_paged(
self,
db: Session,
skip: int = 0,
limit: int = 10,
is_active: Optional[int] = None,
keyword: Optional[str] = None,
order_by: str = "sort_order",
order_type: str = "asc"
) -> tuple:
"""分页获取参数列表"""
query = db.query(self.model).filter(self.model.is_del == 0)
if is_active is not None:
query = query.filter(self.model.is_active == is_active)
if keyword:
query = query.filter(
or_(
self.model.name.like(f"%{keyword}%"),
self.model.code.like(f"%{keyword}%")
)
)
total = query.count()
order_column = getattr(self.model, order_by, None)
if order_column:
if order_type == "asc":
query = query.order_by(order_column.asc())
else:
query = query.order_by(order_column.desc())
else:
query = query.order_by(self.model.sort_order.asc())
items = query.offset(skip).limit(limit).all()
return items, total
def get_active_all(self, db: Session) -> List[BasProductParameter]:
"""获取所有启用的参数"""
return db.query(self.model).filter(
self.model.is_del == 0,
self.model.is_active == 1
).order_by(self.model.sort_order.asc()).all()
def get_by_code(self, db: Session, code: str) -> Optional[BasProductParameter]:
"""根据编码获取参数"""
return db.query(self.model).filter(
self.model.code == code,
self.model.is_del == 0
).first()
def code_exists(self, db: Session, code: str, exclude_id: str = None) -> bool:
"""检查编码是否已存在"""
query = db.query(self.model).filter(
self.model.code == code,
self.model.is_del == 0
)
if exclude_id:
query = query.filter(self.model.id != exclude_id)
return query.first() is not None
class ProductParameterHisCRUD(HistoryCRUD[BasProductParameterHis]):
"""商品参数模板历史CRUD操作"""
pass
product_parameter = ProductParameterCRUD(BasProductParameter)
product_parameter_his = ProductParameterHisCRUD(BasProductParameterHis)
+117
View File
@@ -0,0 +1,117 @@
"""CRUD - 商品参数值"""
from typing import List
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.crud.base import CRUDBase, HistoryCRUD
from app.models.product_parameter_value import BasProductParameterValue, BasProductParameterValueHis
from app.schemas.product_parameter_value import ProductParameterValueCreate
class ProductParameterValueCRUD(CRUDBase[BasProductParameterValue]):
"""商品参数值CRUD操作"""
def get_by_product(self, db: Session, product_id: str) -> List[BasProductParameterValue]:
"""获取商品的所有参数值"""
return db.query(self.model).filter(
self.model.product_id == product_id,
self.model.is_del == 0
).order_by(self.model.sort_order.asc()).all()
def get_by_product_with_detail(self, db: Session, product_id: str) -> List[dict]:
"""获取商品的所有参数值(带参数详情)"""
from app.models.product_parameter import BasProductParameter
results = db.query(
BasProductParameterValue,
BasProductParameter
).join(
BasProductParameter,
and_(
BasProductParameterValue.parameter_id == BasProductParameter.id,
BasProductParameter.is_del == 0
)
).filter(
BasProductParameterValue.product_id == product_id,
BasProductParameterValue.is_del == 0
).order_by(
BasProductParameterValue.sort_order.asc()
).all()
return [
{
"id": ppv.id,
"product_id": ppv.product_id,
"parameter_id": ppv.parameter_id,
"parameter_name": pp.name,
"parameter_code": pp.code,
"param_type": pp.param_type,
"options": pp.options,
"placeholder": pp.placeholder,
"param_value": ppv.param_value,
"sort_order": ppv.sort_order,
}
for ppv, pp in results
]
def batch_save(
self,
db: Session,
product_id: str,
values: List[dict],
creator: dict = None
) -> List[BasProductParameterValue]:
"""批量保存商品参数值"""
from app.models.product_parameter import BasProductParameter
# 先删除原有的参数值
db.query(self.model).filter(
self.model.product_id == product_id,
self.model.is_del == 0
).update({"is_del": 1})
# 创建新的参数值
saved_values = []
for idx, value_item in enumerate(values):
parameter_id = value_item.get("parameter_id")
param_value = value_item.get("param_value")
# 获取参数信息
param = db.query(BasProductParameter).filter(
BasProductParameter.id == parameter_id,
BasProductParameter.is_del == 0
).first()
if param:
item_data = {
"product_id": product_id,
"parameter_id": parameter_id,
"parameter_name": param.name,
"parameter_code": param.code,
"param_type": param.param_type,
"param_value": param_value,
"sort_order": idx,
}
pv = self.create(db, item_data, creator)
saved_values.append(pv)
db.commit()
return saved_values
def delete_by_product(self, db: Session, product_id: str) -> int:
"""删除商品的所有参数值"""
count = db.query(self.model).filter(
self.model.product_id == product_id,
self.model.is_del == 0
).update({"is_del": 1})
db.commit()
return count
class ProductParameterValueHisCRUD(HistoryCRUD[BasProductParameterValueHis]):
"""商品参数值历史CRUD操作"""
pass
product_parameter_value = ProductParameterValueCRUD(BasProductParameterValue)
product_parameter_value_his = ProductParameterValueHisCRUD(BasProductParameterValueHis)
+242
View File
@@ -0,0 +1,242 @@
"""CRUD - 售后评价"""
from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from app.crud.base import CRUDBase, HistoryCRUD
from app.models.product_review import BizProductReview, BizProductReviewHis
from app.schemas.product_review import ProductReviewCreate, ProductReviewReplyRequest
class ProductReviewCRUD(CRUDBase[BizProductReview]):
"""售后评价CRUD操作"""
def get_by_product(
self,
db: Session,
product_id: str,
skip: int = 0,
limit: int = 10,
status: str = "approved",
has_media: int = None
) -> tuple:
"""获取商品的评价列表"""
query = db.query(self.model).filter(
self.model.product_id == product_id,
self.model.is_del == 0
)
if status:
query = query.filter(self.model.status == status)
if has_media == 1:
from app.models.product_review_media import BizProductReviewMedia
review_ids_with_media = db.query(BizProductReviewMedia.review_id).filter(
BizProductReviewMedia.is_del == 0
).distinct().subquery()
query = query.filter(self.model.id.in_(review_ids_with_media))
total = query.count()
items = query.order_by(
self.model.is_top.desc(),
self.model.create_at.desc()
).offset(skip).limit(limit).all()
return items, total
def get_admin_list(
self,
db: Session,
skip: int = 0,
limit: int = 10,
product_id: str = None,
product_name: str = None,
user_name: str = None,
status: str = None,
rating: int = None,
start_date: str = None,
end_date: str = None,
) -> tuple:
"""管理员获取评价列表(支持多条件筛选)"""
query = db.query(self.model).filter(self.model.is_del == 0)
if product_id:
query = query.filter(self.model.product_id == product_id)
if user_name:
query = query.filter(self.model.user_name.like(f"%{user_name}%"))
if status:
query = query.filter(self.model.status == status)
if rating:
query = query.filter(self.model.rating == rating)
if start_date:
query = query.filter(self.model.create_at >= start_date)
if end_date:
query = query.filter(self.model.create_at <= end_date)
total = query.count()
items = query.order_by(
self.model.is_top.desc(),
self.model.create_at.desc()
).offset(skip).limit(limit).all()
return items, total
def get_statistics(self, db: Session, product_id: str = None) -> dict:
"""获取评价统计"""
query = db.query(self.model).filter(
self.model.is_del == 0,
self.model.status == "approved"
)
if product_id:
query = query.filter(self.model.product_id == product_id)
total_count = query.count()
if total_count > 0:
avg_rating = db.query(func.avg(self.model.rating)).filter(
self.model.is_del == 0,
self.model.status == "approved",
self.model.product_id == product_id if product_id else True
).scalar() or 0
else:
avg_rating = 0
rating_dist = {}
for r in range(1, 6):
count = db.query(self.model).filter(
self.model.is_del == 0,
self.model.status == "approved",
self.model.rating == r,
self.model.product_id == product_id if product_id else True
).count()
rating_dist[str(r)] = count
pending_count = db.query(self.model).filter(
self.model.is_del == 0,
self.model.status == "pending",
self.model.product_id == product_id if product_id else True
).count()
rejected_count = db.query(self.model).filter(
self.model.is_del == 0,
self.model.status == "rejected",
self.model.product_id == product_id if product_id else True
).count()
return {
"total_count": total_count,
"pending_count": pending_count,
"approved_count": total_count,
"rejected_count": rejected_count,
"avg_rating": round(float(avg_rating), 1),
"rating_distribution": rating_dist,
}
def reply(
self,
db: Session,
review_id: str,
reply_content: str,
updater: dict = None
) -> Optional[BizProductReview]:
"""回复评价"""
review = self.get(db, review_id)
if review:
review.reply_content = reply_content
review.reply_time = func.now()
if updater:
review.reply_by = updater.get("user_id")
review.reply_by_name = updater.get("real_name")
db.commit()
db.refresh(review)
return review
def reject(
self,
db: Session,
review_id: str,
reject_reason: str,
updater: dict = None
) -> Optional[BizProductReview]:
"""拒绝评价"""
review = self.get(db, review_id)
if review:
review.status = "rejected"
review.reject_reason = reject_reason
db.commit()
db.refresh(review)
return review
def approve(
self,
db: Session,
review_id: str
) -> Optional[BizProductReview]:
"""审核通过评价"""
review = self.get(db, review_id)
if review:
review.status = "approved"
db.commit()
db.refresh(review)
return review
def toggle_top(
self,
db: Session,
review_id: str,
is_top: int
) -> Optional[BizProductReview]:
"""切换置顶状态"""
review = self.get(db, review_id)
if review:
review.is_top = is_top
db.commit()
db.refresh(review)
return review
def like(
self,
db: Session,
review_id: str
) -> Optional[BizProductReview]:
"""点赞评价"""
review = self.get(db, review_id)
if review:
review.like_count = (review.like_count or 0) + 1
db.commit()
db.refresh(review)
return review
def user_can_review(
self,
db: Session,
user_id: str,
product_id: str,
order_id: str = None
) -> bool:
"""检查用户是否已评价过该商品"""
query = db.query(self.model).filter(
self.model.user_id == user_id,
self.model.product_id == product_id,
self.model.is_del == 0
)
if order_id:
query = query.filter(self.model.order_id == order_id)
return query.first() is not None
class ProductReviewHisCRUD(HistoryCRUD[BizProductReviewHis]):
"""售后评价历史CRUD操作"""
pass
product_review = ProductReviewCRUD(BizProductReview)
product_review_his = ProductReviewHisCRUD(BizProductReviewHis)
+63
View File
@@ -0,0 +1,63 @@
"""CRUD - 评价媒体"""
from typing import List
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase, HistoryCRUD
from app.models.product_review_media import BizProductReviewMedia, BizProductReviewMediaHis
class ProductReviewMediaCRUD(CRUDBase[BizProductReviewMedia]):
"""评价媒体CRUD操作"""
def get_by_review(self, db: Session, review_id: str) -> List[BizProductReviewMedia]:
"""获取评价的所有媒体"""
return db.query(self.model).filter(
self.model.review_id == review_id,
self.model.is_del == 0
).order_by(self.model.sort_order.asc()).all()
def create_batch(
self,
db: Session,
review_id: str,
items: List[dict],
creator: dict = None
) -> List[BizProductReviewMedia]:
"""批量创建评价媒体"""
media_list = []
for idx, item in enumerate(items):
item_data = {
"review_id": review_id,
"media_type": item.get("media_type", "image"),
"file_url": item.get("file_url", ""),
"thumbnail_url": item.get("thumbnail_url"),
"sort_order": item.get("sort_order", idx),
}
media = self.create(db, item_data, creator)
media_list.append(media)
return media_list
def delete_by_review(self, db: Session, review_id: str) -> int:
"""删除评价的所有媒体"""
count = db.query(self.model).filter(
self.model.review_id == review_id,
self.model.is_del == 0
).update({"is_del": 1})
db.commit()
return count
def get_media_count(self, db: Session, review_id: str) -> int:
"""获取评价的媒体数量"""
return db.query(self.model).filter(
self.model.review_id == review_id,
self.model.is_del == 0
).count()
class ProductReviewMediaHisCRUD(HistoryCRUD[BizProductReviewMediaHis]):
"""评价媒体历史CRUD操作"""
pass
product_review_media = ProductReviewMediaCRUD(BizProductReviewMedia)
product_review_media_his = ProductReviewMediaHisCRUD(BizProductReviewMediaHis)
+201
View File
@@ -0,0 +1,201 @@
"""报价单CRUD"""
from decimal import Decimal
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from app.models.models import BizQuotation, BizQuotationItem, BizQuotationHis, BizQuotationItemHis
from app.crud.base import CRUDBase
from app.core.utils import generate_id
class QuotationCRUD(CRUDBase[BizQuotation]):
"""报价单CRUD操作"""
def get_by_inquiry(self, db: Session, inquiry_id: str) -> List[BizQuotation]:
"""根据询价单获取报价列表"""
return db.query(BizQuotation).filter(
and_(
BizQuotation.inquiry_id == inquiry_id,
BizQuotation.is_del == 0
)
).all()
def get_by_supplier(self, db: Session, supplier_id: str) -> List[BizQuotation]:
"""根据供应商获取报价列表"""
return db.query(BizQuotation).filter(
and_(
BizQuotation.supplier_id == supplier_id,
BizQuotation.is_del == 0
)
).order_by(BizQuotation.create_at.desc()).all()
def get_by_inquiry_and_supplier(
self,
db: Session,
inquiry_id: str,
supplier_id: str
) -> Optional[BizQuotation]:
"""根据询价单和供应商获取报价"""
return db.query(BizQuotation).filter(
and_(
BizQuotation.inquiry_id == inquiry_id,
BizQuotation.supplier_id == supplier_id,
BizQuotation.is_del == 0
)
).first()
def get_latest_by_inquiry_and_supplier(
self,
db: Session,
inquiry_id: str,
supplier_id: str
) -> Optional[BizQuotation]:
"""获取某供应商在某询价单下最新一次报价"""
return db.query(BizQuotation).filter(
and_(
BizQuotation.inquiry_id == inquiry_id,
BizQuotation.supplier_id == supplier_id,
BizQuotation.is_del == 0
)
).order_by(BizQuotation.create_at.desc()).first()
def get_multi_with_filters(
self,
db: Session,
inquiry_id: str = None,
supplier_id: str = None,
region_id: str = None,
status: str = None,
skip: int = 0,
limit: int = 10
) -> tuple[List[BizQuotation], int]:
"""多条件查询报价单"""
query = db.query(BizQuotation).filter(BizQuotation.is_del == 0)
if inquiry_id:
query = query.filter(BizQuotation.inquiry_id == inquiry_id)
if supplier_id:
query = query.filter(BizQuotation.supplier_id == supplier_id)
if region_id:
query = query.filter(BizQuotation.region_id == region_id)
if status:
query = query.filter(BizQuotation.status == status)
total = query.count()
quotations = query.order_by(BizQuotation.create_at.desc()).offset(skip).limit(limit).all()
return quotations, total
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizQuotation]:
"""更新报价单并记录历史"""
original = self.get(db, id)
if not original:
return None
his_data = {
"id": original.id,
"inquiry_id": original.inquiry_id,
"supplier_id": original.supplier_id,
"region_id": original.region_id,
"status": original.status,
"total_amount": original.total_amount,
"submit_time": original.submit_time,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BizQuotationHis(**his_data)
db.add(his)
return self.update(db, id, obj_in, updater)
def soft_delete_with_history(self, db: Session, id: str) -> bool:
"""删除报价单并记录历史"""
original = self.get(db, id)
if not original:
return False
his_data = {
"id": original.id,
"inquiry_id": original.inquiry_id,
"supplier_id": original.supplier_id,
"region_id": original.region_id,
"status": original.status,
"total_amount": original.total_amount,
"submit_time": original.submit_time,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BizQuotationHis(**his_data)
db.add(his)
return self.delete(db, id)
class QuotationItemCRUD(CRUDBase[BizQuotationItem]):
"""报价单项CRUD操作"""
def get_by_quotation(self, db: Session, quotation_id: str) -> List[BizQuotationItem]:
"""获取报价单的所有报价项"""
return db.query(BizQuotationItem).filter(
and_(
BizQuotationItem.quotation_id == quotation_id,
BizQuotationItem.is_del == 0
)
).all()
def get_multi_by_quotations(
self,
db: Session,
quotation_ids: List[str]
) -> List[BizQuotationItem]:
"""根据多个报价单ID获取报价项"""
return db.query(BizQuotationItem).filter(
and_(
BizQuotationItem.quotation_id.in_(quotation_ids),
BizQuotationItem.is_del == 0
)
).all()
def create_items(
self,
db: Session,
quotation_id: str,
items: List[dict],
creator: dict = None
) -> List[BizQuotationItem]:
"""批量创建报价项"""
result = []
for item in items:
quotation_item = BizQuotationItem(
id=generate_id(),
quotation_id=quotation_id,
product_id=item.get("product_id"),
unit_id=item.get("unit_id"),
price=Decimal(str(item.get("price", 0))),
create_by=creator.get("user_id") if creator else None,
create_name=creator.get("real_name") if creator else None
)
db.add(quotation_item)
result.append(quotation_item)
db.commit()
for item in result:
db.refresh(item)
return result
def delete_by_quotation(self, db: Session, quotation_id: str) -> bool:
"""删除报价单的所有报价项"""
items = self.get_by_quotation(db, quotation_id)
for item in items:
item.is_del = 1
db.commit()
return True
quotation_crud = QuotationCRUD(BizQuotation)
quotation_item_crud = QuotationItemCRUD(BizQuotationItem)
+81
View File
@@ -0,0 +1,81 @@
"""区域管理CRUD"""
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.models.models import SysRegion, SysRegionHis
from app.crud.base import CRUDBase
class RegionCRUD(CRUDBase[SysRegion]):
"""区域CRUD操作"""
def get_by_code(self, db: Session, code: str) -> Optional[SysRegion]:
"""根据编码获取区域(含已删除,匹配数据库唯一索引约束)"""
return db.query(SysRegion).filter(SysRegion.code == code).first()
def get_active_all(self, db: Session) -> List[SysRegion]:
"""获取所有启用的区域"""
return db.query(SysRegion).filter(
and_(SysRegion.is_del == 0, SysRegion.is_active == True)
).order_by(SysRegion.sort_order.asc()).all()
def create_with_history(self, db: Session, obj_in: dict, creator: dict = None) -> SysRegion:
"""创建区域并记录历史"""
obj = self.create(db, obj_in, creator)
return obj
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[SysRegion]:
"""更新区域并记录历史"""
original = self.get(db, id)
if not original:
return None
his_data = {
"id": original.id,
"name": original.name,
"code": original.code,
"description": original.description,
"sort_order": original.sort_order,
"expected_delivery_minutes": original.expected_delivery_minutes,
"delivery_warn_advance_minutes": original.delivery_warn_advance_minutes,
"is_active": original.is_active,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = SysRegionHis(**his_data)
db.add(his)
return self.update(db, id, obj_in, updater)
def soft_delete_with_history(self, db: Session, id: str) -> bool:
"""删除区域并记录历史"""
original = self.get(db, id)
if not original:
return False
his_data = {
"id": original.id,
"name": original.name,
"code": original.code,
"description": original.description,
"sort_order": original.sort_order,
"expected_delivery_minutes": original.expected_delivery_minutes,
"delivery_warn_advance_minutes": original.delivery_warn_advance_minutes,
"is_active": original.is_active,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = SysRegionHis(**his_data)
db.add(his)
return self.delete(db, id)
region_crud = RegionCRUD(SysRegion)
+604
View File
@@ -0,0 +1,604 @@
"""结算与退款CRUD"""
from typing import Optional, List, Tuple
from datetime import datetime
from decimal import Decimal
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from app.models.models import BizSettlement, BizSettlementHis, BizRefund, BizRefundHis, BizOrder
from app.crud.base import CRUDBase
from app.crud.order import order_crud
from app.core.utils import generate_id
from app.core.order_status_compat import derive_legacy_status_from_three
class SettlementCRUD(CRUDBase[BizSettlement]):
"""结算记录CRUD操作"""
def get_multi_with_filters(
self,
db: Session,
region_id: str = None,
client_id: str = None,
status: str = None,
start_date: str = None,
end_date: str = None,
skip: int = 0,
limit: int = 10
) -> Tuple[List[BizSettlement], int]:
"""多条件查询结算记录"""
query = db.query(BizSettlement).filter(BizSettlement.is_del == 0)
if region_id:
query = query.filter(BizSettlement.region_id == region_id)
if client_id:
query = query.filter(BizSettlement.client_id == client_id)
if status:
query = query.filter(BizSettlement.status == status)
if start_date:
query = query.filter(BizSettlement.create_at >= start_date)
if end_date:
query = query.filter(BizSettlement.create_at <= end_date)
total = query.count()
settlements = query.order_by(BizSettlement.create_at.desc()).offset(skip).limit(limit).all()
return settlements, total
def get_by_order(self, db: Session, order_id: str) -> List[BizSettlement]:
"""根据订单获取结算记录"""
return db.query(BizSettlement).filter(
and_(
BizSettlement.order_id == order_id,
BizSettlement.is_del == 0
)
).all()
def create_settlement(
self,
db: Session,
order_id: str,
client_id: str,
region_id: str,
amount: Decimal,
settlement_method: str,
settlement_time: datetime = None,
attachment_url: str = None,
note: str = None,
creator: dict = None
) -> BizSettlement:
"""创建结算记录"""
settlement = BizSettlement(
id=generate_id(),
order_id=order_id,
client_id=client_id,
region_id=region_id,
amount=amount,
settlement_method=settlement_method,
settlement_time=settlement_time or datetime.now(),
settlement_file_url=attachment_url,
settlement_note=note,
status="confirmed",
reviewed_by=creator.get("user_id") if creator else None,
reviewed_time=datetime.now(),
create_by=creator.get("user_id") if creator else None,
create_name=creator.get("real_name") if creator else None
)
db.add(settlement)
db.flush()
# 更新订单的已付金额
order = db.query(BizOrder).filter(BizOrder.id == order_id).first()
if order:
order.paid_amount = (order.paid_amount or Decimal("0")) + amount
if order.paid_amount >= order.receivable_amount:
order.paid_amount = order.receivable_amount
order.update_at = datetime.now()
order_crud.sync_pay_fields_from_amounts(order)
db.commit()
db.refresh(settlement)
return settlement
def batch_create_settlements(
self,
db: Session,
order_ids: List[str],
settlement_method: str,
settlement_time: datetime = None,
attachment_url: str = None,
note: str = None,
creator: dict = None
) -> List[BizSettlement]:
"""批量创建结算记录"""
settlements = []
for order_id in order_ids:
order = db.query(BizOrder).filter(
and_(
BizOrder.id == order_id,
BizOrder.is_del == 0
)
).first()
if order and order.status not in ["cancelled", "completed"]:
# 计算未付金额
unpaid_amount = (order.receivable_amount or Decimal("0")) - (order.paid_amount or Decimal("0"))
settlement = BizSettlement(
id=generate_id(),
order_id=order_id,
client_id=order.client_id,
region_id=order.region_id,
amount=unpaid_amount,
settlement_method=settlement_method,
settlement_time=settlement_time or datetime.now(),
settlement_file_url=attachment_url,
settlement_note=note,
status="confirmed",
reviewed_by=creator.get("user_id") if creator else None,
reviewed_time=datetime.now(),
create_by=creator.get("user_id") if creator else None,
create_name=creator.get("real_name") if creator else None
)
db.add(settlement)
settlements.append(settlement)
# 更新订单的已付金额
order.paid_amount = order.receivable_amount
order.update_at = datetime.now()
order_crud.sync_pay_fields_from_amounts(order)
db.commit()
return settlements
def confirm_settlement(
self,
db: Session,
settlement_id: str,
reviewer_id: str,
note: str = None
) -> Optional[BizSettlement]:
"""确认结算"""
settlement = self.get(db, settlement_id)
if not settlement:
return None
if settlement.status != "pending":
return None
settlement.status = "confirmed"
settlement.reviewed_by = reviewer_id
settlement.reviewed_time = datetime.now()
if note:
settlement.settlement_note = note
settlement.update_at = datetime.now()
# 更新订单的已付金额
order = db.query(BizOrder).filter(BizOrder.id == settlement.order_id).first()
if order:
order.paid_amount = (order.paid_amount or Decimal("0")) + settlement.amount
if order.paid_amount >= order.receivable_amount:
order.paid_amount = order.receivable_amount
order.update_at = datetime.now()
order_crud.sync_pay_fields_from_amounts(order)
db.commit()
db.refresh(settlement)
return settlement
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizSettlement]:
"""更新结算记录并记录历史"""
original = self.get(db, id)
if not original:
return None
his_data = {
"id": original.id,
"order_id": original.order_id,
"client_id": original.client_id,
"region_id": original.region_id,
"amount": original.amount,
"settlement_method": original.settlement_method,
"settlement_time": original.settlement_time,
"settlement_file_url": original.settlement_file_url,
"settlement_note": original.settlement_note,
"status": original.status,
"reviewed_by": original.reviewed_by,
"reviewed_time": original.reviewed_time,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BizSettlementHis(**his_data)
db.add(his)
return self.update(db, id, obj_in, updater)
def get_client_summary(
self,
db: Session,
region_id: str = None,
client_id: str = None,
skip: int = 0,
limit: int = 10
) -> Tuple[List[dict], int]:
"""获取客户待结算汇总"""
query = db.query(
BizOrder.client_id,
func.count(BizOrder.id).label("order_count"),
func.sum(BizOrder.receivable_amount - BizOrder.paid_amount).label("total_unpaid")
).filter(
and_(
BizOrder.is_del == 0,
BizOrder.receivable_amount > BizOrder.paid_amount,
BizOrder.status.notin_(["cancelled"])
)
)
if region_id:
query = query.filter(BizOrder.region_id == region_id)
if client_id:
query = query.filter(BizOrder.client_id == client_id)
query = query.group_by(BizOrder.client_id)
total = query.count()
results = query.offset(skip).limit(limit).all()
return [{"client_id": r.client_id, "order_count": r.order_count, "total_unpaid": r.total_unpaid or Decimal("0")} for r in results], total
def get_statistics(
self,
db: Session,
region_id: str = None,
start_date: str = None,
end_date: str = None
) -> dict:
"""获取结算统计数据"""
# 待结算金额(使用单条SQL查询,避免N+1问题)
pending_query = db.query(
func.count(BizOrder.id).label("order_count"),
func.coalesce(func.sum(BizOrder.receivable_amount - BizOrder.paid_amount), 0).label("pending_amount")
).filter(
and_(
BizOrder.is_del == 0,
BizOrder.receivable_amount > BizOrder.paid_amount,
BizOrder.status.notin_(["cancelled", "completed"])
)
)
if region_id:
pending_query = pending_query.filter(BizOrder.region_id == region_id)
pending_result = pending_query.first()
# 已结算金额
settled_query = db.query(
func.count(BizSettlement.id).label("settlement_count"),
func.coalesce(func.sum(BizSettlement.amount), 0).label("settled_amount")
).filter(
and_(
BizSettlement.is_del == 0,
BizSettlement.status == "confirmed"
)
)
if region_id:
settled_query = settled_query.filter(BizSettlement.region_id == region_id)
if start_date:
settled_query = settled_query.filter(BizSettlement.reviewed_time >= start_date)
if end_date:
settled_query = settled_query.filter(BizSettlement.reviewed_time <= end_date)
settled_result = settled_query.first()
# 待处理退款数
refund_pending_count = db.query(func.count(BizRefund.id)).filter(
and_(
BizRefund.is_del == 0,
BizRefund.status.in_(["pending", "processing"])
)
)
if region_id:
refund_pending_count = refund_pending_count.join(
BizOrder, BizOrder.id == BizRefund.order_id
).filter(BizOrder.region_id == region_id)
refund_pending_count = refund_pending_count.scalar() or 0
return {
"pendingOrderCount": pending_result.order_count or 0,
"pendingAmount": float(pending_result.pending_amount or 0),
"settledCount": settled_result.settlement_count or 0,
"settledAmount": float(settled_result.settled_amount or 0),
"refundPendingCount": refund_pending_count
}
class RefundCRUD(CRUDBase[BizRefund]):
"""退款记录CRUD操作"""
def get_multi_with_filters(
self,
db: Session,
region_id: str = None,
order_id: str = None,
order_no: str = None,
status: str = None,
refund_method: str = None,
start_date: str = None,
end_date: str = None,
skip: int = 0,
limit: int = 10
) -> Tuple[List[BizRefund], int]:
"""多条件查询退款记录"""
query = db.query(BizRefund).filter(BizRefund.is_del == 0)
if region_id:
# 需要关联订单获取区域
query = query.join(BizOrder, BizOrder.id == BizRefund.order_id).filter(
BizOrder.region_id == region_id
)
if order_id:
query = query.filter(BizRefund.order_id == order_id)
if order_no:
query = query.join(BizOrder, BizOrder.id == BizRefund.order_id).filter(
BizOrder.order_no.like(f"%{order_no}%")
)
if status:
query = query.filter(BizRefund.status == status)
if refund_method:
query = query.filter(BizRefund.refund_method == refund_method)
if start_date:
query = query.filter(BizRefund.create_at >= start_date)
if end_date:
query = query.filter(BizRefund.create_at <= end_date)
total = query.count()
refunds = query.order_by(BizRefund.create_at.desc()).offset(skip).limit(limit).all()
return refunds, total
def get_by_order(self, db: Session, order_id: str) -> List[BizRefund]:
"""根据订单获取退款记录"""
return db.query(BizRefund).filter(
and_(
BizRefund.order_id == order_id,
BizRefund.is_del == 0
)
).all()
def get_by_refund_no(self, db: Session, refund_no: str) -> Optional[BizRefund]:
"""根据退款单号获取退款记录"""
return db.query(BizRefund).filter(
and_(
BizRefund.refund_no == refund_no,
BizRefund.is_del == 0
)
).first()
def create_refund(
self,
db: Session,
order_id: str,
refund_amount: Decimal,
refund_method: str,
reason: str = None,
creator: dict = None
) -> BizRefund:
"""创建退款记录"""
refund_no = self._generate_refund_no(db)
refund = BizRefund(
id=generate_id(),
order_id=order_id,
refund_no=refund_no,
refund_amount=refund_amount,
refund_method=refund_method,
refund_result="processing",
status="pending",
create_by=creator.get("user_id") if creator else None,
create_name=creator.get("real_name") if creator else None
)
db.add(refund)
# 更新订单的退款金额和状态
order = db.query(BizOrder).filter(BizOrder.id == order_id).first()
if order:
order.refund_amount = (order.refund_amount or Decimal("0")) + refund_amount
order.update_at = datetime.now()
# 如果是微信退款,更新订单状态为退款中
if refund_method == "wechat":
order.pay_status = "refunding"
order.status = derive_legacy_status_from_three(
order_status=order.order_status,
delivery_status=order.delivery_status,
pay_status=order.pay_status,
)
db.commit()
db.refresh(refund)
return refund
def process_refund(
self,
db: Session,
refund_id: str,
refund_result: str,
refund_no_wechat: str = None,
updater: dict = None
) -> Optional[BizRefund]:
"""处理退款(成功/失败)"""
refund = self.get(db, refund_id)
if not refund:
return None
refund.refund_result = refund_result
refund.refund_time = datetime.now()
if refund_no_wechat:
refund.refund_no_wechat = refund_no_wechat
if refund_result == "success":
refund.status = "completed"
# 更新订单退款金额
order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
if order:
order.refund_amount = (order.refund_amount or Decimal("0")) + refund.refund_amount
order.paid_amount = max(Decimal("0"), (order.paid_amount or Decimal("0")) - refund.refund_amount)
order.pay_status = "refunded" if (order.paid_amount or Decimal("0")) <= Decimal("0") else "paid"
order.status = derive_legacy_status_from_three(
order_status=order.order_status,
delivery_status=order.delivery_status,
pay_status=order.pay_status,
)
order.update_at = datetime.now()
# 如果退款金额等于已支付金额,订单变为已取消
if order.refund_amount >= (order.total_amount - order.paid_amount + order.refund_amount):
# 保留部分退款状态
pass
else:
refund.status = "failed"
# 恢复订单状态
order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
if order and order.pay_status == "refunding":
order.pay_status = "paid" if (order.paid_amount or Decimal("0")) > Decimal("0") else "pending_payment"
order.status = derive_legacy_status_from_three(
order_status=order.order_status,
delivery_status=order.delivery_status,
pay_status=order.pay_status,
)
refund.update_at = datetime.now()
db.commit()
db.refresh(refund)
return refund
def handle_refund_failure(
self,
db: Session,
refund_id: str,
fail_reason: str
) -> Optional[BizRefund]:
"""处理退款失败"""
refund = self.get(db, refund_id)
if not refund:
return None
refund.refund_result = "failed"
refund.refund_fail_reason = fail_reason
refund.refund_time = datetime.now()
refund.status = "failed"
refund.update_at = datetime.now()
# 恢复订单状态
order = db.query(BizOrder).filter(BizOrder.id == refund.order_id).first()
if order and order.pay_status == "refunding":
order.pay_status = "paid" if (order.paid_amount or Decimal("0")) > Decimal("0") else "pending_payment"
order.status = derive_legacy_status_from_three(
order_status=order.order_status,
delivery_status=order.delivery_status,
pay_status=order.pay_status,
)
order.update_at = datetime.now()
db.commit()
db.refresh(refund)
return refund
def _generate_refund_no(self, db: Session) -> str:
"""生成退款单号"""
today = datetime.now().strftime("%Y%m%d")
prefix = f"REF{today}"
last_refund = db.query(BizRefund).filter(
BizRefund.refund_no.like(f"{prefix}%")
).order_by(BizRefund.refund_no.desc()).first()
if last_refund:
last_no = int(last_refund.refund_no[-4:])
new_no = last_no + 1
else:
new_no = 1
return f"{prefix}{str(new_no).zfill(4)}"
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BizRefund]:
"""更新退款记录并记录历史"""
original = self.get(db, id)
if not original:
return None
his_data = {
"id": original.id,
"order_id": original.order_id,
"refund_no": original.refund_no,
"refund_amount": original.refund_amount,
"refund_method": original.refund_method,
"refund_no_wechat": original.refund_no_wechat,
"refund_time": original.refund_time,
"refund_result": original.refund_result,
"refund_fail_reason": original.refund_fail_reason,
"status": original.status,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BizRefundHis(**his_data)
db.add(his)
return self.update(db, id, obj_in, updater)
def get_refund_statistics(
self,
db: Session,
start_date: str = None,
end_date: str = None
) -> dict:
"""获取退款统计数据"""
base_query = db.query(BizRefund).filter(
and_(
BizRefund.is_del == 0,
BizRefund.status == "completed"
)
)
if start_date:
base_query = base_query.filter(BizRefund.refund_time >= start_date)
if end_date:
base_query = base_query.filter(BizRefund.refund_time <= end_date)
# 成功退款金额
success_amount = base_query.filter(
BizRefund.refund_result == "success"
).func.sum(BizRefund.refund_amount).scalar() or Decimal("0")
# 失败退款金额
failed_amount = base_query.filter(
BizRefund.refund_result == "failed"
).func.sum(BizRefund.refund_amount).scalar() or Decimal("0")
# 待处理退款数
pending_count = db.query(func.count(BizRefund.id)).filter(
and_(
BizRefund.is_del == 0,
BizRefund.status.in_(["pending", "processing"])
)
).scalar() or 0
# 退款失败数
failed_count = db.query(func.count(BizRefund.id)).filter(
and_(
BizRefund.is_del == 0,
BizRefund.status == "failed"
)
).scalar() or 0
return {
"successAmount": float(success_amount),
"failedAmount": float(failed_amount),
"pendingCount": pending_count,
"failedCount": failed_count
}
settlement_crud = SettlementCRUD(BizSettlement)
refund_crud = RefundCRUD(BizRefund)
+916
View File
@@ -0,0 +1,916 @@
"""数据统计 CRUD"""
from sqlalchemy.orm import Session
from sqlalchemy import and_, func, desc, extract
from typing import Optional, List, Dict, Any, Tuple
from datetime import datetime, timedelta
from decimal import Decimal
from app.models.models import (
BizOrder, BizOrderItem, BizDeliveryTask,
BizSettlement, BizRefund, BizPriceList, BizPriceListItem,
SysUser, SysRegion, BasProduct,
BizInquiry, BizQuotation, BizDeliveryWarning,
)
class StatisticsCRUD:
"""数据统计CRUD操作"""
@staticmethod
def get_order_statistics(
db: Session,
region_id: str = None,
start_date: str = None,
end_date: str = None
) -> Dict[str, Any]:
"""获取订单统计数据"""
query = db.query(BizOrder).filter(BizOrder.is_del == 0)
if region_id:
query = query.filter(BizOrder.region_id == region_id)
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(BizOrder.create_at >= start_dt)
except:
pass
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
end_dt = end_dt.replace(hour=23, minute=59, second=59)
query = query.filter(BizOrder.create_at <= end_dt)
except:
pass
orders = query.all()
total_orders = len(orders)
pending_payment = sum(1 for o in orders if o.status == "pending_payment")
pending_dispatch = sum(1 for o in orders if getattr(o, "delivery_status", None) == "pending_dispatch")
delivering = sum(1 for o in orders if o.status == "delivering")
completed = sum(1 for o in orders if o.status == "completed")
cancelled = sum(1 for o in orders if o.status == "cancelled")
total_amount = sum(float(o.total_amount or 0) for o in orders)
paid_amount = sum(float(o.paid_amount or 0) for o in orders)
refund_amount = sum(float(o.refund_amount or 0) for o in orders)
return {
"totalOrders": total_orders,
"pendingPayment": pending_payment,
"pendingDelivery": pending_dispatch,
"delivering": delivering,
"completed": completed,
"cancelled": cancelled,
"totalAmount": round(total_amount, 2),
"paidAmount": round(paid_amount, 2),
"refundAmount": round(refund_amount, 2)
}
@staticmethod
def get_order_trend(
db: Session,
region_id: str = None,
days: int = 7,
include_inactive_regions: bool = False,
) -> List[Dict[str, Any]]:
"""获取订单趋势数据(按日统计)"""
result = []
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
region_active_clause = [] if include_inactive_regions else [SysRegion.is_active == True]
for i in range(days - 1, -1, -1):
day_start = today - timedelta(days=i)
day_end = day_start + timedelta(days=1)
query = (
db.query(BizOrder)
.join(SysRegion, BizOrder.region_id == SysRegion.id)
.filter(
and_(
BizOrder.is_del == 0,
BizOrder.create_at >= day_start,
BizOrder.create_at < day_end,
SysRegion.is_del == 0,
*region_active_clause,
)
)
)
if region_id:
query = query.filter(BizOrder.region_id == region_id)
orders = query.all()
order_count = len(orders)
order_amount = sum(float(o.total_amount or 0) for o in orders)
result.append({
"date": day_start.strftime("%Y-%m-%d"),
"orderCount": order_count,
"orderAmount": round(order_amount, 2)
})
return result
@staticmethod
def get_today_order_stats_by_region(
db: Session,
region_id: str = None,
include_inactive_regions: bool = False,
) -> List[Dict[str, Any]]:
"""今日各区域营业额(元)、订单笔数;列出未删除的区域,无单则为 0。"""
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
day_end = today + timedelta(days=1)
region_active_clause = [] if include_inactive_regions else [SysRegion.is_active == True]
reg_q = db.query(SysRegion).filter(
SysRegion.is_del == 0,
)
if region_id:
reg_q = reg_q.filter(SysRegion.id == region_id)
regions = reg_q.order_by(SysRegion.sort_order, SysRegion.name).all()
agg_q = (
db.query(
BizOrder.region_id,
func.count(BizOrder.id).label("order_count"),
func.coalesce(func.sum(BizOrder.total_amount), 0).label("order_amount"),
)
.join(SysRegion, BizOrder.region_id == SysRegion.id)
.filter(
BizOrder.is_del == 0,
BizOrder.create_at >= today,
BizOrder.create_at < day_end,
SysRegion.is_del == 0,
*region_active_clause,
)
.group_by(BizOrder.region_id)
)
if region_id:
agg_q = agg_q.filter(BizOrder.region_id == region_id)
agg_map: Dict[str, Any] = {r.region_id: r for r in agg_q.all() if r.region_id}
out: List[Dict[str, Any]] = []
for reg in regions:
row = agg_map.get(reg.id)
cnt = int(row.order_count or 0) if row else 0
amt = float(row.order_amount or 0) if row else 0.0
out.append({
"regionId": reg.id,
"regionName": reg.name or reg.id,
"orderCount": cnt,
"orderAmount": round(amt, 2),
})
return out
@staticmethod
def get_today_order_stats_by_client(
db: Session,
region_id: str = None,
limit: int = 30,
include_inactive_regions: bool = False,
) -> List[Dict[str, Any]]:
"""今日各下单客户:下单金额合计(元)、商品数量合计(订单行数量之和);按金额降序,最多 limit 条。"""
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
day_end = today + timedelta(days=1)
region_active_clause = [] if include_inactive_regions else [SysRegion.is_active == True]
amt_q = (
db.query(
BizOrder.client_id,
func.coalesce(func.sum(BizOrder.total_amount), 0).label("order_amount"),
)
.join(SysRegion, BizOrder.region_id == SysRegion.id)
.filter(
BizOrder.is_del == 0,
BizOrder.create_at >= today,
BizOrder.create_at < day_end,
SysRegion.is_del == 0,
*region_active_clause,
)
)
if region_id:
amt_q = amt_q.filter(BizOrder.region_id == region_id)
amt_rows = amt_q.group_by(BizOrder.client_id).all()
qty_q = (
db.query(
BizOrder.client_id,
func.coalesce(func.sum(BizOrderItem.quantity), 0).label("qty"),
)
.select_from(BizOrderItem)
.join(
BizOrder,
and_(
BizOrder.id == BizOrderItem.order_id,
BizOrder.is_del == 0,
BizOrder.create_at >= today,
BizOrder.create_at < day_end,
),
)
.join(SysRegion, BizOrder.region_id == SysRegion.id)
.filter(BizOrderItem.is_del == 0)
.filter(SysRegion.is_del == 0, *region_active_clause)
)
if region_id:
qty_q = qty_q.filter(BizOrder.region_id == region_id)
qty_rows = qty_q.group_by(BizOrder.client_id).all()
qty_map: Dict[str, float] = {
r.client_id: float(r.qty or 0) for r in qty_rows if r.client_id
}
merged: List[Dict[str, Any]] = []
for r in amt_rows:
cid = r.client_id
if not cid:
continue
merged.append({
"clientId": cid,
"orderAmount": round(float(r.order_amount or 0), 2),
"productQuantity": round(float(qty_map.get(cid, 0) or 0), 4),
})
merged.sort(key=lambda x: -x["orderAmount"])
merged = merged[: max(1, min(int(limit or 30), 100))]
client_ids = [m["clientId"] for m in merged]
names: Dict[str, str] = {}
if client_ids:
for u in (
db.query(SysUser)
.filter(and_(SysUser.is_del == 0, SysUser.id.in_(client_ids)))
.all()
):
names[u.id] = (u.real_name or u.phone or u.username or u.id) or u.id
out: List[Dict[str, Any]] = []
for m in merged:
cid = m["clientId"]
nm = names.get(cid, cid)
out.append({
"clientId": cid,
"clientName": nm,
"orderAmount": m["orderAmount"],
"productQuantity": m["productQuantity"],
})
return out
@staticmethod
def get_financial_statistics(
db: Session,
region_id: str = None,
start_date: str = None,
end_date: str = None
) -> Dict[str, Any]:
"""获取财务统计数据"""
order_query = db.query(BizOrder).filter(BizOrder.is_del == 0)
settlement_query = db.query(BizSettlement).filter(BizSettlement.is_del == 0)
refund_query = db.query(BizRefund).filter(BizRefund.is_del == 0)
if region_id:
order_query = order_query.filter(BizOrder.region_id == region_id)
settlement_query = settlement_query.filter(BizSettlement.region_id == region_id)
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
order_query = order_query.filter(BizOrder.create_at >= start_dt)
settlement_query = settlement_query.filter(BizSettlement.create_at >= start_dt)
refund_query = refund_query.filter(BizRefund.create_at >= start_dt)
except:
pass
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
end_dt = end_dt.replace(hour=23, minute=59, second=59)
order_query = order_query.filter(BizOrder.create_at <= end_dt)
settlement_query = settlement_query.filter(BizSettlement.create_at <= end_dt)
refund_query = refund_query.filter(BizRefund.create_at <= end_dt)
except:
pass
orders = order_query.all()
settlements = settlement_query.filter(BizSettlement.status == "confirmed").all()
refunds = refund_query.filter(BizRefund.status == "completed").all()
total_revenue = sum(float(o.total_amount or 0) for o in orders)
total_received = sum(float(s.amount or 0) for s in settlements)
total_refund = sum(float(r.refund_amount or 0) for r in refunds)
net_revenue = total_received - total_refund
return {
"totalRevenue": round(total_revenue, 2),
"totalReceived": round(total_received, 2),
"totalRefund": round(total_refund, 2),
"netRevenue": round(net_revenue, 2),
"orderCount": len(orders),
"settlementCount": len(settlements),
"refundCount": len(refunds)
}
@staticmethod
def get_financial_trend(
db: Session,
region_id: str = None,
days: int = 7
) -> List[Dict[str, Any]]:
"""获取财务趋势数据(按日统计)"""
result = []
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
for i in range(days - 1, -1, -1):
day_start = today - timedelta(days=i)
day_end = day_start + timedelta(days=1)
settlement_query = db.query(BizSettlement).filter(
and_(
BizSettlement.is_del == 0,
BizSettlement.status == "confirmed",
BizSettlement.create_at >= day_start,
BizSettlement.create_at < day_end
)
)
refund_query = db.query(BizRefund).filter(
and_(
BizRefund.is_del == 0,
BizRefund.status == "completed",
BizRefund.create_at >= day_start,
BizRefund.create_at < day_end
)
)
if region_id:
settlement_query = settlement_query.filter(BizSettlement.region_id == region_id)
settlements = settlement_query.all()
refunds = refund_query.all()
received = sum(float(s.amount or 0) for s in settlements)
refund = sum(float(r.refund_amount or 0) for r in refunds)
result.append({
"date": day_start.strftime("%Y-%m-%d"),
"received": round(received, 2),
"refund": round(refund, 2),
"net": round(received - refund, 2)
})
return result
@staticmethod
def get_delivery_statistics(
db: Session,
region_id: str = None,
start_date: str = None,
end_date: str = None
) -> Dict[str, Any]:
"""获取配送统计数据"""
query = db.query(BizDeliveryTask).filter(BizDeliveryTask.is_del == 0)
if region_id:
query = query.filter(BizDeliveryTask.region_id == region_id)
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(BizDeliveryTask.create_at >= start_dt)
except:
pass
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
end_dt = end_dt.replace(hour=23, minute=59, second=59)
query = query.filter(BizDeliveryTask.create_at <= end_dt)
except:
pass
tasks = query.all()
pending = sum(1 for t in tasks if t.status == "pending")
picked_up = sum(1 for t in tasks if t.status == "picked_up")
delivering = sum(1 for t in tasks if t.status == "delivering")
completed = sum(1 for t in tasks if t.status == "completed")
cancelled = sum(1 for t in tasks if t.status == "cancelled")
return {
"totalTasks": len(tasks),
"pending": pending,
"pickedUp": picked_up,
"delivering": delivering,
"completed": completed,
"cancelled": cancelled
}
@staticmethod
def get_product_statistics(
db: Session,
region_id: str = None,
start_date: str = None,
end_date: str = None,
limit: int = 10
) -> List[Dict[str, Any]]:
"""获取商品销售排行"""
query = db.query(
BizOrderItem.product_id,
func.sum(BizOrderItem.quantity).label("total_quantity"),
func.sum(BizOrderItem.amount).label("total_amount")
).join(
BizOrder,
and_(
BizOrder.id == BizOrderItem.order_id,
BizOrder.is_del == 0,
BizOrder.status.in_(["completed", "delivering", "pending_confirm"])
)
).filter(
BizOrderItem.is_del == 0
)
if region_id:
query = query.filter(BizOrder.region_id == region_id)
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(BizOrder.create_at >= start_dt)
except:
pass
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
end_dt = end_dt.replace(hour=23, minute=59, second=59)
query = query.filter(BizOrder.create_at <= end_dt)
except:
pass
results = query.group_by(BizOrderItem.product_id).order_by(
desc("total_amount")
).limit(limit).all()
product_stats = []
for r in results:
product = db.query(BasProduct).filter(
and_(BasProduct.id == r.product_id, BasProduct.is_del == 0)
).first()
product_stats.append({
"productId": r.product_id,
"productName": product.name if product else None,
"totalQuantity": float(r.total_quantity or 0),
"totalAmount": float(r.total_amount or 0)
})
return product_stats
@staticmethod
def get_customer_statistics(
db: Session,
region_id: str = None,
start_date: str = None,
end_date: str = None,
limit: int = 10
) -> List[Dict[str, Any]]:
"""获取客户订单排行"""
query = db.query(
BizOrder.client_id,
func.count(BizOrder.id).label("order_count"),
func.sum(BizOrder.total_amount).label("total_amount")
).filter(
and_(
BizOrder.is_del == 0,
BizOrder.status.in_(["completed", "delivering", "pending_confirm", "pending_payment"])
)
)
if region_id:
query = query.filter(BizOrder.region_id == region_id)
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(BizOrder.create_at >= start_dt)
except:
pass
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
end_dt = end_dt.replace(hour=23, minute=59, second=59)
query = query.filter(BizOrder.create_at <= end_dt)
except:
pass
results = query.group_by(BizOrder.client_id).order_by(
desc("total_amount")
).limit(limit).all()
customer_stats = []
for r in results:
customer = db.query(SysUser).filter(
and_(SysUser.id == r.client_id, SysUser.is_del == 0)
).first()
customer_stats.append({
"customerId": r.client_id,
"customerName": customer.real_name if customer else None,
"customerPhone": customer.phone if customer else None,
"orderCount": r.order_count,
"totalAmount": float(r.total_amount or 0)
})
return customer_stats
@staticmethod
def _dashboard_order_region_clause(region_id: str = None):
return [BizOrder.region_id == region_id] if region_id else []
@staticmethod
def get_dashboard_summary(
db: Session,
region_id: str = None,
include_inactive_regions: bool = False,
) -> Dict[str, Any]:
"""获取仪表盘汇总数据(运营工作台;含待办摘要、最新订单、预警数等,字段驼峰)"""
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow_start = today + timedelta(days=1)
month_start = today.replace(day=1)
yesterday_start = today - timedelta(days=1)
region_clause = StatisticsCRUD._dashboard_order_region_clause(region_id)
region_active_clause = [] if include_inactive_regions else [SysRegion.is_active == True]
order_query = (
db.query(BizOrder)
.join(SysRegion, BizOrder.region_id == SysRegion.id)
.filter(
and_(
BizOrder.is_del == 0,
BizOrder.create_at >= month_start,
SysRegion.is_del == 0,
*region_active_clause,
*region_clause,
)
)
)
month_orders = order_query.all()
today_orders = [
o for o in month_orders if o.create_at and o.create_at >= today
]
prev_day_orders = [
o
for o in month_orders
if o.create_at and yesterday_start <= o.create_at < today
]
pending_delivery_q = (
db.query(BizOrder)
.join(SysRegion, BizOrder.region_id == SysRegion.id)
.filter(
and_(
BizOrder.is_del == 0,
BizOrder.delivery_status == "pending_dispatch",
SysRegion.is_del == 0,
*region_active_clause,
*region_clause,
)
)
)
pending_delivery_count = pending_delivery_q.count()
def _delta_hint(cur_n: int, prev_n: int, unit: str) -> str:
d = cur_n - prev_n
if d > 0:
return f"较昨日 +{d}{unit}"
if d < 0:
return f"较昨日 {d}{unit}"
return "与昨日持平"
def _amount_delta_hint(cur_amt: float, prev_amt: float) -> str:
d = round(cur_amt - prev_amt, 2)
if d > 0.0001:
return f"较昨日 +¥{d:,.2f}"
if d < -0.0001:
return f"较昨日 ¥{d:,.2f}"
return "与昨日持平"
today_order_count = len(today_orders)
today_order_amount = round(sum(float(o.total_amount or 0) for o in today_orders), 2)
prev_count = len(prev_day_orders)
prev_amount = round(sum(float(o.total_amount or 0) for o in prev_day_orders), 2)
# 未解决的配送时效预警(可按区域通过任务表关联)
warn_q = (
db.query(BizDeliveryWarning)
.join(
BizDeliveryTask,
and_(
BizDeliveryTask.id == BizDeliveryWarning.task_id,
BizDeliveryTask.is_del == 0,
),
)
.join(SysRegion, BizDeliveryTask.region_id == SysRegion.id)
.filter(
and_(
BizDeliveryWarning.is_del == 0,
BizDeliveryWarning.is_resolved == 0,
SysRegion.is_del == 0,
*region_active_clause,
)
)
)
if region_id:
warn_q = warn_q.filter(BizDeliveryTask.region_id == region_id)
delivery_warning_count = warn_q.count()
# 今日新提交报价:状态为已提交,且提交时间(无则用创建时间)落在当日 0 点~次日 0 点
quot_submitted_at = func.coalesce(BizQuotation.submit_time, BizQuotation.create_at)
sub_quot_q = (
db.query(BizQuotation)
.join(SysRegion, BizQuotation.region_id == SysRegion.id)
.filter(
and_(
BizQuotation.is_del == 0,
BizQuotation.status == "submitted",
quot_submitted_at >= today,
quot_submitted_at < tomorrow_start,
SysRegion.is_del == 0,
*region_active_clause,
)
)
)
if region_id:
sub_quot_q = sub_quot_q.filter(BizQuotation.region_id == region_id)
pending_quotation_count = sub_quot_q.count()
sub_quot_preview = (
db.query(BizQuotation, BizInquiry)
.join(BizInquiry, BizInquiry.id == BizQuotation.inquiry_id)
.join(SysRegion, BizQuotation.region_id == SysRegion.id)
.filter(
BizQuotation.is_del == 0,
BizQuotation.status == "submitted",
quot_submitted_at >= today,
quot_submitted_at < tomorrow_start,
BizInquiry.is_del == 0,
SysRegion.is_del == 0,
*region_active_clause,
*([BizQuotation.region_id == region_id] if region_id else []),
)
.order_by(desc(BizQuotation.submit_time), desc(BizQuotation.create_at))
.limit(2)
.all()
)
quotation_lines = []
for q, inq in sub_quot_preview:
title = (inq.title or "询价单")[:48]
quotation_lines.append(title)
pd_preview = (
db.query(BizOrder)
.join(SysRegion, BizOrder.region_id == SysRegion.id)
.filter(
and_(
BizOrder.is_del == 0,
BizOrder.delivery_status == "pending_dispatch",
SysRegion.is_del == 0,
*region_active_clause,
*region_clause,
)
)
.order_by(desc(BizOrder.create_at))
.limit(2)
.all()
)
delivery_lines = [o.order_no for o in pd_preview]
cancel_preview = (
db.query(BizOrder)
.join(SysRegion, BizOrder.region_id == SysRegion.id)
.filter(
and_(
BizOrder.is_del == 0,
BizOrder.cancel_status == "pending_review",
SysRegion.is_del == 0,
*region_active_clause,
*region_clause,
)
)
.order_by(desc(BizOrder.update_at))
.limit(2)
.all()
)
cancel_client_ids = list({o.client_id for o in cancel_preview if o.client_id})
cancel_clients = {}
if cancel_client_ids:
for u in (
db.query(SysUser)
.filter(and_(SysUser.is_del == 0, SysUser.id.in_(cancel_client_ids)))
.all()
):
cancel_clients[u.id] = u.real_name or u.phone or u.id
cancel_lines = []
for o in cancel_preview:
nm = cancel_clients.get(o.client_id, "")
cancel_lines.append(f"{o.order_no}" + (f" · {nm[:16]}" if nm else ""))
settle_preview = (
db.query(BizOrder)
.join(SysRegion, BizOrder.region_id == SysRegion.id)
.filter(
and_(
BizOrder.is_del == 0,
BizOrder.client_settlement_status == "pending",
BizOrder.pay_status == "paid",
BizOrder.order_status.in_(["pending_confirm", "completed"]),
SysRegion.is_del == 0,
*region_active_clause,
*region_clause,
)
)
.order_by(desc(BizOrder.update_at))
.limit(2)
.all()
)
settle_client_ids = list({o.client_id for o in settle_preview if o.client_id})
settle_clients = {}
if settle_client_ids:
for u in (
db.query(SysUser)
.filter(and_(SysUser.is_del == 0, SysUser.id.in_(settle_client_ids)))
.all()
):
settle_clients[u.id] = u.real_name or u.phone or u.id
settle_lines = []
for o in settle_preview:
nm = settle_clients.get(o.client_id, "")
settle_lines.append(f"{o.order_no}" + (f" · {nm[:16]}" if nm else ""))
pending_settlement_count = (
db.query(BizOrder)
.join(SysRegion, BizOrder.region_id == SysRegion.id)
.filter(
and_(
BizOrder.is_del == 0,
BizOrder.client_settlement_status == "pending",
BizOrder.pay_status == "paid",
BizOrder.order_status.in_(["pending_confirm", "completed"]),
SysRegion.is_del == 0,
*region_active_clause,
*region_clause,
)
)
.count()
)
pending_cancel_count = (
db.query(BizOrder)
.join(SysRegion, BizOrder.region_id == SysRegion.id)
.filter(
and_(
BizOrder.is_del == 0,
BizOrder.cancel_status == "pending_review",
SysRegion.is_del == 0,
*region_active_clause,
*region_clause,
)
)
.count()
)
# 最新订单(工作台列表)
recent_rows = (
db.query(BizOrder)
.join(SysRegion, BizOrder.region_id == SysRegion.id)
.filter(and_(BizOrder.is_del == 0, SysRegion.is_del == 0, *region_active_clause, *region_clause))
.order_by(desc(BizOrder.create_at))
.limit(8)
.all()
)
rid_set = list({o.region_id for o in recent_rows if o.region_id})
cid_set = list({o.client_id for o in recent_rows if o.client_id})
region_name_map = {}
if rid_set:
for r in db.query(SysRegion).filter(
and_(SysRegion.is_del == 0, *region_active_clause, SysRegion.id.in_(rid_set))
).all():
region_name_map[r.id] = r.name
client_name_map = {}
if cid_set:
for u in db.query(SysUser).filter(and_(SysUser.is_del == 0, SysUser.id.in_(cid_set))).all():
client_name_map[u.id] = u.real_name or u.phone or "-"
recent_orders = []
for o in recent_rows:
order_status = getattr(o, "order_status", None) or "pending_payment"
recent_orders.append(
{
"id": o.id,
"orderNo": o.order_no,
"clientName": client_name_map.get(o.client_id, "-"),
"regionName": region_name_map.get(o.region_id, "-"),
"totalAmount": float(o.total_amount) if o.total_amount else 0,
"orderStatus": order_status,
"cancelStatus": getattr(o, "cancel_status", None) or "none",
"deliveryStatus": getattr(o, "delivery_status", None) or "pending_dispatch",
"createAt": str(o.create_at) if o.create_at else None,
}
)
todo_snapshots = [
{
"type": "quotation",
"title": "今日新提交报价",
"count": pending_quotation_count,
"items": quotation_lines,
},
{
"type": "delivery",
"title": "待分配配送",
"count": pending_delivery_count,
"items": delivery_lines,
},
{
"type": "cancel",
"title": "取消待审核",
"count": pending_cancel_count,
"items": cancel_lines,
},
{
"type": "settlement",
"title": "待客户结算",
"count": pending_settlement_count,
"items": settle_lines,
},
]
# “客户总数/供应商总数”口径:统计用户表(sys_user)并按 region_id 区分
# - region_id 为空:不过滤全局
# - region_id 传入:按 SysUser.region_id 过滤
customer_count = (
db.query(SysUser)
.join(SysRegion, SysUser.region_id == SysRegion.id)
.filter(
and_(
SysUser.is_del == 0,
SysUser.role == "client",
SysRegion.is_del == 0,
*region_active_clause,
*([SysUser.region_id == region_id] if region_id else []),
)
)
.count()
)
supplier_count = (
db.query(SysUser)
.join(SysRegion, SysUser.region_id == SysRegion.id)
.filter(
and_(
SysUser.is_del == 0,
SysUser.role == "supplier",
SysRegion.is_del == 0,
*region_active_clause,
*([SysUser.region_id == region_id] if region_id else []),
)
)
.count()
)
return {
"todayOrderCount": today_order_count,
"todayOrderAmount": today_order_amount,
"monthOrderCount": len(month_orders),
"monthOrderAmount": round(sum(float(o.total_amount or 0) for o in month_orders), 2),
"pendingDeliveryCount": pending_delivery_count,
"completedOrderCount": sum(
1 for o in month_orders if (getattr(o, "order_status", None) or o.status) == "completed"
),
"activePriceListCount": (
db.query(BizPriceList)
.join(SysRegion, BizPriceList.region_id == SysRegion.id)
.filter(
and_(
BizPriceList.is_del == 0,
BizPriceList.status == "active",
SysRegion.is_del == 0,
*region_active_clause,
*([BizPriceList.region_id == region_id] if region_id else []),
)
)
.count()
),
"customerCount": customer_count,
"supplierCount": supplier_count,
"todayOrderCountHint": _delta_hint(today_order_count, prev_count, " 单"),
"todayOrderAmountHint": _amount_delta_hint(today_order_amount, prev_amount),
"deliveryWarningCount": delivery_warning_count,
"recentOrders": recent_orders,
"todoSnapshots": todo_snapshots,
}
statistics_crud = StatisticsCRUD()
+72
View File
@@ -0,0 +1,72 @@
"""商品单位CRUD"""
from typing import Optional, List
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.models.models import BasProductUnit, BasProductUnitHis
from app.crud.base import CRUDBase
class UnitCRUD(CRUDBase[BasProductUnit]):
"""商品单位CRUD操作"""
def get_by_code(self, db: Session, code: str) -> Optional[BasProductUnit]:
"""根据编码获取单位"""
return db.query(BasProductUnit).filter(
and_(BasProductUnit.code == code, BasProductUnit.is_del == 0)
).first()
def get_active_all(self, db: Session) -> List[BasProductUnit]:
"""获取所有启用的单位"""
return db.query(BasProductUnit).filter(
and_(BasProductUnit.is_del == 0, BasProductUnit.is_active == True)
).order_by(BasProductUnit.sort_order.asc(), BasProductUnit.id.asc()).all()
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[BasProductUnit]:
"""更新单位并记录历史"""
original = self.get(db, id)
if not original:
return None
his_data = {
"id": original.id,
"name": original.name,
"code": original.code,
"is_active": original.is_active,
"sort_order": original.sort_order,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BasProductUnitHis(**his_data)
db.add(his)
return self.update(db, id, obj_in, updater)
def soft_delete_with_history(self, db: Session, id: str) -> bool:
"""删除单位并记录历史"""
original = self.get(db, id)
if not original:
return False
his_data = {
"id": original.id,
"name": original.name,
"code": original.code,
"is_active": original.is_active,
"sort_order": original.sort_order,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = BasProductUnitHis(**his_data)
db.add(his)
return self.delete(db, id)
unit_crud = UnitCRUD(BasProductUnit)
+177
View File
@@ -0,0 +1,177 @@
"""用户CRUD"""
from typing import Optional, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_
from app.models.models import SysUser, SysUserHis
from app.crud.base import CRUDBase
from app.core.security import get_password_hash
from app.core.utils import generate_random_password
# 运营端「重置密码」专用明文:仅服务端使用,禁止由 HTTP 请求传入
DEFAULT_ADMIN_RESET_PLAIN_PASSWORD = "111111"
class UserCRUD(CRUDBase[SysUser]):
"""用户CRUD操作"""
def get_by_username(self, db: Session, username: str) -> Optional[SysUser]:
"""根据用户名获取用户"""
return db.query(SysUser).filter(
and_(SysUser.username == username, SysUser.is_del == 0)
).first()
def get_by_phone(self, db: Session, phone: str) -> Optional[SysUser]:
"""根据手机号获取用户"""
return db.query(SysUser).filter(
and_(SysUser.phone == phone, SysUser.is_del == 0)
).first()
def get_by_wx_mini_openid(self, db: Session, openid: str) -> Optional[SysUser]:
"""根据微信小程序 openid 获取用户"""
if not openid:
return None
return db.query(SysUser).filter(
and_(SysUser.wx_mini_openid == openid, SysUser.is_del == 0)
).first()
def get_multi_by_role(self, db: Session, role: str, skip: int = 0, limit: int = 100) -> List[SysUser]:
"""根据角色获取用户列表"""
return db.query(SysUser).filter(
and_(SysUser.role == role, SysUser.is_del == 0)
).offset(skip).limit(limit).all()
def get_multi_by_region(self, db: Session, region_id: str, skip: int = 0, limit: int = 100) -> List[SysUser]:
"""根据区域获取用户列表"""
return db.query(SysUser).filter(
and_(SysUser.region_id == region_id, SysUser.is_del == 0)
).offset(skip).limit(limit).all()
def get_multi_with_filters(
self,
db: Session,
role: str = None,
roles: Optional[List[str]] = None,
region_id: str = None,
keyword: str = None,
is_active: bool = None,
skip: int = 0,
limit: int = 10
) -> tuple[List[SysUser], int]:
"""多条件查询用户"""
query = db.query(SysUser).filter(SysUser.is_del == 0)
if role:
query = query.filter(SysUser.role == role)
elif roles:
role_values = [str(x).strip() for x in roles if str(x).strip()]
if role_values:
query = query.filter(SysUser.role.in_(role_values))
if region_id:
query = query.filter(SysUser.region_id == region_id)
if is_active is not None:
query = query.filter(SysUser.is_active == is_active)
if keyword:
query = query.filter(
(SysUser.username.like(f"%{keyword}%")) |
(SysUser.real_name.like(f"%{keyword}%")) |
(SysUser.phone.like(f"%{keyword}%"))
)
total = query.count()
users = query.order_by(SysUser.create_at.desc()).offset(skip).limit(limit).all()
return users, total
def create_with_password(self, db: Session, obj_in: dict, creator: dict = None) -> Tuple[SysUser, Optional[str]]:
"""创建用户。
若请求体包含非空 password(运营端表单),则使用该密码写入 password_hash;
否则生成随机密码,并在返回值中给出明文 initialPassword(仅此种情况返回,供管理员告知用户)。
"""
obj_in = dict(obj_in)
raw_pwd = obj_in.pop("password", None)
plain: Optional[str] = None
if isinstance(raw_pwd, str):
plain = raw_pwd.strip()
if plain:
if len(plain) < 6:
raise ValueError("密码至少6位")
obj_in["password_hash"] = get_password_hash(plain)
initial_reveal: Optional[str] = None
else:
initial_reveal = generate_random_password()
obj_in["password_hash"] = get_password_hash(initial_reveal)
obj_in["is_active"] = obj_in.get("is_active", True)
user = self.create(db, obj_in, creator)
return user, initial_reveal
def reset_password_to_admin_default(self, db: Session, id: str) -> bool:
"""将密码重置为服务端固定默认(DEFAULT_ADMIN_RESET_PLAIN_PASSWORD),不由请求传入。"""
user = self.get(db, id)
if not user:
return False
plain = DEFAULT_ADMIN_RESET_PLAIN_PASSWORD
if len(plain) < 6:
raise ValueError("默认重置密码配置无效:至少6位")
user.password_hash = get_password_hash(plain)
db.commit()
return True
def update_with_history(self, db: Session, id: str, obj_in: dict, updater: dict = None) -> Optional[SysUser]:
"""更新用户并记录历史"""
original = self.get(db, id)
if not original:
return None
his_data = {
"id": original.id,
"username": original.username,
"password_hash": original.password_hash,
"real_name": original.real_name,
"phone": original.phone,
"avatar_url": getattr(original, "avatar_url", None),
"wx_mini_openid": getattr(original, "wx_mini_openid", None),
"role": original.role,
"region_id": original.region_id,
"is_active": original.is_active,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = SysUserHis(**his_data)
db.add(his)
return self.update(db, id, obj_in, updater)
def soft_delete_with_history(self, db: Session, id: str) -> bool:
"""删除用户并记录历史"""
original = self.get(db, id)
if not original:
return False
his_data = {
"id": original.id,
"username": original.username,
"password_hash": original.password_hash,
"real_name": original.real_name,
"phone": original.phone,
"avatar_url": getattr(original, "avatar_url", None),
"wx_mini_openid": getattr(original, "wx_mini_openid", None),
"role": original.role,
"region_id": original.region_id,
"is_active": original.is_active,
"create_at": original.create_at,
"create_by": original.create_by,
"create_name": original.create_name,
"update_at": original.update_at,
"update_by": original.update_by,
}
his = SysUserHis(**his_data)
db.add(his)
return self.delete(db, id)
user_crud = UserCRUD(SysUser)
+35
View File
@@ -0,0 +1,35 @@
"""FastAPI 骨架入口:无业务路由、无数据库依赖,仅健康检查与文档。"""
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
logger = logging.getLogger("zkxw")
app = FastAPI(
title=settings.APP_NAME,
description="中科兴皖一体化门户后端骨架(业务代码已移除,可按需从版本库恢复)",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.BACKEND_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
def root():
return {"name": settings.APP_NAME, "version": "1.0.0", "docs": "/docs"}
@app.get("/health")
def health_check():
return {"status": "healthy"}
+95
View File
@@ -0,0 +1,95 @@
"""数据模型模块"""
from app.models.base import BaseModel, SoftDeleteModel
from app.models.models import (
SysUser, SysUserHis,
SysRegion, SysRegionHis,
SysApiRequestLog, SysPasswordHistory,
SysApiRequestLogHis, SysPasswordHistoryHis,
BasProductCategory, BasProductCategoryHis,
BasProductUnit, BasProductUnitHis,
BasProduct, BasProductHis,
BasChinaRegion,
BasAddress, BasAddressHis,
BizInquiry, BizInquiryItem,
BizQuotation, BizQuotationItem,
BizPriceList, BizPriceListItem,
BizOrder, BizOrderItem, BizOrderStatusLog, BizOrderAdjust,
BizDeliveryTask, BizDeliveryTaskItem,
BizSettlement, BizRefund, BizWechatPay, BizAttachment,
OrdClientSettlement, OrdClientSettlementHis, OrdClientSettlementItem, OrdClientSettlementItemHis,
OrdSupplierSettlement, OrdSupplierSettlementHis, OrdSupplierSettlementItem, OrdSupplierSettlementItemHis,
BizInvoice, BizSystemConfig, BizDelivererReview,
BizDelivererReviewHis, BizDeliveryWarning, BizDeliveryWarningHis, BizOrderStatusLogHis,
UserRole
)
from app.models.product_banner import BasProductBanner, BasProductBannerHis
from app.models.product_detail_media import BasProductDetailMedia, BasProductDetailMediaHis
from app.models.product_parameter import BasProductParameter, BasProductParameterHis
from app.models.category_parameter import BasCategoryParameter, BasCategoryParameterHis
from app.models.product_parameter_value import BasProductParameterValue, BasProductParameterValueHis
from app.models.product_review import BizProductReview, BizProductReviewHis
from app.models.product_review_media import BizProductReviewMedia, BizProductReviewMediaHis
# 兼容旧的模型名称别名
Supplier = BizDelivererReview
Client = SysUser # 客户复用SysUser,通过role字段区分
Deliver = SysUser # 配送员复用SysUser,通过role字段区分
Inquiry = BizInquiry
Quotation = BizQuotation
QuotationItem = BizQuotationItem
PriceList = BizPriceList
PriceListItem = BizPriceListItem
Order = BizOrder
OrderItem = BizOrderItem
OrderStatus = BizOrderStatusLog
DeliveryTask = BizDeliveryTask
DeliveryTaskItem = BizDeliveryTaskItem
Product = BasProduct
ProductUnit = BasProductUnit
Region = SysRegion
User = SysUser
ApiRequestLog = SysApiRequestLog
__all__ = [
"BaseModel",
"SoftDeleteModel",
# 系统模块
"SysUser", "SysUserHis",
"SysRegion", "SysRegionHis",
"SysApiRequestLog", "SysPasswordHistory",
"SysApiRequestLogHis", "SysPasswordHistoryHis",
# 基础模块
"BasProductCategory", "BasProductCategoryHis",
"BasProductUnit", "BasProductUnitHis",
"BasProduct", "BasProductHis",
"BasChinaRegion",
"BasAddress", "BasAddressHis",
# 商品多媒体
"BasProductBanner", "BasProductBannerHis",
"BasProductDetailMedia", "BasProductDetailMediaHis",
# 商品参数
"BasProductParameter", "BasProductParameterHis",
"BasCategoryParameter", "BasCategoryParameterHis",
"BasProductParameterValue", "BasProductParameterValueHis",
# 售后评价
"BizProductReview", "BizProductReviewHis",
"BizProductReviewMedia", "BizProductReviewMediaHis",
# 业务模块
"BizInquiry", "BizInquiryItem",
"BizQuotation", "BizQuotationItem",
"BizPriceList", "BizPriceListItem",
"BizOrder", "BizOrderItem", "BizOrderStatusLog", "BizOrderAdjust",
"BizDeliveryTask", "BizDeliveryTaskItem",
"BizSettlement", "BizRefund", "BizWechatPay", "BizAttachment",
"OrdClientSettlement", "OrdClientSettlementHis", "OrdClientSettlementItem", "OrdClientSettlementItemHis",
"OrdSupplierSettlement", "OrdSupplierSettlementHis", "OrdSupplierSettlementItem", "OrdSupplierSettlementItemHis",
"BizInvoice", "BizSystemConfig", "BizDelivererReview",
"BizDelivererReviewHis", "BizDeliveryWarning", "BizDeliveryWarningHis", "BizOrderStatusLogHis",
"UserRole",
# 兼容别名
"Supplier", "Inquiry", "Quotation", "QuotationItem",
"PriceList", "PriceListItem", "Order", "OrderItem",
"OrderStatus", "DeliveryTask", "DeliveryTaskItem",
"Product", "ProductUnit", "Region", "User",
"ApiRequestLog",
]
+63
View File
@@ -0,0 +1,63 @@
"""基础模型类"""
from datetime import datetime
from typing import Optional, Any
from sqlalchemy import Column, String, DateTime, Integer, func
from sqlalchemy.ext.declarative import declared_attr
from app.core.database import Base
class BaseModel(Base):
"""所有模型的基类,包含通用字段"""
__abstract__ = True
@declared_attr
def __tablename__(cls) -> str:
"""自动生成表名"""
import re
name = cls.__name__
# 驼峰转下划线
return re.sub(r'(?<!^)(?=[A-Z])', '_', name).lower()
id = Column(String(32), primary_key=True, comment="主键ID")
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
def to_dict(self, exclude: list = None) -> dict:
"""转换为字典"""
if exclude is None:
exclude = []
result = {}
for column in self.__table__.columns:
if column.name not in exclude:
value = getattr(self, column.name)
if isinstance(value, datetime):
result[column.name] = value.strftime("%Y-%m-%d %H:%M:%S")
else:
result[column.name] = value
return result
class SoftDeleteModel(BaseModel):
"""支持软删除的模型基类"""
__abstract__ = True
is_del = Column(Integer, default=0, comment="是否删除: 0-否, 1-是")
@classmethod
def get_active(cls, db, **kwargs):
"""获取未删除的记录"""
return db.query(cls).filter(cls.is_del == 0, **kwargs)
def soft_delete(self, db):
"""软删除"""
self.is_del = 1
db.commit()
def restore(self, db):
"""恢复删除"""
self.is_del = 0
db.commit()
+42
View File
@@ -0,0 +1,42 @@
"""分类参数关联模型"""
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Integer
from app.core.database import Base
class BasCategoryParameter(Base):
"""商品分类-参数关联表"""
__tablename__ = "bas_category_parameter"
id = Column(String(32), primary_key=True, comment="主键ID")
category_id = Column(String(32), nullable=False, comment="分类ID")
parameter_id = Column(String(32), nullable=False, comment="参数ID")
is_required = Column(Integer, default=0, comment="该分类下是否必填:0-否, 1-是")
default_value = Column(String(500), nullable=True, comment="默认值")
sort_order = Column(Integer, default=0, comment="排序")
is_del = Column(Integer, default=0, comment="是否删除:0-否, 1-是")
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
class BasCategoryParameterHis(Base):
"""商品分类-参数关联表历史表"""
__tablename__ = "bas_category_parameter_his"
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
category_id = Column(String(32), nullable=True, comment="分类ID")
parameter_id = Column(String(32), nullable=True, comment="参数ID")
is_required = Column(Integer, default=0, comment="是否必填")
default_value = Column(String(500), nullable=True, comment="默认值")
sort_order = Column(Integer, default=0, comment="排序")
create_at = Column(DateTime, nullable=True, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, nullable=True, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
File diff suppressed because it is too large. Load diff
+166
View File
@@ -0,0 +1,166 @@
"""订单相关模型"""
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Enum as SQLEnum, Numeric, Text, ForeignKey
from sqlalchemy.orm import relationship
from app.core.database import Base
import enum
class OrderStatus(str, enum.Enum):
"""订单主状态"""
PENDING_PAYMENT = "pending_payment" # 待支付
PENDING_CONFIRM = "pending_confirm" # 待客户确认收货
COMPLETED = "completed" # 已完结
CANCELLED = "cancelled" # 已取消
class PaymentMethod(str, enum.Enum):
"""支付方式"""
WECHAT = "wechat" # 微信支付
OFFLINE = "offline" # 线下结算
class Order(Base):
"""订单主表"""
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
order_no = Column(String(50), unique=True, nullable=False, comment="订单编号")
region_id = Column(Integer, nullable=False, comment="区域ID")
client_id = Column(Integer, nullable=False, comment="客户ID")
price_list_id = Column(Integer, nullable=False, comment="使用的价格清单ID")
price_version = Column(Integer, nullable=False, comment="价格版本号")
status = Column(SQLEnum(OrderStatus), default=OrderStatus.PENDING_PAYMENT, comment="订单状态")
payment_method = Column(SQLEnum(PaymentMethod), nullable=True, comment="支付方式")
total_amount = Column(Numeric(12, 2), default=0, comment="订单总金额")
paid_amount = Column(Numeric(12, 2), default=0, comment="已付金额")
receivable_amount = Column(Numeric(12, 2), default=0, comment="应收金额")
refund_amount = Column(Numeric(12, 2), default=0, comment="退款金额")
contact_person = Column(String(100), nullable=True, comment="联系人")
contact_phone = Column(String(20), nullable=True, comment="联系电话")
delivery_address = Column(String(500), nullable=True, comment="配送地址")
cancel_reason = Column(String(500), nullable=True, comment="取消原因")
cancel_review_time = Column(DateTime, nullable=True, comment="取消审核时间")
cancel_reviewed_by = Column(Integer, nullable=True, comment="取消审核人ID")
cancel_review_note = Column(String(500), nullable=True, comment="取消审核备注")
completed_time = Column(DateTime, nullable=True, comment="完结时间")
completed_by = Column(Integer, nullable=True, comment="完结人ID")
remark = Column(Text, nullable=True, comment="订单备注")
payment_time = Column(DateTime, nullable=True, comment="支付时间")
delivery_assign_time = Column(DateTime, nullable=True, comment="配送分配时间")
created_at = Column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关联
client = relationship("Client", back_populates="orders")
items = relationship("OrderItem", back_populates="order")
delivery_task = relationship("DeliveryTask", back_populates="order", uselist=False)
status_logs = relationship("OrderStatusLog", back_populates="order")
class OrderItem(Base):
"""订单项表"""
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
order_id = Column(Integer, nullable=False, comment="订单ID")
product_id = Column(Integer, nullable=False, comment="商品ID")
unit_id = Column(Integer, nullable=False, comment="单位ID")
quantity = Column(Numeric(12, 4), default=0, comment="数量")
unit_price = Column(Numeric(10, 2), nullable=False, comment="单价")
amount = Column(Numeric(12, 2), default=0, comment="金额")
delivered_quantity = Column(Numeric(12, 4), default=0, comment="已配送数量")
status = Column(String(20), default="normal", comment="项状态:normal-正常,reduced-已减量,deleted-已删除,substituted-已替代")
note = Column(String(500), nullable=True, comment="备注(如缺货原因)")
created_at = Column(DateTime, default=datetime.utcnow, comment="创建时间")
order = relationship("Order", back_populates="items")
product = relationship("Product", back_populates="order_items")
class OrderStatusLog(Base):
"""订单状态变更日志"""
__tablename__ = "order_status_logs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
order_id = Column(Integer, nullable=False, comment="订单ID")
from_status = Column(String(30), nullable=True, comment="变更前状态")
to_status = Column(String(30), nullable=False, comment="变更后状态")
operator_id = Column(Integer, nullable=False, comment="操作人ID")
operator_role = Column(String(20), nullable=False, comment="操作人角色")
reason = Column(String(500), nullable=True, comment="变更原因")
note = Column(String(1000), nullable=True, comment="备注")
created_at = Column(DateTime, default=datetime.utcnow, comment="创建时间")
order = relationship("Order", back_populates="status_logs")
class DeliveryTask(Base):
"""配送任务表"""
__tablename__ = "delivery_tasks"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
task_no = Column(String(50), unique=True, nullable=False, comment="配送单编号")
order_id = Column(Integer, nullable=False, comment="订单ID")
region_id = Column(Integer, nullable=False, comment="区域ID")
supplier_id = Column(Integer, nullable=False, comment="供应商ID")
deliver_id = Column(Integer, nullable=True, comment="配送员ID")
status = Column(String(20), default="pending", comment="状态:pending-待取货,picking-取货中,delivering-配送中,completed-已完成,cancelled-已取消")
deliverer_status = Column(String(20), default="pending_pickup", comment="配送员状态: pending_pickup/delivering/delivered")
supplier_prepare_status = Column(String(20), default="pending_prepare", comment="供应商备货状态: pending_prepare/prepared/picked_up")
pickup_time = Column(DateTime, nullable=True, comment="取货时间")
pickup_remark = Column(String(500), nullable=True, comment="取货备注")
pickup_image_url = Column(String(500), nullable=True, comment="取货凭证图片")
delivery_time = Column(DateTime, nullable=True, comment="送达时间")
delivery_remark = Column(String(500), nullable=True, comment="送达备注")
delivery_image_url = Column(String(500), nullable=True, comment="送达凭证图片")
expire_warning_sent = Column(Boolean, default=False, comment="是否已发送过期预警")
created_at = Column(DateTime, default=datetime.utcnow, comment="创建时间")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment="更新时间")
# 关联
order = relationship("Order", back_populates="delivery_task")
supplier = relationship("Supplier", back_populates="delivery_tasks")
deliver = relationship("Deliver", back_populates="delivery_tasks")
class Settlement(Base):
"""线下结算记录表"""
__tablename__ = "settlements"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
order_id = Column(Integer, nullable=False, comment="订单ID")
client_id = Column(Integer, nullable=False, comment="客户ID")
region_id = Column(Integer, nullable=False, comment="区域ID")
amount = Column(Numeric(12, 2), nullable=False, comment="结算金额")
settlement_method = Column(String(20), nullable=False, comment="结算方式:transfer-转账,cash-现金,other-其他")
settlement_time = Column(DateTime, nullable=True, comment="结算时间")
settlement_file_url = Column(String(500), nullable=True, comment="结算凭证文件URL")
settlement_note = Column(String(500), nullable=True, comment="结算备注")
status = Column(String(20), default="pending", comment="状态:pending-待确认,completed-已完成")
reviewed_by = Column(Integer, nullable=True, comment="审核人ID")
reviewed_time = Column(DateTime, nullable=True, comment="审核时间")
created_by = Column(Integer, nullable=False, comment="创建人ID")
created_at = Column(DateTime, default=datetime.utcnow, comment="创建时间")
+42
View File
@@ -0,0 +1,42 @@
"""商品轮播图模型"""
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Integer, Text
from app.core.database import Base
class BasProductBanner(Base):
"""商品轮播图表"""
__tablename__ = "bas_product_banner"
id = Column(String(32), primary_key=True, comment="主键ID")
product_id = Column(String(32), nullable=False, comment="商品ID")
media_type = Column(String(20), nullable=False, default="image", comment="媒体类型:image-图片, video-视频")
file_url = Column(String(500), nullable=False, comment="文件URL地址")
thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL")
sort_order = Column(Integer, default=0, comment="排序")
is_del = Column(Integer, default=0, comment="是否删除")
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
class BasProductBannerHis(Base):
"""商品轮播图历史表"""
__tablename__ = "bas_product_banner_his"
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
product_id = Column(String(32), nullable=True, comment="商品ID")
media_type = Column(String(20), default="image", comment="媒体类型")
file_url = Column(String(500), nullable=True, comment="文件URL地址")
thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL")
sort_order = Column(Integer, default=0, comment="排序")
create_at = Column(DateTime, nullable=True, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, nullable=True, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
@@ -0,0 +1,46 @@
"""商品详情媒体模型"""
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Integer, Text
from app.core.database import Base
class BasProductDetailMedia(Base):
"""商品详情媒体表"""
__tablename__ = "bas_product_detail_media"
id = Column(String(32), primary_key=True, comment="主键ID")
product_id = Column(String(32), nullable=False, comment="商品ID")
content_type = Column(String(20), nullable=False, default="image", comment="内容类型:image-图片, video-视频, text-文本")
content_text = Column(Text, nullable=True, comment="文本内容")
file_url = Column(String(500), nullable=True, comment="文件URL地址")
thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL")
description = Column(String(500), nullable=True, comment="描述")
sort_order = Column(Integer, default=0, comment="排序")
is_del = Column(Integer, default=0, comment="是否删除")
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
class BasProductDetailMediaHis(Base):
"""商品详情媒体历史表"""
__tablename__ = "bas_product_detail_media_his"
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
product_id = Column(String(32), nullable=True, comment="商品ID")
content_type = Column(String(20), default="image", comment="内容类型")
content_text = Column(Text, nullable=True, comment="文本内容")
file_url = Column(String(500), nullable=True, comment="文件URL地址")
thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL")
description = Column(String(500), nullable=True, comment="描述")
sort_order = Column(Integer, default=0, comment="排序")
create_at = Column(DateTime, nullable=True, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, nullable=True, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
+50
View File
@@ -0,0 +1,50 @@
"""商品参数码表模型"""
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Integer, Boolean, Text
from app.core.database import Base
class BasProductParameter(Base):
"""商品参数码表"""
__tablename__ = "bas_product_parameter"
id = Column(String(32), primary_key=True, comment="主键ID")
name = Column(String(100), nullable=False, comment="参数名称")
code = Column(String(50), nullable=False, unique=True, comment="参数编码")
param_type = Column(String(20), nullable=False, default="text", comment="参数类型:text-文本输入, select-单选下拉, multi_select-多选")
options = Column(Text, nullable=True, comment="可选值列表,JSON数组")
placeholder = Column(String(100), nullable=True, comment="输入提示文字")
is_required = Column(Integer, default=0, comment="是否必填:0-否, 1-是")
is_active = Column(Integer, default=1, comment="是否启用:0-禁用, 1-启用")
is_del = Column(Integer, default=0, comment="是否删除:0-否, 1-是")
sort_order = Column(Integer, default=0, comment="排序")
remark = Column(String(500), nullable=True, comment="备注说明")
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
class BasProductParameterHis(Base):
"""商品参数码表历史表"""
__tablename__ = "bas_product_parameter_his"
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
name = Column(String(100), nullable=True, comment="参数名称")
code = Column(String(50), nullable=True, comment="参数编码")
param_type = Column(String(20), default="text", comment="参数类型")
options = Column(Text, nullable=True, comment="可选值列表")
placeholder = Column(String(100), nullable=True, comment="输入提示文字")
is_required = Column(Integer, default=0, comment="是否必填")
is_active = Column(Integer, default=1, comment="是否启用")
sort_order = Column(Integer, default=0, comment="排序")
remark = Column(String(500), nullable=True, comment="备注说明")
create_at = Column(DateTime, nullable=True, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, nullable=True, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
@@ -0,0 +1,46 @@
"""商品参数值模型"""
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Integer, Text
from app.core.database import Base
class BasProductParameterValue(Base):
"""商品参数值表"""
__tablename__ = "bas_product_parameter_value"
id = Column(String(32), primary_key=True, comment="主键ID")
product_id = Column(String(32), nullable=False, comment="商品ID")
parameter_id = Column(String(32), nullable=False, comment="参数ID")
parameter_name = Column(String(100), nullable=True, comment="参数名称快照")
parameter_code = Column(String(50), nullable=True, comment="参数编码快照")
param_type = Column(String(20), nullable=True, comment="参数类型快照")
param_value = Column(Text, nullable=True, comment="参数值")
sort_order = Column(Integer, default=0, comment="排序")
is_del = Column(Integer, default=0, comment="是否删除:0-否, 1-是")
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
class BasProductParameterValueHis(Base):
"""商品参数值表历史表"""
__tablename__ = "bas_product_parameter_value_his"
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
product_id = Column(String(32), nullable=True, comment="商品ID")
parameter_id = Column(String(32), nullable=True, comment="参数ID")
parameter_name = Column(String(100), nullable=True, comment="参数名称快照")
parameter_code = Column(String(50), nullable=True, comment="参数编码快照")
param_type = Column(String(20), nullable=True, comment="参数类型快照")
param_value = Column(Text, nullable=True, comment="参数值")
sort_order = Column(Integer, default=0, comment="排序")
create_at = Column(DateTime, nullable=True, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, nullable=True, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
+66
View File
@@ -0,0 +1,66 @@
"""售后评价模型"""
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Integer, Text
from app.core.database import Base
class BizProductReview(Base):
"""商品售后评价表"""
__tablename__ = "biz_product_review"
id = Column(String(32), primary_key=True, comment="主键ID")
product_id = Column(String(32), nullable=False, comment="商品ID")
order_id = Column(String(32), nullable=True, comment="订单ID")
order_item_id = Column(String(32), nullable=True, comment="订单项ID")
user_id = Column(String(32), nullable=False, comment="评价用户ID")
user_name = Column(String(100), nullable=True, comment="评价用户姓名")
rating = Column(Integer, nullable=False, default=5, comment="评分(1-5星)")
rating_tags = Column(String(500), nullable=True, comment="评价标签,多个逗号分隔")
content = Column(Text, nullable=True, comment="评价内容")
is_anonymous = Column(Integer, default=0, comment="是否匿名:0-否, 1-是")
reply_content = Column(Text, nullable=True, comment="商家回复内容")
reply_time = Column(DateTime, nullable=True, comment="商家回复时间")
reply_by = Column(String(32), nullable=True, comment="回复人ID")
reply_by_name = Column(String(100), nullable=True, comment="回复人姓名")
like_count = Column(Integer, default=0, comment="点赞数")
is_top = Column(Integer, default=0, comment="是否置顶:0-否, 1-是")
status = Column(String(20), default="pending", comment="状态:pending-待审核, approved-已通过, rejected-已拒绝")
reject_reason = Column(String(500), nullable=True, comment="拒绝原因")
is_del = Column(Integer, default=0, comment="是否删除:0-否, 1-是")
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
class BizProductReviewHis(Base):
"""商品售后评价历史表"""
__tablename__ = "biz_product_review_his"
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
product_id = Column(String(32), nullable=True, comment="商品ID")
order_id = Column(String(32), nullable=True, comment="订单ID")
order_item_id = Column(String(32), nullable=True, comment="订单项ID")
user_id = Column(String(32), nullable=True, comment="评价用户ID")
user_name = Column(String(100), nullable=True, comment="评价用户姓名")
rating = Column(Integer, default=5, comment="评分")
rating_tags = Column(String(500), nullable=True, comment="评价标签")
content = Column(Text, nullable=True, comment="评价内容")
is_anonymous = Column(Integer, default=0, comment="是否匿名")
reply_content = Column(Text, nullable=True, comment="商家回复内容")
reply_time = Column(DateTime, nullable=True, comment="商家回复时间")
reply_by = Column(String(32), nullable=True, comment="回复人ID")
reply_by_name = Column(String(100), nullable=True, comment="回复人姓名")
like_count = Column(Integer, default=0, comment="点赞数")
is_top = Column(Integer, default=0, comment="是否置顶")
status = Column(String(20), default="pending", comment="状态")
reject_reason = Column(String(500), nullable=True, comment="拒绝原因")
create_at = Column(DateTime, nullable=True, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, nullable=True, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
@@ -0,0 +1,42 @@
"""售后评价媒体模型"""
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Integer
from app.core.database import Base
class BizProductReviewMedia(Base):
"""商品评价媒体表"""
__tablename__ = "biz_product_review_media"
id = Column(String(32), primary_key=True, comment="主键ID")
review_id = Column(String(32), nullable=False, comment="评价ID")
media_type = Column(String(20), nullable=False, default="image", comment="媒体类型:image-图片, video-视频")
file_url = Column(String(500), nullable=False, comment="文件URL地址")
thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL(视频时使用)")
sort_order = Column(Integer, default=0, comment="排序(数字越小越靠前)")
is_del = Column(Integer, default=0, comment="是否删除:0-否, 1-是")
create_at = Column(DateTime, default=datetime.now, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
class BizProductReviewMediaHis(Base):
"""商品评价媒体历史表"""
__tablename__ = "biz_product_review_media_his"
id = Column(String(32), primary_key=True, nullable=False, comment="主键ID")
review_id = Column(String(32), nullable=True, comment="评价ID")
media_type = Column(String(20), default="image", comment="媒体类型")
file_url = Column(String(500), nullable=True, comment="文件URL地址")
thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL")
sort_order = Column(Integer, default=0, comment="排序")
create_at = Column(DateTime, nullable=True, comment="创建时间")
create_by = Column(String(32), nullable=True, comment="创建人ID")
create_name = Column(String(100), nullable=True, comment="创建人姓名")
update_at = Column(DateTime, nullable=True, comment="修改时间")
update_by = Column(String(32), nullable=True, comment="修改人ID")
his_create_at = Column(DateTime, default=datetime.now, comment="历史记录创建时间")
+352
View File
@@ -0,0 +1,352 @@
"""Schema模块"""
from app.schemas.common import (
PaginationParams,
PaginationResult,
IdInput,
StatusInput,
DeleteInput
)
from app.schemas.region import (
RegionBase,
RegionCreate,
RegionUpdate,
RegionResponse,
RegionListResponse
)
from app.schemas.category import (
CategoryBase,
CategoryCreate,
CategoryUpdate,
CategoryTreeNode,
CategoryResponse,
CategoryListResponse
)
from app.schemas.unit import (
UnitBase,
UnitCreate,
UnitUpdate,
UnitResponse,
UnitListResponse
)
from app.schemas.user import (
UserBase,
UserCreate,
UserUpdate,
UserResponse,
UserListResponse,
LoginRequest,
LoginResponse,
ChangePasswordRequest,
ResetPasswordRequest
)
from app.schemas.product import (
ProductUnitBase,
ProductUnitCreate,
ProductUnitResponse,
ProductCategoryBase,
ProductCategoryCreate,
ProductCategoryResponse,
ProductBase,
ProductCreate,
ProductUpdate,
ProductResponse
)
from app.schemas.product_banner import (
ProductBannerBase,
ProductBannerCreate,
ProductBannerCreateBatch,
ProductBannerUpdate,
ProductBannerSortUpdate,
ProductBannerResponse,
ProductBannerListResponse
)
from app.schemas.product_detail_media import (
ProductDetailMediaBase,
ProductDetailMediaCreate,
ProductDetailMediaCreateBatch,
ProductDetailMediaUpdate,
ProductDetailMediaResponse,
ProductDetailMediaListResponse
)
from app.schemas.product_parameter import (
ProductParameterBase,
ProductParameterCreate,
ProductParameterUpdate,
ProductParameterResponse,
ProductParameterListResponse,
ProductParameterSimpleResponse
)
from app.schemas.category_parameter import (
CategoryParameterBase,
CategoryParameterItem,
CategoryParameterCreate,
CategoryParameterUpdate,
CategoryParameterBatchUpdate,
CategoryParameterResponse,
CategoryParameterWithDetailResponse,
CategoryParameterListResponse
)
from app.schemas.product_parameter_value import (
ProductParameterValueBase,
ProductParameterValueCreate,
ProductParameterValueUpdate,
ProductParameterValueBatchSave,
ProductParameterValueResponse,
ProductParameterValueListResponse,
ProductParameterValueWithParameterResponse
)
from app.schemas.product_review import (
ProductReviewBase,
ProductReviewCreate,
ProductReviewUpdate,
ProductReviewReplyRequest,
ProductReviewRejectRequest,
ProductReviewTopRequest,
ProductReviewResponse,
ProductReviewDetailResponse,
ProductReviewListResponse,
ProductReviewStatisticsResponse,
ProductReviewAdminListRequest,
MiniReviewListRequest
)
from app.schemas.product_review_media import (
ProductReviewMediaBase,
ProductReviewMediaCreate,
ProductReviewMediaCreateBatch,
ProductReviewMediaUpdate,
ProductReviewMediaResponse,
ProductReviewMediaListResponse
)
from app.schemas.inquiry import (
InquiryBase,
InquiryCreate,
InquiryUpdate,
InquiryResponse,
InquiryDetailResponse,
InquiryListResponse,
InquiryItemBase,
InquiryItemCreate,
InquiryItemResponse,
InquiryStatusResponse
)
from app.schemas.quotation import (
QuotationBase,
QuotationCreate,
QuotationUpdate,
QuotationResponse,
QuotationDetailResponse,
QuotationListResponse,
QuotationItemBase,
QuotationItemCreate,
QuotationItemResponse,
QuotationStatusResponse,
QuotationWithSupplierResponse,
QuotationWithItemsResponse
)
from app.schemas.price_list import (
PriceListBase,
PriceListCreate,
PriceListUpdate,
PriceListResponse,
PriceListDetailResponse,
PriceListListResponse,
PriceListItemBase,
PriceListItemCreate,
PriceListItemResponse,
PriceListPublishRequest,
PriceListInvalidateRequest,
PriceListStatusResponse,
ProductPriceResponse,
ProductPriceHistoryItem,
ProductPriceHistoryResponse,
PriceListGenerateRequest
)
from app.schemas.order import (
OrderCreate,
OrderPreviewRequest,
OrderResponse,
OrderDetailResponse,
OrderListResponse,
OrderPreviewResponse,
OrderItemCreate,
OrderItemResponse,
OrderCancelRequest,
OrderDirectCancelRequest,
OrderReviewCancelRequest,
OrderStatusResponse,
OrderStatusLogResponse,
DeliveryProgressResponse
)
from app.schemas.delivery import (
DeliveryTaskResponse,
DeliveryTaskDetailResponse,
DeliveryTaskListResponse,
DeliveryWarningListResponse,
DeliveryTaskItemResponse,
DeliveryAssignRequest,
DeliveryAssignBatchRequest,
DeliveryPickupRequest,
DeliveryDeliverItem,
DeliveryDeliverRequest,
DeliveryCancelRequest,
DeliveryReassignRequest,
DeliveryStatusResponse,
DeliveryTaskStatistics,
DeliveryAttachmentResponse,
DeliveryCompleteRequest
)
__all__ = [
"PaginationParams",
"PaginationResult",
"IdInput",
"StatusInput",
"DeleteInput",
"RegionBase",
"RegionCreate",
"RegionUpdate",
"RegionResponse",
"RegionListResponse",
"CategoryBase",
"CategoryCreate",
"CategoryUpdate",
"CategoryTreeNode",
"CategoryResponse",
"CategoryListResponse",
"UnitBase",
"UnitCreate",
"UnitUpdate",
"UnitResponse",
"UnitListResponse",
"UserBase",
"UserCreate",
"UserUpdate",
"UserResponse",
"UserListResponse",
"LoginRequest",
"LoginResponse",
"ChangePasswordRequest",
"ResetPasswordRequest",
"InquiryBase",
"InquiryCreate",
"InquiryUpdate",
"InquiryResponse",
"InquiryDetailResponse",
"InquiryListResponse",
"InquiryItemBase",
"InquiryItemCreate",
"InquiryItemResponse",
"InquiryStatusResponse",
"QuotationBase",
"QuotationCreate",
"QuotationUpdate",
"QuotationResponse",
"QuotationDetailResponse",
"QuotationListResponse",
"QuotationItemBase",
"QuotationItemCreate",
"QuotationItemResponse",
"QuotationStatusResponse",
"QuotationWithSupplierResponse",
"QuotationWithItemsResponse",
"PriceListBase",
"PriceListCreate",
"PriceListUpdate",
"PriceListResponse",
"PriceListDetailResponse",
"PriceListListResponse",
"PriceListItemBase",
"PriceListItemCreate",
"PriceListItemResponse",
"PriceListPublishRequest",
"PriceListInvalidateRequest",
"PriceListStatusResponse",
"ProductPriceResponse",
"ProductPriceHistoryItem",
"ProductPriceHistoryResponse",
"PriceListGenerateRequest",
"OrderCreate",
"OrderPreviewRequest",
"OrderResponse",
"OrderDetailResponse",
"OrderListResponse",
"OrderPreviewResponse",
"OrderItemCreate",
"OrderItemResponse",
"OrderCancelRequest",
"OrderDirectCancelRequest",
"OrderReviewCancelRequest",
"OrderStatusResponse",
"OrderStatusLogResponse",
"DeliveryProgressResponse",
"DeliveryTaskResponse",
"DeliveryTaskDetailResponse",
"DeliveryTaskListResponse",
"DeliveryWarningListResponse",
"DeliveryTaskItemResponse",
"DeliveryAssignRequest",
"DeliveryAssignBatchRequest",
"DeliveryPickupRequest",
"DeliveryDeliverItem",
"DeliveryDeliverRequest",
"DeliveryCancelRequest",
"DeliveryReassignRequest",
"DeliveryStatusResponse",
"DeliveryTaskStatistics",
"DeliveryAttachmentResponse",
# 商品多媒体
"ProductBannerBase",
"ProductBannerCreate",
"ProductBannerCreateBatch",
"ProductBannerUpdate",
"ProductBannerSortUpdate",
"ProductBannerResponse",
"ProductBannerListResponse",
"ProductDetailMediaBase",
"ProductDetailMediaCreate",
"ProductDetailMediaCreateBatch",
"ProductDetailMediaUpdate",
"ProductDetailMediaResponse",
"ProductDetailMediaListResponse",
# 商品参数
"ProductParameterBase",
"ProductParameterCreate",
"ProductParameterUpdate",
"ProductParameterResponse",
"ProductParameterListResponse",
"ProductParameterSimpleResponse",
"CategoryParameterBase",
"CategoryParameterItem",
"CategoryParameterCreate",
"CategoryParameterUpdate",
"CategoryParameterBatchUpdate",
"CategoryParameterResponse",
"CategoryParameterWithDetailResponse",
"CategoryParameterListResponse",
"ProductParameterValueBase",
"ProductParameterValueCreate",
"ProductParameterValueUpdate",
"ProductParameterValueBatchSave",
"ProductParameterValueResponse",
"ProductParameterValueListResponse",
"ProductParameterValueWithParameterResponse",
# 售后评价
"ProductReviewBase",
"ProductReviewCreate",
"ProductReviewUpdate",
"ProductReviewReplyRequest",
"ProductReviewRejectRequest",
"ProductReviewTopRequest",
"ProductReviewResponse",
"ProductReviewDetailResponse",
"ProductReviewListResponse",
"ProductReviewStatisticsResponse",
"ProductReviewAdminListRequest",
"MiniReviewListRequest",
"ProductReviewMediaBase",
"ProductReviewMediaCreate",
"ProductReviewMediaCreateBatch",
"ProductReviewMediaUpdate",
"ProductReviewMediaResponse",
"ProductReviewMediaListResponse",
]
+56
View File
@@ -0,0 +1,56 @@
"""Pydantic schemas - 用户认证"""
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class LoginRequest(BaseModel):
"""登录请求"""
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6)
class LoginResponse(BaseModel):
"""登录响应"""
access_token: str
token_type: str = "bearer"
user_id: int
username: str
role: str
real_name: Optional[str] = None
class UserCreate(BaseModel):
"""创建用户请求"""
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6)
real_name: Optional[str] = None
phone: Optional[str] = None
role: str
region_id: Optional[int] = None
class UserResponse(BaseModel):
"""用户响应"""
id: int
username: str
real_name: Optional[str] = None
phone: Optional[str] = None
role: str
region_id: Optional[int] = None
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class ChangePasswordRequest(BaseModel):
"""修改密码请求"""
old_password: str
new_password: str = Field(..., min_length=6)
class ResetPasswordRequest(BaseModel):
"""重置密码请求(运营操作)"""
user_id: int
+61
View File
@@ -0,0 +1,61 @@
"""商品分类Schema"""
from typing import Optional, List
from pydantic import BaseModel, Field
class CategoryBase(BaseModel):
"""分类基础Schema"""
name: str = Field(..., description="分类名称")
image_url: Optional[str] = Field(None, description="分类图片URL")
parent_id: Optional[str] = Field(None, description="父分类ID")
sort_order: int = Field(0, description="排序")
is_active: bool = Field(True, description="是否启用")
class CategoryCreate(CategoryBase):
"""创建分类"""
pass
class CategoryUpdate(BaseModel):
"""更新分类"""
name: Optional[str] = Field(None, description="分类名称")
image_url: Optional[str] = Field(None, description="分类图片URL")
parent_id: Optional[str] = Field(None, description="父分类ID")
sort_order: Optional[int] = Field(None, description="排序")
is_active: Optional[bool] = Field(None, description="是否启用")
class CategoryTreeNode(BaseModel):
"""分类树节点"""
id: str
name: str
parentId: Optional[str] = None
sortOrder: int = 0
isActive: bool = True
children: List["CategoryTreeNode"] = []
class Config:
from_attributes = True
class CategoryResponse(CategoryBase):
"""分类响应"""
id: str
create_at: Optional[str] = None
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[str] = None
update_by: Optional[str] = None
class Config:
from_attributes = True
class CategoryListResponse(BaseModel):
"""分类列表响应"""
items: List[CategoryResponse]
total: int
page: int
pageSize: int
totalPages: int
+82
View File
@@ -0,0 +1,82 @@
"""Pydantic schemas - 分类参数关联"""
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List
from datetime import datetime
class CategoryParameterBase(BaseModel):
"""分类参数关联基础字段"""
model_config = ConfigDict(populate_by_name=True)
category_id: str = Field(..., description="分类ID", alias="categoryId")
parameter_id: str = Field(..., description="参数ID", alias="parameterId")
is_required: int = Field(default=0, description="该分类下是否必填:0-否, 1-是", alias="isRequired")
default_value: Optional[str] = Field(None, max_length=500, description="默认值", alias="defaultValue")
sort_order: int = Field(default=0, description="排序", alias="sortOrder")
class CategoryParameterItem(BaseModel):
"""批量更新中的单个参数项(不包含categoryId)"""
model_config = ConfigDict(populate_by_name=True)
parameter_id: str = Field(..., description="参数ID", alias="parameterId")
is_required: int = Field(default=0, description="该分类下是否必填:0-否, 1-是", alias="isRequired")
default_value: Optional[str] = Field(None, max_length=500, description="默认值", alias="defaultValue")
sort_order: int = Field(default=0, description="排序", alias="sortOrder")
class CategoryParameterCreate(CategoryParameterBase):
"""创建分类参数关联请求"""
pass
class CategoryParameterUpdate(BaseModel):
"""更新分类参数关联请求"""
model_config = ConfigDict(populate_by_name=True)
is_required: Optional[int] = Field(None, description="是否必填", alias="isRequired")
default_value: Optional[str] = Field(None, max_length=500, description="默认值", alias="defaultValue")
sort_order: Optional[int] = Field(None, description="排序", alias="sortOrder")
class CategoryParameterBatchUpdate(BaseModel):
"""批量更新分类参数关联请求"""
model_config = ConfigDict(populate_by_name=True)
items: List[CategoryParameterItem] = Field(..., description="参数关联列表")
class CategoryParameterResponse(CategoryParameterBase):
"""分类参数关联响应"""
id: str
create_at: datetime
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[datetime] = None
class Config:
from_attributes = True
class CategoryParameterWithDetailResponse(BaseModel):
"""带参数详情的分类参数关联响应"""
id: str
category_id: str
parameter_id: str
parameter_name: str
parameter_code: str
param_type: str
options: Optional[str] = None
placeholder: Optional[str] = None
is_required: int
default_value: Optional[str] = None
sort_order: int
class Config:
from_attributes = True
class CategoryParameterListResponse(BaseModel):
"""分类参数关联列表响应"""
list: List[CategoryParameterWithDetailResponse]
total: int
+33
View File
@@ -0,0 +1,33 @@
"""公共Schema定义"""
from typing import Optional
from pydantic import BaseModel
class PaginationParams(BaseModel):
"""分页参数"""
page: int = 1
pageSize: int = 10
class PaginationResult(BaseModel):
"""分页结果"""
items: list = []
total: int = 0
page: int = 1
pageSize: int = 10
totalPages: int = 0
class IdInput(BaseModel):
"""ID输入"""
id: str
class StatusInput(BaseModel):
"""状态输入"""
is_active: bool = True
class DeleteInput(BaseModel):
"""删除输入"""
pass
+265
View File
@@ -0,0 +1,265 @@
"""Pydantic schemas - 配送任务管理"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
class DeliveryTaskItemResponse(BaseModel):
"""配送任务明细响应"""
id: str
taskId: str
orderId: Optional[str] = None
orderItemId: Optional[str] = None
productId: Optional[str] = None
productName: Optional[str] = None
clientId: Optional[str] = None
deliveryAddress: Optional[str] = None
contactPerson: Optional[str] = None
contactPhone: Optional[str] = None
quantity: Decimal
deliveredQuantity: Optional[Decimal] = None
status: str
deliveryTime: Optional[datetime] = None
class Config:
from_attributes = True
class DeliveryTaskResponse(BaseModel):
"""配送任务响应"""
id: str
taskNo: str
orderId: Optional[str] = None
regionId: Optional[str] = None
regionName: Optional[str] = None
supplierId: Optional[str] = None
supplierName: Optional[str] = None
deliverId: Optional[str] = None
deliverName: Optional[str] = None
status: str
effectiveTime: Optional[datetime] = None
pickupTime: Optional[datetime] = None
pickupRemark: Optional[str] = None
pickupImageUrl: Optional[str] = None
deliveryTime: Optional[datetime] = None
deliveryRemark: Optional[str] = None
deliveryImageUrl: Optional[str] = None
createAt: Optional[datetime] = None
class Config:
from_attributes = True
class DeliveryTaskDetailResponse(DeliveryTaskResponse):
"""配送任务详情响应"""
items: List[DeliveryTaskItemResponse] = []
order: Optional[dict] = None
deliver: Optional[dict] = None
class DeliveryTaskListResponse(BaseModel):
"""配送任务列表响应"""
items: List[DeliveryTaskResponse]
total: int
page: int
pageSize: int
totalPages: int
class DeliveryTaskCreateRequest(BaseModel):
"""创建配送任务请求"""
orderId: str = Field(..., description="订单ID")
regionId: str = Field(..., description="区域ID")
supplierId: str = Field(..., description="供应商ID")
deliverId: Optional[str] = Field(None, description="配送员ID")
effectiveTime: Optional[datetime] = Field(None, description="配送截止时间")
class DeliveryTaskAssignRequest(BaseModel):
"""分配配送员请求"""
deliverId: str = Field(..., description="配送员ID")
class DeliveryTaskPickupRequest(BaseModel):
"""确认取货请求"""
pickupRemark: Optional[str] = Field(None, description="取货备注")
pickupImageUrl: Optional[str] = Field(None, description="取货图片URL")
class DeliveryTaskDeliverRequest(BaseModel):
"""确认送达请求"""
deliveryRemark: Optional[str] = Field(None, description="送达备注")
deliveryImageUrl: Optional[str] = Field(None, description="送达图片URL")
class DeliveryTaskCancelRequest(BaseModel):
"""取消配送任务请求"""
reason: str = Field(..., description="取消原因")
class DeliveryTaskStatusUpdateRequest(BaseModel):
"""更新配送任务状态请求"""
status: str = Field(..., description="新状态")
remark: Optional[str] = Field(None, description="备注")
# ============ 供应商端配送任务 Schema ============
class SupplierTaskResponse(BaseModel):
"""供应商配送任务响应"""
id: str
taskNo: str
orderId: Optional[str] = None
regionId: Optional[str] = None
regionName: Optional[str] = None
clientName: Optional[str] = None
supplierId: Optional[str] = None
deliverId: Optional[str] = None
deliverName: Optional[str] = None
status: str
effectiveTime: Optional[datetime] = None
pickupTime: Optional[datetime] = None
pickupRemark: Optional[str] = None
deliveryTime: Optional[datetime] = None
totalItems: int = 0
deliveredItems: int = 0
createAt: Optional[datetime] = None
class Config:
from_attributes = True
class SupplierTaskListResponse(BaseModel):
"""供应商配送任务列表响应"""
items: List[SupplierTaskResponse]
total: int
page: int
pageSize: int
totalPages: int
class SupplierConfirmPickupRequest(BaseModel):
"""供应商确认取货请求"""
pickupRemark: Optional[str] = Field(None, description="取货备注")
class SupplierConfirmPreparedRequest(BaseModel):
"""供应商确认备货完成请求"""
preparedRemark: Optional[str] = Field(None, description="备货备注")
# ============ 配送统计 Schema ============
class DeliveryStatisticsResponse(BaseModel):
"""配送统计响应"""
totalTasks: int
pendingTasks: int
inProgressTasks: int
completedTasks: int
cancelledTasks: int
averageDeliveryTime: Optional[float] = None
onTimeRate: Optional[float] = None
class DeliveryWarningItem(BaseModel):
"""配送预警项"""
taskId: str
taskNo: str
urgencyLevel: str
remainingMinutes: int
orderId: Optional[str] = None
clientName: Optional[str] = None
deliverName: Optional[str] = None
class DeliveryWarningListResponse(BaseModel):
"""配送预警列表响应"""
items: List[DeliveryWarningItem] = []
total: int
page: int
pageSize: int
totalPages: int
# ============ 运营端配送任务 Schema (兼容旧命名) ============
class DeliveryAssignRequest(BaseModel):
"""分配配送员请求"""
deliverId: str = Field(..., description="配送员ID")
class DeliveryAssignBatchRequest(BaseModel):
"""批量分配配送员请求"""
taskIds: List[str] = Field(..., description="配送任务ID列表")
deliverId: str = Field(..., description="配送员ID")
class DeliveryPickupRequest(BaseModel):
"""配送员取货确认请求"""
taskId: str = Field(..., description="配送任务ID")
pickupRemark: Optional[str] = Field(None, description="取货备注")
pickupImageUrl: Optional[str] = Field(None, description="取货图片")
class DeliveryDeliverItem(BaseModel):
"""送达明细"""
taskItemId: str = Field(..., description="配送任务明细ID")
deliveredQuantity: Decimal = Field(..., description="送达数量")
class DeliveryDeliverRequest(BaseModel):
"""确认送达请求"""
taskId: str = Field(..., description="配送任务ID")
items: List[DeliveryDeliverItem] = Field(default_factory=list, description="送达明细列表")
deliveryRemark: Optional[str] = Field(None, description="送达备注")
deliveryImageUrl: Optional[str] = Field(None, description="送达图片")
class DeliveryCancelRequest(BaseModel):
"""取消配送任务请求"""
taskId: str = Field(..., description="配送任务ID")
reason: str = Field(..., description="取消原因")
class DeliveryReassignRequest(BaseModel):
"""重新分配配送员请求"""
taskId: str = Field(..., description="配送任务ID")
newDeliverId: str = Field(..., description="新配送员ID")
reason: Optional[str] = Field(None, description="重新分配原因")
class DeliveryStatusResponse(BaseModel):
"""配送状态更新响应"""
id: str
status: str
message: str = "操作成功"
class DeliveryTaskStatistics(BaseModel):
"""配送任务统计"""
total: int = 0
pending: int = 0
picking: int = 0
delivering: int = 0
completed: int = 0
cancelled: int = 0
class DeliveryAttachmentResponse(BaseModel):
"""配送附件响应"""
id: str
taskId: str
type: str
url: str
createAt: datetime
class Config:
from_attributes = True
# 兼容别名
DeliveryCompleteRequest = DeliveryTaskDeliverRequest
+81
View File
@@ -0,0 +1,81 @@
"""Pydantic schemas - 询价单管理"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
class InquiryItemBase(BaseModel):
"""询价单项基础字段"""
product_id: str = Field(..., description="商品ID")
class InquiryItemCreate(InquiryItemBase):
"""创建询价单项请求"""
pass
class InquiryItemResponse(InquiryItemBase):
"""询价单项响应"""
id: str
inquiry_id: str
is_del: int
class Config:
from_attributes = True
class InquiryBase(BaseModel):
"""询价单基础字段"""
region_id: str = Field(..., description="区域ID")
title: str = Field(..., max_length=200, description="询价单标题")
remark: Optional[str] = Field(None, max_length=1000, description="备注")
class InquiryCreate(InquiryBase):
"""创建询价单请求"""
product_ids: List[str] = Field(default_factory=list, description="商品ID列表")
class InquiryUpdate(BaseModel):
"""更新询价单请求"""
title: Optional[str] = Field(None, max_length=200, description="询价单标题")
remark: Optional[str] = Field(None, max_length=1000, description="备注")
product_ids: Optional[List[str]] = Field(None, description="商品ID列表")
class InquiryResponse(InquiryBase):
"""询价单响应"""
id: str
status: str
publish_time: Optional[datetime] = None
close_time: Optional[datetime] = None
is_del: int
create_at: Optional[datetime] = None
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[datetime] = None
class Config:
from_attributes = True
class InquiryDetailResponse(InquiryResponse):
"""询价单详情响应(包含询价项)"""
items: List[InquiryItemResponse] = []
class InquiryListResponse(BaseModel):
"""询价单列表响应"""
items: List[InquiryResponse]
total: int
page: int
pageSize: int
totalPages: int
class InquiryStatusResponse(BaseModel):
"""询价单状态更新响应"""
id: str
status: str
message: str = "操作成功"
+35
View File
@@ -0,0 +1,35 @@
"""菜单 Schema 定义"""
from typing import Optional, List
from pydantic import BaseModel
class MenuMeta(BaseModel):
"""菜单元信息"""
title: str
icon: Optional[str] = None
keepAlive: Optional[bool] = False
isHide: Optional[bool] = False
isIframe: Optional[bool] = False
link: Optional[str] = None
showBadge: Optional[bool] = False
showTextBadge: Optional[str] = None
isHideTab: Optional[bool] = False
class MenuItem(BaseModel):
"""菜单项"""
id: int
name: str
path: str
component: Optional[str] = None
redirect: Optional[str] = None
meta: Optional[MenuMeta] = None
children: Optional[List["MenuItem"]] = None
class MenuListResponse(BaseModel):
"""菜单列表响应"""
menuList: List[MenuItem]
MenuItem.model_rebuild()
+339
View File
@@ -0,0 +1,339 @@
"""Pydantic schemas - 订单管理"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
class OrderItemBase(BaseModel):
"""订单项基础字段"""
productId: str = Field(..., description="商品ID")
unitId: str = Field(..., description="单位ID")
quantity: Decimal = Field(..., description="数量")
unitPrice: Decimal = Field(..., description="单价")
class OrderItemCreate(OrderItemBase):
"""创建订单项请求"""
note: Optional[str] = None
class OrderItemResponse(BaseModel):
"""订单项响应"""
id: str
productId: str
productName: Optional[str] = None
unitId: str
unitName: Optional[str] = None
quantity: Decimal
unitPrice: Decimal
amount: Decimal
deliveredQuantity: Optional[Decimal] = None
status: str
class Config:
from_attributes = True
class OrderCreate(BaseModel):
"""创建订单请求"""
priceListId: str = Field(..., description="价格清单ID")
items: List[OrderItemCreate] = Field(..., description="订单项列表")
addressId: str = Field(..., description="配送地址ID")
paymentMethod: str = Field(..., description="支付方式: wechat/offline")
remark: Optional[str] = Field(None, description="备注")
class OrderPreviewRequest(BaseModel):
"""订单预览请求"""
priceListId: str = Field(..., description="价格清单ID")
items: List[dict] = Field(..., description="商品列表")
addressId: str = Field(..., description="配送地址ID")
class OrderResponse(BaseModel):
"""订单响应"""
id: str
orderNo: str
regionId: str
regionName: Optional[str] = None
clientId: str
clientName: Optional[str] = None
priceListId: str
priceVersion: int
status: str
paymentMethod: Optional[str] = None
totalAmount: Decimal
paidAmount: Decimal
receivableAmount: Decimal
refundAmount: Decimal
contactPerson: Optional[str] = None
contactPhone: Optional[str] = None
deliveryAddress: Optional[str] = None
remark: Optional[str] = None
lockOperatorId: Optional[str] = None
lockTime: Optional[datetime] = None
createAt: Optional[datetime] = None
paymentTime: Optional[datetime] = None
cancelReason: Optional[str] = None
cancelReviewNote: Optional[str] = None
completedTime: Optional[datetime] = None
class Config:
from_attributes = True
class OrderDetailResponse(OrderResponse):
"""订单详情响应(包含订单项和状态日志)"""
items: List[OrderItemResponse] = []
statusLogs: List[dict] = []
class OrderListResponse(BaseModel):
"""订单列表响应"""
items: List[OrderResponse]
total: int
page: int
pageSize: int
totalPages: int
class OrderPreviewResponse(BaseModel):
"""订单预览响应"""
priceListId: str
priceVersion: int
items: List[dict]
totalAmount: Decimal
address: dict
class OrderCancelRequest(BaseModel):
"""取消订单请求"""
reason: str = Field(..., description="取消原因")
class OrderDirectCancelRequest(BaseModel):
"""直接取消订单请求(未支付订单)"""
reason: str = Field(..., description="取消原因")
class OrderReviewCancelRequest(BaseModel):
"""审核取消订单请求"""
approved: bool = Field(..., description="是否审核通过")
reviewNote: Optional[str] = Field(None, description="审核备注")
class OrderStatusResponse(BaseModel):
"""订单状态更新响应"""
id: str
status: str
message: str = "操作成功"
class OrderStatusLogResponse(BaseModel):
"""订单状态日志响应"""
id: str
fromStatus: Optional[str] = None
toStatus: str
toStatusText: str
operatorName: Optional[str] = None
operatorRole: str
reason: Optional[str] = None
note: Optional[str] = None
createAt: Optional[datetime] = None
class Config:
from_attributes = True
class DeliveryProgressResponse(BaseModel):
"""配送进度响应"""
orderId: str
orderNo: str
status: str
deliveryStatus: Optional[str] = None
items: List[dict] = []
estimatedDeliveryTime: Optional[str] = None
# ============ 订单调整相关 Schema ============
class OrderAdjustItem(BaseModel):
"""订单调整项"""
orderItemId: str = Field(..., description="订单项ID")
adjustType: str = Field(..., description="调整类型: reduce/delete/substitute")
newQuantity: Optional[Decimal] = Field(None, description="新数量(减量时使用)")
newProductId: Optional[str] = Field(None, description="新商品ID(替代时使用)")
reason: str = Field(..., description="调整原因")
class OrderAdjustRequest(BaseModel):
"""订单调整请求"""
adjustments: List[OrderAdjustItem] = Field(..., description="调整项列表")
class OrderAdjustResponse(BaseModel):
"""订单调整响应"""
id: str
orderId: str
orderItemId: Optional[str] = None
adjustType: str
beforeQuantity: Decimal
afterQuantity: Decimal
beforeAmount: Decimal
afterAmount: Decimal
differenceAmount: Decimal
reason: str
operatorName: str
createAt: Optional[datetime] = None
class Config:
from_attributes = True
class OrderAdjustResult(BaseModel):
"""订单调整结果"""
id: str
totalAmount: Decimal
receivableAmount: Decimal
adjustments: List[dict]
message: str = "调整成功"
# ============ 订单锁定相关 Schema ============
class OrderLockRequest(BaseModel):
"""订单锁定请求"""
pass
class OrderLockResponse(BaseModel):
"""订单锁定响应"""
id: str
lockOperatorId: Optional[str] = None
lockTime: Optional[datetime] = None
message: str = "锁定成功"
class OrderUnlockRequest(BaseModel):
"""订单解锁请求"""
pass
class OrderUnlockResponse(BaseModel):
"""订单解锁响应"""
id: str
message: str = "解锁成功"
# ============ 订单状态管理 Schema ============
class OrderForceCompleteRequest(BaseModel):
"""强制完结订单请求"""
reason: Optional[str] = Field(None, description="完结原因")
class OrderForceCompleteResponse(BaseModel):
"""强制完结订单响应"""
id: str
status: str
completedTime: Optional[datetime] = None
message: str = "强制完结成功"
class OrderPartialDeliveredRequest(BaseModel):
"""标记部分送达请求"""
reason: Optional[str] = Field(None, description="原因")
class OrderPartialDeliveredResponse(BaseModel):
"""标记部分送达响应"""
id: str
status: str
message: str = "已标记为部分送达"
class OrderConfirmPartialRequest(BaseModel):
"""确认部分送达请求"""
reason: Optional[str] = Field(None, description="原因")
class OrderConfirmPartialResponse(BaseModel):
"""确认部分送达响应"""
id: str
status: str
receivableAmount: Decimal
message: str = "已确认"
# ============ 配送任务分配 Schema ============
class AssignDelivererRequest(BaseModel):
"""分配配送员请求"""
deliverId: str = Field(..., description="配送员ID")
class AssignDelivererResponse(BaseModel):
"""分配配送员响应"""
id: str
taskNo: str
deliverId: str
deliverName: str
status: str
message: str = "配送员分配成功"
# ============ 配送时效预警 Schema ============
class DeliveryWarningResponse(BaseModel):
"""配送时效预警响应"""
id: str
taskNo: str
orderId: Optional[str] = None
regionId: Optional[str] = None
regionName: Optional[str] = None
clientName: Optional[str] = None
supplierId: Optional[str] = None
supplierName: Optional[str] = None
deliverId: Optional[str] = None
deliverName: Optional[str] = None
status: str
effectiveTime: Optional[datetime] = None
remainingMinutes: int
urgencyLevel: str
isAssigned: bool
createAt: Optional[datetime] = None
class Config:
from_attributes = True
class DeliveryWarningStatistics(BaseModel):
"""配送时效预警统计"""
totalPending: int
criticalCount: int
urgentCount: int
normalCount: int
unassignedCount: int
class ReassignDeliveryRequest(BaseModel):
"""紧急重新分配配送任务请求"""
deliverId: str = Field(..., description="新配送员ID")
reason: Optional[str] = Field(None, description="重新分配原因")
class ReassignDeliveryResponse(BaseModel):
"""紧急重新分配配送任务响应"""
id: str
taskNo: str
newDeliverId: str
newDeliverName: str
oldDeliverId: Optional[str] = None
oldDeliverName: Optional[str] = None
message: str = "任务重新分配成功"
+17
View File
@@ -0,0 +1,17 @@
"""订单调整相关枚举"""
import enum
class AdjustType(str, enum.Enum):
"""调整类型"""
REDUCE = "reduce" # 减量
DELETE = "delete" # 删除
SUBSTITUTE = "substitute" # 替代
class AdjustStatus(str, enum.Enum):
"""调整状态"""
PENDING = "pending" # 待处理
APPROVED = "approved" # 已批准
REJECTED = "rejected" # 已拒绝
CANCELLED = "cancelled" # 已取消
+160
View File
@@ -0,0 +1,160 @@
"""Pydantic schemas - 价格清单管理"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
class PriceListItemBase(BaseModel):
"""价格清单项基础字段"""
product_id: str = Field(..., description="商品ID")
unit_id: str = Field(..., description="单位ID")
price: Decimal = Field(..., description="单价")
class PriceListItemCreate(PriceListItemBase):
"""创建价格清单项请求"""
pass
class PriceListItemUpdate(BaseModel):
"""更新价格清单项请求"""
unit_id: Optional[str] = None
price: Optional[Decimal] = None
class PriceListItemResponse(PriceListItemBase):
"""价格清单项响应"""
id: str
price_list_id: str
is_del: int
create_at: Optional[datetime] = None
# 关联信息
product_name: Optional[str] = None
category_name: Optional[str] = None
unit_name: Optional[str] = None
image_url: Optional[str] = None
class Config:
from_attributes = True
class PriceListBase(BaseModel):
"""价格清单基础字段"""
region_id: str = Field(..., description="区域ID")
supplier_id: str = Field(..., description="供应商ID")
class PriceListCreate(PriceListBase):
"""创建价格清单请求"""
effective_time: Optional[datetime] = Field(None, description="生效时间")
effective_type: str = Field("immediate", description="生效方式: immediate-立即生效/scheduled-定时生效")
items: List[PriceListItemCreate] = Field(default_factory=list, description="价格项列表")
remark: Optional[str] = Field(None, max_length=500, description="备注")
class PriceListUpdate(BaseModel):
"""更新价格清单请求"""
effective_time: Optional[datetime] = None
items: Optional[List[PriceListItemCreate]] = None
remark: Optional[str] = None
class PriceListResponse(BaseModel):
"""价格清单响应"""
id: str
region_id: str
supplier_id: str
version: int
status: str
effective_time: Optional[datetime] = None
published_by: Optional[str] = None
published_time: Optional[datetime] = None
remark: Optional[str] = None
is_del: int
create_at: Optional[datetime] = None
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[datetime] = None
class Config:
from_attributes = True
class PriceListDetailResponse(PriceListResponse):
"""价格清单详情响应(包含价格项)"""
items: List[PriceListItemResponse] = []
# 关联信息
region_name: Optional[str] = None
supplier_name: Optional[str] = None
published_by_name: Optional[str] = None
class PriceListListResponse(BaseModel):
"""价格清单列表响应"""
items: List[PriceListResponse]
total: int
page: int
pageSize: int
totalPages: int
class PriceListPublishRequest(BaseModel):
"""发布价格清单请求"""
effective_type: str = Field("immediate", description="生效方式: immediate-立即生效/scheduled-定时生效")
effective_time: Optional[datetime] = Field(None, description="定时生效时间")
remark: Optional[str] = Field(None, max_length=500, description="备注")
class PriceListInvalidateRequest(BaseModel):
"""使价格清单失效请求"""
remark: Optional[str] = Field(None, max_length=500, description="失效原因备注")
class PriceListStatusResponse(BaseModel):
"""价格清单状态更新响应"""
id: str
status: str
message: str = "操作成功"
class ProductPriceResponse(BaseModel):
"""商品价格响应"""
product_id: str
product_name: str
category_name: Optional[str] = None
unit_id: str
unit_name: Optional[str] = None
price: Decimal
price_list_id: str
price_list_version: int
class ProductPriceHistoryItem(BaseModel):
"""商品价格历史项"""
price: Decimal
unit_id: str
unit_name: Optional[str] = None
price_list_id: str
version: int
published_time: datetime
status: str
class ProductPriceHistoryResponse(BaseModel):
"""商品价格历史响应"""
product_id: str
product_name: str
history: List[ProductPriceHistoryItem]
total: int
page: int
pageSize: int
class PriceListGenerateRequest(BaseModel):
"""从报价单生成价格清单请求"""
quotation_id: str = Field(..., description="报价单ID")
region_id: str = Field(..., description="区域ID")
effective_type: str = Field("immediate", description="生效方式")
effective_time: Optional[datetime] = None
remark: Optional[str] = Field(None, max_length=500, description="备注")
+82
View File
@@ -0,0 +1,82 @@
"""Pydantic schemas - 商品管理"""
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class ProductUnitBase(BaseModel):
"""商品单位基础字段"""
name: str = Field(..., max_length=50)
code: str = Field(..., max_length=20)
class ProductUnitCreate(ProductUnitBase):
"""创建商品单位请求"""
pass
class ProductUnitResponse(ProductUnitBase):
"""商品单位响应"""
id: int
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class ProductCategoryBase(BaseModel):
"""商品分类基础字段"""
name: str = Field(..., max_length=100)
parent_id: Optional[int] = None
sort_order: int = 0
class ProductCategoryCreate(ProductCategoryBase):
"""创建商品分类请求"""
pass
class ProductCategoryResponse(ProductCategoryBase):
"""商品分类响应"""
id: int
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class ProductBase(BaseModel):
"""商品基础字段"""
name: str = Field(..., max_length=200)
category_id: Optional[int] = None
unit_id: Optional[int] = None
description: Optional[str] = None
image_url: Optional[str] = None
class ProductCreate(ProductBase):
"""创建商品请求"""
pass
class ProductUpdate(BaseModel):
"""更新商品请求"""
name: Optional[str] = None
category_id: Optional[int] = None
unit_id: Optional[int] = None
description: Optional[str] = None
image_url: Optional[str] = None
is_active: Optional[bool] = None
class ProductResponse(ProductBase):
"""商品响应"""
id: int
is_active: bool
created_at: datetime
unit: Optional[ProductUnitResponse] = None
class Config:
from_attributes = True
+69
View File
@@ -0,0 +1,69 @@
"""Pydantic schemas - 商品轮播图"""
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List
from datetime import datetime
class ProductBannerBase(BaseModel):
"""商品轮播图基础字段"""
model_config = ConfigDict(populate_by_name=True)
product_id: str = Field(..., description="商品ID", alias="productId")
media_type: str = Field(default="image", description="媒体类型:image-图片, video-视频", alias="mediaType")
file_url: str = Field(..., max_length=500, description="文件URL地址", alias="fileUrl")
thumbnail_url: Optional[str] = Field(None, max_length=500, description="缩略图URL", alias="thumbnailUrl")
sort_order: int = Field(default=0, description="排序", alias="sortOrder")
class ProductBannerCreate(ProductBannerBase):
"""创建商品轮播图请求"""
pass
class ProductBannerCreateBatch(BaseModel):
"""批量创建商品轮播图请求"""
model_config = ConfigDict(populate_by_name=True)
items: List[ProductBannerCreate] = Field(..., description="轮播图列表")
class ProductBannerUpdate(BaseModel):
"""更新商品轮播图请求"""
model_config = ConfigDict(populate_by_name=True)
media_type: Optional[str] = Field(None, alias="mediaType")
file_url: Optional[str] = Field(None, max_length=500, alias="fileUrl")
thumbnail_url: Optional[str] = Field(None, max_length=500, alias="thumbnailUrl")
sort_order: Optional[int] = Field(None, alias="sortOrder")
class ProductBannerSortItem(BaseModel):
"""单条排序项"""
model_config = ConfigDict(populate_by_name=True)
id: str
sort_order: int = Field(..., alias="sortOrder")
class ProductBannerSortUpdate(BaseModel):
"""更新轮播图排序请求"""
model_config = ConfigDict(populate_by_name=True)
items: List[ProductBannerSortItem] = Field(..., description="排序项列表")
class ProductBannerResponse(ProductBannerBase):
"""商品轮播图响应"""
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
id: str
create_at: datetime
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[datetime] = None
class ProductBannerListResponse(BaseModel):
"""商品轮播图列表响应"""
list: List[ProductBannerResponse]
total: int
@@ -0,0 +1,77 @@
"""Pydantic schemas - 商品详情媒体"""
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List
from datetime import datetime
class ProductDetailMediaBase(BaseModel):
"""商品详情媒体基础字段"""
model_config = ConfigDict(populate_by_name=True)
product_id: str = Field(..., description="商品ID", alias="productId")
content_type: str = Field(
default="image",
description="内容类型:image-图片, video-视频, text-文本",
alias="contentType",
)
content_text: Optional[str] = Field(None, description="文本内容", alias="contentText")
file_url: Optional[str] = Field(None, max_length=500, description="文件URL地址", alias="fileUrl")
thumbnail_url: Optional[str] = Field(None, max_length=500, description="缩略图URL", alias="thumbnailUrl")
description: Optional[str] = Field(None, max_length=500, description="描述", alias="description")
sort_order: int = Field(default=0, description="排序", alias="sortOrder")
class ProductDetailMediaCreate(ProductDetailMediaBase):
"""创建商品详情媒体请求"""
pass
class ProductDetailMediaCreateBatch(BaseModel):
"""批量创建商品详情媒体请求"""
model_config = ConfigDict(populate_by_name=True)
items: List[ProductDetailMediaCreate] = Field(..., description="详情媒体列表")
class ProductDetailMediaUpdate(BaseModel):
"""更新商品详情媒体请求"""
model_config = ConfigDict(populate_by_name=True)
content_type: Optional[str] = Field(None, alias="contentType")
content_text: Optional[str] = Field(None, alias="contentText")
file_url: Optional[str] = Field(None, alias="fileUrl")
thumbnail_url: Optional[str] = Field(None, alias="thumbnailUrl")
description: Optional[str] = Field(None, alias="description")
sort_order: Optional[int] = Field(None, alias="sortOrder")
class ProductDetailMediaSortItem(BaseModel):
"""单条排序项"""
model_config = ConfigDict(populate_by_name=True)
id: str
sort_order: int = Field(..., alias="sortOrder")
class ProductDetailMediaSortUpdate(BaseModel):
"""更新详情媒体排序请求"""
model_config = ConfigDict(populate_by_name=True)
items: List[ProductDetailMediaSortItem] = Field(..., description="排序项列表")
class ProductDetailMediaResponse(ProductDetailMediaBase):
"""商品详情媒体响应"""
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
id: str
create_at: datetime
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[datetime] = None
class ProductDetailMediaListResponse(BaseModel):
"""商品详情媒体列表响应"""
list: List[ProductDetailMediaResponse]
total: int
+71
View File
@@ -0,0 +1,71 @@
"""Pydantic schemas - 商品参数模板"""
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List
from datetime import datetime
class ProductParameterBase(BaseModel):
"""商品参数基础字段"""
model_config = ConfigDict(populate_by_name=True)
name: str = Field(..., max_length=100, description="参数名称", alias="name")
code: str = Field(..., max_length=50, description="参数编码", alias="code")
param_type: str = Field(default="text", description="参数类型:text-文本输入, select-单选下拉, multi_select-多选", alias="paramType")
options: Optional[str] = Field(None, description="可选值列表,JSON数组格式", alias="options")
placeholder: Optional[str] = Field(None, max_length=100, description="输入提示文字", alias="placeholder")
is_required: int = Field(default=0, description="是否必填:0-否, 1-是", alias="isRequired")
is_active: int = Field(default=1, description="是否启用:0-禁用, 1-启用", alias="isActive")
sort_order: int = Field(default=0, description="排序", alias="sortOrder")
remark: Optional[str] = Field(None, max_length=500, description="备注说明", alias="remark")
class ProductParameterCreate(ProductParameterBase):
"""创建商品参数请求"""
pass
class ProductParameterUpdate(BaseModel):
"""更新商品参数请求"""
model_config = ConfigDict(populate_by_name=True)
name: Optional[str] = Field(None, max_length=100, description="参数名称", alias="name")
code: Optional[str] = Field(None, max_length=50, description="参数编码", alias="code")
param_type: Optional[str] = Field(None, description="参数类型", alias="paramType")
options: Optional[str] = Field(None, description="可选值列表", alias="options")
placeholder: Optional[str] = Field(None, max_length=100, description="输入提示文字", alias="placeholder")
is_required: Optional[int] = Field(None, description="是否必填", alias="isRequired")
is_active: Optional[int] = Field(None, description="是否启用", alias="isActive")
sort_order: Optional[int] = Field(None, description="排序", alias="sortOrder")
remark: Optional[str] = Field(None, max_length=500, description="备注说明", alias="remark")
class ProductParameterResponse(ProductParameterBase):
"""商品参数响应"""
id: str
create_at: datetime
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[datetime] = None
class Config:
from_attributes = True
class ProductParameterListResponse(BaseModel):
"""商品参数列表响应"""
list: List[ProductParameterResponse]
total: int
page: int
pageSize: int
class ProductParameterSimpleResponse(BaseModel):
"""商品参数简洁响应(用于下拉选择)"""
id: str
name: str
code: str
param_type: str
options: Optional[str] = None
class Config:
from_attributes = True
@@ -0,0 +1,75 @@
"""Pydantic schemas - 商品参数值"""
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List
from datetime import datetime
class ProductParameterValueBase(BaseModel):
"""商品参数值基础字段"""
model_config = ConfigDict(populate_by_name=True)
product_id: str = Field(..., description="商品ID", alias="productId")
parameter_id: str = Field(..., description="参数ID", alias="parameterId")
param_value: Optional[str] = Field(None, description="参数值", alias="paramValue")
class ProductParameterValueCreate(ProductParameterValueBase):
"""创建商品参数值请求"""
pass
class ProductParameterValueUpdate(BaseModel):
"""更新商品参数值请求"""
model_config = ConfigDict(populate_by_name=True)
param_value: Optional[str] = Field(None, description="参数值", alias="paramValue")
class ProductParameterValueBatchSave(BaseModel):
"""批量保存商品参数值请求"""
model_config = ConfigDict(populate_by_name=True)
product_id: str = Field(..., description="商品ID", alias="productId")
values: List[ProductParameterValueCreate] = Field(..., description="参数值列表")
class ProductParameterValueResponse(BaseModel):
"""商品参数值响应"""
id: str
product_id: str
parameter_id: str
parameter_name: Optional[str] = None
parameter_code: Optional[str] = None
param_type: Optional[str] = None
param_value: Optional[str] = None
sort_order: int
create_at: datetime
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[datetime] = None
class Config:
from_attributes = True
class ProductParameterValueListResponse(BaseModel):
"""商品参数值列表响应"""
list: List[ProductParameterValueResponse]
total: int
class ProductParameterValueWithParameterResponse(BaseModel):
"""带参数信息的商品参数值响应"""
id: str
product_id: str
parameter_id: str
parameter_name: str
parameter_code: str
param_type: str
options: Optional[str] = None
placeholder: Optional[str] = None
param_value: Optional[str] = None
sort_order: int
class Config:
from_attributes = True
+110
View File
@@ -0,0 +1,110 @@
"""Pydantic schemas - 售后评价"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
class ProductReviewBase(BaseModel):
"""售后评价基础字段"""
product_id: str = Field(..., description="商品ID")
order_id: Optional[str] = Field(None, description="订单ID")
order_item_id: Optional[str] = Field(None, description="订单项ID")
user_id: str = Field(..., description="评价用户ID")
rating: int = Field(default=5, ge=1, le=5, description="评分(1-5星)")
rating_tags: Optional[str] = Field(None, max_length=500, description="评价标签,多个逗号分隔")
content: Optional[str] = Field(None, description="评价内容")
is_anonymous: int = Field(default=0, description="是否匿名:0-否, 1-是")
class ProductReviewCreate(ProductReviewBase):
"""创建售后评价请求"""
pass
class ProductReviewUpdate(BaseModel):
"""更新售后评价请求(用户可更新自己的评价)"""
rating: Optional[int] = Field(None, ge=1, le=5, description="评分")
rating_tags: Optional[str] = None
content: Optional[str] = None
is_anonymous: Optional[int] = None
class ProductReviewReplyRequest(BaseModel):
"""回复评价请求"""
reply_content: str = Field(..., description="回复内容")
class ProductReviewRejectRequest(BaseModel):
"""拒绝评价请求"""
reject_reason: str = Field(..., max_length=500, description="拒绝原因")
class ProductReviewTopRequest(BaseModel):
"""置顶评价请求"""
is_top: int = Field(..., description="是否置顶:0-否, 1-是")
class ProductReviewResponse(ProductReviewBase):
"""售后评价响应"""
id: str
user_name: Optional[str] = None
reply_content: Optional[str] = None
reply_time: Optional[datetime] = None
reply_by: Optional[str] = None
reply_by_name: Optional[str] = None
like_count: int = 0
is_top: int = 0
status: str = "pending"
reject_reason: Optional[str] = None
create_at: datetime
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[datetime] = None
class Config:
from_attributes = True
class ProductReviewDetailResponse(ProductReviewResponse):
"""售后评价详情响应(包含媒体列表)"""
media_list: List["ProductReviewMediaResponse"] = []
class ProductReviewListResponse(BaseModel):
"""售后评价列表响应"""
list: List[ProductReviewDetailResponse]
total: int
page: int
pageSize: int
class ProductReviewStatisticsResponse(BaseModel):
"""评价统计响应"""
total_count: int = Field(description="总评价数")
pending_count: int = Field(description="待审核数")
approved_count: int = Field(description="已通过数")
rejected_count: int = Field(description="已拒绝数")
avg_rating: float = Field(description="平均评分")
rating_distribution: dict = Field(description="评分分布,格式:{'5': 10, '4': 5, '3': 2, '2': 1, '1': 0}")
class ProductReviewAdminListRequest(BaseModel):
"""管理员评价列表查询参数"""
product_id: Optional[str] = None
product_name: Optional[str] = None
user_name: Optional[str] = None
status: Optional[str] = None
rating: Optional[int] = None
start_date: Optional[str] = None
end_date: Optional[str] = None
class MiniReviewListRequest(BaseModel):
"""小程序评价列表查询参数"""
product_id: str = Field(..., description="商品ID")
has_media: Optional[int] = Field(None, description="是否有媒体:0-全部, 1-有图/视频")
# 前向引用
from app.schemas.product_review_media import ProductReviewMediaResponse
ProductReviewDetailResponse.model_rebuild()
@@ -0,0 +1,49 @@
"""Pydantic schemas - 评价媒体"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
class ProductReviewMediaBase(BaseModel):
"""评价媒体基础字段"""
review_id: str = Field(..., description="评价ID")
media_type: str = Field(default="image", description="媒体类型:image-图片, video-视频")
file_url: str = Field(..., max_length=500, description="文件URL地址")
thumbnail_url: Optional[str] = Field(None, max_length=500, description="缩略图URL(视频时使用)")
sort_order: int = Field(default=0, description="排序(数字越小越靠前)")
class ProductReviewMediaCreate(ProductReviewMediaBase):
"""创建评价媒体请求"""
pass
class ProductReviewMediaCreateBatch(BaseModel):
"""批量创建评价媒体请求"""
items: List[ProductReviewMediaCreate] = Field(..., description="媒体列表")
class ProductReviewMediaUpdate(BaseModel):
"""更新评价媒体请求"""
media_type: Optional[str] = None
file_url: Optional[str] = None
thumbnail_url: Optional[str] = None
sort_order: Optional[int] = None
class ProductReviewMediaResponse(ProductReviewMediaBase):
"""评价媒体响应"""
id: str
create_at: datetime
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[datetime] = None
class Config:
from_attributes = True
class ProductReviewMediaListResponse(BaseModel):
"""评价媒体列表响应"""
list: List[ProductReviewMediaResponse]
total: int
+106
View File
@@ -0,0 +1,106 @@
"""Pydantic schemas - 报价单管理"""
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
class QuotationItemBase(BaseModel):
"""报价单项基础字段"""
product_id: str = Field(..., description="商品ID")
unit_id: str = Field(..., description="单位ID")
price: Decimal = Field(..., description="单价")
class QuotationItemCreate(QuotationItemBase):
"""创建报价单项请求"""
pass
class QuotationItemUpdate(BaseModel):
"""更新报价单项请求"""
unit_id: Optional[str] = None
price: Optional[Decimal] = None
class QuotationItemResponse(QuotationItemBase):
"""报价单项响应"""
id: str
quotation_id: str
is_del: int
create_at: Optional[datetime] = None
class Config:
from_attributes = True
class QuotationBase(BaseModel):
"""报价单基础字段"""
inquiry_id: str = Field(..., description="询价单ID")
items: List[QuotationItemCreate] = Field(default_factory=list, description="报价项列表")
class QuotationCreate(QuotationBase):
"""创建报价单请求"""
pass
class QuotationUpdate(BaseModel):
"""更新报价单请求"""
items: Optional[List[QuotationItemCreate]] = None
class QuotationResponse(BaseModel):
"""报价单响应"""
id: str
inquiry_id: str
supplier_id: str
region_id: str
status: str
total_amount: Decimal
submit_time: Optional[datetime] = None
is_del: int
create_at: Optional[datetime] = None
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[datetime] = None
class Config:
from_attributes = True
class QuotationDetailResponse(QuotationResponse):
"""报价单详情响应(包含报价项)"""
items: List[QuotationItemResponse] = []
class QuotationListResponse(BaseModel):
"""报价单列表响应"""
items: List[QuotationResponse]
total: int
page: int
pageSize: int
totalPages: int
class QuotationStatusResponse(BaseModel):
"""报价单状态更新响应"""
id: str
status: str
message: str = "操作成功"
class QuotationWithSupplierResponse(QuotationResponse):
"""带供应商信息的报价单响应"""
supplier_name: Optional[str] = None
supplier_phone: Optional[str] = None
region_name: Optional[str] = None
class QuotationWithItemsResponse(QuotationDetailResponse):
"""带询价信息的报价单响应"""
inquiry_title: Optional[str] = None
inquiry_status: Optional[str] = None
supplier_name: Optional[str] = None
supplier_phone: Optional[str] = None
region_name: Optional[str] = None
+67
View File
@@ -0,0 +1,67 @@
"""区域Schema"""
from typing import Optional, List
from pydantic import BaseModel, Field, ConfigDict
class RegionBase(BaseModel):
"""区域基础Schema"""
model_config = ConfigDict(populate_by_name=True)
name: str = Field(..., description="区域名称")
code: str = Field(..., description="区域编码")
description: Optional[str] = Field(None, description="区域描述")
sort_order: int = Field(0, alias="sortOrder", description="排序")
is_active: bool = Field(True, alias="isActive", description="是否启用")
expected_delivery_minutes: Optional[int] = Field(
None, alias="expectedDeliveryMinutes", description="预计配送时长(分钟)"
)
delivery_warn_advance_minutes: Optional[int] = Field(
None,
alias="deliveryWarnAdvanceMinutes",
description="计划到货前未送达时预警提前时间(分钟)",
)
class RegionCreate(RegionBase):
"""创建区域"""
pass
class RegionUpdate(BaseModel):
"""更新区域"""
model_config = ConfigDict(populate_by_name=True)
name: Optional[str] = Field(None, description="区域名称")
code: Optional[str] = Field(None, description="区域编码")
description: Optional[str] = Field(None, description="区域描述")
sort_order: Optional[int] = Field(None, alias="sortOrder", description="排序")
is_active: Optional[bool] = Field(None, alias="isActive", description="是否启用")
expected_delivery_minutes: Optional[int] = Field(
None, alias="expectedDeliveryMinutes", description="预计配送时长(分钟)"
)
delivery_warn_advance_minutes: Optional[int] = Field(
None,
alias="deliveryWarnAdvanceMinutes",
description="计划到货前未送达时预警提前时间(分钟)",
)
class RegionResponse(RegionBase):
"""区域响应"""
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
id: str
create_at: Optional[str] = None
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[str] = None
update_by: Optional[str] = None
class RegionListResponse(BaseModel):
"""区域列表响应"""
items: List[RegionResponse]
total: int
page: int
pageSize: int
totalPages: int
+46
View File
@@ -0,0 +1,46 @@
"""商品单位Schema"""
from typing import Optional, List
from pydantic import BaseModel, Field
class UnitBase(BaseModel):
"""单位基础Schema"""
name: str = Field(..., description="单位名称")
code: str = Field(..., description="单位编码")
is_active: bool = Field(True, description="是否启用")
sort_order: int = Field(0, description="排序字段,数值越小越靠前")
class UnitCreate(UnitBase):
"""创建单位"""
pass
class UnitUpdate(BaseModel):
"""更新单位"""
name: Optional[str] = Field(None, description="单位名称")
code: Optional[str] = Field(None, description="单位编码")
is_active: Optional[bool] = Field(None, description="是否启用")
sort_order: Optional[int] = Field(None, description="排序字段")
class UnitResponse(UnitBase):
"""单位响应"""
id: str
create_at: Optional[str] = None
create_by: Optional[str] = None
create_name: Optional[str] = None
update_at: Optional[str] = None
update_by: Optional[str] = None
class Config:
from_attributes = True
class UnitListResponse(BaseModel):
"""单位列表响应"""
items: List[UnitResponse]
total: int
page: int
pageSize: int
totalPages: int
+70
View File
@@ -0,0 +1,70 @@
"""用户Schema"""
from typing import Optional, List
from pydantic import BaseModel, Field
class UserBase(BaseModel):
"""用户基础Schema"""
username: str = Field(..., description="用户名")
real_name: Optional[str] = Field(None, description="真实姓名")
phone: Optional[str] = Field(None, description="联系电话")
role: str = Field(..., description="角色")
region_id: Optional[str] = Field(None, description="所属区域ID")
is_active: bool = Field(True, description="是否激活")
class UserCreate(UserBase):
"""创建用户"""
pass
class UserUpdate(BaseModel):
"""更新用户"""
real_name: Optional[str] = Field(None, description="真实姓名")
phone: Optional[str] = Field(None, description="联系电话")
role: Optional[str] = Field(None, description="角色")
region_id: Optional[str] = Field(None, description="所属区域ID")
is_active: Optional[bool] = Field(None, description="是否激活")
class UserResponse(UserBase):
"""用户响应"""
id: str
create_at: Optional[str] = None
class Config:
from_attributes = True
class UserListResponse(BaseModel):
"""用户列表响应"""
items: List[UserResponse]
total: int
page: int
pageSize: int
totalPages: int
class LoginRequest(BaseModel):
"""登录请求"""
username: str = Field(..., description="用户名")
password: str = Field(..., description="密码")
class LoginResponse(BaseModel):
"""登录响应"""
token: str
refreshToken: str
expiresIn: int
userInfo: dict
class ChangePasswordRequest(BaseModel):
"""修改密码请求"""
oldPassword: str = Field(..., description="旧密码")
newPassword: str = Field(..., description="新密码")
class ResetPasswordRequest(BaseModel):
"""重置密码请求"""
userId: str = Field(..., description="用户ID")
+70
View File
@@ -0,0 +1,70 @@
"""中国行政区划(bas_china_region)树形结构,供运营端与小程序共用。"""
from __future__ import annotations
import copy
import threading
import time
from collections import defaultdict
from typing import Dict, List
from sqlalchemy.orm import Session
from app.models.models import BasChinaRegion
_CHINA_REGION_TREE_CACHE_TTL_SEC = 600
_china_region_tree_cache = {"expires_at": 0.0, "tree": None}
_china_region_tree_cache_lock = threading.Lock()
def query_china_regions_active(db: Session) -> List[BasChinaRegion]:
return (
db.query(
BasChinaRegion.id,
BasChinaRegion.parent_id,
BasChinaRegion.region_code,
BasChinaRegion.region_name,
BasChinaRegion.sort_order,
)
.filter(BasChinaRegion.is_del == 0)
.order_by(BasChinaRegion.sort_order.asc(), BasChinaRegion.region_code.asc())
.all()
)
def build_china_region_tree(rows: List[BasChinaRegion]) -> List[dict]:
"""{ value: region_code, label: region_name, children?: [...] },与历史 china-region-cascader.json 结构一致。"""
children_map: Dict[str, List[BasChinaRegion]] = defaultdict(list)
for r in rows:
key = getattr(r, "parent_id", None) or ""
children_map[key].append(r)
for key in children_map:
children_map[key].sort(key=lambda x: (getattr(x, "sort_order", 0), getattr(x, "region_code", "")))
def to_node(r: BasChinaRegion) -> dict:
rid = getattr(r, "id", None)
subs = children_map.get(rid, [])
node: dict = {"value": getattr(r, "region_code", None), "label": getattr(r, "region_name", None)}
if subs:
node["children"] = [to_node(c) for c in subs]
return node
roots = children_map.get("", [])
return [to_node(r) for r in roots]
def get_china_region_tree_cached(db: Session) -> List[dict]:
"""获取中国行政区划树,带进程内短 TTL 缓存。"""
now = time.time()
with _china_region_tree_cache_lock:
cache_tree = _china_region_tree_cache.get("tree")
expires_at = float(_china_region_tree_cache.get("expires_at") or 0.0)
if cache_tree is not None and now < expires_at:
return copy.deepcopy(cache_tree)
rows = query_china_regions_active(db)
tree = build_china_region_tree(rows)
with _china_region_tree_cache_lock:
_china_region_tree_cache["tree"] = tree
_china_region_tree_cache["expires_at"] = now + _CHINA_REGION_TREE_CACHE_TTL_SEC
return copy.deepcopy(tree)
+20
View File
@@ -0,0 +1,20 @@
"""定时任务模块(骨架:不启动叁强遗留的订单/配送定时任务)"""
import logging
logger = logging.getLogger(__name__)
def init_scheduled_tasks(get_db_session):
"""中科兴皖骨架工程 — 不注册 APScheduler 业务任务。"""
logger.info("init_scheduled_tasks: 已跳过业务定时任务(骨架模式)")
def shutdown_scheduler():
"""无调度器实例时为空操作。"""
pass
__all__ = [
"init_scheduled_tasks",
"shutdown_scheduler",
]
+236
View File
@@ -0,0 +1,236 @@
"""配送时效预警定时任务
依据订单分配时写入的配送截止时间(biz_delivery_task.effective_time)及区域表 sys_region.delivery_warn_advance_minutes(缺省 30 分钟)生成运营端预警:
- 进入「截止时间前 N 分钟」窗口且仍未完成配送:生成一条 advance_deadline 预警;
- 已超过截止时间仍未完成:生成 timeout 预警;
- 配送员确认送达(任务状态 delivering)或任务取消/完成时,由业务侧自动结案预警。
建议:每 1~5 分钟执行一次 check_delivery_warnings(db)。
"""
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from app.models.models import BizDeliveryTask, BizDeliveryWarning, SysRegion
from app.core.utils import generate_id
from app.crud.delivery_warning_resolve import auto_resolve_open_warnings_for_task
logger = logging.getLogger(__name__)
DEFAULT_ADVANCE_MINUTES = 30
class DeliveryWarningTask:
"""配送时效预警任务"""
def __init__(self, db: Session):
self.db = db
def check_delivery_warnings(self) -> dict:
"""检查配送任务并生成预警"""
result = {
"checked": 0,
"warnings_created": 0,
"notifications_sent": 0,
"errors": []
}
try:
tasks = (
self.db.query(BizDeliveryTask)
.filter(BizDeliveryTask.is_del == 0)
.all()
)
result["checked"] = len(tasks)
for task in tasks:
try:
if task.status in ("delivering", "completed", "cancelled"):
auto_resolve_open_warnings_for_task(self.db, task.id)
continue
if task.status not in ("pending", "assigned", "picking"):
continue
warnings = self._check_task_warnings(task)
result["warnings_created"] += len(warnings)
for warning in warnings:
self._send_warning_notification(warning)
result["notifications_sent"] += 1
except Exception as e:
logger.error(f"处理任务 {task.id} 时出错: {str(e)}")
result["errors"].append({
"task_id": task.id,
"error": str(e)
})
self.db.commit()
except Exception as e:
logger.error(f"配送时效预警任务执行失败: {str(e)}")
self.db.rollback()
result["errors"].append({"task_id": "all", "error": str(e)})
return result
def _region_advance_minutes(self, region_id: str) -> int:
reg = self.db.query(SysRegion).filter(SysRegion.id == region_id, SysRegion.is_del == 0).first()
if not reg:
return DEFAULT_ADVANCE_MINUTES
m = getattr(reg, "delivery_warn_advance_minutes", None)
if m is None or int(m) < 1:
return DEFAULT_ADVANCE_MINUTES
return int(m)
def _check_task_warnings(self, task: BizDeliveryTask) -> list:
warnings = []
if not task.effective_time:
return warnings
now = datetime.now()
remaining_minutes = int((task.effective_time - now).total_seconds() // 60)
advance = self._region_advance_minutes(task.region_id)
warning_type = None
urgency_level = None
message = None
if remaining_minutes < 0:
warning_type = "timeout"
urgency_level = "已超时"
message = f"已超过配送时效 {abs(remaining_minutes)} 分钟,仍未完成配送"
elif remaining_minutes <= advance:
warning_type = "advance_deadline"
urgency_level = "时效预警"
message = f"距配送截止不足 {advance} 分钟(剩余约 {remaining_minutes} 分钟),请督促配送"
else:
return warnings
w = self._upsert_task_warning(
task=task,
warning_type=warning_type,
urgency_level=urgency_level,
remaining_minutes=remaining_minutes,
message=message,
)
if w:
warnings.append(w)
return warnings
def _upsert_task_warning(
self,
task: BizDeliveryTask,
warning_type: str,
urgency_level: str,
remaining_minutes: int,
message: str
) -> Optional[BizDeliveryWarning]:
"""单任务单预警:存在则更新,不存在则创建"""
try:
warning = self._get_latest_warning_by_task(task.id)
now = datetime.now()
if warning:
warning.warning_type = warning_type
warning.urgency_level = urgency_level
warning.remaining_minutes = remaining_minutes
warning.message = message
warning.order_id = task.order_id
warning.is_sent = 0
# 未完成前统一保持未结案(若历史上被手工结案,则重新激活)
warning.is_resolved = 0
warning.resolved_time = None
warning.resolved_by = None
warning.update_at = now
warning.update_by = "system"
self.db.flush()
return warning
warning = BizDeliveryWarning(
id=generate_id(),
task_id=task.id,
order_id=task.order_id,
warning_type=warning_type,
urgency_level=urgency_level,
remaining_minutes=remaining_minutes,
message=message,
is_sent=0,
is_resolved=0,
create_by="system",
create_name="系统",
)
self.db.add(warning)
self.db.flush()
return warning
except Exception as e:
logger.error(f"创建预警记录失败: {str(e)}")
return None
def _get_latest_warning_by_task(self, task_id: str) -> Optional[BizDeliveryWarning]:
return self.db.query(BizDeliveryWarning).filter(
BizDeliveryWarning.task_id == task_id,
BizDeliveryWarning.is_del == 0,
).order_by(BizDeliveryWarning.update_at.desc(), BizDeliveryWarning.create_at.desc()).first()
def _send_warning_notification(self, warning: BizDeliveryWarning) -> bool:
"""发送预警通知(可通过微信模板消息等)"""
try:
# 获取配送员信息
task = self.db.query(BizDeliveryTask).filter(
BizDeliveryTask.id == warning.task_id
).first()
if not task or not task.deliver_id:
logger.warning(f"任务 {warning.task_id} 未分配配送员,跳过通知")
return False
# TODO: 调用微信模板消息接口发送通知
# from app.core.wechat_pay import send_delivery_warning_message
# send_delivery_warning_message(
# deliver_id=task.deliver_id,
# task_no=task.task_no,
# message=warning.message
# )
logger.info(f"已发送预警通知: {task.task_no} - {warning.message}")
return True
except Exception as e:
logger.error(f"发送预警通知失败: {str(e)}")
return False
def check_delivery_warnings(db: Session) -> dict:
"""执行配送时效预警检查(供外部调用)"""
task = DeliveryWarningTask(db)
return task.check_delivery_warnings()
def cleanup_expired_warnings(db: Session, days: int = 7) -> int:
"""清理过期的预警记录
Args:
db: 数据库会话
days: 保留天数,默认7天
Returns:
int: 删除的记录数
"""
from datetime import timedelta
cutoff_date = datetime.now() - timedelta(days=days)
count = db.query(BizDeliveryWarning).filter(
BizDeliveryWarning.create_at < cutoff_date,
BizDeliveryWarning.is_resolved == 1,
BizDeliveryWarning.is_del == 0
).update({"is_del": 1})
db.commit()
logger.info(f"已清理 {count} 条过期预警记录")
return count
@@ -0,0 +1,28 @@
"""送达后超时未确认收货,系统自动确认。"""
import logging
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.crud.order import order_crud
logger = logging.getLogger(__name__)
def run_auto_confirm_receive_overdue(db: Session) -> int:
from app.api.system_config import get_auto_confirm_receive_days_for_job
days = get_auto_confirm_receive_days_for_job(db)
before = datetime.now() - timedelta(days=days)
count = order_crud.auto_confirm_receive_overdue(
db,
before_threshold=before,
configured_days=days,
)
return count
def run_auto_confirm_receive_job(db: Session):
count = run_auto_confirm_receive_overdue(db)
if count:
logger.info(f"自动确认收货完成: {count} 条")
@@ -0,0 +1,27 @@
"""订单支付超时自动关闭任务。"""
import logging
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.crud.order import order_crud
logger = logging.getLogger(__name__)
def close_timeout_online_orders(db: Session) -> int:
"""关闭超时未支付的线上订单(超时分钟数来自系统设置 auto_cancel_minutes)。"""
from app.api.system_config import get_auto_cancel_minutes_for_job
timeout_minutes = get_auto_cancel_minutes_for_job(db)
before_time = datetime.now() - timedelta(minutes=timeout_minutes)
timeout_note = f"超过未支付自动取消时限({timeout_minutes} 分钟)"
return order_crud.close_unpaid_online_orders(
db, before_time=before_time, timeout_note=timeout_note
)
def run_close_timeout_online_orders(db: Session):
"""定时任务包装。"""
count = close_timeout_online_orders(db)
logger.info(f"线上支付超时订单自动关闭完成: {count} 条")
+2
View File
@@ -0,0 +1,2 @@
"""WebSocket modules."""
+187
View File
@@ -0,0 +1,187 @@
"""WebSocket:区域价格清单更新推送(客户端实时刷新)"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from typing import Dict, Set, Optional, Any
import anyio
from fastapi import WebSocket, WebSocketDisconnect
from app.core.database import SessionLocal
from app.core.auth import get_current_user_by_token
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class PriceListUpdatedEvent:
regionId: str
priceListId: str
priceVersion: int
publishedTime: Optional[str] = None
def to_message(self) -> dict:
return {
"event": "price_list_updated",
"data": {
"regionId": self.regionId,
"priceListId": self.priceListId,
"priceVersion": self.priceVersion,
"publishedTime": self.publishedTime,
},
}
class RegionWSManager:
"""按 region_id 管理 websocket 连接并广播。"""
def __init__(self) -> None:
self._lock = asyncio.Lock()
self._rooms: Dict[str, Set[WebSocket]] = {}
async def connect(self, websocket: WebSocket, *, token: str) -> dict:
"""鉴权并接入房间。返回 current_user dict。"""
# 验证 token(包含 Redis 会话校验 + DB 用户校验)
db = SessionLocal()
try:
current_user = await get_current_user_by_token(token, db)
finally:
db.close()
region_id = (current_user.get("region_id") or "").strip()
if not region_id:
logger.warning("[ws-mini] reject: empty region_id user=%s", current_user.get("id"))
await websocket.close(code=1008)
raise RuntimeError("user has no region_id")
await websocket.accept()
async with self._lock:
self._rooms.setdefault(region_id, set()).add(websocket)
room_size = len(self._rooms.get(region_id, set()))
logger.warning(
"[ws-mini] connected user=%s role=%s region=%s room_size=%s",
current_user.get("id"),
current_user.get("role"),
region_id,
room_size,
)
return current_user
async def disconnect(self, websocket: WebSocket, *, region_id: str) -> None:
async with self._lock:
s = self._rooms.get(region_id)
if not s:
return
if websocket in s:
s.remove(websocket)
room_size = len(s)
if not s:
self._rooms.pop(region_id, None)
logger.warning("[ws-mini] disconnected region=%s room_size=%s", region_id, room_size if "room_size" in locals() else 0)
async def broadcast_region(self, region_id: str, message: dict) -> int:
"""向 region 房间广播消息。返回尝试发送的连接数。"""
async with self._lock:
targets = list(self._rooms.get(region_id, set()))
if not targets:
logger.warning("[ws-mini] broadcast skip: no targets region=%s event=%s", region_id, message.get("event"))
return 0
logger.warning(
"[ws-mini] broadcasting region=%s event=%s target_count=%s",
region_id,
message.get("event"),
len(targets),
)
dead: list[WebSocket] = []
for ws in targets:
try:
await ws.send_json(message)
except Exception:
dead.append(ws)
if dead:
async with self._lock:
room = self._rooms.get(region_id)
if room:
for ws in dead:
room.discard(ws)
if not room:
self._rooms.pop(region_id, None)
return len(targets)
manager = RegionWSManager()
async def ws_mini_price_updates(websocket: WebSocket) -> None:
"""小程序端 WebSocket 入口:/ws/mini"""
token = (websocket.query_params.get("token") or "").strip()
if not token:
# 兼容 Authorization: Bearer <token>
auth = (websocket.headers.get("authorization") or "").strip()
if auth.lower().startswith("bearer "):
token = auth[7:].strip()
if not token:
logger.warning("[ws-mini] reject: missing token ip=%s", websocket.client.host if websocket.client else "")
await websocket.close(code=1008)
return
try:
current_user = await manager.connect(websocket, token=token)
except Exception:
logger.exception("[ws-mini] connect failed")
# connect 内部会 close;这里兜底
try:
await websocket.close(code=1008)
except Exception:
pass
return
region_id = (current_user.get("region_id") or "").strip()
try:
# 当前实现:仅服务器推送;客户端无需主动发消息。
while True:
await websocket.receive_text()
except WebSocketDisconnect:
logger.warning("[ws-mini] client disconnected user=%s region=%s", current_user.get("id"), region_id)
pass
except Exception:
logger.exception("[ws-mini] receive loop error user=%s region=%s", current_user.get("id"), region_id)
pass
finally:
await manager.disconnect(websocket, region_id=region_id)
def notify_price_list_updated(event: PriceListUpdatedEvent) -> None:
"""线程/同步上下文安全:广播价格更新事件。"""
async def _run():
sent = await manager.broadcast_region(event.regionId, event.to_message())
logger.warning(
"[ws-mini] notify price update region=%s price_list_id=%s version=%s sent=%s",
event.regionId,
event.priceListId,
event.priceVersion,
sent,
)
try:
loop = asyncio.get_running_loop()
loop.create_task(_run())
except RuntimeError:
# sync endpoint 常在 worker thread:使用 anyio 跳回事件循环
anyio.from_thread.run(_run)
def register_ws_routes(app: Any) -> None:
"""在 FastAPI app 上注册 websocket 路由。"""
app.websocket("/ws/mini")(ws_mini_price_updates)
+17
View File
@@ -0,0 +1,17 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
pymysql==1.1.0
pydantic==2.5.3
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
cryptography>=42.0.0
bcrypt==4.0.1
python-multipart==0.0.6
alembic==1.13.1
redis==5.0.1
httpx==0.26.0
python-dotenv==1.0.0
apscheduler==3.10.4
minio==7.2.20
Pillow>=10.0.0
@@ -0,0 +1,17 @@
-- /api/web/admin/api-request-logs 列表性能索引补强
-- 典型查询:
-- WHERE is_del=0 [AND http_method=?] [AND status_code=?]
-- [AND create_at between ? and ?]
-- ORDER BY create_at DESC
-- 默认列表 + 时间倒序分页
ALTER TABLE sys_api_request_log
ADD INDEX idx_arl_isdel_createat (is_del, create_at);
-- 按方法过滤
ALTER TABLE sys_api_request_log
ADD INDEX idx_arl_isdel_method_createat (is_del, http_method, create_at);
-- 按状态码过滤
ALTER TABLE sys_api_request_log
ADD INDEX idx_arl_isdel_status_createat (is_del, status_code, create_at);
@@ -0,0 +1,12 @@
-- 给 /api/web/admin/products 列表(GET,前端加密传输时为 POST + X-HTTP-Method-Override: GET)做索引补强
-- 典型 SQL:WHERE is_del = 0 [AND category_id = ?] [AND (name LIKE ... OR description LIKE ...)] ORDER BY create_at DESC
-- LIKE '%keyword%' 无法有效走 name/description 索引;无关键词时下面索引可帮助 COUNT + 分页排序。
-- 执行前请先 SHOW INDEX FROM bas_product; 若索引已存在可跳过对应语句。
-- 1) 全量列表 / 仅启用筛选:is_del + 排序字段
ALTER TABLE bas_product
ADD INDEX idx_product_isdel_createat (is_del, create_at);
-- 2) 按分类筛选 + 排序(与 1 并存;分类列表走此索引更省扫描)
ALTER TABLE bas_product
ADD INDEX idx_product_isdel_category_createat (is_del, category_id, create_at);
@@ -0,0 +1,26 @@
-- /api/web/delivery-tasks 列表性能索引补强
-- 目标查询特征:
-- 1) WHERE is_del=0 ORDER BY create_at DESC(默认列表)
-- 2) WHERE is_del=0 AND region_id=? AND status=? ORDER BY create_at DESC
-- 3) WHERE is_del=0 AND deliver_id=? ORDER BY create_at DESC
-- 4) 列表中按 task_id 聚合明细数量(biz_delivery_task_item)
-- 1) 默认列表与时间倒序分页
ALTER TABLE biz_delivery_task
ADD INDEX idx_dt_isdel_createat (is_del, create_at);
-- 2) 区域 + 状态过滤场景
ALTER TABLE biz_delivery_task
ADD INDEX idx_dt_isdel_region_status_createat (is_del, region_id, status, create_at);
-- 3) 配送员维度过滤场景
ALTER TABLE biz_delivery_task
ADD INDEX idx_dt_isdel_deliver_createat (is_del, deliver_id, create_at);
-- 4) 供应商维度过滤场景(供应商端任务列表)
ALTER TABLE biz_delivery_task
ADD INDEX idx_dt_isdel_supplier_createat (is_del, supplier_id, create_at);
-- 5) 列表 itemCount 聚合:WHERE task_id IN (...) AND is_del=0 GROUP BY task_id
ALTER TABLE biz_delivery_task_item
ADD INDEX idx_dti_task_isdel (task_id, is_del);
Loaded 100 of 3454 files, more files were not shown because too many files have changed in this diff. Show more