From 6b6d2a97b6fca254e5bfa51d1713753bd0b80abf Mon Sep 17 00:00:00 2001 From: colaftc Date: Tue, 13 Jan 2026 18:19:31 +0800 Subject: [PATCH] feat: new module: 'shipment', new signal when process was finished --- flower/settings.py | 1 + printing/apps.py | 12 + printing/handlers.py | 83 +++++++ shipment/__init__.py | 1 + shipment/admin.py | 49 ++++ shipment/apps.py | 7 + shipment/migrations/0001_initial.py | 57 +++++ .../0002_salesitem_shipment_nullable.py | 19 ++ shipment/migrations/__init__.py | 0 shipment/models.py | 154 +++++++++++++ stateflow/services.py | 210 ++++++++++-------- stateflow/signals.py | 55 +++++ 12 files changed, 550 insertions(+), 98 deletions(-) create mode 100644 printing/handlers.py create mode 100644 shipment/__init__.py create mode 100644 shipment/admin.py create mode 100644 shipment/apps.py create mode 100644 shipment/migrations/0001_initial.py create mode 100644 shipment/migrations/0002_salesitem_shipment_nullable.py create mode 100644 shipment/migrations/__init__.py create mode 100644 shipment/models.py create mode 100644 stateflow/signals.py diff --git a/flower/settings.py b/flower/settings.py index 64b6a5c..a177d87 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -126,6 +126,7 @@ INSTALLED_APPS = [ 'stateflow', 'sse', 'api_v2', + 'shipment', ] MIDDLEWARE = [ diff --git a/printing/apps.py b/printing/apps.py index b934494..73c20c6 100644 --- a/printing/apps.py +++ b/printing/apps.py @@ -5,3 +5,15 @@ class PrintingConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'printing' verbose_name = '印染管理' + + def ready(self): + """注册信号处理函数""" + from stateflow.signals import process_completed + from .models import PrintingJob + from . import handlers + + # 监听 PrintingJob 流程完成信号 + process_completed.connect( + handlers.on_printing_job_process_completed, + sender=PrintingJob + ) \ No newline at end of file diff --git a/printing/handlers.py b/printing/handlers.py new file mode 100644 index 0000000..e1184b1 --- /dev/null +++ b/printing/handlers.py @@ -0,0 +1,83 @@ +""" +Printing 模块信号处理函数 + +监听 stateflow 信号,在流程完成时执行业务逻辑。 +""" + +import logging +from django.db import transaction + +logger = logging.getLogger(__name__) + + +def on_printing_job_process_completed(sender, **kwargs): + """ + PrintingJob 流程完成时的处理 + + 当 PrintingJob 的工艺流程全部完成时,自动创建 SalesItem(销售品)。 + + Args: + sender: PrintingJob 类 + content_object: PrintingJob 实例 + last_completed_by: 最后步骤操作人 + 其他参数见 stateflow.signals.process_completed + """ + from shipment.models import SalesItem, UnitChoices + + content_object = kwargs.get('content_object') + last_completed_by = kwargs.get('last_completed_by') + + if content_object is None: + logger.warning('process_completed 信号缺少 content_object') + return + + printing_job = content_object + + # 单位映射:PrintingJob.unit(字符串)→ SalesItem.unit(IntegerChoices) + unit_mapping = { + '米': UnitChoices.METER, + 'm': UnitChoices.METER, + 'M': UnitChoices.METER, + '件': UnitChoices.PIECE, + '码': UnitChoices.YARD, + 'yd': UnitChoices.YARD, + 'YD': UnitChoices.YARD, + '个': UnitChoices.UNIT, + 'pcs': UnitChoices.UNIT, + 'PCS': UnitChoices.UNIT, + } + + # 默认单位为"件" + sales_unit = unit_mapping.get(printing_job.unit, UnitChoices.PIECE) + + # 获取产品名称 + product_name = printing_job.product.name if printing_job.product else '未知产品' + + # 获取客户ID(从主订单获取) + customer_id = None + if printing_job.printing_order and printing_job.printing_order.customer: + customer_id = printing_job.printing_order.customer_id + + try: + with transaction.atomic(): + sales_item = SalesItem.objects.create( + shipment=None, # 暂不关联出货单,待后续分配 + name=product_name, + quantity=printing_job.quantity, + unit=sales_unit, + created_by=last_completed_by, + printing_job_id=printing_job.id, + customer_id=customer_id, + ) + + logger.info( + f'PrintingJob #{printing_job.id} 流程完成,' + f'已创建销售品 SalesItem #{sales_item.id}: ' + f'{product_name} x {printing_job.quantity}' + ) + + except Exception as e: + logger.error( + f'PrintingJob #{printing_job.id} 流程完成后创建销售品失败: {e}', + exc_info=True + ) diff --git a/shipment/__init__.py b/shipment/__init__.py new file mode 100644 index 0000000..92c1da5 --- /dev/null +++ b/shipment/__init__.py @@ -0,0 +1 @@ +# Shipment module diff --git a/shipment/admin.py b/shipment/admin.py new file mode 100644 index 0000000..7283842 --- /dev/null +++ b/shipment/admin.py @@ -0,0 +1,49 @@ +from django.contrib import admin +from .models import Shipment, SalesItem + + +class SalesItemInline(admin.TabularInline): + model = SalesItem + extra = 1 + fields = ['name', 'quantity', 'unit', 'printing_job_id', 'customer_id', 'created_by'] + readonly_fields = ['created_by'] + + +@admin.register(Shipment) +class ShipmentAdmin(admin.ModelAdmin): + list_display = ['id', 'customer', 'shipment_date', 'items_count', 'created_by', 'created_at'] + list_filter = ['shipment_date', 'created_at'] + search_fields = ['customer__name', 'remark'] + readonly_fields = ['created_at', 'updated_at', 'created_by'] + date_hierarchy = 'shipment_date' + inlines = [SalesItemInline] + + def items_count(self, obj): + return obj.items.count() + items_count.short_description = '销售品数量' + + def save_model(self, request, obj, form, change): + if not change: + obj.created_by = request.user + super().save_model(request, obj, form, change) + + def save_formset(self, request, form, formset, change): + instances = formset.save(commit=False) + for instance in instances: + if isinstance(instance, SalesItem) and not instance.pk: + instance.created_by = request.user + instance.save() + formset.save_m2m() + + +@admin.register(SalesItem) +class SalesItemAdmin(admin.ModelAdmin): + list_display = ['id', 'name', 'quantity', 'unit', 'shipment', 'printing_job_id', 'customer_id', 'created_by', 'created_at'] + list_filter = ['unit', 'created_at'] + search_fields = ['name', 'shipment__customer__name'] + readonly_fields = ['created_at', 'updated_at', 'created_by'] + + def save_model(self, request, obj, form, change): + if not change: + obj.created_by = request.user + super().save_model(request, obj, form, change) diff --git a/shipment/apps.py b/shipment/apps.py new file mode 100644 index 0000000..defbb6e --- /dev/null +++ b/shipment/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ShipmentConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'shipment' + verbose_name = '出货管理' diff --git a/shipment/migrations/0001_initial.py b/shipment/migrations/0001_initial.py new file mode 100644 index 0000000..2fc0a17 --- /dev/null +++ b/shipment/migrations/0001_initial.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.8 on 2026-01-13 09:45 + +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', '0024_frontend_page_and_visible_pages'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Shipment', + 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='更新时间')), + ('shipment_date', models.DateField(help_text='实际出货日期', verbose_name='出货日期')), + ('remark', models.TextField(blank=True, default='', verbose_name='备注')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_shipments', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shipments', to='basic_info.customer', verbose_name='客户')), + ], + options={ + 'verbose_name': '出货单', + 'verbose_name_plural': '出货单', + 'db_table': 'shipment', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='SalesItem', + 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='更新时间')), + ('name', models.CharField(max_length=200, verbose_name='名称')), + ('quantity', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='数量')), + ('unit', models.IntegerField(choices=[(1, '米'), (2, '件'), (3, '码'), (4, '个')], default=2, verbose_name='单位')), + ('printing_job_id', models.PositiveIntegerField(blank=True, help_text='关联的 PrintingJob ID(可空)', null=True, verbose_name='关联生产任务ID')), + ('customer_id', models.PositiveIntegerField(blank=True, help_text='销售品级别的客户ID(可空)', null=True, verbose_name='客户ID')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_sales_items', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('shipment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shipment.shipment', verbose_name='出货单')), + ], + options={ + 'verbose_name': '销售品', + 'verbose_name_plural': '销售品', + 'db_table': 'sales_item', + 'ordering': ['id'], + }, + ), + ] diff --git a/shipment/migrations/0002_salesitem_shipment_nullable.py b/shipment/migrations/0002_salesitem_shipment_nullable.py new file mode 100644 index 0000000..6162ae0 --- /dev/null +++ b/shipment/migrations/0002_salesitem_shipment_nullable.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.8 on 2026-01-13 10:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shipment', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='salesitem', + name='shipment', + field=models.ForeignKey(blank=True, help_text='为空表示待分配', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shipment.shipment', verbose_name='出货单'), + ), + ] diff --git a/shipment/migrations/__init__.py b/shipment/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shipment/models.py b/shipment/models.py new file mode 100644 index 0000000..a904c02 --- /dev/null +++ b/shipment/models.py @@ -0,0 +1,154 @@ +from django.db import models +from django.contrib.auth import get_user_model +from flower.common import ModelBase +from basic_info import models as basic_models + +User = get_user_model() + + +class UnitChoices(models.IntegerChoices): + """销售品单位""" + METER = 1, '米' + PIECE = 2, '件' + YARD = 3, '码' + UNIT = 4, '个' + + +class Shipment(ModelBase): + """ + 出货单 + + 记录一次出货操作,包含多个销售品。 + """ + customer = models.ForeignKey( + basic_models.Customer, + on_delete=models.PROTECT, + related_name='shipments', + verbose_name='客户' + ) + + shipment_date = models.DateField( + verbose_name='出货日期', + help_text='实际出货日期' + ) + + remark = models.TextField( + blank=True, + default='', + verbose_name='备注' + ) + + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='created_shipments', + verbose_name='创建人' + ) + + class Meta: + db_table = 'shipment' + verbose_name = '出货单' + verbose_name_plural = '出货单' + ordering = ['-created_at'] + + def __str__(self): + return f'出货单 #{self.id} - {self.customer.name}' + + +class SalesItem(ModelBase): + """ + 销售品 + + 出货单中的具体商品明细。 + 销售品可以先创建(待分配状态),后续再关联到出货单。 + """ + shipment = models.ForeignKey( + Shipment, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='items', + verbose_name='出货单', + help_text='为空表示待分配' + ) + + name = models.CharField( + max_length=200, + verbose_name='名称' + ) + + quantity = models.DecimalField( + max_digits=12, + decimal_places=2, + verbose_name='数量' + ) + + unit = models.IntegerField( + choices=UnitChoices.choices, + default=UnitChoices.PIECE, + verbose_name='单位' + ) + + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='created_sales_items', + verbose_name='创建人' + ) + + # 关联的生产明细订单(可空) + # 使用 PositiveIntegerField 而非 ForeignKey,避免模块间强依赖 + printing_job_id = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name='关联生产任务ID', + help_text='关联的 PrintingJob ID(可空)' + ) + + # 客户ID(可空,用于销售品级别的客户关联) + customer_id = models.PositiveIntegerField( + null=True, + blank=True, + verbose_name='客户ID', + help_text='销售品级别的客户ID(可空)' + ) + + class Meta: + db_table = 'sales_item' + verbose_name = '销售品' + verbose_name_plural = '销售品' + ordering = ['id'] + + def __str__(self): + return f'{self.name} x {self.quantity} {self.get_unit_display()}' + + def get_printing_job(self): + """ + 获取关联的 PrintingJob 实例 + + Returns: + PrintingJob 实例,如果不存在则返回 None + """ + if not self.printing_job_id: + return None + try: + from printing.models import PrintingJob + return PrintingJob.objects.get(id=self.printing_job_id) + except Exception: + return None + + def get_customer(self): + """ + 获取关联的 Customer 实例 + + Returns: + Customer 实例,如果不存在则返回 None + """ + if not self.customer_id: + return None + try: + return basic_models.Customer.objects.get(id=self.customer_id) + except basic_models.Customer.DoesNotExist: + return None diff --git a/stateflow/services.py b/stateflow/services.py index 75508dd..e1c2bf7 100644 --- a/stateflow/services.py +++ b/stateflow/services.py @@ -257,8 +257,40 @@ def advance_to_next_state(business_object: 'models.BusinessObject', user, **para # 检查是否所有状态都已完成 after_advance = get_next_pending_state(business_object, include_parameters=False) - if after_advance is None: - # 所有状态都已完成 + is_process_completed = (after_advance is None) + + # 发送状态推进信号(在事务内,确保数据已持久化) + # sender 使用 content_object 的类,以便监听者按类型过滤 + from . import signals + content_object = business_object.content_object + sender_class = content_object.__class__ if content_object else None + + signals.state_advanced.send( + sender=sender_class, + process_id=business_object.process_id, + business_object_id=business_object.id, + business_object=business_object, + content_object=content_object, + state=next_state, + state_log=state_log, + completed_by=user, + is_process_completed=is_process_completed, + last_completed_state_id=next_state.id, + last_completed_by=user, + ) + + if is_process_completed: + # 流程全部完成,发送流程完成信号 + signals.process_completed.send( + sender=sender_class, + process_id=business_object.process_id, + business_object_id=business_object.id, + business_object=business_object, + content_object=content_object, + completed_by=user, + last_completed_state_id=next_state.id, + last_completed_by=user, + ) return True, f"流程已完成,最后状态: {next_state.name}", state_log return True, f"已完成状态: {next_state.name}", state_log @@ -739,101 +771,83 @@ def clone_business_object( - created_at/updated_at 等 ModelBase 字段也会被同步(使用 update 绕过 auto_now*) - **重要**:克隆必须提供新的 object_id(当 source.content_type 非空时),否则克隆与业务绑定无差异,容易造成误用 - expected_content_type_id 仅用于校验调用方意图:必须与 source.content_type_id 一致,否则拒绝克隆 + + 2026-01-13 暂停使用:为避免误用导致流程副本错误,暂时禁用此能力。 """ - if source is None: - raise ValueError("source 不能为空") + raise NotImplementedError("clone_business_object is disabled temporarily (2026-01-13)") - # 新增约束:禁止克隆“未绑定”的 BusinessObject(会产生新的不可追溯流程实例) - if source.content_type_id is None or source.object_id is None: - raise ValueError("源对象未绑定关联对象(content_type/object_id),禁止克隆") - if expected_content_type_id is None: - raise ValueError("必须提供 expected_content_type_id") - - # 重新加载 source,确保拿到完整关系(避免调用方未预取导致 N+1) - source = ( - models.BusinessObject.objects - .select_related('process', 'content_type') - .prefetch_related( - 'state_logs__state', - 'state_logs__completed_by', - 'state_logs__parameter_records', - ) - .get(id=source.id) - ) - - # 校验 content_type 一致性(仅比对 id,不做额外校验) - if source.content_type_id != expected_content_type_id: - raise ValueError("content_type 与源对象不一致,拒绝克隆") - - # 绑定规则(强制): - # - 必须提供新的 object_id,且与源对象不同 - if new_object_id is None: - raise ValueError("必须提供新的 object_id") - if source.object_id == new_object_id: - raise ValueError("object_id 必须与源对象不同") - # 目标对象必须存在(避免产生悬空绑定) - try: - source.content_type.get_object_for_this_type(pk=new_object_id) - except ObjectDoesNotExist: - raise ValueError( - f"目标关联对象不存在:{source.content_type.app_label}.{source.content_type.model} #{new_object_id}" - ) - - with transaction.atomic(): - cloned = models.BusinessObject.objects.create( - name=source.name, - process=source.process, - description=source.description, - content_type=source.content_type, - object_id=new_object_id, - ) - - # 同步 BusinessObject 的时间戳字段(保持一致性) - models.BusinessObject.objects.filter(id=cloned.id).update( - created_at=source.created_at, - updated_at=source.updated_at, - ) - - # 复制 state_logs(按完成时间排序,保证克隆后的序列稳定) - source_logs = sorted( - list(source.state_logs.all()), - key=lambda log: (log.completed_at, log.id), - ) - - for src_log in source_logs: - new_log = models.StateFlowRecord.objects.create( - business_object=cloned, - state=src_log.state, - completed_by=src_log.completed_by, - is_cancelled=src_log.is_cancelled, - cancelled_at=src_log.cancelled_at, - ) - - # 同步 StateFlowRecord 的时间戳字段(含 completed_at) - models.StateFlowRecord.objects.filter(id=new_log.id).update( - completed_at=src_log.completed_at, - created_at=src_log.created_at, - updated_at=src_log.updated_at, - cancelled_at=src_log.cancelled_at, - ) - - # 复制参数记录(按 created_at 排序) - src_param_records = sorted( - list(src_log.parameter_records.all()), - key=lambda rec: (rec.created_at, rec.id), - ) - - for src_rec in src_param_records: - new_rec = models.StateLogParameterRecord.objects.create( - state_log=new_log, - parameters=copy.deepcopy(src_rec.parameters), - remark=src_rec.remark, - ) - models.StateLogParameterRecord.objects.filter(id=new_rec.id).update( - created_at=src_rec.created_at, - updated_at=src_rec.updated_at, - ) - - # 重新加载,确保返回对象的字段与 DB 一致(尤其是时间戳) - cloned.refresh_from_db() - return cloned + # 原始实现(保留供后续恢复参考): + # if source is None: + # raise ValueError("source 不能为空") + # if source.content_type_id is None or source.object_id is None: + # raise ValueError("源对象未绑定关联对象(content_type/object_id),禁止克隆") + # if expected_content_type_id is None: + # raise ValueError("必须提供 expected_content_type_id") + # source = ( + # models.BusinessObject.objects + # .select_related('process', 'content_type') + # .prefetch_related( + # 'state_logs__state', + # 'state_logs__completed_by', + # 'state_logs__parameter_records', + # ) + # .get(id=source.id) + # ) + # if source.content_type_id != expected_content_type_id: + # raise ValueError("content_type 与源对象不一致,拒绝克隆") + # if new_object_id is None: + # raise ValueError("必须提供新的 object_id") + # if source.object_id == new_object_id: + # raise ValueError("object_id 必须与源对象不同") + # try: + # source.content_type.get_object_for_this_type(pk=new_object_id) + # except ObjectDoesNotExist: + # raise ValueError( + # f"目标关联对象不存在:{source.content_type.app_label}.{source.content_type.model} #{new_object_id}" + # ) + # with transaction.atomic(): + # cloned = models.BusinessObject.objects.create( + # name=source.name, + # process=source.process, + # description=source.description, + # content_type=source.content_type, + # object_id=new_object_id, + # ) + # models.BusinessObject.objects.filter(id=cloned.id).update( + # created_at=source.created_at, + # updated_at=source.updated_at, + # ) + # source_logs = sorted( + # list(source.state_logs.all()), + # key=lambda log: (log.completed_at, log.id), + # ) + # for src_log in source_logs: + # new_log = models.StateFlowRecord.objects.create( + # business_object=cloned, + # state=src_log.state, + # completed_by=src_log.completed_by, + # is_cancelled=src_log.is_cancelled, + # cancelled_at=src_log.cancelled_at, + # ) + # models.StateFlowRecord.objects.filter(id=new_log.id).update( + # completed_at=src_log.completed_at, + # created_at=src_log.created_at, + # updated_at=src_log.updated_at, + # cancelled_at=src_log.cancelled_at, + # ) + # src_param_records = sorted( + # list(src_log.parameter_records.all()), + # key=lambda rec: (rec.created_at, rec.id), + # ) + # for src_rec in src_param_records: + # new_rec = models.StateLogParameterRecord.objects.create( + # state_log=new_log, + # parameters=copy.deepcopy(src_rec.parameters), + # remark=src_rec.remark, + # ) + # models.StateLogParameterRecord.objects.filter(id=new_rec.id).update( + # created_at=src_rec.created_at, + # updated_at=src_rec.updated_at, + # ) + # cloned.refresh_from_db() + # return cloned diff --git a/stateflow/signals.py b/stateflow/signals.py new file mode 100644 index 0000000..c07e841 --- /dev/null +++ b/stateflow/signals.py @@ -0,0 +1,55 @@ +""" +Stateflow 信号定义 + +stateflow 模块通过信号通知外部模块状态变化, +外部模块可以监听这些信号来执行业务逻辑(通知、簿记等)。 + +sender 说明: + sender 是 content_object 的类(如 PrintingJob、PlateOrder), + 监听者可以在 connect 时指定 sender 来过滤只接收特定类型的信号。 + +使用方式: + # 在业务模块的 apps.py 中监听 + from django.apps import AppConfig + + class MyAppConfig(AppConfig): + def ready(self): + from stateflow.signals import process_completed + from .models import PrintingJob + from . import handlers + + # 只监听 PrintingJob 相关的流程完成信号 + process_completed.connect(handlers.on_process_completed, sender=PrintingJob) + + # 或监听所有类型(不指定 sender) + # process_completed.connect(handlers.on_any_process_completed) +""" + +from django.dispatch import Signal + +# 流程完成信号 +# 参数: +# sender: content_object 的类(如 PrintingJob),用于过滤 +# process_id: 流程 ID +# business_object_id: BusinessObject ID +# business_object: BusinessObject 实例 +# content_object: 关联的业务对象实例(如 PrintingJob) +# completed_by: 完成操作的用户(本次推进的操作人) +# last_completed_state_id: 最后完成的节点 ID +# last_completed_by: 最后步骤操作人(User 实例) +process_completed = Signal() + +# 状态推进信号(每次推进都会触发) +# 参数: +# sender: content_object 的类(如 PrintingJob),用于过滤 +# process_id: 流程 ID +# business_object_id: BusinessObject ID +# business_object: BusinessObject 实例 +# content_object: 关联的业务对象实例 +# state: 刚完成的 State 实例 +# state_log: StateFlowRecord 实例 +# completed_by: 完成操作的用户 +# is_process_completed: 流程是否已全部完成 +# last_completed_state_id: 最后完成的节点 ID +# last_completed_by: 最后步骤操作人(User 实例) +state_advanced = Signal()