1
0
forked from erp-dev/erp

feat: new module for daily settlement

This commit is contained in:
2026-02-08 11:37:52 +08:00
parent 72597ce81f
commit c564b1af32
15 changed files with 991 additions and 0 deletions

1
settlement/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""日结管理模块"""

27
settlement/admin.py Normal file
View File

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

27
settlement/apps.py Normal file
View File

@@ -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 信号处理器'
)

30
settlement/handlers.py Normal file
View File

@@ -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'通知渠道=企业微信(暂不发送具体内容)'
)

View File

@@ -0,0 +1 @@
"""Settlement management commands"""

View File

@@ -0,0 +1 @@
"""Settlement management commands"""

View File

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

View File

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

View File

49
settlement/models.py Normal file
View File

@@ -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 = '日结配置'

61
settlement/services.py Normal file
View File

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

12
settlement/signals.py Normal file
View File

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

140
settlement/tasks.py Normal file
View File

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