1
0
forked from erp-dev/erp

feat: sse && multi_merchant completed

This commit is contained in:
2025-11-11 10:56:20 +08:00
parent b2078dfa46
commit 2aafb93aad
43 changed files with 2445 additions and 244 deletions

20
api_v1/views/__init__.py Normal file
View File

@@ -0,0 +1,20 @@
"""
API v1 views package
"""
# 使用新的类视图版本(模块化重构)
from .stock_change_views import (
create_full_stock_change,
list_stock_changes,
get_stock_change,
set_merchant_auto_complete_stock_change,
)
# 旧版本保留在 stock_change.py 文件中,测试通过后可删除
__all__ = [
'create_full_stock_change',
'list_stock_changes',
'get_stock_change',
'set_merchant_auto_complete_stock_change',
]

176
api_v1/views/inventory.py Normal file
View File

@@ -0,0 +1,176 @@
from rest_framework.generics import GenericAPIView
from rest_framework.mixins import ListModelMixin
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import serializers, status
from rest_framework.pagination import LimitOffsetPagination
from stock import models as stock_models
from basic_info import models as basic_models
from api_man.serializers import ProductSerializer
import logging
logger = logging.getLogger(__name__)
class WarehouseSimpleSerializer(serializers.ModelSerializer):
"""仓库简单序列化器"""
class Meta:
model = basic_models.WareHouse
fields = ['id', 'name', 'location', 'area']
class InventorySerializer(serializers.ModelSerializer):
"""库存项序列化器"""
product = ProductSerializer(read_only=True)
warehouse = WarehouseSimpleSerializer(read_only=True)
class Meta:
model = stock_models.Inventory
fields = [
'id',
'product',
'warehouse',
'quantity',
'num_of_rolls',
'spec',
]
def __init__(self, *args, **kwargs):
"""重写初始化方法,确保 context 传递给嵌套序列化器"""
super().__init__(*args, **kwargs)
# 如果有 context将其传递给嵌套的序列化器字段
if hasattr(self, 'context'):
for field_name, field in self.fields.items():
if isinstance(field, serializers.ModelSerializer):
field.context.update(self.context)
class InventoryAPIView(ListModelMixin, GenericAPIView):
"""
库存查询接口
只返回当前用户所属商户的库存信息
查询参数:
- limit: 返回的记录数量默认20最大100
- offset: 跳过的记录数量默认0
- warehouse: 仓库ID可选
- product: 产品ID可选
示例:
- /api/v1/inventory/ - 获取前20条
- /api/v1/inventory/?limit=50&offset=0 - 获取前50条
- /api/v1/inventory/?limit=20&offset=40 - 跳过前40条获取接下来的20条
"""
serializer_class = InventorySerializer
pagination_class = LimitOffsetPagination
permission_classes = [IsAuthenticated]
def get_queryset(self):
"""
获取查询集,自动过滤当前用户所属商户的库存
"""
# 检查用户是否有员工身份
if not hasattr(self.request.user, 'employee'):
logger.warning(f"用户 {self.request.user.username} 无员工信息,拒绝访问库存")
return stock_models.Inventory.objects.none()
employee = self.request.user.employee
merchant_id = employee.merchant_id
# 使用 select_related 优化查询,只查询当前商户的库存
queryset = stock_models.Inventory.objects.filter(
merchant_id=merchant_id
).select_related(
'product',
'product__category',
'warehouse'
).order_by('-quantity', 'product__name')
# 可选过滤:按仓库
warehouse_id = self.request.query_params.get('warehouse')
if warehouse_id:
try:
warehouse_id = int(warehouse_id)
# 验证仓库属于当前商户
if basic_models.WareHouse.objects.filter(
id=warehouse_id,
merchant_id=merchant_id
).exists():
queryset = queryset.filter(warehouse_id=warehouse_id)
else:
logger.warning(f"仓库ID {warehouse_id} 不属于商户 {merchant_id}")
return queryset.none()
except ValueError:
logger.warning(f"warehouse 参数格式错误: {warehouse_id}")
return queryset.none()
# 可选过滤:按产品
product_id = self.request.query_params.get('product')
if product_id:
try:
product_id = int(product_id)
# 验证产品属于当前商户
if basic_models.Product.objects.filter(
id=product_id,
merchant_id=merchant_id
).exists():
queryset = queryset.filter(product_id=product_id)
else:
logger.warning(f"产品ID {product_id} 不属于商户 {merchant_id}")
return queryset.none()
except ValueError:
logger.warning(f"product 参数格式错误: {product_id}")
return queryset.none()
return queryset
def get_serializer_context(self):
"""
确保序列化器获得 request context
"""
context = super().get_serializer_context()
context['request'] = self.request
return context
def list(self, request, *args, **kwargs):
"""
重写 list 方法,添加统计信息
"""
queryset = self.filter_queryset(self.get_queryset())
# 统计信息(在分页前)
total_items = queryset.count()
total_quantity = sum(item.quantity for item in queryset)
total_rolls = sum(item.num_of_rolls for item in queryset)
# 使用 ListModelMixin 的分页和序列化逻辑
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
response = self.get_paginated_response(serializer.data)
# 添加统计信息
response.data['statistics'] = {
'total_items': total_items,
'total_quantity': float(total_quantity),
'total_rolls': total_rolls,
}
logger.info(
f"用户 {request.user.username} 查询库存列表,"
f"商户ID: {request.user.employee.merchant_id},共 {total_items} 条记录"
)
return response
# 如果没有分页,返回全部数据
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def get(self, request, *args, **kwargs):
"""
GET 请求处理
"""
return self.list(request, *args, **kwargs)

