diff --git a/docs/settlement/DESIGN.md b/docs/settlement/DESIGN.md new file mode 100644 index 0000000..c8b9691 --- /dev/null +++ b/docs/settlement/DESIGN.md @@ -0,0 +1,441 @@ +# 日结服务设计方案 + +## 一、概述 + +日结服务是一个基于 Celery 的定时任务系统,用于在每天凌晨5点自动执行各商户的日结统计。服务采用独立模块设计,支持商户级别的配置,包括统计模块选择和通知渠道配置。 + +## 二、设计原则 + +1. **独立模块**:`settlement` 模块独立,职责清晰 +2. **配置驱动**:每个商户可独立配置统计模块和通知渠道 +3. **统一调度**:运行时间在 settings.py 中统一配置,便于管理 +4. **商户隔离**:每个商户的日结任务独立执行,互不干扰 +5. **信号通知**:通过 signal 通知日结完成,便于后续扩展 +6. **模块化统计**:统计函数集中在 `services.py`,便于后续填充具体逻辑 +7. **容错性强**:单个模块失败不影响其他模块 +8. **可扩展性**:未来可轻松添加新的统计模块和通知渠道 + +## 三、模块结构 + +``` +settlement/ +├── __init__.py +├── apps.py # 应用配置,注册信号处理器 +├── models.py # 日结配置模型 +├── signals.py # 日结完成信号定义 +├── handlers.py # 信号处理器(日志输出) +├── services.py # 统计服务函数(空壳) +├── tasks.py # Celery定时任务 +└── migrations/ # 数据库迁移文件 +``` + +## 四、模型设计 + +### 4.1 日结配置模型 + +```python +class NotificationChannelEnum(models.TextChoices): + """通知渠道枚举""" + WECOM = 'wecom', '企业微信' + NONE = 'none', '不通知' + + +class SettlementModuleEnum(models.TextChoices): + """日结模块枚举""" + PLATE_ORDER = 'plate_order', '开版订单' + PRINTING_ORDER = 'printing_order', '生产订单' + + +class DailySettlementConfig(ModelBase): + """日结配置 - 每个商户一条记录(可选)""" + id = models.BigAutoField(primary_key=True) + merchant = models.OneToOneField( + 'basic_info.Merchant', + on_delete=models.CASCADE, + related_name='settlement_config', + verbose_name='所属商户' + ) + + # 统计模块配置(JSON数组,存储枚举值) + settlement_modules = models.JSONField( + default=list, + verbose_name='统计模块', + help_text='例如: ["plate_order", "printing_order"]' + ) + + # 通知配置 + notification_enabled = models.BooleanField(default=False, verbose_name='启用通知') + notification_channel = models.CharField( + max_length=20, + choices=NotificationChannelEnum.choices, + default=NotificationChannelEnum.NONE, + verbose_name='通知渠道' + ) + + # 其他配置 + description = models.TextField(blank=True, null=True, verbose_name='备注') + + class Meta: + verbose_name = '日结配置' + verbose_name_plural = '日结配置' +``` + +**说明**: +- 新商户不创建配置,则不参与日结统计 +- 配置中不包含 `run_hour` 和 `run_minute`,统一在 settings.py 中配置 + +## 五、信号设计 + +### 5.1 信号定义 + +```python +from django.dispatch import Signal + +# 日结完成信号 +# 参数: +# merchant: Merchant 实例 +# settlement_date: datetime.date +# status: 'success' / 'failed' / 'partial' +# modules: list[str] - 参与统计的模块列表 +# errors: dict - 模块错误详情 {'module_name': 'error message'} +# task_id: str - Celery 任务ID +daily_settlement_completed = Signal() +``` + +### 5.2 信号处理器 + +```python +def on_daily_settlement_completed(sender, **kwargs): + """日结完成时的处理(日志输出)""" + merchant = kwargs.get('merchant') + settlement_date = kwargs.get('settlement_date') + status = kwargs.get('status') + modules = kwargs.get('modules', []) + errors = kwargs.get('errors', {}) + task_id = kwargs.get('task_id') + + # 检查配置是否启用通知 + config = merchant.settlement_config + if not config or not config.notification_enabled: + logger.info( + f'[settlement.handlers] 商户 {merchant.id} 日结完成,通知未启用' + ) + return + + # 根据通知渠道处理(目前只有企业微信) + if config.notification_channel == NotificationChannelEnum.WECOM: + logger.info( + f'[settlement.handlers] 商户 {merchant.id} 日结完成,' + f'状态={status}, 模块={modules}, 错误={errors}, ' + f'通知渠道=企业微信(暂不发送具体内容)' + ) + # 未来可以在这里添加具体的企业微信通知逻辑 +``` + +## 六、统计服务函数(空壳) + +### 6.1 开版订单统计 + +```python +def calculate_plate_order_daily_summary( + merchant_id: int, + settlement_date: datetime.date +) -> dict: + """ + 计算开版订单日结汇总(空壳实现) + + Args: + merchant_id: 商户ID + settlement_date: 结算日期 + + Returns: + dict: 统计结果(空壳) + """ + logger.info( + f'[settlement.services] 计算开版订单日结汇总: ' + f'merchant_id={merchant_id}, date={settlement_date}' + ) + + return { + 'merchant_id': merchant_id, + 'settlement_date': str(settlement_date), + 'total_orders': 0, + 'completed_orders': 0, + 'in_progress_orders': 0, + } +``` + +### 6.2 生产订单统计 + +```python +def calculate_printing_order_daily_summary( + merchant_id: int, + settlement_date: datetime.date +) -> dict: + """ + 计算生产订单日结汇总(空壳实现) + + Args: + merchant_id: 商户ID + settlement_date: 结算日期 + + Returns: + dict: 统计结果(空壳) + """ + logger.info( + f'[settlement.services] 计算生产订单日结汇总: ' + f'merchant_id={merchant_id}, date={settlement_date}' + ) + + return { + 'merchant_id': merchant_id, + 'settlement_date': str(settlement_date), + 'total_orders': 0, + 'completed_orders': 0, + 'in_progress_orders': 0, + } +``` + +## 七、Celery 任务设计 + +### 7.1 主调度任务 + +```python +@shared_task(bind=True) +def run_daily_settlement(self): + """ + 执行所有商户的日结任务(固定时间触发) + + 在 settings.py 中配置的固定时间触发, + 拉取所有配置了统计模块的商户,逐个执行日结。 + """ + from basic_info.models import Merchant + + # 获取所有配置了统计模块的商户 + configs = DailySettlementConfig.objects.filter( + settlement_modules__len__gt=0 + ).select_related('merchant') + + logger.info( + f'[settlement.tasks] 开始执行日结,共 {configs.count()} 个商户需要统计' + ) + + # 为每个商户触发日结任务 + for config in configs: + settlement_date = timezone.localdate() - datetime.timedelta(days=1) + run_merchant_daily_settlement.delay( + merchant_id=config.merchant.id, + settlement_date=str(settlement_date) + ) + + return { + 'task_id': self.request.id, + 'total_merchants': configs.count(), + 'settlement_date': str(timezone.localdate() - datetime.timedelta(days=1)), + } +``` + +### 7.2 商户日结任务 + +```python +@shared_task(bind=True) +def run_merchant_daily_settlement( + self, + merchant_id: int, + settlement_date: str | None = None +): + """ + 执行单个商户的日结 + + Args: + merchant_id: 商户ID + settlement_date: 结算日期(YYYY-MM-DD),默认为昨天 + + Returns: + dict: 包含 task_id 和执行结果的 payload + """ + from basic_info.models import Merchant + + # 解析日期 + if settlement_date: + date_obj = datetime.datetime.strptime(settlement_date, '%Y-%m-%d').date() + else: + date_obj = timezone.localdate() - datetime.timedelta(days=1) + + merchant = Merchant.objects.get(id=merchant_id) + config = merchant.settlement_config + + modules = config.settlement_modules + summaries = {} + errors = {} + overall_status = 'success' + + logger.info( + f'[settlement.tasks] 开始执行商户 {merchant_id} 的日结,' + f'日期={date_obj}, 模块={modules}' + ) + + # 执行各模块的统计 + for module in modules: + try: + if module == SettlementModuleEnum.PLATE_ORDER: + summary = calculate_plate_order_daily_summary( + merchant_id=merchant_id, + settlement_date=date_obj + ) + summaries['plate_order'] = summary + + elif module == SettlementModuleEnum.PRINTING_ORDER: + summary = calculate_printing_order_daily_summary( + merchant_id=merchant_id, + settlement_date=date_obj + ) + summaries['printing_order'] = summary + + except Exception as e: + logger.exception( + f'[settlement.tasks] 商户 {merchant_id} 的 {module} 统计失败: {e}' + ) + errors[module] = str(e) + overall_status = 'partial' + + # 如果所有模块都失败 + if len(errors) == len(modules): + overall_status = 'failed' + + # 发送信号(不包含统计结果详情) + daily_settlement_completed.send( + sender=run_merchant_daily_settlement, + merchant=merchant, + settlement_date=date_obj, + status=overall_status, + modules=modules, + errors=errors, + task_id=self.request.id + ) + + payload = { + 'task_id': self.request.id, + 'merchant_id': merchant_id, + 'settlement_date': str(date_obj), + 'status': overall_status, + 'modules': modules, + 'errors': errors, + } + + logger.info( + f'[settlement.tasks] 商户 {merchant_id} 日结完成: {payload}' + ) + return payload +``` + +## 八、Celery Beat 配置 + +在 `settings.py` 的 `CELERY_BEAT_SCHEDULE` 中添加: + +```python +# 日结任务 - 每天凌晨5点执行 +'daily_settlement': { + 'task': 'settlement.tasks.run_daily_settlement', + 'schedule': crontab(hour=5, minute=0), +}, +``` + +**说明**: +- 运行时间统一在 settings.py 中配置(`hour=5, minute=0`) +- 不需要每小时检查,直接在固定时间触发 + +## 九、settings.py 配置 + +```python +# 日结服务配置 +# ------------------------------------------------------------------------------ +# 日结运行时间(固定时间,所有商户统一) +SETTLEMENT_RUN_HOUR = env.int('SETTLEMENT_RUN_HOUR', default=5) +SETTLEMENT_RUN_MINUTE = env.int('SETTLEMENT_RUN_MINUTE', default=0) +# ------------------------------------------------------------------------------ +``` + +然后在 CELERY_BEAT_SCHEDULE 中使用: + +```python +from celery.schedules import crontab + +'daily_settlement': { + 'task': 'settlement.tasks.run_daily_settlement', + 'schedule': crontab( + hour=settings.SETTLEMENT_RUN_HOUR, + minute=settings.SETTLEMENT_RUN_MINUTE + ), +}, +``` + +## 十、应用配置 + +```python +class SettlementConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'settlement' + verbose_name = '日结管理' + + def ready(self): + """注册信号处理函数""" + from .signals import daily_settlement_completed + from . import handlers + + # 注册日结完成信号处理器 + daily_settlement_completed.connect( + handlers.on_daily_settlement_completed, + dispatch_uid='settlement.on_daily_settlement_completed' + ) + + logger.info( + f'[settlement.apps] 已注册 daily_settlement_completed 信号处理器' + ) +``` + +## 十一、错误处理策略 + +1. **模块级隔离**:每个模块的统计失败不影响其他模块 +2. **错误记录**:记录每个模块的错误信息到 `errors` 字典 +3. **状态判断**: + - `success`:所有模块都成功 + - `partial`:部分模块成功 + - `failed`:所有模块都失败 +4. **日志记录**:使用 logger 记录详细日志(开始、完成、异常) + +## 十二、并发控制说明 + +**暂不考虑并发控制**,原因: +1. 日结任务在凌晨时段触发,业务量较小 +2. 商户数量预期不多,可以逐个串行执行 +3. 未来如需提升性能,可以通过增加 Celery worker 数量来解决 +4. Celery 本身支持任务队列和并发控制,后续可以轻松扩展 + +## 十三、实施步骤 + +1. 创建 `settlement` 模块目录结构 +2. 实现 `models.py`(配置模型) +3. 实现 `signals.py`(信号定义) +4. 实现 `services.py`(空壳统计函数) +5. 实现 `handlers.py`(信号处理器) +6. 实现 `tasks.py`(Celery 任务) +7. 实现 `apps.py`(注册信号处理器) +8. 在 `settings.py` 中添加 `settlement` 到 `INSTALLED_APPS` +9. 在 `settings.py` 中配置 Celery Beat 和日结运行时间 +10. 创建数据库迁移 +11. 编写测试用例 +12. 手动触发测试,验证结果 + +## 十四、方案优势 + +1. **独立模块**:`settlement` 模块独立,职责清晰 +2. **配置驱动**:每个商户可独立配置统计模块和通知渠道 +3. **统一调度**:运行时间在 settings.py 中统一配置,便于管理 +4. **商户隔离**:每个商户的日结任务独立执行,互不干扰 +5. **信号通知**:通过 signal 通知日结完成,便于后续扩展 +6. **模块化统计**:统计函数集中在 `services.py`,便于后续填充具体逻辑 +7. **容错性强**:单个模块失败不影响其他模块 +8. **可扩展性**:未来可轻松添加新的统计模块和通知渠道 +9. **简化信号**:信号参数只包含基本信息,不包含统计结果详情 +10. **日志友好**:handler 中只做日志输出,便于调试 diff --git a/flower/settings.py b/flower/settings.py index a1ecde2..bf09a70 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -148,6 +148,7 @@ INSTALLED_APPS = [ 'sse', 'api_v2', 'shipment', + 'settlement', ] MIDDLEWARE = [ @@ -485,6 +486,13 @@ AUTO_CREATE_SALESITEM_FROM_PRINT_ORDER = env.bool('AUTO_CREATE_SALESITEM_FROM_PR PRINTING_SALES_ITEM_SOURCE_STATE_ID = env.int('PRINTING_SALES_ITEM_SOURCE_STATE_ID') # 必须在 .env 中配置 PRINTING_SALES_ITEM_QUANTITY_KEY = '米数' # 数量参数的 key +# Settlement module settings +# ------------------------------------------------------------------------------ +# 日结运行时间(固定时间,所有商户统一) +SETTLEMENT_RUN_HOUR = env.int('SETTLEMENT_RUN_HOUR', default=5) +SETTLEMENT_RUN_MINUTE = env.int('SETTLEMENT_RUN_MINUTE', default=0) +# ------------------------------------------------------------------------------ + # API 审计日志配置 # ------------------------------------------------------------------------------ # 用于记录特定模块POST请求的历史现场,便于追踪"创建"操作。 @@ -535,6 +543,13 @@ CELERY_BEAT_SCHEDULE = { 'filename_prefix': 'db-backup', }, }, + 'daily_settlement': { + 'task': 'settlement.tasks.run_daily_settlement', + 'schedule': crontab( + hour=SETTLEMENT_RUN_HOUR, + minute=SETTLEMENT_RUN_MINUTE + ), + }, # 'daily_plate_order_tiia_image_upload': { # 'task': 'printing.tasks.upload_yesterday_plate_order_images_to_tencent_tiia', # 'schedule': crontab(hour=3, minute=0), diff --git a/settlement/__init__.py b/settlement/__init__.py new file mode 100644 index 0000000..2e3e9de --- /dev/null +++ b/settlement/__init__.py @@ -0,0 +1 @@ +"""日结管理模块""" diff --git a/settlement/admin.py b/settlement/admin.py new file mode 100644 index 0000000..fd124fb --- /dev/null +++ b/settlement/admin.py @@ -0,0 +1,27 @@ +"""日结模块管理后台配置""" +from django.contrib import admin +from .models import DailySettlementConfig, NotificationChannelEnum, SettlementModuleEnum + + +@admin.register(DailySettlementConfig) +class DailySettlementConfigAdmin(admin.ModelAdmin): + list_display = ['id', 'merchant', 'notification_enabled', 'notification_channel', 'created_at'] + list_filter = ['notification_enabled', 'notification_channel'] + search_fields = ['merchant__name', 'description'] + + fieldsets = ( + ('基本信息', { + 'fields': ('merchant', 'description') + }), + ('统计配置', { + 'fields': ('settlement_modules',) + }), + ('通知配置', { + 'fields': ('notification_enabled', 'notification_channel') + }), + ) + + def get_readonly_fields(self, request, obj=None): + if obj: + return ['merchant'] + return [] diff --git a/settlement/apps.py b/settlement/apps.py new file mode 100644 index 0000000..b3cae39 --- /dev/null +++ b/settlement/apps.py @@ -0,0 +1,27 @@ +"""日结模块应用配置""" +import logging + +from django.apps import AppConfig + +logger = logging.getLogger(__name__) + + +class SettlementConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'settlement' + verbose_name = '日结管理' + + def ready(self): + """注册信号处理函数""" + from .signals import daily_settlement_completed + from . import handlers + + # 注册日结完成信号处理器 + daily_settlement_completed.connect( + handlers.on_daily_settlement_completed, + dispatch_uid='settlement.on_daily_settlement_completed' + ) + + logger.info( + f'[settlement.apps] 已注册 daily_settlement_completed 信号处理器' + ) diff --git a/settlement/handlers.py b/settlement/handlers.py new file mode 100644 index 0000000..65dbeca --- /dev/null +++ b/settlement/handlers.py @@ -0,0 +1,30 @@ +"""日结模块信号处理函数""" +import logging + +logger = logging.getLogger(__name__) + + +def on_daily_settlement_completed(sender, **kwargs): + """日结完成时的处理(日志输出)""" + merchant = kwargs.get('merchant') + settlement_date = kwargs.get('settlement_date') + status = kwargs.get('status') + modules = kwargs.get('modules', []) + errors = kwargs.get('errors', {}) + task_id = kwargs.get('task_id') + + # 检查配置是否启用通知 + config = merchant.settlement_config + if not config or not config.notification_enabled: + logger.info( + f'[settlement.handlers] 商户 {merchant.id} 日结完成,通知未启用' + ) + return + + # 根据通知渠道处理(目前只有企业微信) + if config.notification_channel == config.NotificationChannelEnum.WECOM: + logger.info( + f'[settlement.handlers] 商户 {merchant.id} 日结完成,' + f'状态={status}, 模块={modules}, 错误={errors}, ' + f'通知渠道=企业微信(暂不发送具体内容)' + ) diff --git a/settlement/management/__init__.py b/settlement/management/__init__.py new file mode 100644 index 0000000..e3071bc --- /dev/null +++ b/settlement/management/__init__.py @@ -0,0 +1 @@ +"""Settlement management commands""" diff --git a/settlement/management/commands/__init__.py b/settlement/management/commands/__init__.py new file mode 100644 index 0000000..e3071bc --- /dev/null +++ b/settlement/management/commands/__init__.py @@ -0,0 +1 @@ +"""Settlement management commands""" diff --git a/settlement/management/commands/run_daily_settlement.py b/settlement/management/commands/run_daily_settlement.py new file mode 100644 index 0000000..56119e7 --- /dev/null +++ b/settlement/management/commands/run_daily_settlement.py @@ -0,0 +1,151 @@ +""" +手动触发日结任务(management command) + +用法: + python manage.py run_daily_settlement + +选项: + --merchant-id: 仅处理指定商户ID + --date: 指定结算日期(YYYY-MM-DD),默认为昨天 + --sync: 同步执行(阻塞),默认为异步(通过 Celery) +""" + +from datetime import datetime + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone + +from settlement.models import DailySettlementConfig + + +class Command(BaseCommand): + help = '手动触发日结任务(支持指定商户、日期、同步/异步执行)' + + def add_arguments(self, parser): + parser.add_argument( + '--merchant-id', + type=int, + help='仅处理指定商户ID', + ) + parser.add_argument( + '--date', + type=str, + help='指定结算日期(YYYY-MM-DD),默认为昨天', + ) + parser.add_argument( + '--sync', + action='store_true', + help='同步执行(阻塞),默认为异步(通过 Celery)', + ) + + def handle(self, *args, **options): + from settlement.tasks import run_daily_settlement, run_merchant_daily_settlement + + merchant_id = options.get('merchant_id') + date_str = options.get('date') + sync_mode = bool(options['sync']) + + # 解析日期 + if date_str: + try: + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() + except ValueError as e: + raise CommandError(f'--date 必须为 YYYY-MM-DD 格式: {e}') + else: + date_obj = timezone.localdate() - timezone.timedelta(days=1) + + self.stdout.write(f'[日结] 开始执行: 日期={date_obj}, 同步模式={sync_mode}') + + if merchant_id: + # 处理单个商户 + self._handle_single_merchant( + merchant_id=merchant_id, + date_obj=date_obj, + sync_mode=sync_mode + ) + else: + # 处理所有商户 + self._handle_all_merchants( + date_obj=date_obj, + sync_mode=sync_mode + ) + + def _handle_single_merchant(self, merchant_id: int, date_obj, sync_mode: bool): + """处理单个商户的日结""" + from settlement.tasks import run_merchant_daily_settlement + + try: + config = DailySettlementConfig.objects.select_related('merchant').get( + merchant_id=merchant_id + ) + except DailySettlementConfig.DoesNotExist: + self.stderr.write( + self.style.ERROR( + f'商户 {merchant_id} 未配置日结,跳过' + ) + ) + return + + if not config.settlement_modules: + self.stdout.write( + f'商户 {config.merchant.name} (ID={merchant_id}) 未配置统计模块,跳过' + ) + return + + self.stdout.write( + f'处理商户: {config.merchant.name} (ID={merchant_id}), ' + f'模块={config.settlement_modules}' + ) + + if sync_mode: + # 同步执行 + result = run_merchant_daily_settlement( + merchant_id=merchant_id, + settlement_date=str(date_obj) + ) + self.stdout.write( + self.style.SUCCESS( + f'商户 {merchant_id} 日结完成: {result}' + ) + ) + else: + # 异步执行(通过 Celery) + task = run_merchant_daily_settlement.delay( + merchant_id=merchant_id, + settlement_date=str(date_obj) + ) + self.stdout.write( + f'商户 {merchant_id} 日结任务已提交: task_id={task.id}' + ) + + def _handle_all_merchants(self, date_obj, sync_mode: bool): + """处理所有商户的日结""" + from settlement.tasks import run_daily_settlement + + configs = DailySettlementConfig.objects.filter( + settlement_modules__len__gt=0 + ).select_related('merchant') + + if not configs.exists(): + self.stdout.write('没有配置日结的商户,跳过') + return + + self.stdout.write( + f'共 {configs.count()} 个商户需要执行日结' + ) + + if sync_mode: + # 同步执行(逐个商户) + for config in configs: + self._handle_single_merchant( + merchant_id=config.merchant.id, + date_obj=date_obj, + sync_mode=True + ) + else: + # 异步执行(通过 Celery) + task = run_daily_settlement.delay() + self.stdout.write( + f'所有商户日结任务已提交: task_id={task.id}' + ) diff --git a/settlement/migrations/0001_initial.py b/settlement/migrations/0001_initial.py new file mode 100644 index 0000000..4f1466a --- /dev/null +++ b/settlement/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.8 on 2026-02-08 00:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('basic_info', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DailySettlementConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('merchant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settlement_config', to='basic_info.merchant', verbose_name='所属商户')), + ('settlement_modules', models.JSONField(default=list, verbose_name='统计模块')), + ('notification_enabled', models.BooleanField(default=False, verbose_name='启用通知')), + ('notification_channel', models.CharField(choices=[('wecom', '企业微信'), ('none', '不通知')], default='none', max_length=20, verbose_name='通知渠道')), + ('description', models.TextField(blank=True, null=True, verbose_name='备注')), + ], + options={ + 'verbose_name': '日结配置', + 'verbose_name_plural': '日结配置', + 'db_table': 'daily_settlement_config', + }, + ), + ] diff --git a/settlement/migrations/__init__.py b/settlement/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/settlement/models.py b/settlement/models.py new file mode 100644 index 0000000..5d7feab --- /dev/null +++ b/settlement/models.py @@ -0,0 +1,49 @@ +"""日结模块数据模型""" +from django.db import models +from flower.common import ModelBase + + +class NotificationChannelEnum(models.TextChoices): + """通知渠道枚举""" + WECOM = 'wecom', '企业微信' + NONE = 'none', '不通知' + + +class SettlementModuleEnum(models.TextChoices): + """日结模块枚举""" + PLATE_ORDER = 'plate_order', '开版订单' + PRINTING_ORDER = 'printing_order', '生产订单' + + +class DailySettlementConfig(ModelBase): + """日结配置 - 每个商户一条记录(可选)""" + id = models.BigAutoField(primary_key=True) + merchant = models.OneToOneField( + 'basic_info.Merchant', + on_delete=models.CASCADE, + related_name='settlement_config', + verbose_name='所属商户' + ) + + # 统计模块配置(JSON数组,存储枚举值) + settlement_modules = models.JSONField( + default=list, + verbose_name='统计模块', + help_text='例如: ["plate_order", "printing_order"]' + ) + + # 通知配置 + notification_enabled = models.BooleanField(default=False, verbose_name='启用通知') + notification_channel = models.CharField( + max_length=20, + choices=NotificationChannelEnum.choices, + default=NotificationChannelEnum.NONE, + verbose_name='通知渠道' + ) + + # 其他配置 + description = models.TextField(blank=True, null=True, verbose_name='备注') + + class Meta: + verbose_name = '日结配置' + verbose_name_plural = '日结配置' diff --git a/settlement/services.py b/settlement/services.py new file mode 100644 index 0000000..4070cff --- /dev/null +++ b/settlement/services.py @@ -0,0 +1,61 @@ +"""日结模块统计服务函数""" +import logging +from datetime import datetime + +logger = logging.getLogger(__name__) + + +def calculate_plate_order_daily_summary( + merchant_id: int, + settlement_date: datetime.date +) -> dict: + """ + 计算开版订单日结汇总(空壳实现) + + Args: + merchant_id: 商户ID + settlement_date: 结算日期 + + Returns: + dict: 统计结果(空壳) + """ + logger.info( + f'[settlement.services] 计算开版订单日结汇总: ' + f'merchant_id={merchant_id}, date={settlement_date}' + ) + + return { + 'merchant_id': merchant_id, + 'settlement_date': str(settlement_date), + 'total_orders': 0, + 'completed_orders': 0, + 'in_progress_orders': 0, + } + + +def calculate_printing_order_daily_summary( + merchant_id: int, + settlement_date: datetime.date +) -> dict: + """ + 计算生产订单日结汇总(空壳实现) + + Args: + merchant_id: 商户ID + settlement_date: 结算日期 + + Returns: + dict: 统计结果(空壳) + """ + logger.info( + f'[settlement.services] 计算生产订单日结汇总: ' + f'merchant_id={merchant_id}, date={settlement_date}' + ) + + return { + 'merchant_id': merchant_id, + 'settlement_date': str(settlement_date), + 'total_orders': 0, + 'completed_orders': 0, + 'in_progress_orders': 0, + } diff --git a/settlement/signals.py b/settlement/signals.py new file mode 100644 index 0000000..da25eb9 --- /dev/null +++ b/settlement/signals.py @@ -0,0 +1,12 @@ +"""日结模块信号定义""" +from django.dispatch import Signal + +# 日结完成信号 +# 参数: +# merchant: Merchant 实例 +# settlement_date: datetime.date +# status: 'success' / 'failed' / 'partial' +# modules: list[str] - 参与统计的模块列表 +# errors: dict - 模块错误详情 {'module_name': 'error message'} +# task_id: str - Celery 任务ID +daily_settlement_completed = Signal() diff --git a/settlement/tasks.py b/settlement/tasks.py new file mode 100644 index 0000000..f125aff --- /dev/null +++ b/settlement/tasks.py @@ -0,0 +1,140 @@ +"""日结模块 Celery 任务""" +import logging +import datetime + +from celery import shared_task +from django.utils import timezone + +from .models import DailySettlementConfig, SettlementModuleEnum +from .services import ( + calculate_plate_order_daily_summary, + calculate_printing_order_daily_summary, +) +from .signals import daily_settlement_completed + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True) +def run_daily_settlement(self): + """ + 执行所有商户的日结任务(固定时间触发) + + 在 settings.py 中配置的固定时间触发, + 拉取所有配置了统计模块的商户,逐个执行日结。 + """ + from basic_info.models import Merchant + + # 获取所有配置了统计模块的商户 + configs = DailySettlementConfig.objects.filter( + settlement_modules__len__gt=0 + ).select_related('merchant') + + logger.info( + f'[settlement.tasks] 开始执行日结,共 {configs.count()} 个商户需要统计' + ) + + # 为每个商户触发日结任务 + for config in configs: + settlement_date = timezone.localdate() - datetime.timedelta(days=1) + run_merchant_daily_settlement.delay( + merchant_id=config.merchant.id, + settlement_date=str(settlement_date) + ) + + return { + 'task_id': self.request.id, + 'total_merchants': configs.count(), + 'settlement_date': str(timezone.localdate() - datetime.timedelta(days=1)), + } + + +@shared_task(bind=True) +def run_merchant_daily_settlement( + self, + merchant_id: int, + settlement_date: str | None = None +): + """ + 执行单个商户的日结 + + Args: + merchant_id: 商户ID + settlement_date: 结算日期(YYYY-MM-DD),默认为昨天 + + Returns: + dict: 包含 task_id 和执行结果的 payload + """ + from basic_info.models import Merchant + + # 解析日期 + if settlement_date: + date_obj = datetime.datetime.strptime(settlement_date, '%Y-%m-%d').date() + else: + date_obj = timezone.localdate() - datetime.timedelta(days=1) + + merchant = Merchant.objects.get(id=merchant_id) + config = merchant.settlement_config + + modules = config.settlement_modules + summaries = {} + errors = {} + overall_status = 'success' + + logger.info( + f'[settlement.tasks] 开始执行商户 {merchant_id} 的日结,' + f'日期={date_obj}, 模块={modules}' + ) + + # 执行各模块的统计 + for module in modules: + try: + if module == SettlementModuleEnum.PLATE_ORDER: + summary = calculate_plate_order_daily_summary( + merchant_id=merchant_id, + settlement_date=date_obj + ) + summaries['plate_order'] = summary + + elif module == SettlementModuleEnum.PRINTING_ORDER: + summary = calculate_printing_order_daily_summary( + merchant_id=merchant_id, + settlement_date=date_obj + ) + summaries['printing_order'] = summary + + except Exception as e: + logger.exception( + f'[settlement.tasks] 商户 {merchant_id} 的 {module} 统计失败: {e}' + ) + errors[module] = str(e) + overall_status = 'partial' + + # 如果所有模块都失败 + if len(errors) == len(modules): + overall_status = 'failed' + + # 发送信号(不包含统计结果详情) + daily_settlement_completed.send( + sender=run_merchant_daily_settlement, + merchant=merchant, + settlement_date=date_obj, + status=overall_status, + modules=modules, + errors=errors, + task_id=self.request.id + ) + + payload = { + 'task_id': self.request.id, + 'merchant_id': merchant_id, + 'settlement_date': str(date_obj), + 'status': overall_status, + 'modules': modules, + 'errors': errors, + } + + logger.info( + f'[settlement.tasks] 商户 {merchant_id} 日结完成: {payload}' + ) + return payload