From 86f5dab32bdb7bd876de727e2a920dc13af1cba2 Mon Sep 17 00:00:00 2001 From: colaftc Date: Fri, 27 Feb 2026 18:26:12 +0800 Subject: [PATCH] feat: settlement first api beta --- .coverage | Bin 53248 -> 114688 bytes api_v1/urls.py | 8 + api_v1/views/settlement/__init__.py | 1 + api_v1/views/settlement/test_api.py | 320 ++++++++++++ api_v1/views/settlement/views.py | 75 +++ docker-compose.yml | 10 +- docs/settlement/API.md | 203 ++++++++ docs/settlement/DESIGN.md | 44 +- docs/settlement/Service.md | 186 +++++++ settlement/handlers.py | 4 +- settlement/models.py | 1 + settlement/services.py | 209 +++++++- settlement/tasks.py | 4 +- settlement/test_integration_table_mapping.py | 60 +++ settlement/test_services.py | 510 +++++++++++++++++++ settlement/test_tasks_handlers_admin.py | 229 +++++++++ 16 files changed, 1854 insertions(+), 10 deletions(-) create mode 100644 api_v1/views/settlement/__init__.py create mode 100644 api_v1/views/settlement/test_api.py create mode 100644 api_v1/views/settlement/views.py create mode 100644 docs/settlement/API.md create mode 100644 docs/settlement/Service.md create mode 100644 settlement/test_integration_table_mapping.py create mode 100644 settlement/test_services.py create mode 100644 settlement/test_tasks_handlers_admin.py diff --git a/.coverage b/.coverage index f66a8fec3648f0b914709425fee447ca9d4a01fc..0e299dee27dcb15da7356935e9bf4e2af252a7b6 100644 GIT binary patch literal 114688 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 L6b39}AGz>ahni@4J%0r||w2FXM(Brr^ znDpT*n|AaMIb&N*nn)z3trqK<`lzujsBMFlgd;We+M*sUDj}zAXFv~Q{NrwZ^PBH{ zzq>P^Y*Uk|=^(c!8I-n^-uwcEVHmg2!l+l&!)m=+s9I16oj@O>4CNQ)Jtf6_*4%84 zliTGA*(#lrwo0!^taw4(DZVKt2)_$0$75qk1U|>jG4)#8+(hkamxG<@(yq*NXuSod zajQ1hR&A)Pt<6|fzP@}zMP){5Y1M|Ry3*1~)%7{fN1KwOs+!7*%Bt$Rj4f4_@77X@ zY)kck8JAYy+SB(h6maBCtg zHEvQ#(i+_6f^@vAf~@8NWV167eP_Bi0sBp0W{VYk)CI+CnHleOKp9&k)08ZHKNXVL zwS$B~?oWdk*hewP%DQHRuj)mHVOx=)>g+EFX|4DClBA`hxVYtaf+gyy1Ll#a$D7qTe#lrANp z98vs=M(A6jcqO+1M4S8D<2iRYql?tY#oC-?oaDT7a!v<-rtzz;fG5j8v^6=&=AeDY zQ~h9)+fMp3?04!fcHc)eUo)AJFHc?3@1MW5fA`>Ra3|2!glDgM!Du7P$i^D2bhNTU zJQ?HS=MR9{LMo?a`T;$7PhTm-(S9813$e6{C6ydQDtio3%+hB`Dw){S=?4aTOcJ6s zI^5Bz=b98!$0+j$WHVKp_jPR3^;|_J6SwRodjoyCjs%I!<0N7#ilmAc_jQChS)eT#J^9On+dVBs3`Rg+ZggN0*;mF_m z#+;`1?M3|`tbcXdA)v5cI27#j2L?-q^{&W+o4VfM4-WT)vLm`43a#|k z20b47Vj+_pEvgp>}!%%zo z&7oU^p5Wl6=kG^`BaQ9vv;@N8%?th*iVT +``` + +**响应示例**: +```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'])