1
0
forked from erp-dev/erp

feat: settlement first api beta

This commit is contained in:
2026-02-27 18:26:12 +08:00
parent c564b1af32
commit 86f5dab32b
16 changed files with 1854 additions and 10 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -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)),
] ]

View File

@@ -0,0 +1 @@
"""Settlement API views"""

View 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)

View 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
)

View File

@@ -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
View 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

View File

@@ -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
View 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. 生产约束:索引变更需使用并发建索引方式并在低峰执行

View File

@@ -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}, '

View File

@@ -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'

View File

@@ -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

View File

@@ -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(

View 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
View 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
)

View 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'])