diff --git a/.coverage b/.coverage index f66a8fe..0e299de 100644 Binary files a/.coverage and b/.coverage differ diff --git a/api_v1/urls.py b/api_v1/urls.py index 30b3bb4..1abfc46 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -34,6 +34,7 @@ from .views.shipment import ( ShipmentDetailView, ShipmentExternalCreateView, ) +from .views.settlement.views import PlateOrderSummaryView # 创建 DRF Router for Stateflow stateflow_router = DefaultRouter() @@ -156,6 +157,13 @@ urlpatterns = [ name='sales_items_by_printing_order' ), + # Settlement API + path( + 'settlement/plate-orders/summary/', + PlateOrderSummaryView.as_view(), + name='plate_order_summary' + ), + # 主 Router (printing-orders 等) path('', include(main_router.urls)), ] diff --git a/api_v1/views/settlement/__init__.py b/api_v1/views/settlement/__init__.py new file mode 100644 index 0000000..5dc6b90 --- /dev/null +++ b/api_v1/views/settlement/__init__.py @@ -0,0 +1 @@ +"""Settlement API views""" diff --git a/api_v1/views/settlement/test_api.py b/api_v1/views/settlement/test_api.py new file mode 100644 index 0000000..f1afb65 --- /dev/null +++ b/api_v1/views/settlement/test_api.py @@ -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) diff --git a/api_v1/views/settlement/views.py b/api_v1/views/settlement/views.py new file mode 100644 index 0000000..190cd62 --- /dev/null +++ b/api_v1/views/settlement/views.py @@ -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 + ) diff --git a/docker-compose.yml b/docker-compose.yml index 1f9c52e..e2c7a98 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,9 +70,15 @@ services: context: . target: base 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: - - "8000:8000" + - "8100:8100" volumes: - .:/app environment: diff --git a/docs/settlement/API.md b/docs/settlement/API.md new file mode 100644 index 0000000..46b1ce4 --- /dev/null +++ b/docs/settlement/API.md @@ -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 +``` + +**响应示例**: +```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) diff --git a/docs/settlement/DESIGN.md b/docs/settlement/DESIGN.md index c8b9691..ce3060f 100644 --- a/docs/settlement/DESIGN.md +++ b/docs/settlement/DESIGN.md @@ -78,6 +78,7 @@ class DailySettlementConfig(ModelBase): class Meta: verbose_name = '日结配置' verbose_name_plural = '日结配置' + db_table = 'daily_settlement_config' ``` **说明**: @@ -212,8 +213,8 @@ def run_daily_settlement(self): from basic_info.models import Merchant # 获取所有配置了统计模块的商户 - configs = DailySettlementConfig.objects.filter( - settlement_modules__len__gt=0 + configs = DailySettlementConfig.objects.exclude( + settlement_modules=[] ).select_related('merchant') logger.info( @@ -412,7 +413,42 @@ class SettlementConfig(AppConfig): 3. 未来如需提升性能,可以通过增加 Celery worker 数量来解决 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` 模块目录结构 2. 实现 `models.py`(配置模型) @@ -427,7 +463,7 @@ class SettlementConfig(AppConfig): 11. 编写测试用例 12. 手动触发测试,验证结果 -## 十四、方案优势 +## 十五、方案优势 1. **独立模块**:`settlement` 模块独立,职责清晰 2. **配置驱动**:每个商户可独立配置统计模块和通知渠道 diff --git a/docs/settlement/Service.md b/docs/settlement/Service.md new file mode 100644 index 0000000..d4f6776 --- /dev/null +++ b/docs/settlement/Service.md @@ -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. 生产约束:索引变更需使用并发建索引方式并在低峰执行 diff --git a/settlement/handlers.py b/settlement/handlers.py index 65dbeca..c4ce546 100644 --- a/settlement/handlers.py +++ b/settlement/handlers.py @@ -1,6 +1,8 @@ """日结模块信号处理函数""" import logging +from .models import NotificationChannelEnum + logger = logging.getLogger(__name__) @@ -22,7 +24,7 @@ def on_daily_settlement_completed(sender, **kwargs): return # 根据通知渠道处理(目前只有企业微信) - if config.notification_channel == config.NotificationChannelEnum.WECOM: + if config.notification_channel == NotificationChannelEnum.WECOM: logger.info( f'[settlement.handlers] 商户 {merchant.id} 日结完成,' f'状态={status}, 模块={modules}, 错误={errors}, ' diff --git a/settlement/models.py b/settlement/models.py index 5d7feab..8ccf29e 100644 --- a/settlement/models.py +++ b/settlement/models.py @@ -47,3 +47,4 @@ class DailySettlementConfig(ModelBase): class Meta: verbose_name = '日结配置' verbose_name_plural = '日结配置' + db_table = 'daily_settlement_config' diff --git a/settlement/services.py b/settlement/services.py index 4070cff..b8ff5e2 100644 --- a/settlement/services.py +++ b/settlement/services.py @@ -1,10 +1,217 @@ """日结模块统计服务函数""" 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__) +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( merchant_id: int, settlement_date: datetime.date diff --git a/settlement/tasks.py b/settlement/tasks.py index f125aff..f5f6cd0 100644 --- a/settlement/tasks.py +++ b/settlement/tasks.py @@ -26,8 +26,8 @@ def run_daily_settlement(self): from basic_info.models import Merchant # 获取所有配置了统计模块的商户 - configs = DailySettlementConfig.objects.filter( - settlement_modules__len__gt=0 + configs = DailySettlementConfig.objects.exclude( + settlement_modules=[] ).select_related('merchant') logger.info( diff --git a/settlement/test_integration_table_mapping.py b/settlement/test_integration_table_mapping.py new file mode 100644 index 0000000..bdfd269 --- /dev/null +++ b/settlement/test_integration_table_mapping.py @@ -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', + ) diff --git a/settlement/test_services.py b/settlement/test_services.py new file mode 100644 index 0000000..eac1b79 --- /dev/null +++ b/settlement/test_services.py @@ -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 + ) diff --git a/settlement/test_tasks_handlers_admin.py b/settlement/test_tasks_handlers_admin.py new file mode 100644 index 0000000..cf5d131 --- /dev/null +++ b/settlement/test_tasks_handlers_admin.py @@ -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'])