提交
This commit is contained in:
1 parent
d336fd0226
commit
2e9d020d50
3454 files changed
+878963
-319
No files matched your search
+102
@@ -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、分类全路径),需同步评估接口与类型定义,避免前端仅末级展示造成歧义。
|
||||
- 提交信息使用完整句子,说明「改了什么、为何」,避免无意义前缀堆砌。
|
||||
|
||||
---
|
||||
|
||||
## 语言
|
||||
|
||||
- 与用户沟通时:若用户使用简体中文,助手回复使用**简体中文**。
|
||||
- 代码注释与用户可见文案:遵循项目现有语言习惯(中文业务文案以产品为准)。
|
||||
@@ -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
|
||||
@@ -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 进行用户认证
|
||||
@@ -0,0 +1 @@
|
||||
11632
|
||||
@@ -0,0 +1 @@
|
||||
# 中科兴皖一体化门户后端(骨架)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""API 包:业务路由模块已删除,仅保留包占位。恢复历史实现请使用 git。"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.core.config import settings
|
||||
|
||||
__all__ = ["settings"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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, "请使用加密方式登录"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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
@@ -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="创建时间")
|
||||
@@ -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="历史记录创建时间")
|
||||
@@ -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="历史记录创建时间")
|
||||
@@ -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="历史记录创建时间")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = "操作成功"
|
||||
@@ -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()
|
||||
@@ -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 = "任务重新分配成功"
|
||||
@@ -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" # 已取消
|
||||
@@ -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="备注")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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} 条")
|
||||
@@ -0,0 +1,2 @@
|
||||
"""WebSocket modules."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in new issue
Block a user