View File

@@ -0,0 +1,185 @@
"""
产品图片上传视图
处理前后端分离场景下的图片上传
"""
from rest_framework.views import APIView
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import status
from basic_info.models import Product
import logging
logger = logging.getLogger(__name__)
class ProductImageUploadView(APIView):
"""
产品图片上传接口
支持两种方式:
1. 直接上传图片文件multipart/form-data
2. 更新已有产品的图片
"""
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
def post(self, request, product_id):
"""
上传/更新产品图片
请求参数:
- product_id: 产品 IDURL 路径参数)
- image: 图片文件multipart/form-data
前端使用示例:
```javascript
const formData = new FormData();
formData.append('image', file);
fetch('/api/v1/products/{id}/image/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData
});
```
"""
# 检查用户权限
if not hasattr(request.user, 'employee'):
return Response(
{'error': '用户无员工信息,无权操作'},
status=status.HTTP_403_FORBIDDEN
)
merchant_id = request.user.employee.merchant_id
# 获取产品
try:
product = Product.objects.get(id=product_id, merchant_id=merchant_id)
except Product.DoesNotExist:
return Response(
{'error': f'产品 ID {product_id} 不存在或不属于当前商户'},
status=status.HTTP_404_NOT_FOUND
)
# 获取上传的图片
image_file = request.FILES.get('image')
if not image_file:
return Response(
{'error': '请提供图片文件字段名image'},
status=status.HTTP_400_BAD_REQUEST
)
# 验证文件类型
allowed_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
if image_file.content_type not in allowed_types:
return Response(
{
'error': f'不支持的图片格式:{image_file.content_type}',
'allowed_types': allowed_types
},
status=status.HTTP_400_BAD_REQUEST
)
# 验证文件大小(最大 5MB
max_size = 5 * 1024 * 1024 # 5MB
if image_file.size > max_size:
return Response(
{
'error': f'图片大小超过限制:{image_file.size} bytes',
'max_size': f'{max_size / 1024 / 1024}MB'
},
status=status.HTTP_400_BAD_REQUEST
)
try:
# 保存图片(会自动上传到七牛云)
old_image = product.image
product.image = image_file
product.save(update_fields=['image', 'updated_at'])
# 删除旧图片(如果存在)
# 注意:七牛云的 delete 需要特殊处理,这里只是示例
if old_image:
try:
old_image.delete(save=False)
except Exception as e:
logger.warning(f"删除旧图片失败: {e}")
# 构建图片 URL
image_url = request.build_absolute_uri(product.image.url)
logger.info(
f"产品 {product_id} 图片上传成功,"
f"用户:{request.user.username},大小:{image_file.size} bytes"
)
return Response({
'message': '图片上传成功',
'product_id': product.id,
'image_url': image_url,
'image_name': product.image.name,
'size': image_file.size,
}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"图片上传失败: {str(e)}", exc_info=True)
return Response(
{'error': '图片上传失败', 'message': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
def delete(self, request, product_id):
"""
删除产品图片
请求参数:
- product_id: 产品 IDURL 路径参数)
"""
# 检查用户权限
if not hasattr(request.user, 'employee'):
return Response(
{'error': '用户无员工信息,无权操作'},
status=status.HTTP_403_FORBIDDEN
)
merchant_id = request.user.employee.merchant_id
# 获取产品
try:
product = Product.objects.get(id=product_id, merchant_id=merchant_id)
except Product.DoesNotExist:
return Response(
{'error': f'产品 ID {product_id} 不存在或不属于当前商户'},
status=status.HTTP_404_NOT_FOUND
)
if not product.image:
return Response(
{'message': '产品没有图片'},
status=status.HTTP_200_OK
)
try:
# 删除图片
product.image.delete(save=False)
product.image = None
product.save(update_fields=['image', 'updated_at'])
logger.info(f"产品 {product_id} 图片已删除,用户:{request.user.username}")
return Response({
'message': '图片删除成功',
'product_id': product.id
}, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"图片删除失败: {str(e)}", exc_info=True)
return Response(
{'error': '图片删除失败', 'message': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View File

@@ -0,0 +1,137 @@
# Stock Change Views 重构说明
## 重构概览
将原来的 `stock_change.py` 单文件546行重构为模块化的类视图结构。
## 文件结构
```
stock_change_views/
├── __init__.py # 模块导出,提供向后兼容的函数式接口
├── mixins.py # 公共 Mixin 类,提供可复用方法
├── create.py # 创建库存变动视图
├── list.py # 列表查询视图
├── detail.py # 详情查询视图
└── settings.py # 商户设置视图
```
## 重构收益
### 1. 代码复用(减少 ~60% 重复代码)
**重复逻辑提取到 `StockChangeViewMixin`**
-`check_employee_permission()` - 员工权限检查
-`validate_warehouse_visibility()` - 仓库可见性验证
-`validate_product_visibility()` - 产品可见性验证
-`filter_visible_details()` - 过滤可见明细
-`build_record_data()` - 构建记录数据
-`build_detail_data()` - 构建明细数据
-`error_response()` - 统一错误响应
-`permission_error_response()` - 权限错误响应
-`not_found_response()` - 未找到响应
### 2. 代码组织清晰
| 文件 | 行数 | 职责 |
|------|------|------|
| `mixins.py` | ~100 | 公共方法 |
| `create.py` | ~190 | 创建逻辑 |
| `list.py` | ~230 | 列表查询 |
| `detail.py` | ~120 | 详情查询 |
| `settings.py` | ~70 | 设置接口 |
**对比原文件 546 行单文件,每个类职责更清晰。**
### 3. 更好的可维护性
- 每个视图类独立文件,修改不影响其他视图
- Mixin 统一管理公共逻辑,修改一处即可
- 更容易编写单元测试
### 4. 符合 DRF 规范
- 使用 `APIView` 类视图
- 明确的 `permission_classes`
- 完整的 `@extend_schema` 文档注释
## 向后兼容
`__init__.py` 中导出函数式接口:
```python
# 这些接口保持不变URL 配置无需修改
create_full_stock_change = CreateStockChangeView.as_view()
list_stock_changes = ListStockChangesView.as_view()
get_stock_change = GetStockChangeView.as_view()
set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view()
```
## 逻辑一致性保证
**所有业务逻辑完全保持不变:**
- 权限检查逻辑相同
- 数据验证逻辑相同
- 数据库操作逻辑相同
- 响应格式相同
- 错误处理相同
## 使用方式
### 在 URL 配置中(无需修改)
```python
from api_v1.views import stock_change_views
urlpatterns = [
path('stock-change/', stock_change_views.create_full_stock_change),
path('stock-changes/', stock_change_views.list_stock_changes),
path('stock-change/<int:record_id>/', stock_change_views.get_stock_change),
path('set-merchant-auto-complete-stock-change/', stock_change_views.set_merchant_auto_complete_stock_change),
]
```
### 也可以直接使用类视图
```python
from api_v1.views.stock_change_views import (
CreateStockChangeView,
ListStockChangesView,
GetStockChangeView,
SetMerchantAutoCompleteView,
)
urlpatterns = [
path('stock-change/', CreateStockChangeView.as_view()),
path('stock-changes/', ListStockChangesView.as_view()),
path('stock-change/<int:record_id>/', GetStockChangeView.as_view()),
path('set-merchant-auto-complete-stock-change/', SetMerchantAutoCompleteView.as_view()),
]
```
## 迁移步骤
1. ✅ 创建新的 `stock_change_views/` 模块
2. ✅ 实现所有类视图,保持逻辑一致
3. ✅ 在 `views/__init__.py` 中添加兼容导入
4. ⏳ 测试所有接口功能正常
5. ⏳ 删除旧的 `stock_change.py` 文件
## 测试验证
```bash
# 测试导入
python manage.py shell -c "from api_v1.views.stock_change_views import create_full_stock_change; print('✓ 导入成功')"
# 测试服务器启动
python manage.py runserver
# 测试 API 接口
curl -X POST http://localhost:8000/api/v1/stock-change/ -H "Authorization: Bearer <token>" -d '{"type": 1, ...}'
```
## 下一步
如果测试通过,可以安全删除 `api_v1/views/stock_change.py` 文件。

View File

@@ -0,0 +1,29 @@
"""
库存变动视图模块
使用类视图重构,提供更好的代码复用和可维护性
"""
from .mixins import StockChangeViewMixin
from .create import CreateStockChangeView
from .list import ListStockChangesView
from .detail import GetStockChangeView
from .settings import SetMerchantAutoCompleteView
# 向后兼容:保持原有的函数式接口
create_full_stock_change = CreateStockChangeView.as_view()
list_stock_changes = ListStockChangesView.as_view()
get_stock_change = GetStockChangeView.as_view()
set_merchant_auto_complete_stock_change = SetMerchantAutoCompleteView.as_view()
__all__ = [
'StockChangeViewMixin',
'CreateStockChangeView',
'ListStockChangesView',
'GetStockChangeView',
'SetMerchantAutoCompleteView',
'create_full_stock_change',
'list_stock_changes',
'get_stock_change',
'set_merchant_auto_complete_stock_change',
]

View File

@@ -0,0 +1,174 @@
"""
创建库存变动记录视图
"""
from rest_framework import status, views
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.db import transaction
import logging
from stock import models as stock_models
from basic_info import models as basic_info_models
from drf_spectacular.utils import extend_schema
from api_v1 import serializers
from .mixins import StockChangeViewMixin
logger = logging.getLogger(__name__)
class CreateStockChangeView(StockChangeViewMixin, views.APIView):
"""创建完整的库存变动记录"""
permission_classes = [IsAuthenticated]
@extend_schema(
tags=['创建出入库'],
request=serializers.CreateStockChangeSerializer,
responses={201: serializers.CreateStockChangeResponseSerializer},
summary="创建完整的库存变动记录",
description="创建一个新的库存变动记录及其明细"
)
def post(self, request):
"""
创建完整的库存变动记录(包含记录创建参数)
请求参数:
{
"type": 1, // 1=入库, 2=出库
"warehouse": 1, // 仓库ID
"source_type": 1, // 来源类型
"source_id": 1, // 可选来源单据ID
"products": [
{
"product": 1,
"quantity": [85, 75, 90]
}
]
}
"""
# 检查员工权限
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
# 提取库存变动记录参数
record_data = {
'type': request.data.get('type'),
'warehouse': request.data.get('warehouse'),
'source_type': request.data.get('source_type'),
'source_id': request.data.get('source_id'),
}
# 验证必要参数
if not all([record_data['type'], record_data['warehouse'], record_data['source_type']]):
return Response({
'error': '缺少必要参数',
'message': '请提供 type, warehouse, source_type'
}, status=status.HTTP_400_BAD_REQUEST)
products_data = request.data.get('products', [])
if not products_data:
return Response({
'error': '产品列表不能为空'
}, status=status.HTTP_400_BAD_REQUEST)
# 验证仓库是否本员工可见
if not self.validate_warehouse_visibility(record_data['warehouse'], request):
return Response({
'error': f'仓库ID {record_data["warehouse"]} 对当前用户不可见'
}, status=status.HTTP_403_FORBIDDEN)
# 验证产品是否本员工可见
for p in products_data:
product_id = p.get('product')
if not self.validate_product_visibility(product_id, request):
return Response({
'error': f'产品ID {product_id} 对当前用户不可见'
}, status=status.HTTP_403_FORBIDDEN)
# 验证产品数据
product_serializer = serializers.CreateStockChangeSerializer(data={
'products': products_data
})
if not product_serializer.is_valid():
return Response({
'error': '产品数据验证失败',
'details': product_serializer.errors
}, status=status.HTTP_400_BAD_REQUEST)
try:
with transaction.atomic():
# 1. 创建库存变动记录
try:
warehouse = basic_info_models.WareHouse.objects.get(id=record_data['warehouse'])
except basic_info_models.WareHouse.DoesNotExist:
return Response({
'error': f'仓库ID {record_data["warehouse"]} 不存在'
}, status=status.HTTP_400_BAD_REQUEST)
stock_change_record = stock_models.StockChangeRecord.objects.create(
type=record_data['type'],
warehouse=warehouse,
source_type=record_data['source_type'],
source_id=record_data['source_id'],
merchant=request.user.employee.merchant,
created_by=request.user
)
logger.info(f"创建新库存变动记录 ID: {stock_change_record.id}")
# 2. 创建库存变动明细
created_details = []
created_count = 0
for product_data in products_data:
product_id = product_data['product']
quantities = product_data['quantity']
# 获取产品信息
try:
product = basic_info_models.Product.objects.get(id=product_id)
except basic_info_models.Product.DoesNotExist:
raise ValueError(f'产品ID {product_id} 不存在')
# 为每个数量创建明细记录
for quantity in quantities:
detail = stock_models.StockChangeDetail.objects.create(
stock_change_record=stock_change_record,
product=product,
quantity=quantity,
merchant=request.user.employee.merchant,
unit=product.unit
)
created_details.append(detail)
created_count += 1
if warehouse.merchant.auto_complete_stock_change:
# 自动确认库存变动
from stock import services as stock_services
stock_services.make_stock_change_completed(stock_change_record)
logger.info(f"自动确认库存变动记录 ID: {stock_change_record.id}")
# 3. 构建响应
response_serializer = serializers.CreateStockChangeResponseSerializer({
'stock_change_record': stock_change_record,
'details': created_details,
'message': f'成功创建库存变动记录及 {created_count} 条明细',
'created_details_count': created_count
})
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
except ValueError as e:
return Response({
'error': str(e)
}, status=status.HTTP_400_BAD_REQUEST)
except Exception as e:
logger.error(f"创建完整库存变动记录失败: {str(e)}", exc_info=True)
return Response({
'error': '创建库存变动记录失败',
'message': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -0,0 +1,113 @@
"""
库存变动记录详情查询视图
"""
from rest_framework import status, views
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
import logging
from stock import models as stock_models
from drf_spectacular.utils import extend_schema
from .mixins import StockChangeViewMixin
logger = logging.getLogger(__name__)
class GetStockChangeView(StockChangeViewMixin, views.APIView):
"""获取单个库存变动记录"""
permission_classes = [IsAuthenticated]
@extend_schema(
tags=['读取出入库'],
responses={200: dict},
summary="获取单个库存变动记录",
description="根据ID获取库存变动记录详情包含明细"
)
def get(self, request, record_id):
"""
获取库存变动记录详情(包含所有明细)
GET /api/v1/stock-change/<record_id>/
返回:
{
"stock_change_record": {
"id": 1,
"type": 1,
"warehouse": 1,
"warehouse_name": "主仓库",
"source_type": 1,
"source_id": 1,
"is_finished": true,
"finished_at": "2024-01-01T12:00:00Z",
"created_at": "2024-01-01T12:00:00Z",
"created_by": "admin"
},
"details": [
{
"id": 1,
"product": 1,
"product_name": "产品名称",
"quantity": 85.00,
"unit": 1,
"unit_display": ""
}
],
"total_details": 3
}
"""
# 检查员工权限
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
merchant_id = self.get_merchant_id(request)
try:
# 获取库存变动记录
try:
stock_change_record = stock_models.StockChangeRecord.objects.select_related(
'warehouse', 'created_by'
).get(id=record_id)
except stock_models.StockChangeRecord.DoesNotExist:
return self.not_found_response(f'库存变动记录ID {record_id} 不存在')
# 验证权限:检查记录是否属于当前用户的商户
if stock_change_record.merchant_id != merchant_id:
return self.permission_error_response('无权访问该库存变动记录')
# 验证仓库是否对当前用户可见
if not self.validate_warehouse_visibility(stock_change_record.warehouse_id, request):
return self.permission_error_response('该库存变动记录的仓库对当前用户不可见')
# 获取所有明细记录
details = stock_models.StockChangeDetail.objects.filter(
stock_change_record=stock_change_record
).select_related('product').order_by('id')
# 过滤掉用户不可见的产品明细
visible_details = self.filter_visible_details(details, request)
# 构建响应数据
response_data = {
'stock_change_record': self.build_record_data(stock_change_record),
'details': [self.build_detail_data(detail) for detail in visible_details],
'total_details': len(visible_details)
}
logger.info(f"用户 {request.user.username} 读取库存变动记录 ID: {record_id}")
return Response(response_data, status=status.HTTP_200_OK)
except AttributeError as e:
logger.error(f"用户 {request.user.username} 无员工信息: {str(e)}", exc_info=True)
return self.permission_error_response('用户无员工信息,无权访问')
except Exception as e:
logger.error(f"读取库存变动记录失败: {str(e)}", exc_info=True)
return Response({
'error': '读取库存变动记录失败',
'message': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -0,0 +1,221 @@
"""
库存变动记录列表查询视图
"""
from rest_framework import status, views
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from datetime import datetime, timedelta
import logging
from stock import models as stock_models
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from .mixins import StockChangeViewMixin
logger = logging.getLogger(__name__)
class ListStockChangesView(StockChangeViewMixin, views.APIView):
"""获取库存变动记录列表"""
permission_classes = [IsAuthenticated]
@extend_schema(
tags=['读取出入库列表'],
parameters=[
OpenApiParameter(name='start', type=OpenApiTypes.DATE, description='开始日期 (YYYY-MM-DD),默认为昨天'),
OpenApiParameter(name='end', type=OpenApiTypes.DATE, description='结束日期 (YYYY-MM-DD),默认为昨天'),
OpenApiParameter(name='type', type=OpenApiTypes.INT, description='变动类型 (1=入库, 2=出库)'),
OpenApiParameter(name='warehouse', type=OpenApiTypes.INT, description='仓库ID'),
OpenApiParameter(name='product', type=OpenApiTypes.INT, description='产品ID'),
OpenApiParameter(name='is_finished', type=OpenApiTypes.BOOL, description='是否已完成'),
OpenApiParameter(name='include_details', type=OpenApiTypes.BOOL, description='是否包含明细默认true'),
],
responses={200: dict},
summary="获取库存变动记录列表",
description="按时间范围查询库存变动记录,支持多种过滤条件"
)
def get(self, request):
"""
获取库存变动记录列表(按时间范围查询)
查询参数:
- start: 开始日期 (YYYY-MM-DD),可选,默认为昨天
- end: 结束日期 (YYYY-MM-DD),可选,默认为昨天
- type: 变动类型 (1=入库, 2=出库),可选
- warehouse: 仓库ID可选
- product: 产品ID可选
- is_finished: 是否已完成 (true/false),可选
- include_details: 是否包含明细记录 (true/false),可选,默认为 true
"""
# 检查员工权限
if not self.check_employee_permission(request):
return self.permission_error_response('无权限访问')
merchant_id = self.get_merchant_id(request)
try:
# 获取查询参数
start_date_str = request.GET.get('start')
end_date_str = request.GET.get('end')
# 默认查询昨天的数据
if not start_date_str and not end_date_str:
yesterday = datetime.now().date() - timedelta(days=1)
start_date = yesterday
end_date = yesterday
else:
# 解析日期参数
try:
if start_date_str:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
else:
start_date = datetime.now().date() - timedelta(days=1)
if end_date_str:
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
else:
end_date = start_date
except ValueError:
return Response({
'error': '日期格式错误,请使用 YYYY-MM-DD 格式'
}, status=status.HTTP_400_BAD_REQUEST)
# 验证日期范围
if start_date > end_date:
return Response({
'error': '开始日期不能大于结束日期'
}, status=status.HTTP_400_BAD_REQUEST)
# 构建查询
queryset = stock_models.StockChangeRecord.objects.filter(
merchant_id=merchant_id,
created_at__date__gte=start_date,
created_at__date__lte=end_date
).select_related('warehouse', 'created_by')
# 可选过滤条件
change_type = request.GET.get('type')
if change_type:
try:
queryset = queryset.filter(type=int(change_type))
except ValueError:
return Response({
'error': 'type 参数必须为整数'
}, status=status.HTTP_400_BAD_REQUEST)
warehouse_id = request.GET.get('warehouse')
if warehouse_id:
try:
warehouse_id = int(warehouse_id)
# 验证仓库是否对用户可见
if not self.validate_warehouse_visibility(warehouse_id, request):
return Response({
'error': f'仓库ID {warehouse_id} 对当前用户不可见'
}, status=status.HTTP_403_FORBIDDEN)
queryset = queryset.filter(warehouse_id=warehouse_id)
except ValueError:
return Response({
'error': 'warehouse 参数必须为整数'
}, status=status.HTTP_400_BAD_REQUEST)
# 产品过滤参数
product_id = request.GET.get('product')
if product_id:
try:
product_id = int(product_id)
# 验证产品是否对用户可见
if not self.validate_product_visibility(product_id, request):
return Response({
'error': f'产品ID {product_id} 对当前用户不可见'
}, status=status.HTTP_403_FORBIDDEN)
# 只返回包含该产品的库存变动记录
queryset = queryset.filter(details__product_id=product_id).distinct()
except ValueError:
return Response({
'error': 'product 参数必须为整数'
}, status=status.HTTP_400_BAD_REQUEST)
is_finished = request.GET.get('is_finished')
if is_finished is not None:
if is_finished.lower() == 'true':
queryset = queryset.filter(is_finished=True)
elif is_finished.lower() == 'false':
queryset = queryset.filter(is_finished=False)
# 是否包含明细记录,默认为 true
include_details_str = request.GET.get('include_details', 'true')
include_details = include_details_str.lower() != 'false'
# 过滤掉用户不可见的仓库和产品的记录
visible_records = []
for record in queryset:
# 检查仓库可见性
if not self.validate_warehouse_visibility(record.warehouse_id, request):
continue
# 检查该记录是否包含至少一个用户可见的产品
has_visible_product = False
record_details = stock_models.StockChangeDetail.objects.filter(
stock_change_record=record
).select_related('product')
for detail in record_details:
if self.validate_product_visibility(detail.product_id, request):
has_visible_product = True
break
# 只有当记录包含至少一个可见产品时才添加
if has_visible_product:
visible_records.append(record)
# 构建响应数据
results = []
for record in visible_records:
# 获取该记录的所有明细
all_details = stock_models.StockChangeDetail.objects.filter(
stock_change_record=record
).select_related('product')
# 只统计和返回用户可见的产品明细
visible_details = self.filter_visible_details(all_details, request)
total_quantity = sum(float(detail.quantity) for detail in visible_details)
record_data = self.build_record_data(record)
record_data['details_count'] = len(visible_details)
record_data['total_quantity'] = total_quantity
# 如果需要包含明细,则添加明细数据
if include_details:
record_data['details'] = [self.build_detail_data(detail) for detail in visible_details]
results.append(record_data)
response_data = {
'count': len(results),
'results': results,
'date_range': {
'start': start_date.strftime('%Y-%m-%d'),
'end': end_date.strftime('%Y-%m-%d')
}
}
logger.info(
f"用户 {request.user.username} 查询库存变动记录列表,"
f"时间范围: {start_date}{end_date},共 {len(results)}"
)
return Response(response_data, status=status.HTTP_200_OK)
except AttributeError as e:
logger.error(f"用户 {request.user.username} 无员工信息: {str(e)}", exc_info=True)
return self.permission_error_response('用户无员工信息,无权访问')
except Exception as e:
logger.error(f"查询库存变动记录列表失败: {str(e)}", exc_info=True)
return Response({
'error': '查询库存变动记录列表失败',
'message': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -0,0 +1,90 @@
"""
库存变动视图的公共 Mixin
提供通用的权限检查、数据构建、错误响应等方法
"""
from rest_framework import status
from rest_framework.response import Response
from basic_info.services import DataVisibilityService
import logging
logger = logging.getLogger(__name__)
class StockChangeViewMixin:
"""库存变动视图基类,提供公共方法"""
def get_merchant_id(self, request):
"""获取当前用户的商户ID"""
try:
return request.user.employee.merchant_id
except AttributeError:
return None
def check_employee_permission(self, request):
"""检查用户是否有员工信息"""
if not hasattr(request.user, 'employee'):
logger.error(f"用户 {request.user.username} 无员工信息")
return False
return True
def validate_warehouse_visibility(self, warehouse_id, request):
"""验证仓库对当前用户是否可见"""
return DataVisibilityService.is_warehouse_visible_to_employee(warehouse_id, request.user)
def validate_product_visibility(self, product_id, request):
"""验证产品对当前用户是否可见"""
return DataVisibilityService.is_product_visible_to_employee(product_id, request.user)
def filter_visible_details(self, details, request):
"""过滤出用户可见的产品明细"""
visible_details = []
for detail in details:
if self.validate_product_visibility(detail.product_id, request):
visible_details.append(detail)
return visible_details
def build_record_data(self, record):
"""构建库存变动记录数据"""
return {
'id': record.id,
'type': record.type,
'type_display': record.get_type_display(),
'warehouse': record.warehouse_id,
'warehouse_name': record.warehouse.name,
'source_type': record.source_type,
'source_type_display': record.get_source_type_display(),
'source_id': record.source_id,
'is_finished': record.is_finished,
'finished_at': record.finished_at,
'created_at': record.created_at,
'created_by': record.created_by.username if record.created_by else None,
'remarks': record.remarks,
}
def build_detail_data(self, detail):
"""构建明细数据"""
return {
'id': detail.id,
'product': detail.product_id,
'product_name': detail.product.name,
'quantity': float(detail.quantity),
'unit': detail.unit,
'unit_display': detail.get_unit_display(),
}
def error_response(self, error_msg, details=None, status_code=status.HTTP_400_BAD_REQUEST):
"""统一的错误响应"""
response_data = {'error': error_msg}
if details:
response_data['details'] = details
return Response(response_data, status=status_code)
def permission_error_response(self, message='无权限访问'):
"""权限错误响应"""
return Response({'error': message}, status=status.HTTP_403_FORBIDDEN)
def not_found_response(self, message):
"""未找到资源响应"""
return Response({'error': message}, status=status.HTTP_404_NOT_FOUND)

View File

@@ -0,0 +1,70 @@
"""
商户设置相关视图
"""
from rest_framework import status, views
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
import logging
from drf_spectacular.utils import extend_schema
from api_v1 import serializers
from .mixins import StockChangeViewMixin
logger = logging.getLogger(__name__)
class SetMerchantAutoCompleteView(StockChangeViewMixin, views.APIView):
"""设置商户自动完成库存变动"""
permission_classes = [IsAuthenticated]
@extend_schema(
tags=['修改商户自动确认出入库设定'],
request=serializers.SetMerchantAutoCompleteStockChangeSerializer,
responses={200: dict},
summary="设置商户自动确认库存变动",
description="开启或关闭商户的库存变动自动完成功能"
)
def post(self, request):
"""
设置商户自动确认库存变动设定
POST /api/v1/merchant/set-auto-complete-stock-change/
请求参数:
{
"auto_complete": true // 是否自动确认库存变动
}
"""
# 检查员工权限
if not self.check_employee_permission(request):
return self.permission_error_response('无权限操作')
try:
emp = request.user.employee
merchant = emp.merchant
serializer = serializers.SetMerchantAutoCompleteStockChangeSerializer(data=request.data)
if serializer.is_valid():
auto_complete = serializer.validated_data['auto_complete']
merchant.auto_complete_stock_change = auto_complete
merchant.save()
logger.info(f"用户 {request.user.username} 设置商户 {merchant.name} 自动确认库存变动为 {auto_complete}")
return Response({'status': 'success'}, status=status.HTTP_200_OK)
return self.error_response('参数验证失败', serializer.errors)
except AttributeError:
logger.error(f"用户 {request.user.username} 无权限设置商户自动确认库存变动", exc_info=True)
return self.permission_error_response('无权限操作')
except Exception as e:
logger.error(f"设置商户自动确认库存变动失败: {str(e)}", exc_info=True)
return Response({
'error': '设置失败',
'message': str(e)
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

78
api_v1/views/user_info.py Normal file
View File

@@ -0,0 +1,78 @@
from rest_framework.decorators import api_view, permission_classes
from rest_framework import serializers
from rest_framework.response import Response
from basic_info.models import Employee, Merchant
class MerchantSerializer(serializers.ModelSerializer):
"""商户信息序列化器"""
type_display = serializers.CharField(source='get_type_display', read_only=True)
class Meta:
model = Merchant
fields = [
'id',
'name',
'type',
'type_display',
'area',
'email',
'mobile',
'contact',
'auto_complete_stock_change',
'description',
]
class EmployeeSerializer(serializers.ModelSerializer):
"""员工信息序列化器(嵌套商户信息)"""
job_type_display = serializers.CharField(source='get_job_type_display', read_only=True)
status_display = serializers.CharField(source='get_status_display', read_only=True)
merchant = MerchantSerializer(read_only=True)
class Meta:
model = Employee
fields = [
'id',
'name',
'job_type',
'job_type_display',
'mobile',
'area',
'status',
'status_display',
'description',
'merchant',
]
class UserInfoSerializer(serializers.Serializer):
"""用户信息序列化器(嵌套员工和商户信息)"""
username = serializers.CharField()
email = serializers.EmailField()
is_staff = serializers.BooleanField()
employee = EmployeeSerializer()
@api_view(['GET'])
def user_info(request):
"""
获取当前用户的基本信息(包含员工信息和商户信息)
GET /api/v1/user-info/
返回:
- username: 用户名
- email: 邮箱
- is_staff: 是否为管理员
- employee: 员工信息(包含 merchant 商户信息)
"""
user = request.user
# 验证用户必须登录且有员工身份
if not user.is_authenticated or not hasattr(user, 'employee'):
return Response({'error': 'Unauthorized'}, status=401)
# 使用序列化器序列化数据
serializer = UserInfoSerializer(user)
return Response(serializer.data, status=200)