forked from erp-dev/erp
feat: settlement first api beta
This commit is contained in:
@@ -34,6 +34,7 @@ from .views.shipment import (
|
|||||||
ShipmentDetailView,
|
ShipmentDetailView,
|
||||||
ShipmentExternalCreateView,
|
ShipmentExternalCreateView,
|
||||||
)
|
)
|
||||||
|
from .views.settlement.views import PlateOrderSummaryView
|
||||||
|
|
||||||
# 创建 DRF Router for Stateflow
|
# 创建 DRF Router for Stateflow
|
||||||
stateflow_router = DefaultRouter()
|
stateflow_router = DefaultRouter()
|
||||||
@@ -156,6 +157,13 @@ urlpatterns = [
|
|||||||
name='sales_items_by_printing_order'
|
name='sales_items_by_printing_order'
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# Settlement API
|
||||||
|
path(
|
||||||
|
'settlement/plate-orders/summary/',
|
||||||
|
PlateOrderSummaryView.as_view(),
|
||||||
|
name='plate_order_summary'
|
||||||
|
),
|
||||||
|
|
||||||
# 主 Router (printing-orders 等)
|
# 主 Router (printing-orders 等)
|
||||||
path('', include(main_router.urls)),
|
path('', include(main_router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
1
api_v1/views/settlement/__init__.py
Normal file
1
api_v1/views/settlement/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Settlement API views"""
|
||||||
320
api_v1/views/settlement/test_api.py
Normal file
320
api_v1/views/settlement/test_api.py
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
"""Settlement API 测试"""
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework import status
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from basic_info import models as basic_models
|
||||||
|
from printing import models as printing_models
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class PlateOrderSummaryAPITestCase(TestCase):
|
||||||
|
"""测试开版订单统计 API"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
self.merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='测试商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY
|
||||||
|
)
|
||||||
|
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='testuser',
|
||||||
|
password='testpass123'
|
||||||
|
)
|
||||||
|
self.employee = basic_models.Employee.objects.create(
|
||||||
|
sys_user=self.user,
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='测试员工'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.customer = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='测试客户',
|
||||||
|
created_by=self.employee
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
def test_requires_date_parameter(self):
|
||||||
|
"""测试必须提供 date 参数"""
|
||||||
|
response = self.client.get('/api/v1/settlement/plate-orders/summary/')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn('缺少 date 参数', response.data['error'])
|
||||||
|
|
||||||
|
def test_invalid_date_format(self):
|
||||||
|
"""测试无效的日期格式"""
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/v1/settlement/plate-orders/summary/?date=2026/02/08'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn('日期格式错误', response.data['error'])
|
||||||
|
|
||||||
|
def test_invalid_date(self):
|
||||||
|
"""测试不存在的日期"""
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/v1/settlement/plate-orders/summary/?date=2026-02-30'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertIn('日期不存在', response.data['error'])
|
||||||
|
|
||||||
|
def test_requires_merchant(self):
|
||||||
|
"""测试用户必须关联商户"""
|
||||||
|
user_without_merchant = User.objects.create_user(
|
||||||
|
username='no-merchant',
|
||||||
|
password='pass123'
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=user_without_merchant)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/v1/settlement/plate-orders/summary/?date=2026-02-08'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertIn('用户未关联商户', response.data['error'])
|
||||||
|
|
||||||
|
def test_returns_summary_for_valid_date(self):
|
||||||
|
"""测试返回有效的统计数据"""
|
||||||
|
today = date(2026, 2, 8)
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/v1/settlement/plate-orders/summary/?date=2026-02-08'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn('data', response.data)
|
||||||
|
self.assertEqual(len(response.data['data']), 1)
|
||||||
|
self.assertEqual(response.data['data'][0]['client_id'], self.customer.id)
|
||||||
|
self.assertEqual(response.data['data'][0]['client_name'], '测试客户')
|
||||||
|
self.assertEqual(len(response.data['data'][0]['plate_order_count']), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data['data'][0]['plate_order_count'][0]['type'],
|
||||||
|
'首版-定位'
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data['data'][0]['plate_order_count'][0]['today'],
|
||||||
|
1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_calculates_current_month(self):
|
||||||
|
"""测试计算本月累计数量"""
|
||||||
|
today = date(2026, 2, 8)
|
||||||
|
month_start = date(2026, 2, 1)
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(month_start, datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/v1/settlement/plate-orders/summary/?date=2026-02-08'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data['data'][0]['plate_order_count'][0]['current_month'],
|
||||||
|
2
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_filters_null_plate_type(self):
|
||||||
|
"""测试过滤 plate_type 为空的订单"""
|
||||||
|
today = date(2026, 2, 8)
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
plate_type=None,
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/v1/settlement/plate-orders/summary/?date=2026-02-08'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data['data'][0]['plate_order_count'][0]['today'],
|
||||||
|
1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_filters_null_production_method(self):
|
||||||
|
"""测试过滤 production_method 为空的订单"""
|
||||||
|
today = date(2026, 2, 8)
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method=None,
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/v1/settlement/plate-orders/summary/?date=2026-02-08'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data['data']), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data['data'][0]['plate_order_count'][0]['type'],
|
||||||
|
'首版-定位'
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data['data'][0]['plate_order_count'][0]['today'],
|
||||||
|
1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_filters_zero_data(self):
|
||||||
|
"""测试过滤全0数据"""
|
||||||
|
today = date(2026, 2, 8)
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(date(2026, 1, 1), datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/v1/settlement/plate-orders/summary/?date=2026-02-08'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data['data']), 0)
|
||||||
|
|
||||||
|
def test_applies_merchant_isolation(self):
|
||||||
|
"""测试应用商户隔离"""
|
||||||
|
other_merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='其他商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY
|
||||||
|
)
|
||||||
|
other_customer = basic_models.Customer.objects.create(
|
||||||
|
merchant=other_merchant,
|
||||||
|
name='其他客户'
|
||||||
|
)
|
||||||
|
|
||||||
|
today = date(2026, 2, 8)
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=other_merchant,
|
||||||
|
customer=other_customer,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/v1/settlement/plate-orders/summary/?date=2026-02-08'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data['data']), 1)
|
||||||
|
self.assertEqual(response.data['data'][0]['client_id'], self.customer.id)
|
||||||
|
self.assertEqual(response.data['data'][0]['client_name'], '测试客户')
|
||||||
|
|
||||||
|
def test_applies_customer_visibility(self):
|
||||||
|
"""测试应用客户可见性过滤"""
|
||||||
|
other_employee = basic_models.Employee.objects.create(
|
||||||
|
sys_user=User.objects.create_user('other', 'pass123'),
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='其他员工'
|
||||||
|
)
|
||||||
|
|
||||||
|
customer_visible = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='可见客户',
|
||||||
|
created_by=other_employee
|
||||||
|
)
|
||||||
|
customer_visible.visible_employees.set([self.employee])
|
||||||
|
|
||||||
|
customer_invisible = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='不可见客户',
|
||||||
|
created_by=other_employee
|
||||||
|
)
|
||||||
|
|
||||||
|
today = date(2026, 2, 8)
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=customer_visible,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=customer_invisible,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/v1/settlement/plate-orders/summary/?date=2026-02-08'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data['data']), 1)
|
||||||
|
self.assertEqual(response.data['data'][0]['client_id'], customer_visible.id)
|
||||||
|
self.assertEqual(response.data['data'][0]['client_name'], '可见客户')
|
||||||
|
|
||||||
|
def test_requires_authentication(self):
|
||||||
|
"""测试需要认证"""
|
||||||
|
self.client.force_authenticate(user=None)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/v1/settlement/plate-orders/summary/?date=2026-02-08'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
75
api_v1/views/settlement/views.py
Normal file
75
api_v1/views/settlement/views.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Settlement API views"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
|
from settlement.services import get_plate_order_summary_by_customer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PlateOrderSummaryView(APIView):
|
||||||
|
"""
|
||||||
|
开版订单统计 API
|
||||||
|
|
||||||
|
GET /api/v1/settlement/plate-orders/summary/
|
||||||
|
|
||||||
|
参数:
|
||||||
|
date: 统计日期(YYYY-MM-DD)
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""
|
||||||
|
获取开版订单统计
|
||||||
|
"""
|
||||||
|
date_str = request.query_params.get('date')
|
||||||
|
|
||||||
|
if not date_str:
|
||||||
|
return Response(
|
||||||
|
{'error': '缺少 date 参数'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
|
||||||
|
return Response(
|
||||||
|
{'error': '日期格式错误,请使用 YYYY-MM-DD 格式'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
settlement_date = date.fromisoformat(date_str)
|
||||||
|
except ValueError:
|
||||||
|
return Response(
|
||||||
|
{'error': '日期不存在'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
emp = getattr(request.user, 'employee', None)
|
||||||
|
if emp is None or emp.merchant is None:
|
||||||
|
return Response(
|
||||||
|
{'error': '用户未关联商户'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = get_plate_order_summary_by_customer(
|
||||||
|
merchant_id=emp.merchant.id,
|
||||||
|
settlement_date=settlement_date,
|
||||||
|
user=request.user
|
||||||
|
)
|
||||||
|
return Response({'data': data})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
f'[settlement.views] 获取开版订单统计失败: {e}'
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{'error': '获取统计数据失败'},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
@@ -70,9 +70,15 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
target: base
|
target: base
|
||||||
container_name: flower_web
|
container_name: flower_web
|
||||||
command: python manage.py runserver 0.0.0.0:8000
|
command:
|
||||||
|
- uvicorn
|
||||||
|
- flower.asgi:application
|
||||||
|
- --host
|
||||||
|
- 0.0.0.0
|
||||||
|
- --port
|
||||||
|
- "8100"
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8100:8100"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
203
docs/settlement/API.md
Normal file
203
docs/settlement/API.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Settlement API 设计文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
本文档描述 settlement 模块的 API 设计,遵循项目 API 规范。
|
||||||
|
|
||||||
|
## API 列表
|
||||||
|
|
||||||
|
### 1. 开版订单统计 API
|
||||||
|
|
||||||
|
获取按客户分组的开版订单统计。
|
||||||
|
|
||||||
|
**基本信息**:
|
||||||
|
- **URL**: `/api/v1/settlement/plate-orders/summary/`
|
||||||
|
- **方法**: GET
|
||||||
|
- **认证**: 需要认证
|
||||||
|
- **权限**: 需要关联商户
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|
||||||
|
|--------|------|------|------|------|
|
||||||
|
| date | string | 是 | 统计日期,格式 YYYY-MM-DD | 2026-02-08 |
|
||||||
|
|
||||||
|
**请求示例**:
|
||||||
|
```http
|
||||||
|
GET /api/v1/settlement/plate-orders/summary/?date=2026-02-08 HTTP/1.1
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"client_id": 101,
|
||||||
|
"client_name": "客户A",
|
||||||
|
"plate_order_count": [
|
||||||
|
{
|
||||||
|
"type": "首版-定位",
|
||||||
|
"today": 26,
|
||||||
|
"current_month": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "修改-定位",
|
||||||
|
"today": 3,
|
||||||
|
"current_month": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "首版-匹布",
|
||||||
|
"today": 3,
|
||||||
|
"current_month": 7
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": 102,
|
||||||
|
"client_name": "客户B",
|
||||||
|
"plate_order_count": [
|
||||||
|
{
|
||||||
|
"type": "首版-定位",
|
||||||
|
"today": 10,
|
||||||
|
"current_month": 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应字段说明**:
|
||||||
|
| 字段名 | 类型 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| data | array | 客户统计列表 |
|
||||||
|
| data[].client_id | integer | 客户ID |
|
||||||
|
| data[].client_name | string | 客户名称 |
|
||||||
|
| data[].plate_order_count | array | 订单统计列表 |
|
||||||
|
| data[].plate_order_count[].type | string | 类型组合(plate_type-production_method) |
|
||||||
|
| data[].plate_order_count[].today | integer | 今日数量 |
|
||||||
|
| data[].plate_order_count[].current_month | integer | 本月累计数量 |
|
||||||
|
|
||||||
|
**业务规则**:
|
||||||
|
1. **商户隔离**: 只返回当前用户所属商户的数据
|
||||||
|
2. **客户可见性**: 应用客户可见性过滤
|
||||||
|
3. **过滤条件**:
|
||||||
|
- `plate_type` 不为空的订单才纳入统计
|
||||||
|
- `production_method` 不为空的订单才纳入统计
|
||||||
|
4. **统计维度**: 按 `customer_id`、`customer_name`、`plate_type`、`production_method` 分组(同名客户不会被合并)
|
||||||
|
5. **today**: 指定日期当天的订单数量(基于 plate_date 字段)
|
||||||
|
6. **current_month**: 从当月1日(含)到指定日期(含)的订单数量
|
||||||
|
7. **数据过滤**:
|
||||||
|
- 不返回没有数据的客户
|
||||||
|
- 客户中不显示全0数据的类型组合
|
||||||
|
|
||||||
|
**type 组合规则**:
|
||||||
|
- 格式: `{plate_type}-{production_method}`
|
||||||
|
- plate_type 可能的值: `首版`、`修改`
|
||||||
|
- production_method 可能的值: `定位`、`匹布`
|
||||||
|
- 组合示例: `首版-定位`、`首版-匹布`、`修改-定位`、`修改-匹布`
|
||||||
|
|
||||||
|
**错误响应**:
|
||||||
|
|
||||||
|
**400 Bad Request** - 缺少日期参数:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "缺少 date 参数"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**400 Bad Request** - 日期格式错误:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "日期格式错误,请使用 YYYY-MM-DD 格式"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**403 Forbidden** - 用户未关联商户:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "用户未关联商户"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**403 Forbidden** - 日期不存在:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "日期不存在"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现细节
|
||||||
|
|
||||||
|
### View 层
|
||||||
|
- **文件**: `api_v1/views/settlement/views.py`
|
||||||
|
- **类**: `PlateOrderSummaryView`
|
||||||
|
- **继承**: `APIView`
|
||||||
|
- **职责**:
|
||||||
|
- 参数验证
|
||||||
|
- 商户隔离
|
||||||
|
- 调用 service 层
|
||||||
|
- 错误处理
|
||||||
|
|
||||||
|
### Service 层
|
||||||
|
- **文件**: `settlement/services.py`
|
||||||
|
- **函数**: `get_plate_order_summary_by_customer`
|
||||||
|
- **职责**:
|
||||||
|
- 复杂的统计逻辑
|
||||||
|
- 数据查询和聚合
|
||||||
|
- 数据格式化
|
||||||
|
|
||||||
|
### URL 配置
|
||||||
|
- **文件**: `api_v1/urls.py`
|
||||||
|
- **路由**: 添加到 settlement 路由组
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
### 1. 正常情况
|
||||||
|
- 请求有效日期,返回正确数据
|
||||||
|
- 验证商户隔离
|
||||||
|
- 验证客户可见性过滤
|
||||||
|
- 验证 today 和 current_month 计算正确
|
||||||
|
|
||||||
|
### 2. 边界情况
|
||||||
|
- 请求当月第一天(current_month = today)
|
||||||
|
- 请求跨月日期
|
||||||
|
- 客户没有数据(不返回该客户)
|
||||||
|
- 类型组合全0(不返回该类型)
|
||||||
|
|
||||||
|
### 3. 错误情况
|
||||||
|
- 日期格式错误
|
||||||
|
- 日期不存在(如 2026-02-30)
|
||||||
|
- 用户未关联商户
|
||||||
|
|
||||||
|
### 4. 数据过滤
|
||||||
|
- plate_type 为空的订单不纳入统计
|
||||||
|
- production_method 为空的订单不纳入统计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能考虑
|
||||||
|
|
||||||
|
1. **查询优化**: 使用 Django ORM 的 `annotate` 和聚合函数,避免 N+1 查询
|
||||||
|
2. **条件聚合**: 使用 `Case`/`When` 一次查询获取 today 和 current_month
|
||||||
|
3. **索引优化**: 确保 `plate_date`、`customer`、`plate_type`、`production_method` 字段有索引
|
||||||
|
4. **分页**: 暂不需要分页(数据量不大)
|
||||||
|
|
||||||
|
### 数据库优化评估记录(PostgreSQL)
|
||||||
|
|
||||||
|
1. 已评估索引、视图、物化视图等数据库层优化路线
|
||||||
|
2. 当前阶段暂不实施数据库结构优化,以保持线上稳定性
|
||||||
|
3. 生产环境不可接受阻塞风险:后续若加索引必须使用 `CREATE INDEX CONCURRENTLY`,并采用低峰分批策略
|
||||||
|
4. 优化上线前必须完成预发压测与 `EXPLAIN ANALYZE` 对比
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后续扩展
|
||||||
|
|
||||||
|
1. 支持日期范围查询(start_date, end_date)
|
||||||
|
2. 支持按商户过滤(管理员功能)
|
||||||
|
3. 支持导出 Excel
|
||||||
|
4. 支持缓存(Redis)
|
||||||
@@ -78,6 +78,7 @@ class DailySettlementConfig(ModelBase):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = '日结配置'
|
verbose_name = '日结配置'
|
||||||
verbose_name_plural = '日结配置'
|
verbose_name_plural = '日结配置'
|
||||||
|
db_table = 'daily_settlement_config'
|
||||||
```
|
```
|
||||||
|
|
||||||
**说明**:
|
**说明**:
|
||||||
@@ -212,8 +213,8 @@ def run_daily_settlement(self):
|
|||||||
from basic_info.models import Merchant
|
from basic_info.models import Merchant
|
||||||
|
|
||||||
# 获取所有配置了统计模块的商户
|
# 获取所有配置了统计模块的商户
|
||||||
configs = DailySettlementConfig.objects.filter(
|
configs = DailySettlementConfig.objects.exclude(
|
||||||
settlement_modules__len__gt=0
|
settlement_modules=[]
|
||||||
).select_related('merchant')
|
).select_related('merchant')
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -412,7 +413,42 @@ class SettlementConfig(AppConfig):
|
|||||||
3. 未来如需提升性能,可以通过增加 Celery worker 数量来解决
|
3. 未来如需提升性能,可以通过增加 Celery worker 数量来解决
|
||||||
4. Celery 本身支持任务队列和并发控制,后续可以轻松扩展
|
4. Celery 本身支持任务队列和并发控制,后续可以轻松扩展
|
||||||
|
|
||||||
## 十三、实施步骤
|
## 十三、数据库优化评估(PostgreSQL)
|
||||||
|
|
||||||
|
### 13.1 当前结论(暂不实施)
|
||||||
|
|
||||||
|
已评估从数据库层面优化开版订单统计(索引、视图、物化视图),当前阶段暂不实施结构性优化,维持现有实现。
|
||||||
|
|
||||||
|
### 13.2 暂缓原因
|
||||||
|
|
||||||
|
1. 当前测试与功能已稳定,优先保证行为一致性
|
||||||
|
2. 生产环境要求“不可接受阻塞风险”,索引变更需专项窗口与监控保障
|
||||||
|
3. 现阶段数据规模下,统计查询尚可接受
|
||||||
|
|
||||||
|
### 13.3 后续可选优化路线(按优先级)
|
||||||
|
|
||||||
|
1. **索引优先**:为 `plate_order` 统计路径增加复合/部分索引
|
||||||
|
2. **表达式索引**:针对 `date(plate_date)` 的筛选场景
|
||||||
|
3. **物化视图**:当数据规模明显增大时,将日粒度聚合前置
|
||||||
|
|
||||||
|
### 13.4 生产安全约束
|
||||||
|
|
||||||
|
若后续执行索引优化,必须遵循:
|
||||||
|
|
||||||
|
1. 使用 PostgreSQL `CREATE INDEX CONCURRENTLY`
|
||||||
|
2. 迁移使用 `atomic = False`
|
||||||
|
3. 低峰分批执行,一次一个索引
|
||||||
|
4. 全程监控 CPU/IO/WAL、慢查询与复制延迟
|
||||||
|
|
||||||
|
### 13.5 验证要求
|
||||||
|
|
||||||
|
任何数据库优化上线前,需要在预发(接近生产数据量)完成:
|
||||||
|
|
||||||
|
1. `EXPLAIN ANALYZE` 对比
|
||||||
|
2. 回归测试通过(`settlement` + `api_v1.views.settlement`)
|
||||||
|
3. 回滚脚本预演
|
||||||
|
|
||||||
|
## 十四、实施步骤
|
||||||
|
|
||||||
1. 创建 `settlement` 模块目录结构
|
1. 创建 `settlement` 模块目录结构
|
||||||
2. 实现 `models.py`(配置模型)
|
2. 实现 `models.py`(配置模型)
|
||||||
@@ -427,7 +463,7 @@ class SettlementConfig(AppConfig):
|
|||||||
11. 编写测试用例
|
11. 编写测试用例
|
||||||
12. 手动触发测试,验证结果
|
12. 手动触发测试,验证结果
|
||||||
|
|
||||||
## 十四、方案优势
|
## 十五、方案优势
|
||||||
|
|
||||||
1. **独立模块**:`settlement` 模块独立,职责清晰
|
1. **独立模块**:`settlement` 模块独立,职责清晰
|
||||||
2. **配置驱动**:每个商户可独立配置统计模块和通知渠道
|
2. **配置驱动**:每个商户可独立配置统计模块和通知渠道
|
||||||
|
|||||||
186
docs/settlement/Service.md
Normal file
186
docs/settlement/Service.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# Settlement Service 设计文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
本文档描述 settlement 模块的 service 层设计,遵循职责分离原则,每个函数职责单一、可测试。
|
||||||
|
|
||||||
|
## 函数列表
|
||||||
|
|
||||||
|
### 1. `get_plate_order_summary_by_customer`
|
||||||
|
|
||||||
|
获取按客户分组的开版订单统计。
|
||||||
|
|
||||||
|
**参数**:
|
||||||
|
- `merchant_id: int` - 商户ID
|
||||||
|
- `settlement_date: datetime.date` - 统计日期
|
||||||
|
- `user: User | None` - 当前用户(用于客户可见性过滤)
|
||||||
|
|
||||||
|
**返回**:
|
||||||
|
- `list[dict]` - 客户统计列表
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 参数验证
|
||||||
|
- 调用底层数据查询函数
|
||||||
|
- 应用客户可见性过滤
|
||||||
|
- 数据格式化
|
||||||
|
|
||||||
|
**参数校验规则**:
|
||||||
|
- `merchant_id` 必须是正整数
|
||||||
|
- `settlement_date` 必须是 `date` 或 `datetime` 类型(`datetime` 会自动转换为 `date`)
|
||||||
|
- 参数非法时抛出 `ValueError`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. `_get_plate_order_queryset`
|
||||||
|
|
||||||
|
获取开版订单的基础查询集。
|
||||||
|
|
||||||
|
**参数**:
|
||||||
|
- `merchant_id: int` - 商户ID
|
||||||
|
- `user: User | None` - 当前用户(用于客户可见性过滤)
|
||||||
|
|
||||||
|
**返回**:
|
||||||
|
- `QuerySet[PlateOrder]` - 过滤后的查询集
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 应用商户隔离
|
||||||
|
- 应用客户可见性过滤
|
||||||
|
- 过滤 `plate_type` 不为空的订单
|
||||||
|
- 过滤 `production_method` 不为空的订单
|
||||||
|
- 优化查询(select_related)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. `_get_month_date_range`
|
||||||
|
|
||||||
|
获取从月初到指定日期的日期范围。
|
||||||
|
|
||||||
|
**参数**:
|
||||||
|
- `settlement_date: datetime.date` - 统计日期
|
||||||
|
|
||||||
|
**返回**:
|
||||||
|
- `tuple[date, date]` - (月初日期, 统计日期)
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 计算当月第一天
|
||||||
|
- 返回日期范围元组
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `_aggregate_plate_orders_by_customer_and_type`
|
||||||
|
|
||||||
|
按客户和类型分组聚合订单数据。
|
||||||
|
|
||||||
|
**参数**:
|
||||||
|
- `queryset: QuerySet[PlateOrder]` - 基础查询集
|
||||||
|
- `settlement_date: datetime.date` - 统计日期
|
||||||
|
|
||||||
|
**返回**:
|
||||||
|
- `QuerySet[PlateOrder]` - 添加了聚合标注的查询集
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 按 customer_id、customer_name、plate_type、production_method 分组
|
||||||
|
- 计算今日数量(条件聚合)
|
||||||
|
- 计算本月累计数量(条件聚合)
|
||||||
|
- 生成 type 字段(plate_type + '-' + production_method)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. `_format_plate_order_summary`
|
||||||
|
|
||||||
|
格式化聚合结果为 API 返回格式。
|
||||||
|
|
||||||
|
**参数**:
|
||||||
|
- `aggregated_data: QuerySet[PlateOrder]` - 聚合后的查询集
|
||||||
|
|
||||||
|
**返回**:
|
||||||
|
- `list[dict]` - 格式化后的数据
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 遍历聚合结果
|
||||||
|
- 按客户分组
|
||||||
|
- 返回客户ID(`client_id`)和客户名称(`client_name`)
|
||||||
|
- 过滤全0数据
|
||||||
|
- 生成最终的 API 返回格式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. `_filter_zero_data`
|
||||||
|
|
||||||
|
过滤全0数据。
|
||||||
|
|
||||||
|
**参数**:
|
||||||
|
- `plate_order_counts: list[dict]` - 订单统计列表
|
||||||
|
|
||||||
|
**返回**:
|
||||||
|
- `list[dict]` - 过滤后的列表
|
||||||
|
|
||||||
|
**职责**:
|
||||||
|
- 移除 today 和 current_month 都为 0 的数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据流程
|
||||||
|
|
||||||
|
```
|
||||||
|
get_plate_order_summary_by_customer
|
||||||
|
↓
|
||||||
|
_get_plate_order_queryset (获取基础查询集)
|
||||||
|
↓
|
||||||
|
_aggregate_plate_orders_by_customer_and_type (分组聚合)
|
||||||
|
↓
|
||||||
|
_get_month_date_range (获取日期范围)
|
||||||
|
↓
|
||||||
|
_format_plate_order_summary (格式化结果)
|
||||||
|
↓
|
||||||
|
_filter_zero_data (过滤全0数据)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 过滤规则
|
||||||
|
|
||||||
|
1. **商户隔离**: 只查询指定商户的订单
|
||||||
|
2. **plate_type 过滤**: `plate_type` 为空的订单不纳入统计
|
||||||
|
3. **production_method 过滤**: `production_method` 为空的订单不纳入统计
|
||||||
|
4. **客户可见性**: 非超级用户只能看到自己创建的客户或被授权可见的客户
|
||||||
|
5. **全0数据过滤**: today 和 current_month 都为 0 的类型组合不返回
|
||||||
|
6. **客户分组稳健性**: 以 customer_id 分组,避免同名客户被合并
|
||||||
|
|
||||||
|
## type 组合规则
|
||||||
|
|
||||||
|
**格式**: `{plate_type}-{production_method}`
|
||||||
|
|
||||||
|
**plate_type 可能的值**:
|
||||||
|
- `首版`
|
||||||
|
- `修改`
|
||||||
|
|
||||||
|
**production_method 可能的值**:
|
||||||
|
- `定位`
|
||||||
|
- `匹布`
|
||||||
|
|
||||||
|
**组合示例**:
|
||||||
|
- `首版-定位`
|
||||||
|
- `首版-匹布`
|
||||||
|
- `修改-定位`
|
||||||
|
- `修改-匹布`
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
每个函数都应该有独立的单元测试:
|
||||||
|
- `_get_plate_order_queryset`: 测试商户隔离、客户可见性过滤、plate_type 和 production_method 过滤
|
||||||
|
- `_get_month_date_range`: 测试日期范围计算
|
||||||
|
- `_aggregate_plate_orders_by_customer_and_type`: 测试分组聚合逻辑
|
||||||
|
- `_format_plate_order_summary`: 测试数据格式化
|
||||||
|
- `_filter_zero_data`: 测试全0数据过滤
|
||||||
|
- `get_plate_order_summary_by_customer`: 集成测试
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
1. 使用 `select_related` 减少查询次数
|
||||||
|
2. 使用 `annotate` 和聚合函数避免 N+1 查询
|
||||||
|
3. 使用条件聚合(Case/When)一次查询获取 today 和 current_month
|
||||||
|
|
||||||
|
## PostgreSQL 优化评估记录
|
||||||
|
|
||||||
|
1. 已评估数据库层优化(索引、视图、物化视图)
|
||||||
|
2. 当前决策:暂不实施结构性优化,优先保持线上稳定性
|
||||||
|
3. 后续若优化,优先级为:索引 > 表达式索引 > 物化视图
|
||||||
|
4. 生产约束:索引变更需使用并发建索引方式并在低峰执行
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"""日结模块信号处理函数"""
|
"""日结模块信号处理函数"""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from .models import NotificationChannelEnum
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ def on_daily_settlement_completed(sender, **kwargs):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 根据通知渠道处理(目前只有企业微信)
|
# 根据通知渠道处理(目前只有企业微信)
|
||||||
if config.notification_channel == config.NotificationChannelEnum.WECOM:
|
if config.notification_channel == NotificationChannelEnum.WECOM:
|
||||||
logger.info(
|
logger.info(
|
||||||
f'[settlement.handlers] 商户 {merchant.id} 日结完成,'
|
f'[settlement.handlers] 商户 {merchant.id} 日结完成,'
|
||||||
f'状态={status}, 模块={modules}, 错误={errors}, '
|
f'状态={status}, 模块={modules}, 错误={errors}, '
|
||||||
|
|||||||
@@ -47,3 +47,4 @@ class DailySettlementConfig(ModelBase):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = '日结配置'
|
verbose_name = '日结配置'
|
||||||
verbose_name_plural = '日结配置'
|
verbose_name_plural = '日结配置'
|
||||||
|
db_table = 'daily_settlement_config'
|
||||||
|
|||||||
@@ -1,10 +1,217 @@
|
|||||||
"""日结模块统计服务函数"""
|
"""日结模块统计服务函数"""
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
from django.db.models import Q, Sum, Case, When, Value, CharField, IntegerField
|
||||||
|
from django.db.models.functions import Concat
|
||||||
|
|
||||||
|
from printing.models import PlateOrder
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_plate_order_summary_by_customer(
|
||||||
|
merchant_id: int,
|
||||||
|
settlement_date: date,
|
||||||
|
user=None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
获取按客户分组的开版订单统计
|
||||||
|
|
||||||
|
Args:
|
||||||
|
merchant_id: 商户ID
|
||||||
|
settlement_date: 统计日期
|
||||||
|
user: 当前用户(用于客户可见性过滤)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict]: 客户统计列表
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f'[settlement.services] 获取开版订单统计: '
|
||||||
|
f'merchant_id={merchant_id}, date={settlement_date}'
|
||||||
|
)
|
||||||
|
|
||||||
|
settlement_date = _validate_and_normalize_summary_args(
|
||||||
|
merchant_id=merchant_id,
|
||||||
|
settlement_date=settlement_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = _get_plate_order_queryset(merchant_id, user)
|
||||||
|
aggregated_data = _aggregate_plate_orders_by_customer_and_type(
|
||||||
|
queryset, settlement_date
|
||||||
|
)
|
||||||
|
return _format_plate_order_summary(aggregated_data)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_and_normalize_summary_args(
|
||||||
|
merchant_id: int,
|
||||||
|
settlement_date: date,
|
||||||
|
) -> date:
|
||||||
|
"""校验并标准化统计参数。"""
|
||||||
|
if not isinstance(merchant_id, int) or merchant_id <= 0:
|
||||||
|
raise ValueError('merchant_id 必须为正整数')
|
||||||
|
|
||||||
|
if isinstance(settlement_date, datetime):
|
||||||
|
settlement_date = settlement_date.date()
|
||||||
|
|
||||||
|
if not isinstance(settlement_date, date):
|
||||||
|
raise ValueError('settlement_date 必须为 date 或 datetime 类型')
|
||||||
|
|
||||||
|
return settlement_date
|
||||||
|
|
||||||
|
|
||||||
|
def _get_plate_order_queryset(merchant_id: int, user=None):
|
||||||
|
"""
|
||||||
|
获取开版订单的基础查询集
|
||||||
|
|
||||||
|
Args:
|
||||||
|
merchant_id: 商户ID
|
||||||
|
user: 当前用户(用于客户可见性过滤)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet[PlateOrder]: 过滤后的查询集
|
||||||
|
"""
|
||||||
|
queryset = PlateOrder.objects.filter(
|
||||||
|
merchant_id=merchant_id,
|
||||||
|
plate_type__isnull=False,
|
||||||
|
production_method__isnull=False
|
||||||
|
).select_related('customer')
|
||||||
|
|
||||||
|
if user and not user.is_superuser:
|
||||||
|
emp = getattr(user, 'employee', None)
|
||||||
|
if emp:
|
||||||
|
visible_customer_filter = (
|
||||||
|
Q(customer__created_by=emp) |
|
||||||
|
Q(customer__visible_employees=emp)
|
||||||
|
)
|
||||||
|
no_customer_filter = Q(customer__isnull=True)
|
||||||
|
queryset = queryset.filter(
|
||||||
|
visible_customer_filter | no_customer_filter
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
def _get_month_date_range(settlement_date: date) -> tuple[date, date]:
|
||||||
|
"""
|
||||||
|
获取从月初到指定日期的日期范围
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settlement_date: 统计日期
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[date, date]: (月初日期, 统计日期)
|
||||||
|
"""
|
||||||
|
month_start = settlement_date.replace(day=1)
|
||||||
|
return month_start, settlement_date
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_plate_orders_by_customer_and_type(queryset, settlement_date: date):
|
||||||
|
"""
|
||||||
|
按客户和类型分组聚合订单数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
queryset: 基础查询集
|
||||||
|
settlement_date: 统计日期
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QuerySet[PlateOrder]: 添加了聚合标注的查询集
|
||||||
|
"""
|
||||||
|
month_start, _ = _get_month_date_range(settlement_date)
|
||||||
|
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
type=Concat(
|
||||||
|
'plate_type',
|
||||||
|
Value('-'),
|
||||||
|
'production_method',
|
||||||
|
output_field=CharField()
|
||||||
|
)
|
||||||
|
).values(
|
||||||
|
'customer__id',
|
||||||
|
'customer__name',
|
||||||
|
'type'
|
||||||
|
).annotate(
|
||||||
|
today=Sum(
|
||||||
|
Case(
|
||||||
|
When(
|
||||||
|
plate_date__date=settlement_date,
|
||||||
|
then=1
|
||||||
|
),
|
||||||
|
default=0,
|
||||||
|
output_field=IntegerField()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
current_month=Sum(
|
||||||
|
Case(
|
||||||
|
When(
|
||||||
|
plate_date__date__gte=month_start,
|
||||||
|
plate_date__date__lte=settlement_date,
|
||||||
|
then=1
|
||||||
|
),
|
||||||
|
default=0,
|
||||||
|
output_field=IntegerField()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by('customer__name', 'customer__id', 'type')
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
def _format_plate_order_summary(aggregated_data):
|
||||||
|
"""
|
||||||
|
格式化聚合结果为 API 返回格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
aggregated_data: 聚合后的查询集
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict]: 格式化后的数据
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for item in aggregated_data:
|
||||||
|
client_id = item['customer__id']
|
||||||
|
client_name = item['customer__name']
|
||||||
|
client_key = (client_id, client_name)
|
||||||
|
if client_key not in result:
|
||||||
|
result[client_key] = {
|
||||||
|
'client_id': client_id,
|
||||||
|
'client_name': client_name,
|
||||||
|
'plate_order_count': []
|
||||||
|
}
|
||||||
|
|
||||||
|
plate_order_count = {
|
||||||
|
'type': item['type'],
|
||||||
|
'today': item['today'],
|
||||||
|
'current_month': item['current_month']
|
||||||
|
}
|
||||||
|
|
||||||
|
result[client_key]['plate_order_count'].append(plate_order_count)
|
||||||
|
|
||||||
|
for client_key in result:
|
||||||
|
result[client_key]['plate_order_count'] = _filter_zero_data(
|
||||||
|
result[client_key]['plate_order_count']
|
||||||
|
)
|
||||||
|
|
||||||
|
return [v for k, v in result.items() if v['plate_order_count']]
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_zero_data(plate_order_counts):
|
||||||
|
"""
|
||||||
|
过滤全0数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plate_order_counts: 订单统计列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict]: 过滤后的列表
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
item for item in plate_order_counts
|
||||||
|
if item['today'] > 0 or item['current_month'] > 0
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def calculate_plate_order_daily_summary(
|
def calculate_plate_order_daily_summary(
|
||||||
merchant_id: int,
|
merchant_id: int,
|
||||||
settlement_date: datetime.date
|
settlement_date: datetime.date
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ def run_daily_settlement(self):
|
|||||||
from basic_info.models import Merchant
|
from basic_info.models import Merchant
|
||||||
|
|
||||||
# 获取所有配置了统计模块的商户
|
# 获取所有配置了统计模块的商户
|
||||||
configs = DailySettlementConfig.objects.filter(
|
configs = DailySettlementConfig.objects.exclude(
|
||||||
settlement_modules__len__gt=0
|
settlement_modules=[]
|
||||||
).select_related('merchant')
|
).select_related('merchant')
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
60
settlement/test_integration_table_mapping.py
Normal file
60
settlement/test_integration_table_mapping.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from datetime import date
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from basic_info import models as basic_models
|
||||||
|
from settlement.models import (
|
||||||
|
DailySettlementConfig,
|
||||||
|
NotificationChannelEnum,
|
||||||
|
SettlementModuleEnum,
|
||||||
|
)
|
||||||
|
from settlement.tasks import run_daily_settlement
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementTableMappingIntegrationTestCase(TestCase):
|
||||||
|
def test_daily_settlement_config_can_persist_in_real_db(self):
|
||||||
|
merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='表映射测试商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = DailySettlementConfig.objects.create(
|
||||||
|
merchant=merchant,
|
||||||
|
settlement_modules=[SettlementModuleEnum.PLATE_ORDER],
|
||||||
|
notification_enabled=False,
|
||||||
|
notification_channel=NotificationChannelEnum.NONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
fetched = DailySettlementConfig.objects.get(id=config.id)
|
||||||
|
self.assertEqual(fetched.merchant_id, merchant.id)
|
||||||
|
self.assertEqual(fetched.settlement_modules, [SettlementModuleEnum.PLATE_ORDER])
|
||||||
|
|
||||||
|
@patch('settlement.tasks.run_merchant_daily_settlement.delay')
|
||||||
|
@patch('settlement.tasks.timezone.localdate')
|
||||||
|
def test_run_daily_settlement_queries_real_config_table(
|
||||||
|
self,
|
||||||
|
mock_localdate,
|
||||||
|
mock_delay,
|
||||||
|
):
|
||||||
|
mock_localdate.return_value = date(2026, 2, 9)
|
||||||
|
|
||||||
|
merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='任务查询测试商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||||
|
)
|
||||||
|
DailySettlementConfig.objects.create(
|
||||||
|
merchant=merchant,
|
||||||
|
settlement_modules=[SettlementModuleEnum.PLATE_ORDER],
|
||||||
|
notification_enabled=False,
|
||||||
|
notification_channel=NotificationChannelEnum.NONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_daily_settlement.run()
|
||||||
|
|
||||||
|
self.assertEqual(result['total_merchants'], 1)
|
||||||
|
self.assertEqual(result['settlement_date'], '2026-02-08')
|
||||||
|
mock_delay.assert_called_once_with(
|
||||||
|
merchant_id=merchant.id,
|
||||||
|
settlement_date='2026-02-08',
|
||||||
|
)
|
||||||
510
settlement/test_services.py
Normal file
510
settlement/test_services.py
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
"""Settlement Service 测试"""
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from basic_info import models as basic_models
|
||||||
|
from printing import models as printing_models
|
||||||
|
from settlement import services
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementServiceTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='测试商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY
|
||||||
|
)
|
||||||
|
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='test-user',
|
||||||
|
password='pass123'
|
||||||
|
)
|
||||||
|
self.employee = basic_models.Employee.objects.create(
|
||||||
|
sys_user=self.user,
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='测试员工'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.customer1 = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='客户A',
|
||||||
|
created_by=self.employee
|
||||||
|
)
|
||||||
|
self.customer2 = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='客户B',
|
||||||
|
created_by=self.employee
|
||||||
|
)
|
||||||
|
|
||||||
|
self.settlement_date = date(2026, 2, 8)
|
||||||
|
|
||||||
|
|
||||||
|
class GetPlateOrderQuerySetTestCase(SettlementServiceTestCase):
|
||||||
|
def test_filters_by_merchant(self):
|
||||||
|
other_merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='其他商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY
|
||||||
|
)
|
||||||
|
other_customer = basic_models.Customer.objects.create(
|
||||||
|
merchant=other_merchant,
|
||||||
|
name='其他客户'
|
||||||
|
)
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位'
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=other_merchant,
|
||||||
|
customer=other_customer,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位'
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = services._get_plate_order_queryset(
|
||||||
|
self.merchant.id,
|
||||||
|
self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertEqual(queryset.first().customer.name, '客户A')
|
||||||
|
|
||||||
|
def test_filters_by_plate_type_not_null(self):
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位'
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type=None,
|
||||||
|
production_method='定位'
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = services._get_plate_order_queryset(
|
||||||
|
self.merchant.id,
|
||||||
|
self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertIsNotNone(queryset.first().plate_type)
|
||||||
|
|
||||||
|
def test_applies_customer_visibility_filter(self):
|
||||||
|
other_employee = basic_models.Employee.objects.create(
|
||||||
|
sys_user=User.objects.create_user('other', 'pass123'),
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='其他员工'
|
||||||
|
)
|
||||||
|
|
||||||
|
customer_visible = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='可见客户',
|
||||||
|
created_by=other_employee
|
||||||
|
)
|
||||||
|
customer_visible.visible_employees.set([self.employee])
|
||||||
|
customer_invisible = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='不可见客户',
|
||||||
|
created_by=other_employee
|
||||||
|
)
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=customer_visible,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位'
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=customer_invisible,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位'
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = services._get_plate_order_queryset(
|
||||||
|
self.merchant.id,
|
||||||
|
self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertEqual(queryset.first().customer.name, '可见客户')
|
||||||
|
|
||||||
|
def test_superuser_sees_all_customers(self):
|
||||||
|
self.user.is_superuser = True
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
other_employee = basic_models.Employee.objects.create(
|
||||||
|
sys_user=User.objects.create_user('other', 'pass123'),
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='其他员工'
|
||||||
|
)
|
||||||
|
customer_invisible = basic_models.Customer.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='不可见客户',
|
||||||
|
created_by=other_employee
|
||||||
|
)
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=customer_invisible,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位'
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = services._get_plate_order_queryset(
|
||||||
|
self.merchant.id,
|
||||||
|
self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class GetMonthDateRangeTestCase(TestCase):
|
||||||
|
def test_returns_month_start_and_settlement_date(self):
|
||||||
|
settlement_date = date(2026, 2, 8)
|
||||||
|
month_start, end_date = services._get_month_date_range(settlement_date)
|
||||||
|
|
||||||
|
self.assertEqual(month_start, date(2026, 2, 1))
|
||||||
|
self.assertEqual(end_date, date(2026, 2, 8))
|
||||||
|
|
||||||
|
def test_handles_month_start(self):
|
||||||
|
settlement_date = date(2026, 2, 1)
|
||||||
|
month_start, end_date = services._get_month_date_range(settlement_date)
|
||||||
|
|
||||||
|
self.assertEqual(month_start, date(2026, 2, 1))
|
||||||
|
self.assertEqual(end_date, date(2026, 2, 1))
|
||||||
|
|
||||||
|
def test_handles_month_end(self):
|
||||||
|
settlement_date = date(2026, 2, 28)
|
||||||
|
month_start, end_date = services._get_month_date_range(settlement_date)
|
||||||
|
|
||||||
|
self.assertEqual(month_start, date(2026, 2, 1))
|
||||||
|
self.assertEqual(end_date, date(2026, 2, 28))
|
||||||
|
|
||||||
|
def test_handles_different_months(self):
|
||||||
|
settlement_date = date(2026, 3, 15)
|
||||||
|
month_start, end_date = services._get_month_date_range(settlement_date)
|
||||||
|
|
||||||
|
self.assertEqual(month_start, date(2026, 3, 1))
|
||||||
|
self.assertEqual(end_date, date(2026, 3, 15))
|
||||||
|
|
||||||
|
|
||||||
|
class AggregatePlateOrdersByCustomerAndTypeTestCase(SettlementServiceTestCase):
|
||||||
|
def test_aggregates_by_customer_and_type(self):
|
||||||
|
today = self.settlement_date
|
||||||
|
yesterday = date(2026, 2, 7)
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='修改',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(yesterday, datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = services._get_plate_order_queryset(
|
||||||
|
self.merchant.id,
|
||||||
|
self.user
|
||||||
|
)
|
||||||
|
aggregated = services._aggregate_plate_orders_by_customer_and_type(
|
||||||
|
queryset, self.settlement_date
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(aggregated), 2)
|
||||||
|
|
||||||
|
def test_calculates_today_count(self):
|
||||||
|
today = self.settlement_date
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(date(2026, 2, 7), datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = services._get_plate_order_queryset(
|
||||||
|
self.merchant.id,
|
||||||
|
self.user
|
||||||
|
)
|
||||||
|
aggregated = services._aggregate_plate_orders_by_customer_and_type(
|
||||||
|
queryset, self.settlement_date
|
||||||
|
)
|
||||||
|
|
||||||
|
today_item = [item for item in aggregated if item['type'] == '首版-定位'][0]
|
||||||
|
self.assertEqual(today_item['today'], 1)
|
||||||
|
|
||||||
|
def test_calculates_current_month_count(self):
|
||||||
|
today = self.settlement_date
|
||||||
|
month_start = date(2026, 2, 1)
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(month_start, datetime.min.time()))
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(date(2026, 1, 31), datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = services._get_plate_order_queryset(
|
||||||
|
self.merchant.id,
|
||||||
|
self.user
|
||||||
|
)
|
||||||
|
aggregated = services._aggregate_plate_orders_by_customer_and_type(
|
||||||
|
queryset, self.settlement_date
|
||||||
|
)
|
||||||
|
|
||||||
|
today_item = [item for item in aggregated if item['type'] == '首版-定位'][0]
|
||||||
|
self.assertEqual(today_item['current_month'], 2)
|
||||||
|
|
||||||
|
def test_filters_null_production_method(self):
|
||||||
|
today = self.settlement_date
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method=None,
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = services._get_plate_order_queryset(
|
||||||
|
self.merchant.id,
|
||||||
|
self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(queryset.count(), 1)
|
||||||
|
self.assertEqual(queryset.first().production_method, '定位')
|
||||||
|
|
||||||
|
|
||||||
|
class FilterZeroDataTestCase(SettlementServiceTestCase):
|
||||||
|
def test_filters_all_zero_data(self):
|
||||||
|
data = [
|
||||||
|
{'type': '首版-定位', 'today': 0, 'current_month': 0},
|
||||||
|
{'type': '修改-定位', 'today': 0, 'current_month': 0}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = services._filter_zero_data(data)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 0)
|
||||||
|
|
||||||
|
def test_keeps_non_zero_today(self):
|
||||||
|
data = [
|
||||||
|
{'type': '首版-定位', 'today': 1, 'current_month': 0},
|
||||||
|
{'type': '修改-定位', 'today': 0, 'current_month': 0}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = services._filter_zero_data(data)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0]['type'], '首版-定位')
|
||||||
|
|
||||||
|
def test_keeps_non_zero_current_month(self):
|
||||||
|
data = [
|
||||||
|
{'type': '首版-定位', 'today': 0, 'current_month': 5},
|
||||||
|
{'type': '修改-定位', 'today': 0, 'current_month': 0}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = services._filter_zero_data(data)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0]['type'], '首版-定位')
|
||||||
|
|
||||||
|
|
||||||
|
class FormatPlateOrderSummaryTestCase(SettlementServiceTestCase):
|
||||||
|
def test_formats_aggregated_data(self):
|
||||||
|
aggregated = [
|
||||||
|
{
|
||||||
|
'customer__id': self.customer1.id,
|
||||||
|
'customer__name': '客户A',
|
||||||
|
'type': '首版-定位',
|
||||||
|
'today': 5,
|
||||||
|
'current_month': 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'customer__id': self.customer1.id,
|
||||||
|
'customer__name': '客户A',
|
||||||
|
'type': '修改-定位',
|
||||||
|
'today': 2,
|
||||||
|
'current_month': 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = services._format_plate_order_summary(aggregated)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0]['client_id'], self.customer1.id)
|
||||||
|
self.assertEqual(result[0]['client_name'], '客户A')
|
||||||
|
self.assertEqual(len(result[0]['plate_order_count']), 2)
|
||||||
|
|
||||||
|
def test_filters_customers_with_zero_data(self):
|
||||||
|
aggregated = [
|
||||||
|
{
|
||||||
|
'customer__id': self.customer1.id,
|
||||||
|
'customer__name': '客户A',
|
||||||
|
'type': '首版-定位',
|
||||||
|
'today': 0,
|
||||||
|
'current_month': 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = services._format_plate_order_summary(aggregated)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 0)
|
||||||
|
|
||||||
|
def test_groups_by_customer(self):
|
||||||
|
aggregated = [
|
||||||
|
{
|
||||||
|
'customer__id': self.customer1.id,
|
||||||
|
'customer__name': '客户A',
|
||||||
|
'type': '首版-定位',
|
||||||
|
'today': 5,
|
||||||
|
'current_month': 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'customer__id': self.customer2.id,
|
||||||
|
'customer__name': '客户B',
|
||||||
|
'type': '首版-定位',
|
||||||
|
'today': 3,
|
||||||
|
'current_month': 6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = services._format_plate_order_summary(aggregated)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertEqual(result[0]['client_id'], self.customer1.id)
|
||||||
|
self.assertEqual(result[0]['client_name'], '客户A')
|
||||||
|
self.assertEqual(result[1]['client_id'], self.customer2.id)
|
||||||
|
self.assertEqual(result[1]['client_name'], '客户B')
|
||||||
|
|
||||||
|
def test_groups_same_name_customers_by_customer_id(self):
|
||||||
|
aggregated = [
|
||||||
|
{
|
||||||
|
'customer__id': self.customer1.id,
|
||||||
|
'customer__name': '同名客户',
|
||||||
|
'type': '首版-定位',
|
||||||
|
'today': 1,
|
||||||
|
'current_month': 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'customer__id': self.customer2.id,
|
||||||
|
'customer__name': '同名客户',
|
||||||
|
'type': '修改-定位',
|
||||||
|
'today': 3,
|
||||||
|
'current_month': 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = services._format_plate_order_summary(aggregated)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertNotEqual(result[0]['client_id'], result[1]['client_id'])
|
||||||
|
|
||||||
|
|
||||||
|
class GetPlateOrderSummaryByCustomerTestCase(SettlementServiceTestCase):
|
||||||
|
def test_returns_summary_for_multiple_customers(self):
|
||||||
|
today = self.settlement_date
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer2,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='匹布',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
result = services.get_plate_order_summary_by_customer(
|
||||||
|
self.merchant.id,
|
||||||
|
self.settlement_date,
|
||||||
|
self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertIn('client_id', result[0])
|
||||||
|
|
||||||
|
def test_filters_zero_data(self):
|
||||||
|
today = self.settlement_date
|
||||||
|
|
||||||
|
printing_models.PlateOrder.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
customer=self.customer1,
|
||||||
|
plate_type='首版',
|
||||||
|
production_method='定位',
|
||||||
|
plate_date=timezone.make_aware(datetime.combine(date(2026, 1, 1), datetime.min.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
result = services.get_plate_order_summary_by_customer(
|
||||||
|
self.merchant.id,
|
||||||
|
self.settlement_date,
|
||||||
|
self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(result), 0)
|
||||||
|
|
||||||
|
def test_raises_for_invalid_merchant_id(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
services.get_plate_order_summary_by_customer(
|
||||||
|
merchant_id=0,
|
||||||
|
settlement_date=self.settlement_date,
|
||||||
|
user=self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_raises_for_invalid_settlement_date(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
services.get_plate_order_summary_by_customer(
|
||||||
|
merchant_id=self.merchant.id,
|
||||||
|
settlement_date='2026-02-08',
|
||||||
|
user=self.user
|
||||||
|
)
|
||||||
229
settlement/test_tasks_handlers_admin.py
Normal file
229
settlement/test_tasks_handlers_admin.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
from datetime import date
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.admin.sites import AdminSite
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from basic_info import models as basic_models
|
||||||
|
from settlement import handlers
|
||||||
|
from settlement.admin import DailySettlementConfigAdmin
|
||||||
|
from settlement.models import (
|
||||||
|
DailySettlementConfig,
|
||||||
|
NotificationChannelEnum,
|
||||||
|
SettlementModuleEnum,
|
||||||
|
)
|
||||||
|
from settlement.tasks import run_daily_settlement, run_merchant_daily_settlement
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementTasksTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='任务测试商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||||
|
)
|
||||||
|
self.config = DailySettlementConfig.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
settlement_modules=[SettlementModuleEnum.PLATE_ORDER],
|
||||||
|
notification_enabled=False,
|
||||||
|
notification_channel=NotificationChannelEnum.NONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch('settlement.tasks.run_merchant_daily_settlement.delay')
|
||||||
|
@patch('settlement.tasks.timezone.localdate')
|
||||||
|
def test_run_daily_settlement_dispatches_only_configured_merchants(
|
||||||
|
self,
|
||||||
|
mock_localdate,
|
||||||
|
mock_delay,
|
||||||
|
):
|
||||||
|
mock_localdate.return_value = date(2026, 2, 9)
|
||||||
|
|
||||||
|
merchant_without_modules = basic_models.Merchant.objects.create(
|
||||||
|
name='无模块商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||||
|
)
|
||||||
|
DailySettlementConfig.objects.create(
|
||||||
|
merchant=merchant_without_modules,
|
||||||
|
settlement_modules=[],
|
||||||
|
notification_enabled=False,
|
||||||
|
notification_channel=NotificationChannelEnum.NONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_daily_settlement.run()
|
||||||
|
|
||||||
|
self.assertEqual(result['total_merchants'], 1)
|
||||||
|
self.assertEqual(result['settlement_date'], '2026-02-08')
|
||||||
|
mock_delay.assert_called_once_with(
|
||||||
|
merchant_id=self.merchant.id,
|
||||||
|
settlement_date='2026-02-08',
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch('settlement.tasks.daily_settlement_completed.send')
|
||||||
|
@patch('settlement.tasks.calculate_printing_order_daily_summary')
|
||||||
|
@patch('settlement.tasks.calculate_plate_order_daily_summary')
|
||||||
|
def test_run_merchant_daily_settlement_success(
|
||||||
|
self,
|
||||||
|
mock_plate_summary,
|
||||||
|
mock_printing_summary,
|
||||||
|
mock_signal_send,
|
||||||
|
):
|
||||||
|
self.config.settlement_modules = [
|
||||||
|
SettlementModuleEnum.PLATE_ORDER,
|
||||||
|
SettlementModuleEnum.PRINTING_ORDER,
|
||||||
|
]
|
||||||
|
self.config.save(update_fields=['settlement_modules'])
|
||||||
|
|
||||||
|
mock_plate_summary.return_value = {'total_orders': 1}
|
||||||
|
mock_printing_summary.return_value = {'total_orders': 2}
|
||||||
|
|
||||||
|
result = run_merchant_daily_settlement.run(
|
||||||
|
merchant_id=self.merchant.id,
|
||||||
|
settlement_date='2026-02-08',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result['merchant_id'], self.merchant.id)
|
||||||
|
self.assertEqual(result['settlement_date'], '2026-02-08')
|
||||||
|
self.assertEqual(result['status'], 'success')
|
||||||
|
self.assertEqual(result['errors'], {})
|
||||||
|
mock_plate_summary.assert_called_once()
|
||||||
|
mock_printing_summary.assert_called_once()
|
||||||
|
mock_signal_send.assert_called_once()
|
||||||
|
|
||||||
|
@patch('settlement.tasks.daily_settlement_completed.send')
|
||||||
|
@patch('settlement.tasks.calculate_printing_order_daily_summary')
|
||||||
|
@patch('settlement.tasks.calculate_plate_order_daily_summary')
|
||||||
|
def test_run_merchant_daily_settlement_partial_when_one_module_fails(
|
||||||
|
self,
|
||||||
|
mock_plate_summary,
|
||||||
|
mock_printing_summary,
|
||||||
|
mock_signal_send,
|
||||||
|
):
|
||||||
|
self.config.settlement_modules = [
|
||||||
|
SettlementModuleEnum.PLATE_ORDER,
|
||||||
|
SettlementModuleEnum.PRINTING_ORDER,
|
||||||
|
]
|
||||||
|
self.config.save(update_fields=['settlement_modules'])
|
||||||
|
|
||||||
|
mock_plate_summary.side_effect = RuntimeError('plate failed')
|
||||||
|
mock_printing_summary.return_value = {'total_orders': 2}
|
||||||
|
|
||||||
|
result = run_merchant_daily_settlement.run(
|
||||||
|
merchant_id=self.merchant.id,
|
||||||
|
settlement_date='2026-02-08',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result['status'], 'partial')
|
||||||
|
self.assertIn(SettlementModuleEnum.PLATE_ORDER, result['errors'])
|
||||||
|
self.assertEqual(result['errors'][SettlementModuleEnum.PLATE_ORDER], 'plate failed')
|
||||||
|
mock_signal_send.assert_called_once()
|
||||||
|
|
||||||
|
@patch('settlement.tasks.daily_settlement_completed.send')
|
||||||
|
@patch('settlement.tasks.calculate_plate_order_daily_summary')
|
||||||
|
def test_run_merchant_daily_settlement_failed_when_all_modules_fail(
|
||||||
|
self,
|
||||||
|
mock_plate_summary,
|
||||||
|
mock_signal_send,
|
||||||
|
):
|
||||||
|
self.config.settlement_modules = [SettlementModuleEnum.PLATE_ORDER]
|
||||||
|
self.config.save(update_fields=['settlement_modules'])
|
||||||
|
|
||||||
|
mock_plate_summary.side_effect = RuntimeError('plate failed')
|
||||||
|
|
||||||
|
result = run_merchant_daily_settlement.run(
|
||||||
|
merchant_id=self.merchant.id,
|
||||||
|
settlement_date='2026-02-08',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result['status'], 'failed')
|
||||||
|
self.assertIn(SettlementModuleEnum.PLATE_ORDER, result['errors'])
|
||||||
|
mock_signal_send.assert_called_once()
|
||||||
|
|
||||||
|
@patch('settlement.tasks.daily_settlement_completed.send')
|
||||||
|
@patch('settlement.tasks.calculate_plate_order_daily_summary')
|
||||||
|
@patch('settlement.tasks.timezone.localdate')
|
||||||
|
def test_run_merchant_daily_settlement_uses_yesterday_when_no_date(
|
||||||
|
self,
|
||||||
|
mock_localdate,
|
||||||
|
mock_plate_summary,
|
||||||
|
mock_signal_send,
|
||||||
|
):
|
||||||
|
mock_localdate.return_value = date(2026, 2, 9)
|
||||||
|
self.config.settlement_modules = [SettlementModuleEnum.PLATE_ORDER]
|
||||||
|
self.config.save(update_fields=['settlement_modules'])
|
||||||
|
mock_plate_summary.return_value = {'total_orders': 1}
|
||||||
|
|
||||||
|
result = run_merchant_daily_settlement.run(merchant_id=self.merchant.id)
|
||||||
|
|
||||||
|
self.assertEqual(result['settlement_date'], '2026-02-08')
|
||||||
|
mock_signal_send.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementHandlersTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='处理器测试商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch('settlement.handlers.logger.info')
|
||||||
|
def test_handler_logs_when_notification_disabled(self, mock_logger_info):
|
||||||
|
DailySettlementConfig.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
settlement_modules=[SettlementModuleEnum.PLATE_ORDER],
|
||||||
|
notification_enabled=False,
|
||||||
|
notification_channel=NotificationChannelEnum.NONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
handlers.on_daily_settlement_completed(
|
||||||
|
sender=self.__class__,
|
||||||
|
merchant=self.merchant,
|
||||||
|
settlement_date=date(2026, 2, 8),
|
||||||
|
status='success',
|
||||||
|
modules=[SettlementModuleEnum.PLATE_ORDER],
|
||||||
|
errors={},
|
||||||
|
task_id='task-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(mock_logger_info.called)
|
||||||
|
|
||||||
|
@patch('settlement.handlers.logger.info')
|
||||||
|
def test_handler_logs_wecom_when_notification_enabled(self, mock_logger_info):
|
||||||
|
DailySettlementConfig.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
settlement_modules=[SettlementModuleEnum.PLATE_ORDER],
|
||||||
|
notification_enabled=True,
|
||||||
|
notification_channel=NotificationChannelEnum.WECOM,
|
||||||
|
)
|
||||||
|
|
||||||
|
handlers.on_daily_settlement_completed(
|
||||||
|
sender=self.__class__,
|
||||||
|
merchant=self.merchant,
|
||||||
|
settlement_date=date(2026, 2, 8),
|
||||||
|
status='success',
|
||||||
|
modules=[SettlementModuleEnum.PLATE_ORDER],
|
||||||
|
errors={},
|
||||||
|
task_id='task-2',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(mock_logger_info.called)
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementAdminTestCase(TestCase):
|
||||||
|
def test_get_readonly_fields(self):
|
||||||
|
admin_obj = DailySettlementConfigAdmin(
|
||||||
|
DailySettlementConfig,
|
||||||
|
admin_site=AdminSite(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(admin_obj.get_readonly_fields(request=None, obj=None), [])
|
||||||
|
|
||||||
|
merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='后台测试商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||||
|
)
|
||||||
|
config = DailySettlementConfig.objects.create(
|
||||||
|
merchant=merchant,
|
||||||
|
settlement_modules=[SettlementModuleEnum.PLATE_ORDER],
|
||||||
|
notification_enabled=False,
|
||||||
|
notification_channel=NotificationChannelEnum.NONE,
|
||||||
|
)
|
||||||
|
self.assertEqual(admin_obj.get_readonly_fields(request=None, obj=config), ['merchant'])
|
||||||
Reference in New Issue
Block a user