From e91d06e4b6426330db16fb3a98c6c68e6eef3266 Mon Sep 17 00:00:00 2001 From: colaftc Date: Sat, 11 Jul 2026 00:05:05 +0800 Subject: [PATCH] fix: appversions --- api_v1/admin.py | 26 +- api_v1/models.py | 5 +- api_v1/urls.py | 28 +- api_v1/views/settlement/test_api.py | 222 +++++++++ api_v1/views/settlement/views.py | 197 +++++++- api_v1/views/shipment/__init__.py | 4 + api_v1/views/shipment/serializers.py | 26 ++ api_v1/views/shipment/test_api.py | 161 ++++++- api_v1/views/shipment/views.py | 104 ++++- ...signer_workflow_task_summary_2026-07-10.md | 248 ++++++++++ ...plate_order_designer_summary_2026-07-10.md | 183 ++++++++ docs/api_v1_shipment_api_2026-07-08.md | 81 ++++ ...hipment_delivery_status_flow_2026-07-10.md | 197 ++++++++ flower/settings.py | 2 + flower/test_app_version_api.py | 33 ++ mission/handlers.py | 7 +- notifier/backends.py | 142 ++++++ notifier/jpush.py | 81 ++++ .../migrations/0004_alter_notifier_channel.py | 25 + notifier/models.py | 3 +- notifier/registry.py | 3 +- notifier/tasks.py | 7 +- .../events/mission_created_jpush.json | 10 + notifier/tests.py | 62 +++ settlement/services.py | 438 +++++++++++++++++- settlement/test_services.py | 306 ++++++++++++ 26 files changed, 2582 insertions(+), 19 deletions(-) create mode 100644 docs/api_v1_settlement_designer_workflow_task_summary_2026-07-10.md create mode 100644 docs/api_v1_settlement_plate_order_designer_summary_2026-07-10.md create mode 100644 docs/api_v1_shipment_delivery_status_flow_2026-07-10.md create mode 100644 notifier/jpush.py create mode 100644 notifier/migrations/0004_alter_notifier_channel.py create mode 100644 notifier/templates/notifier/events/mission_created_jpush.json diff --git a/api_v1/admin.py b/api_v1/admin.py index d7517f4..b5b633b 100644 --- a/api_v1/admin.py +++ b/api_v1/admin.py @@ -1,5 +1,6 @@ from django.contrib import admin from django.contrib.admin import action +from django import forms from api_v1.models import ( UploadedFile, AppVersion, @@ -14,6 +15,18 @@ from api_v1.models import ( from .tasks import backup_database +class AppVersionAdminForm(forms.ModelForm): + class Meta: + model = AppVersion + fields = '__all__' + + def clean(self): + cleaned_data = super().clean() + if not cleaned_data.get('download_url') and not cleaned_data.get('package_file'): + raise forms.ValidationError('请填写下载地址或上传安装包文件。') + return cleaned_data + + @admin.register(UploadedFile) class UploadedFileAdmin(admin.ModelAdmin): list_display = ['id', 'original_filename', 'owner', 'file_size', 'content_type', 'created_at', 'is_deleted'] @@ -35,6 +48,7 @@ class UploadedFileAdmin(admin.ModelAdmin): @admin.register(AppVersion) class AppVersionAdmin(admin.ModelAdmin): + form = AppVersionAdminForm list_display = [ 'id', 'version_display', @@ -46,15 +60,15 @@ class AppVersionAdmin(admin.ModelAdmin): ] list_filter = ['is_current', 'force', 'publish_date', 'created_at'] search_fields = ['download_url', 'message'] - readonly_fields = ['download_url', 'created_at', 'updated_at'] + readonly_fields = ['created_at', 'updated_at'] date_hierarchy = 'created_at' ordering = ['-is_current', '-created_at'] fields = [ 'major', 'minor', 'build', - 'package_file', 'download_url', + 'package_file', 'force', 'publish_date', 'message', @@ -67,6 +81,14 @@ class AppVersionAdmin(admin.ModelAdmin): def version_display(self, obj): return str(obj) + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + field = form.base_fields.get('download_url') + if field: + field.required = False + field.help_text = '可填写已可下载的 APK URL;如同时上传安装包,以此 URL 优先。' + return form + @admin.register(DataSync) class DataSyncAdmin(admin.ModelAdmin): diff --git a/api_v1/models.py b/api_v1/models.py index eff0ba7..101149d 100644 --- a/api_v1/models.py +++ b/api_v1/models.py @@ -70,11 +70,12 @@ class AppVersion(ModelBase): return f'{self.major}.{self.minor}.{self.build}' def save(self, *args, **kwargs): - if self.package_file: - self.download_url = self.package_file.url if self.is_current: AppVersion.objects.exclude(pk=self.pk).filter(is_current=True).update(is_current=False) super().save(*args, **kwargs) + if self.package_file and not self.download_url: + self.download_url = self.package_file.url + AppVersion.objects.filter(pk=self.pk).update(download_url=self.download_url) class UploadedFile(ModelBase): diff --git a/api_v1/urls.py b/api_v1/urls.py index d077944..8e0052b 100644 --- a/api_v1/urls.py +++ b/api_v1/urls.py @@ -46,14 +46,20 @@ from .views.shipment import ( ShipmentDeliveryByPrintingOrderView, ShipmentDeliveryCancelView, ShipmentDeliveryDetailView, + ShipmentDeliveryPrintingJobListView, ShipmentDeliveryListCreateView, ShipmentDeliveryStatusUpdateView, ShipmentSalesItemCustomerListView, ShipmentListCreateView, ShipmentDetailView, ShipmentExternalCreateView, + ShipmentPrintingJobListView, +) +from .views.settlement.views import ( + DesignerWorkflowTaskSummaryView, + PlateOrderDesignerSummaryView, + PlateOrderSummaryView, ) -from .views.settlement.views import PlateOrderSummaryView # 创建 DRF Router for Stateflow stateflow_router = DefaultRouter() @@ -352,6 +358,11 @@ urlpatterns = [ ShipmentDetailView.as_view(), name="shipment_detail", ), + path( + "shipment/shipments//printing-jobs/", + ShipmentPrintingJobListView.as_view(), + name="shipment_printing_jobs", + ), path( "shipment/shipments//status/", ShipmentStatusUpdateView.as_view(), @@ -377,6 +388,11 @@ urlpatterns = [ ShipmentDeliveryDetailView.as_view(), name="shipment_delivery_detail", ), + path( + "shipment/deliveries//printing-jobs/", + ShipmentDeliveryPrintingJobListView.as_view(), + name="shipment_delivery_printing_jobs", + ), path( "shipment/deliveries//status/", ShipmentDeliveryStatusUpdateView.as_view(), @@ -436,6 +452,16 @@ urlpatterns = [ PlateOrderSummaryView.as_view(), name="plate_order_summary", ), + path( + "settlement/plate-orders/designer-summary/", + PlateOrderDesignerSummaryView.as_view(), + name="plate_order_designer_summary", + ), + path( + "settlement/workflows/designer-task-summary/", + DesignerWorkflowTaskSummaryView.as_view(), + name="designer_workflow_task_summary", + ), # 主 Router (printing-orders 等) path("", include(main_router.urls)), ] diff --git a/api_v1/views/settlement/test_api.py b/api_v1/views/settlement/test_api.py index f1afb65..2455816 100644 --- a/api_v1/views/settlement/test_api.py +++ b/api_v1/views/settlement/test_api.py @@ -9,6 +9,7 @@ from django.contrib.auth import get_user_model from basic_info import models as basic_models from printing import models as printing_models +from stateflow import models as stateflow_models User = get_user_model() @@ -318,3 +319,224 @@ class PlateOrderSummaryAPITestCase(TestCase): ) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class PlateOrderDesignerSummaryAPITestCase(TestCase): + """测试开版订单设计师统计 API""" + + def setUp(self): + self.client = APIClient() + self.merchant = basic_models.Merchant.objects.create( + name='测试商户', + type=basic_models.MerchantTypeEnum.FACTORY + ) + self.user = User.objects.create_user( + username='designer-summary-user', + password='testpass123' + ) + self.employee = basic_models.Employee.objects.create( + sys_user=self.user, + merchant=self.merchant, + name='测试员工' + ) + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='测试客户', + created_by=self.employee + ) + self.process = stateflow_models.Process.objects.create(name='开版流程') + self.drawing_done = stateflow_models.State.objects.create(name='画图完成') + self.color_done = stateflow_models.State.objects.create(name='调色完成') + self.client.force_authenticate(user=self.user) + + def _create_plate_order(self, *, plate_date=None): + plate_order = printing_models.PlateOrder.objects.create( + merchant=self.merchant, + customer=self.customer, + plate_type='首版', + production_method='定位', + plate_date=timezone.make_aware(datetime.combine(plate_date or date(2026, 2, 8), datetime.min.time())) + ) + business_object = stateflow_models.BusinessObject.objects.create( + name=f'PlateOrder-{plate_order.id}', + process=self.process, + description='', + ) + plate_order.business_object = business_object + plate_order.save(update_fields=['business_object']) + return plate_order + + def _add_designer_param(self, plate_order, *, designer_name, state=None): + state_log = stateflow_models.StateFlowRecord.objects.create( + business_object=plate_order.business_object, + state=state or self.drawing_done, + completed_by=self.user, + ) + stateflow_models.StateLogParameterRecord.objects.create( + state_log=state_log, + parameters={'设计师名称': designer_name}, + ) + + def test_returns_designer_summary(self): + plate_order = self._create_plate_order() + self._add_designer_param(plate_order, designer_name='设计师A') + + response = self.client.get( + '/api/v1/settlement/plate-orders/designer-summary/?date=2026-02-08' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['meta']['state_names'], ['画图完成']) + self.assertEqual(len(response.data['data']), 1) + self.assertEqual(response.data['data'][0]['designer_name'], '设计师A') + self.assertEqual(response.data['data'][0]['plate_order_count'][0]['today'], 1) + + def test_accepts_state_names_query_param(self): + plate_order = self._create_plate_order() + self._add_designer_param(plate_order, designer_name='设计师A', state=self.color_done) + + response = self.client.get( + '/api/v1/settlement/plate-orders/designer-summary/?date=2026-02-08&state_names=调色完成' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['meta']['state_names'], ['调色完成']) + self.assertEqual(response.data['data'][0]['designer_name'], '设计师A') + + def test_requires_date_parameter(self): + response = self.client.get('/api/v1/settlement/plate-orders/designer-summary/') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('缺少 date 参数', response.data['error']) + + def test_invalid_state_names(self): + response = self.client.get( + '/api/v1/settlement/plate-orders/designer-summary/?date=2026-02-08&state_names=,,' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('state_names 不能为空', response.data['error']) + + +class DesignerWorkflowTaskSummaryAPITestCase(TestCase): + """测试设计师工序任务量统计 API""" + + def setUp(self): + self.client = APIClient() + self.merchant = basic_models.Merchant.objects.create( + name='测试商户', + type=basic_models.MerchantTypeEnum.FACTORY + ) + self.user = User.objects.create_user( + username='designer-task-summary-user', + password='testpass123' + ) + self.employee = basic_models.Employee.objects.create( + sys_user=self.user, + merchant=self.merchant, + name='测试员工' + ) + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='测试客户', + created_by=self.employee + ) + self.process = stateflow_models.Process.objects.create(name='开版流程') + self.drawing_done = stateflow_models.State.objects.create(name='画图完成') + self.color_done = stateflow_models.State.objects.create(name='调色完成') + self.drawing_in_progress = stateflow_models.State.objects.create(name='画图中') + self.client.force_authenticate(user=self.user) + + def _create_plate_order(self): + plate_order = printing_models.PlateOrder.objects.create( + merchant=self.merchant, + customer=self.customer, + plate_type='首版', + production_method='定位', + plate_date=timezone.make_aware(datetime.combine(date(2026, 2, 8), datetime.min.time())) + ) + business_object = stateflow_models.BusinessObject.objects.create( + name=f'PlateOrder-{plate_order.id}', + process=self.process, + description='', + ) + plate_order.business_object = business_object + plate_order.save(update_fields=['business_object']) + return plate_order + + def _add_task(self, *, designer_name='左威', quantity='3', state=None): + plate_order = self._create_plate_order() + state_log = stateflow_models.StateFlowRecord.objects.create( + business_object=plate_order.business_object, + state=state or self.drawing_done, + completed_by=self.user, + ) + stateflow_models.StateLogParameterRecord.objects.create( + state_log=state_log, + parameters={ + '设计师名称': designer_name, + '完成数量': quantity, + '完成时间': '2026-02-08 10:00:00', + }, + ) + + def test_returns_workflow_task_summary(self): + self._add_task(designer_name='左威', quantity='3') + + response = self.client.get( + '/api/v1/settlement/workflows/designer-task-summary/?date=2026-02-08' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['meta']['state_names'], []) + self.assertEqual(response.data['meta']['state_filter'], {'mode': 'suffix', 'suffix': '完成'}) + self.assertEqual(response.data['meta']['quantity_param_key'], '完成数量') + self.assertEqual(len(response.data['data']), 1) + self.assertEqual(response.data['data'][0]['designer_name'], '左威') + self.assertEqual(response.data['data'][0]['today'], 3) + self.assertEqual(response.data['data'][0]['task_count'][0]['state_name'], '画图完成') + + def test_default_state_filter_includes_only_done_suffix(self): + self._add_task(designer_name='左威', quantity='3', state=self.drawing_done) + self._add_task(designer_name='左威', quantity='2', state=self.color_done) + self._add_task(designer_name='左威', quantity='10', state=self.drawing_in_progress) + + response = self.client.get( + '/api/v1/settlement/workflows/designer-task-summary/?date=2026-02-08' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['data'][0]['today'], 5) + states = {item['state_name'] for item in response.data['data'][0]['task_count']} + self.assertEqual(states, {'画图完成', '调色完成'}) + + def test_accepts_state_and_designer_filters(self): + self._add_task(designer_name='左威', quantity='3', state=self.drawing_done) + self._add_task(designer_name='王五', quantity='2', state=self.color_done) + + response = self.client.get( + '/api/v1/settlement/workflows/designer-task-summary/' + '?date=2026-02-08&state_names=调色完成&designer_names=王五' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['meta']['state_names'], ['调色完成']) + self.assertEqual(response.data['meta']['state_filter'], {'mode': 'exact', 'state_names': ['调色完成']}) + self.assertEqual(response.data['meta']['designer_names'], ['王五']) + self.assertEqual(len(response.data['data']), 1) + self.assertEqual(response.data['data'][0]['designer_name'], '王五') + self.assertEqual(response.data['data'][0]['today'], 2) + + def test_requires_date_parameter(self): + response = self.client.get('/api/v1/settlement/workflows/designer-task-summary/') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('缺少 date 参数', response.data['error']) + + def test_invalid_designer_names(self): + response = self.client.get( + '/api/v1/settlement/workflows/designer-task-summary/?date=2026-02-08&designer_names=,,' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('designer_names 不能为空', response.data['error']) diff --git a/api_v1/views/settlement/views.py b/api_v1/views/settlement/views.py index ae8bed3..aac197a 100644 --- a/api_v1/views/settlement/views.py +++ b/api_v1/views/settlement/views.py @@ -13,7 +13,16 @@ from rest_framework.permissions import IsAuthenticated from rest_framework_simplejwt.authentication import JWTAuthentication from basic_info.models import Merchant -from settlement.services import get_plate_order_summary_by_customer +from settlement.services import ( + DEFAULT_DESIGNER_SUMMARY_STATE_NAMES, + DEFAULT_DESIGNER_TASK_STATE_NAME_SUFFIX, + DEFAULT_DESIGNER_PARAM_KEY, + DEFAULT_DESIGNER_TASK_QUANTITY_PARAM_KEY, + DEFAULT_DESIGNER_TASK_TIME_PARAM_KEY, + get_plate_order_summary_by_customer, + get_plate_order_summary_by_designer, + get_designer_workflow_task_summary, +) from .mixins import SettlementVisibilityMixin logger = logging.getLogger(__name__) @@ -120,3 +129,189 @@ class PlateOrderSummaryView(SettlementVisibilityMixin, APIView): {"error": "获取统计数据失败"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + + +class PlateOrderDesignerSummaryView(SettlementVisibilityMixin, APIView): + """ + 开版订单设计师统计 API + + GET /api/v1/settlement/plate-orders/designer-summary/ + + 参数: + date: 统计日期(YYYY-MM-DD) + state_names: 可选,逗号分隔或重复传参的 state 名称,默认“画图完成” + """ + + authentication_classes = PlateOrderSummaryView.authentication_classes + permission_classes = [IsAuthenticated] + view_all_permission = "printing.view_all_plateorders" + + def get(self, request): + date_str = request.query_params.get("date") + + if not date_str: + return Response( + {"error": "缺少 date 参数"}, status=status.HTTP_400_BAD_REQUEST + ) + + if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str): + return Response( + {"error": "日期格式错误,请使用 YYYY-MM-DD 格式"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + settlement_date = date.fromisoformat(date_str) + except ValueError: + return Response({"error": "日期不存在"}, status=status.HTTP_403_FORBIDDEN) + + emp = getattr(request.user, "employee", None) + if emp is None or emp.merchant is None: + if request.user.is_superuser: + merchant = Merchant.objects.first() + if merchant is None: + return Response( + {"error": "系统中没有商户"}, status=status.HTTP_403_FORBIDDEN + ) + merchant_id = merchant.id + else: + return Response( + {"error": "用户未关联商户"}, status=status.HTTP_403_FORBIDDEN + ) + else: + merchant_id = emp.merchant.id + + state_names = _parse_state_names(request) + + try: + data = get_plate_order_summary_by_designer( + merchant_id=merchant_id, + settlement_date=settlement_date, + user=self.get_service_user(request.user), + state_names=state_names, + ) + return Response({ + "data": data, + "meta": { + "state_names": list(state_names or DEFAULT_DESIGNER_SUMMARY_STATE_NAMES), + "designer_param_key": DEFAULT_DESIGNER_PARAM_KEY, + }, + }) + except ValueError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + logger.exception(f"[settlement.views] 获取开版订单设计师统计失败: {e}") + return Response( + {"error": "获取统计数据失败"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + +def _parse_state_names(request): + """解析 state_names,支持逗号分隔和重复传参。""" + return _parse_csv_query_param(request, "state_names") + + +def _parse_designer_names(request): + """解析 designer_names,支持逗号分隔和重复传参。""" + return _parse_csv_query_param(request, "designer_names") + + +def _parse_csv_query_param(request, name: str): + raw_values = request.query_params.getlist("state_names") + if name != "state_names": + raw_values = request.query_params.getlist(name) + if not raw_values: + return None + + values = [] + for raw_value in raw_values: + values.extend( + item.strip() for item in str(raw_value).split(',') if item.strip() + ) + return values + + +class DesignerWorkflowTaskSummaryView(SettlementVisibilityMixin, APIView): + """ + 设计师工序任务量统计 API + + GET /api/v1/settlement/workflows/designer-task-summary/ + """ + + authentication_classes = PlateOrderSummaryView.authentication_classes + permission_classes = [IsAuthenticated] + view_all_permission = "printing.view_all_plateorders" + + def get(self, request): + date_str = request.query_params.get("date") + + if not date_str: + return Response( + {"error": "缺少 date 参数"}, status=status.HTTP_400_BAD_REQUEST + ) + + if not re.match(r"^\d{4}-\d{2}-\d{2}$", date_str): + return Response( + {"error": "日期格式错误,请使用 YYYY-MM-DD 格式"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + settlement_date = date.fromisoformat(date_str) + except ValueError: + return Response({"error": "日期不存在"}, status=status.HTTP_403_FORBIDDEN) + + emp = getattr(request.user, "employee", None) + if emp is None or emp.merchant is None: + if request.user.is_superuser: + merchant = Merchant.objects.first() + if merchant is None: + return Response( + {"error": "系统中没有商户"}, status=status.HTTP_403_FORBIDDEN + ) + merchant_id = merchant.id + else: + return Response( + {"error": "用户未关联商户"}, status=status.HTTP_403_FORBIDDEN + ) + else: + merchant_id = emp.merchant.id + + state_names = _parse_state_names(request) + designer_names = _parse_designer_names(request) + + try: + data = get_designer_workflow_task_summary( + merchant_id=merchant_id, + settlement_date=settlement_date, + user=self.get_service_user(request.user), + state_names=state_names, + designer_names=designer_names, + ) + return Response({ + "data": data, + "meta": { + "date": settlement_date.isoformat(), + "state_names": list(state_names or []), + "state_filter": ( + {"mode": "exact", "state_names": list(state_names)} + if state_names is not None + else {"mode": "suffix", "suffix": DEFAULT_DESIGNER_TASK_STATE_NAME_SUFFIX} + ), + "designer_names": list(designer_names or []), + "designer_param_key": DEFAULT_DESIGNER_PARAM_KEY, + "quantity_param_key": DEFAULT_DESIGNER_TASK_QUANTITY_PARAM_KEY, + "time_param_key": DEFAULT_DESIGNER_TASK_TIME_PARAM_KEY, + "empty_quantity_default": 1, + "time_source": "parameters.完成时间; fallback=StateFlowRecord.completed_at", + }, + }) + except ValueError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + logger.exception(f"[settlement.views] 获取设计师工序任务量统计失败: {e}") + return Response( + {"error": "获取统计数据失败"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/api_v1/views/shipment/__init__.py b/api_v1/views/shipment/__init__.py index c128986..2d47f78 100644 --- a/api_v1/views/shipment/__init__.py +++ b/api_v1/views/shipment/__init__.py @@ -15,6 +15,7 @@ from .views import ( ShipmentDeliveryBindShipmentsView, ShipmentDeliveryByPrintingOrderView, ShipmentDeliveryDetailView, + ShipmentDeliveryPrintingJobListView, ShipmentDeliveryListCreateView, ShipmentDeliveryCancelView, ShipmentDeliveryStatusUpdateView, @@ -22,6 +23,7 @@ from .views import ( ShipmentListCreateView, ShipmentDetailView, ShipmentExternalCreateView, + ShipmentPrintingJobListView, ) __all__ = [ @@ -36,6 +38,7 @@ __all__ = [ 'ShipmentDeliveryBindShipmentsView', 'ShipmentDeliveryByPrintingOrderView', 'ShipmentDeliveryDetailView', + 'ShipmentDeliveryPrintingJobListView', 'ShipmentDeliveryListCreateView', 'ShipmentDeliveryCancelView', 'ShipmentDeliveryStatusUpdateView', @@ -43,4 +46,5 @@ __all__ = [ 'ShipmentListCreateView', 'ShipmentDetailView', 'ShipmentExternalCreateView', + 'ShipmentPrintingJobListView', ] diff --git a/api_v1/views/shipment/serializers.py b/api_v1/views/shipment/serializers.py index c0f71a1..f0d54f7 100644 --- a/api_v1/views/shipment/serializers.py +++ b/api_v1/views/shipment/serializers.py @@ -650,6 +650,9 @@ class ShipmentSalesItemCustomerSerializer(serializers.Serializer): class ShipmentDeliveryShipmentSummarySerializer(serializers.ModelSerializer): customer_name = serializers.CharField(source="customer.name", read_only=True) + address_id = serializers.IntegerField( + source="customer_address.id", read_only=True, allow_null=True + ) status_display = serializers.CharField(source="get_status_display", read_only=True) fabric = serializers.SerializerMethodField() order_description = serializers.SerializerMethodField() @@ -661,6 +664,14 @@ class ShipmentDeliveryShipmentSummarySerializer(serializers.ModelSerializer): "id", "customer", "customer_name", + "address_id", + "address", + "contact_name", + "contact_phone", + "area", + "coordinates", + "geo_coordinates", + "extra", "fabric", "order_description", "shipment_date", @@ -822,6 +833,21 @@ class ShipmentDeliveryByPrintingOrderSerializer(serializers.ModelSerializer): ).data +class ShipmentPrintingJobSummarySerializer(serializers.Serializer): + id = serializers.IntegerField(read_only=True) + printing_order_id = serializers.IntegerField(read_only=True) + external_order_id = serializers.CharField( + source="printing_order.external_order_id", + read_only=True, + allow_null=True, + ) + customer_name = serializers.CharField( + source="printing_order.customer.name", + read_only=True, + allow_null=True, + ) + + class ShipmentDeliveryCreateSerializer(serializers.Serializer): driver_name = serializers.CharField(max_length=100, help_text="司机名") vehicle_trip = serializers.CharField(max_length=100, help_text="车次") diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index 4a4fd39..9a2768c 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -2527,6 +2527,128 @@ class ShipmentQueryAPITestCase(TestCase): self.assertIn("external_finished_products", result) self.assertIsInstance(result["external_finished_products"], list) + def test_list_shipment_printing_jobs_returns_minimal_paginated_jobs(self): + other_order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant1, + customer=self.customer1, + fabric="第二生产单面料", + width="160cm", + process=self.process1, + created_by=self.user1, + external_order_id="QUERY-PO-002", + ) + other_job = printing_models.PrintingJob.objects.create( + merchant=self.merchant1, + printing_order=other_order, + product=self.product1, + quantity=10, + unit="米", + created_by=self.user1, + ) + shipment_models.SalesItem.objects.create( + merchant=self.merchant1, + shipment=self.shipment1, + name="重复销售品", + quantity=Decimal("1.00"), + unit=shipment_models.UnitChoices.METER, + printing_job_id=self.printing_job1.id, + customer_id=self.customer1.id, + created_by=self.user1, + ) + shipment_models.SalesItem.objects.create( + merchant=self.merchant1, + shipment=self.shipment1, + name="第二销售品", + quantity=Decimal("2.00"), + unit=shipment_models.UnitChoices.METER, + printing_job_id=other_job.id, + customer_id=self.customer1.id, + created_by=self.user1, + ) + + resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment1.id}/printing-jobs/?limit=1") + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.json() + self.assertEqual(data["count"], 2) + self.assertEqual(len(data["results"]), 1) + item = data["results"][0] + self.assertEqual(set(item.keys()), {"id", "printing_order_id", "external_order_id", "customer_name"}) + self.assertEqual(item["id"], self.printing_job1.id) + self.assertEqual(item["printing_order_id"], self.printing_order1.id) + self.assertEqual(item["external_order_id"], "QUERY-PO-001") + self.assertEqual(item["customer_name"], "客户1") + + def test_list_delivery_printing_jobs_returns_jobs_across_shipments(self): + delivery = shipment_models.ShipmentDelivery.objects.create( + merchant=self.merchant1, + driver_name="生产任务司机", + vehicle_trip="JOB-DELIVERY", + created_by=self.user1, + ) + self.shipment1.delivery = delivery + self.shipment1.save(update_fields=["delivery", "updated_at"]) + other_order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant1, + customer=self.customer1, + fabric="送货单生产单面料", + width="160cm", + process=self.process1, + created_by=self.user1, + external_order_id="QUERY-PO-DELIVERY", + ) + other_job = printing_models.PrintingJob.objects.create( + merchant=self.merchant1, + printing_order=other_order, + product=self.product1, + quantity=10, + unit="米", + created_by=self.user1, + ) + other_shipment = shipment_models.Shipment.objects.create( + merchant=self.merchant1, + customer=self.customer1, + shipment_date="2026-01-24", + created_by=self.user1, + delivery=delivery, + ) + shipment_models.SalesItem.objects.create( + merchant=self.merchant1, + shipment=other_shipment, + name="送货单销售品", + quantity=Decimal("2.00"), + unit=shipment_models.UnitChoices.METER, + printing_job_id=other_job.id, + customer_id=self.customer1.id, + created_by=self.user1, + ) + + resp = self.client.get(f"/api/v1/shipment/deliveries/{delivery.id}/printing-jobs/?limit=10") + + self.assertEqual(resp.status_code, status.HTTP_200_OK) + data = resp.json() + self.assertEqual(data["count"], 2) + ids = [item["id"] for item in data["results"]] + self.assertEqual(ids, [self.printing_job1.id, other_job.id]) + + def test_printing_jobs_sub_endpoints_respect_merchant_scope(self): + other_delivery = shipment_models.ShipmentDelivery.objects.create( + merchant=self.merchant2, + driver_name="其他商户司机", + vehicle_trip="OTHER-MERCHANT", + created_by=self.user2, + ) + + shipment_resp = self.client.get( + f"/api/v1/shipment/shipments/{self.shipment2.id}/printing-jobs/" + ) + delivery_resp = self.client.get( + f"/api/v1/shipment/deliveries/{other_delivery.id}/printing-jobs/" + ) + + self.assertEqual(shipment_resp.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(delivery_resp.status_code, status.HTTP_404_NOT_FOUND) + def test_patch_shipment_area_success(self): """ 新增字段 area:支持更新(PATCH)并回显。 @@ -3548,14 +3670,43 @@ class ShipmentDeliveryAPITestCase(APITestCase): self.assertEqual(data["results"][0]["id"], delivery.id) def test_detail_returns_nested_shipment_summaries(self): + customer_address = basic_models.CustomerAddress.objects.create( + merchant=self.merchant, + customer=self.customer, + address="客户地址库地址", + contact_name="地址库联系人", + contact_phone="13600136000", + area="杭州", + coordinates="120.1551,30.2741", + created_by=self.employee, + ) delivery = shipment_models.ShipmentDelivery.objects.create( merchant=self.merchant, driver_name="张司机", vehicle_trip="KD-004", created_by=self.user, ) + self.shipment1.customer_address = customer_address + self.shipment1.address = "杭州市测试路 1 号" + self.shipment1.contact_name = "张三" + self.shipment1.contact_phone = "13800138000" + self.shipment1.area = "华东" + self.shipment1.coordinates = "120.1551,30.2741" + self.shipment1.geo_coordinates = {"lng": 120.1551, "lat": 30.2741} + self.shipment1.extra = {"dock": "A"} self.shipment1.delivery = delivery - self.shipment1.save(update_fields=["delivery", "updated_at"]) + self.shipment1.save(update_fields=[ + "customer_address", + "address", + "contact_name", + "contact_phone", + "area", + "coordinates", + "geo_coordinates", + "extra", + "delivery", + "updated_at", + ]) self.shipment2.delivery = delivery self.shipment2.save(update_fields=["delivery", "updated_at"]) @@ -3572,6 +3723,14 @@ class ShipmentDeliveryAPITestCase(APITestCase): self.assertIn("fabric", item) self.assertIn("order_description", item) first_shipment = next(item for item in data["shipments"] if item["id"] == self.shipment1.id) + self.assertEqual(first_shipment["address_id"], customer_address.id) + self.assertEqual(first_shipment["address"], "杭州市测试路 1 号") + self.assertEqual(first_shipment["contact_name"], "张三") + self.assertEqual(first_shipment["contact_phone"], "13800138000") + self.assertEqual(first_shipment["area"], "华东") + self.assertEqual(first_shipment["coordinates"], "120.1551,30.2741") + self.assertEqual(first_shipment["geo_coordinates"], {"lng": 120.1551, "lat": 30.2741}) + self.assertEqual(first_shipment["extra"], {"dock": "A"}) self.assertIsNone(first_shipment["fabric"]) self.assertIsNone(first_shipment["order_description"]) diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index b3d0a2c..789eb07 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -12,7 +12,7 @@ from rest_framework.views import APIView from django.utils.dateparse import parse_date, parse_datetime from flower.viewsets import LimitedLimitOffsetPagination -from shipment.models import Shipment, ShipmentDelivery, ShipmentDeliveryStatus, ShipmentStatus +from shipment.models import SalesItem, Shipment, ShipmentDelivery, ShipmentDeliveryStatus, ShipmentStatus from .serializers import ( SalesItemDetailSerializer, @@ -26,6 +26,7 @@ from .serializers import ( ShipmentDeliverySerializer, ShipmentDeliveryStatusUpdateSerializer, ShipmentDeliveryUpdateSerializer, + ShipmentPrintingJobSummarySerializer, ShipmentSerializer, ShipmentCreateNormalSerializer, ShipmentCreateExternalSerializer, @@ -97,6 +98,26 @@ def _build_sales_item_serializer_context(items): } +def _get_printing_jobs_for_shipment_ids(*, merchant, shipment_ids): + from printing.models import PrintingJob + + printing_job_ids = ( + SalesItem.objects.filter( + merchant=merchant, + shipment_id__in=shipment_ids, + delete_at__isnull=True, + printing_job_id__isnull=False, + ) + .values_list("printing_job_id", flat=True) + .distinct() + ) + return ( + PrintingJob.objects.filter(id__in=printing_job_ids) + .select_related("printing_order", "printing_order__customer") + .order_by("id") + ) + + class ShipmentListCreateView(ListModelMixin, GenericAPIView): """ 出货单:查询列表 / 创建 @@ -417,6 +438,40 @@ class ShipmentStatusUpdateView(APIView): return self.patch(request, pk=pk) +class ShipmentPrintingJobListView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = ShipmentPrintingJobSummarySerializer + pagination_class = LimitedLimitOffsetPagination + + def get_shipment_queryset(self): + qs = Shipment.objects.all().select_related("merchant") + user = self.request.user + if getattr(user, "is_superuser", False): + return qs + + emp = getattr(user, "employee", None) + merchant = getattr(emp, "merchant", None) if emp else None + if not merchant: + return Shipment.objects.none() + return qs.filter(merchant=merchant) + + def get(self, request, pk: int): + shipment = self.get_shipment_queryset().filter(id=pk).first() + if shipment is None: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + + queryset = _get_printing_jobs_for_shipment_ids( + merchant=shipment.merchant, + shipment_ids=[shipment.id], + ) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + class ShipmentDeliveryListCreateView(ListModelMixin, GenericAPIView): """ 送货单:查询列表 / 创建 @@ -435,6 +490,7 @@ class ShipmentDeliveryListCreateView(ListModelMixin, GenericAPIView): ).prefetch_related( "shipments", "shipments__customer", + "shipments__customer_address", ) user = self.request.user @@ -489,6 +545,7 @@ class ShipmentDeliveryListCreateView(ListModelMixin, GenericAPIView): ).prefetch_related( "shipments", "shipments__customer", + "shipments__customer_address", ).get(id=delivery.id) return Response( @@ -652,7 +709,11 @@ class ShipmentDeliveryByPrintingOrderView(GenericAPIView): shipments__items__delete_at__isnull=True, ) .select_related("merchant", "created_by", "operator", "cancelled_by") - .prefetch_related("shipments", "shipments__customer") + .prefetch_related( + "shipments", + "shipments__customer", + "shipments__customer_address", + ) ) queryset = self._apply_filters(queryset) @@ -682,6 +743,7 @@ class ShipmentDeliveryDetailView(RetrieveModelMixin, GenericAPIView): ).prefetch_related( "shipments", "shipments__customer", + "shipments__customer_address", ) user = self.request.user @@ -744,6 +806,41 @@ class ShipmentDeliveryDetailView(RetrieveModelMixin, GenericAPIView): return Response(status=status.HTTP_204_NO_CONTENT) +class ShipmentDeliveryPrintingJobListView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = ShipmentPrintingJobSummarySerializer + pagination_class = LimitedLimitOffsetPagination + + def get_delivery_queryset(self): + qs = ShipmentDelivery.objects.all().select_related("merchant") + user = self.request.user + if getattr(user, "is_superuser", False): + return qs + + emp = getattr(user, "employee", None) + merchant = getattr(emp, "merchant", None) if emp else None + if not merchant: + return ShipmentDelivery.objects.none() + return qs.filter(merchant=merchant) + + def get(self, request, pk: int): + delivery = self.get_delivery_queryset().filter(id=pk).first() + if delivery is None: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + + shipment_ids = delivery.shipments.values_list("id", flat=True) + queryset = _get_printing_jobs_for_shipment_ids( + merchant=delivery.merchant, + shipment_ids=shipment_ids, + ) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + class ShipmentDeliveryStatusUpdateView(APIView): """ 修改送货单状态 @@ -787,6 +884,7 @@ class ShipmentDeliveryStatusUpdateView(APIView): ).prefetch_related( "shipments", "shipments__customer", + "shipments__customer_address", ).get(id=delivery.id) return Response( @@ -837,6 +935,7 @@ class ShipmentDeliveryCancelView(APIView): ).prefetch_related( "shipments", "shipments__customer", + "shipments__customer_address", ).get(id=delivery.id) return Response( @@ -888,6 +987,7 @@ class ShipmentDeliveryBindShipmentsView(APIView): ).prefetch_related( "shipments", "shipments__customer", + "shipments__customer_address", ).get(id=delivery.id) return Response( diff --git a/docs/api_v1_settlement_designer_workflow_task_summary_2026-07-10.md b/docs/api_v1_settlement_designer_workflow_task_summary_2026-07-10.md new file mode 100644 index 0000000..b821680 --- /dev/null +++ b/docs/api_v1_settlement_designer_workflow_task_summary_2026-07-10.md @@ -0,0 +1,248 @@ +# 设计师工序任务量统计 API v1 文档 + +面向前端和业务报表对接。接口统一前缀为 `/api/v1/`。 + +## 接口 + +`GET /api/v1/settlement/workflows/designer-task-summary/` + +按设计师统计工序完成任务量。该接口统计的是“工序任务量”,不是去重后的开版订单数量。 + +## 与旧接口区别 + +旧接口: + +`GET /api/v1/settlement/plate-orders/designer-summary/` + +- 统计口径:设计师关联的开版订单数量。 +- 统计单位:去重后的 `PlateOrder`。 +- 时间口径:`PlateOrder.plate_date`。 + +新接口: + +`GET /api/v1/settlement/workflows/designer-task-summary/` + +- 统计口径:设计师实际完成的工序任务量。 +- 统计单位:未撤销的 `StateFlowRecord`,一条状态流转记录代表一次工序完成事件。 +- 时间口径:优先使用参数 `完成时间`,为空或无法解析时回退 `StateFlowRecord.completed_at`。 +- 数量口径:读取参数 `完成数量`,有效正数按实际值,否则按 `1`。 + +## 认证 + +- 标准登录认证:JWT / Session。 +- Agent 专用认证:请求头 `X-AGENT-SECRET: RCYH_BOT_0083`。 +- 普通用户必须关联员工和商户。 + +## 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `date` | string | 是 | 统计截止日期,格式 `YYYY-MM-DD` | +| `state_names` | string/string[] | 否 | 参与统计的工序节点名称;不传时默认统计所有名称以 `完成` 结尾的节点;传入时按节点名称精确过滤;支持逗号分隔或重复传参 | +| `designer_names` | string/string[] | 否 | 按设计师名称筛选;支持逗号分隔或重复传参 | + +请求示例: + +```http +GET /api/v1/settlement/workflows/designer-task-summary/?date=2026-07-10 +GET /api/v1/settlement/workflows/designer-task-summary/?date=2026-07-10&state_names=画图完成,调色完成 +GET /api/v1/settlement/workflows/designer-task-summary/?date=2026-07-10&state_names=画图完成&state_names=调色完成&designer_names=左威 +``` + +## 统计口径 + +核心链路: + +```text +StateFlowRecord +-> State +-> StateLogParameterRecord +-> BusinessObject +-> PlateOrder +``` + +有效记录条件: + +- `StateFlowRecord.is_cancelled = false`。 +- 不传 `state_names` 时,`State.name` 必须以 `完成` 结尾。 +- 传入 `state_names` 时,`State.name` 必须在 `state_names` 范围内。 +- 能够关联到当前商户下的 `PlateOrder`。 +- `PlateOrder.plate_type` 和 `PlateOrder.production_method` 非空。 +- 同一次状态流转合并后的参数中能读取到非空 `设计师名称`。 +- 满足当前用户的客户可见性规则。 + +统计单位: + +- 一条有效 `StateFlowRecord` 代表一次工序完成事件。 +- 不按 `PlateOrder` 去重。 +- 同一个开版单完成多个工序时,每个工序分别统计。 +- 同一个开版单同一工序存在多次有效完成记录时,每次记录分别统计。 +- 一次状态流转关联多条参数记录时,只累计一次,后提交的参数会覆盖同名旧参数。 + +任务量规则: + +| `完成数量` 原始值 | 计入任务量 | +| --- | ---: | +| 参数不存在 | 1 | +| `null` | 1 | +| 空字符串 | 1 | +| `0` | 1 | +| 负数 | 1 | +| 非数字 | 1 | +| `1` | 1 | +| `3` | 3 | +| `1.5` | 1.5 | + +时间规则: + +- 优先使用合并参数中的 `完成时间`。 +- 如果 `完成时间` 为空或无法解析,使用 `StateFlowRecord.completed_at`。 +- `today`:有效统计时间在 `date` 当天 00:00(含)至次日 00:00(不含)。 +- `current_month`:有效统计时间在当月 1 日 00:00(含)至 `date` 次日 00:00(不含)。 +- 时间范围按 Django 当前业务时区构造。 + +分组维度: + +- 设计师名称:`设计师名称` +- 工序节点:`State.name` +- 开版类型:`PlateOrder.plate_type-PlateOrder.production_method` + +## 响应 + +成功响应:`200 OK` + +```json +{ + "data": [ + { + "designer_name": "左威", + "task_count": [ + { + "state_name": "画图完成", + "type": "首版-定位", + "today": 4, + "current_month": 18 + }, + { + "state_name": "调色完成", + "type": "首版-定位", + "today": 2, + "current_month": 10 + } + ], + "today": 6, + "current_month": 28 + } + ], + "meta": { + "date": "2026-07-10", + "state_names": ["画图完成", "调色完成"], + "state_filter": { + "mode": "exact", + "state_names": ["画图完成", "调色完成"] + }, + "designer_names": [], + "designer_param_key": "设计师名称", + "quantity_param_key": "完成数量", + "time_param_key": "完成时间", + "empty_quantity_default": 1, + "time_source": "parameters.完成时间; fallback=StateFlowRecord.completed_at" + } +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `designer_name` | string | 设计师名称 | +| `task_count` | array | 该设计师按工序和开版类型拆分的任务量 | +| `state_name` | string | 工序节点名称 | +| `type` | string | `plate_type-production_method` | +| `task_count[].today` | number | 当前分组当天任务量 | +| `task_count[].current_month` | number | 当前分组当月累计任务量 | +| `data[].today` | number | 该设计师当天全部分组任务量合计 | +| `data[].current_month` | number | 该设计师当月全部分组任务量合计 | + +无数据时: + +```json +{ + "data": [], + "meta": { + "date": "2026-07-10", + "state_names": [], + "state_filter": { + "mode": "suffix", + "suffix": "完成" + }, + "designer_names": [], + "designer_param_key": "设计师名称", + "quantity_param_key": "完成数量", + "time_param_key": "完成时间", + "empty_quantity_default": 1, + "time_source": "parameters.完成时间; fallback=StateFlowRecord.completed_at" + } +} +``` + +## 错误响应 + +缺少日期:`400 Bad Request` + +```json +{"error": "缺少 date 参数"} +``` + +日期格式错误:`400 Bad Request` + +```json +{"error": "日期格式错误,请使用 YYYY-MM-DD 格式"} +``` + +日期不存在:`403 Forbidden` + +```json +{"error": "日期不存在"} +``` + +`state_names` 传入后为空:`400 Bad Request` + +```json +{"error": "state_names 不能为空"} +``` + +`designer_names` 传入后为空:`400 Bad Request` + +```json +{"error": "designer_names 不能为空"} +``` + +用户未关联商户:`403 Forbidden` + +```json +{"error": "用户未关联商户"} +``` + +系统异常:`500 Internal Server Error` + +```json +{"error": "获取统计数据失败"} +``` + +## 实现说明 + +核心 service:`settlement.services.get_designer_workflow_task_summary(...)`。 + +实现策略: + +- 以 `StateFlowRecord` 为主查询对象,避免参数表 join 导致重复累计。 +- 预取 `StateLogParameterRecord` 后按 `created_at, id` 顺序合并参数。 +- 对每个 `StateFlowRecord` 最多累计一次任务量。 +- 使用 Python 安全解析 `完成数量` 和 `完成时间`。 + +性能说明: + +- 当前实现优先保证统计口径正确。 +- `完成时间` 来源于 JSON 参数时不可直接依赖普通数据库索引。 +- 如果后续数据量增长明显,建议将有效统计时间和任务数量冗余落地到可索引字段或报表中间表。 diff --git a/docs/api_v1_settlement_plate_order_designer_summary_2026-07-10.md b/docs/api_v1_settlement_plate_order_designer_summary_2026-07-10.md new file mode 100644 index 0000000..f240aec --- /dev/null +++ b/docs/api_v1_settlement_plate_order_designer_summary_2026-07-10.md @@ -0,0 +1,183 @@ +# 开版订单设计师统计 API v1 文档 + +面向前端和 Agent 对接。接口统一前缀为 `/api/v1/`。 + +## 接口 + +`GET /api/v1/settlement/plate-orders/designer-summary/` + +按设计师统计指定日期当天、以及当月截至指定日期的开版订单数量。 + +## 认证 + +- 标准登录认证:JWT / Session。 +- Agent 专用认证:请求头 `X-AGENT-SECRET: RCYH_BOT_0083`。 +- 普通用户必须关联员工和商户。 + +## 查询参数 + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `date` | string | 是 | 统计日期,格式 `YYYY-MM-DD` | +| `state_names` | string/string[] | 否 | 参与统计的 stateflow 节点名称,默认 `画图完成`;支持逗号分隔或重复传参 | + +`state_names` 示例: + +```http +GET /api/v1/settlement/plate-orders/designer-summary/?date=2026-02-08 +GET /api/v1/settlement/plate-orders/designer-summary/?date=2026-02-08&state_names=画图完成,调色完成 +GET /api/v1/settlement/plate-orders/designer-summary/?date=2026-02-08&state_names=画图完成&state_names=调色完成 +``` + +## 统计口径 + +数据来源不是 `PlateOrder.designer` 字段,而是 stateflow 参数记录。 + +默认口径: + +- 只统计 stateflow 节点名为 `画图完成` 的记录。 +- 从 `StateLogParameterRecord.parameters["设计师名称"]` 读取设计师名称。 +- 只统计未撤销的状态流转记录:`StateFlowRecord.is_cancelled = false`。 +- 只统计当前商户的 `PlateOrder`。 +- 只统计 `plate_type` 和 `production_method` 非空的 `PlateOrder`。 +- 按 `plate_type-production_method` 生成类型,例如 `首版-定位`。 +- 同一个设计师在同一个开版单、同一个类型下出现多条参数记录时,只计 1 单。 +- 如果通过 `state_names` 纳入多个节点,同一个开版单在不同节点出现不同设计师时,会分别计入对应设计师。 + +时间口径: + +- `today`:`PlateOrder.plate_date` 在 `date` 当天内的去重开版单数量。 +- `current_month`:`PlateOrder.plate_date` 在当月 1 日 00:00 到 `date` 次日 00:00 之前的去重开版单数量。 +- 时间范围按 Django 当前时区构造,避免直接用 `plate_date__date` 导致数据库索引利用变差。 + +可见性口径: + +- 超级管理员或拥有 `printing.view_all_plateorders` 权限:可看当前商户下所有客户的开版单统计。 +- 普通员工:只统计其创建的客户,或 `Customer.visible_employees` 包含该员工的客户。 + +## 响应 + +成功响应:`200 OK` + +```json +{ + "data": [ + { + "designer_name": "徐煜耀", + "plate_order_count": [ + { + "type": "首版-定位", + "today": 2, + "current_month": 9 + } + ] + } + ], + "meta": { + "state_names": ["画图完成"], + "designer_param_key": "设计师名称" + } +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `data` | array | 设计师统计列表 | +| `designer_name` | string | 从 stateflow 参数 `设计师名称` 读取的设计师名称 | +| `plate_order_count` | array | 该设计师下按类型分组的开版单数量 | +| `type` | string | `plate_type-production_method` | +| `today` | integer | 指定日期当天数量 | +| `current_month` | integer | 当月截至指定日期的累计数量 | +| `meta.state_names` | string[] | 本次参与统计的 stateflow 节点名称 | +| `meta.designer_param_key` | string | 设计师名称来源参数 key,目前固定为 `设计师名称` | + +无数据时: + +```json +{ + "data": [], + "meta": { + "state_names": ["画图完成"], + "designer_param_key": "设计师名称" + } +} +``` + +## 错误响应 + +缺少日期:`400 Bad Request` + +```json +{ + "error": "缺少 date 参数" +} +``` + +日期格式错误:`400 Bad Request` + +```json +{ + "error": "日期格式错误,请使用 YYYY-MM-DD 格式" +} +``` + +日期不存在:`403 Forbidden` + +```json +{ + "error": "日期不存在" +} +``` + +`state_names` 传入后为空:`400 Bad Request` + +```json +{ + "error": "state_names 不能为空" +} +``` + +用户未关联商户:`403 Forbidden` + +```json +{ + "error": "用户未关联商户" +} +``` + +系统异常:`500 Internal Server Error` + +```json +{ + "error": "获取统计数据失败" +} +``` + +## 实现说明 + +核心 service:`settlement.services.get_plate_order_summary_by_designer(...)`。 + +核心关联链路: + +```text +StateLogParameterRecord +-> StateFlowRecord +-> State +-> BusinessObject +-> PlateOrder +``` + +默认参数: + +```python +DEFAULT_DESIGNER_SUMMARY_STATE_NAMES = ("画图完成",) +DEFAULT_DESIGNER_PARAM_KEY = "设计师名称" +``` + +性能说明: + +- 当前实现使用 Django ORM。 +- 查询使用 `plate_date >= start`、`plate_date < end` 的 datetime range,避免 `plate_date__date`。 +- `StateLogParameterRecord.parameters` 当前没有 GIN 索引;如果参数记录规模明显增长,可评估重新增加 JSONB GIN 索引或将设计师统计字段冗余落地。 diff --git a/docs/api_v1_shipment_api_2026-07-08.md b/docs/api_v1_shipment_api_2026-07-08.md index 632d4e0..6dbbe58 100644 --- a/docs/api_v1_shipment_api_2026-07-08.md +++ b/docs/api_v1_shipment_api_2026-07-08.md @@ -159,6 +159,28 @@ } ``` +### ShipmentPrintingJobSummary + +出货单/送货单关联生产任务的精简 DTO,仅用于快速跳转生产任务详情。 + +```json +{ + "id": 123, + "printing_order_id": 456, + "external_order_id": "KD20453713", + "customer_name": "客户A" +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | integer | `PrintingJob.id` | +| `printing_order_id` | integer | 关联的 `PrintingOrder.id` | +| `external_order_id` | string/null | 关联生产订单的外部订单编号 | +| `customer_name` | string/null | 关联生产订单客户名称 | + `shipments` 内元素为送货单内出货单摘要: ```json @@ -166,6 +188,14 @@ "id": 1, "customer": 10, "customer_name": "客户A", + "address_id": 5, + "address": "杭州市测试路 1 号", + "contact_name": "张三", + "contact_phone": "13800138000", + "area": "华东", + "coordinates": "120.1551,30.2741", + "geo_coordinates": null, + "extra": {"dock": "A"}, "fabric": "面料信息", "order_description": "订单描述", "shipment_date": "2026-01-14", @@ -176,6 +206,8 @@ } ``` +地址说明:送货单本身不保存地址;一个送货单可包含多个出货单,每个出货单可能有不同地址。送货单返回的 `shipments[]` 中地址字段均来自对应 `Shipment` 的地址快照字段,`address_id` 仅表示关联的客户地址 ID。 + ## 出货单接口 ### 查询出货单列表 @@ -314,6 +346,30 @@ 响应:`200 Shipment`。 +### 查询出货单关联生产任务 + +`GET /api/v1/shipment/shipments/{id}/printing-jobs/` + +用于快速获取该出货单关联的生产任务列表,不返回销售品明细。 + +关联路径:`Shipment -> SalesItem.printing_job_id -> PrintingJob`。 + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `limit` | integer | 否 | 分页大小 | +| `offset` | integer | 否 | 分页偏移 | + +响应:分页 `ShipmentPrintingJobSummary[]`。 + +说明: + +- 仅统计未软删除销售品:`SalesItem.delete_at IS NULL`。 +- 相同 `printing_job_id` 会去重。 +- 默认按 `PrintingJob.id` 升序。 +- 如需销售品明细,调用 `GET /api/v1/shipment/shipments/{id}/`。 + ### 更新出货单 `PATCH /api/v1/shipment/shipments/{id}/` @@ -473,6 +529,31 @@ 响应:`200 ShipmentDelivery`。 +### 查询送货单关联生产任务 + +`GET /api/v1/shipment/deliveries/{id}/printing-jobs/` + +用于快速获取该送货单下所有出货单关联的生产任务列表,不返回销售品明细。 + +关联路径:`ShipmentDelivery -> Shipments -> SalesItem.printing_job_id -> PrintingJob`。 + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `limit` | integer | 否 | 分页大小 | +| `offset` | integer | 否 | 分页偏移 | + +响应:分页 `ShipmentPrintingJobSummary[]`。 + +说明: + +- 仅统计未软删除销售品:`SalesItem.delete_at IS NULL`。 +- 同一个生产任务通过多个销售品或多个出货单关联时只返回一次。 +- 默认按 `PrintingJob.id` 升序。 +- 如需送货单内出货单摘要,调用 `GET /api/v1/shipment/deliveries/{id}/`。 +- 如需某个出货单的销售品明细,调用 `GET /api/v1/shipment/shipments/{shipment_id}/`。 + ### 更新送货单 `PATCH /api/v1/shipment/deliveries/{id}/` diff --git a/docs/api_v1_shipment_delivery_status_flow_2026-07-10.md b/docs/api_v1_shipment_delivery_status_flow_2026-07-10.md new file mode 100644 index 0000000..9bf03f5 --- /dev/null +++ b/docs/api_v1_shipment_delivery_status_flow_2026-07-10.md @@ -0,0 +1,197 @@ +# 送货单状态流转 API v1 说明 + +本文档说明送货单状态枚举、状态流转规则,以及如何通过 API 将送货单切换到“送货中/已送达/已取消”。 + +接口统一前缀为 `/api/v1/`。 + +## 状态枚举 + +`ShipmentDeliveryStatus` + +| 值 | 枚举名 | 展示文案 | 说明 | +| --- | --- | --- | --- | +| `1` | `PENDING` | 待送货 | 送货单已创建,尚未开始送货 | +| `2` | `IN_TRANSIT` | 送货中 | 已开始送货,系统会记录 `started_at` | +| `3` | `DELIVERED` | 已送达 | 已完成送达,系统会记录 `delivered_at` | +| `4` | `CANCELLED` | 已取消 | 送货单已取消,系统会记录 `cancelled_at` | + +## 状态流转规则 + +当前允许的正常流转: + +```text +待送货(1) -> 送货中(2) -> 已送达(3) +``` + +取消流转: + +```text +待送货(1) -> 已取消(4) +送货中(2) -> 已取消(4) +已送达(3) -> 已取消(4) +``` + +注意:取消不通过通用 `/status/` 接口完成,而是通过独立 `/cancel/` 接口。 + +不允许的流转: + +| 当前状态 | 目标状态 | 结果 | +| --- | --- | --- | +| 待送货(1) | 已送达(3) | 不允许,必须先切到送货中 | +| 送货中(2) | 待送货(1) | 不允许回退 | +| 已送达(3) | 待送货(1)/送货中(2) | 不允许回退 | +| 已取消(4) | 任意状态 | 不允许恢复 | +| 任意状态 | 已取消(4) via `/status/` | 不允许,请使用 `/cancel/` | + +如果传入的目标状态等于当前状态,后端会幂等返回当前送货单,不重复更新时间字段。 + +## 修改为送货中 + +`POST /api/v1/shipment/deliveries/{id}/status/` + +请求体: + +```json +{ + "status": 2 +} +``` + +成功响应:`200 ShipmentDelivery` + +关键字段示例: + +```json +{ + "id": 1, + "status": 2, + "status_display": "送货中", + "started_at": "2026-07-10T14:30:00+08:00", + "delivered_at": null, + "cancelled_at": null, + "operator_id": 10, + "operator_name": "操作人" +} +``` + +行为说明: + +- 仅允许当前状态为 `待送货(1)` 时切换到 `送货中(2)`。 +- 成功后自动写入 `started_at`。 +- 成功后自动写入当前操作人为 `operator`。 + +## 修改为已送达 + +`POST /api/v1/shipment/deliveries/{id}/status/` + +请求体: + +```json +{ + "status": 3 +} +``` + +成功响应:`200 ShipmentDelivery` + +关键字段示例: + +```json +{ + "id": 1, + "status": 3, + "status_display": "已送达", + "started_at": "2026-07-10T14:30:00+08:00", + "delivered_at": "2026-07-10T16:30:00+08:00", + "cancelled_at": null, + "operator_id": 10, + "operator_name": "操作人" +} +``` + +行为说明: + +- 仅允许当前状态为 `送货中(2)` 时切换到 `已送达(3)`。 +- 成功后自动写入 `delivered_at`。 +- 成功后自动写入当前操作人为 `operator`。 + +## 取消送货单 + +`POST /api/v1/shipment/deliveries/{id}/cancel/` + +请求体: + +```json +{} +``` + +成功响应:`200 ShipmentDelivery` + +关键字段示例: + +```json +{ + "id": 1, + "status": 4, + "status_display": "已取消", + "cancelled_at": "2026-07-10T17:00:00+08:00", + "cancelled_by_id": 1, + "cancelled_by_name": "取消人", + "operator_id": 10, + "operator_name": "操作人" +} +``` + +权限要求:需要 `shipment.cancel_shipmentdelivery`。 + +行为说明: + +- 取消接口是独立接口,不使用 `/status/`。 +- 成功后自动写入 `cancelled_at`。 +- 成功后自动写入 `cancelled_by`。 +- 成功后自动写入当前操作人为 `operator`。 +- 对已经取消的送货单再次调用取消接口是幂等的。 + +## 错误响应 + +目标状态非法:`400 Bad Request` + +```json +{ + "status": ["\"4\" 不是合法选项。"] +} +``` + +说明:`/status/` 接口只接受 `1/2/3`,取消请使用 `/cancel/`。 + +不允许的状态流转:`400 Bad Request` + +```json +{ + "detail": "不允许将送货单状态从 待送货 修改为 已送达" +} +``` + +送货单不存在或无权访问:`404 Not Found` + +```json +{ + "detail": "未找到送货单" +} +``` + +取消权限不足:`403 Forbidden` + +```json +{ + "detail": "没有权限取消送货单" +} +``` + +## 前端建议 + +- “开始送货”按钮:调用 `/status/`,传 `status=2`。 +- “确认送达”按钮:调用 `/status/`,传 `status=3`。 +- “取消送货单”按钮:调用 `/cancel/`。 +- 不要通过 `/status/` 传 `status=4`。 +- 不要尝试状态回退;当前业务不支持从 `送货中/已送达/已取消` 回退。 diff --git a/flower/settings.py b/flower/settings.py index 05df633..a0eada2 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -40,6 +40,8 @@ Env.read_env(str(BASE_DIR / '.env')) # 允许一次请求携带更多表单字段,避免 admin 批量操作时报 TooManyFieldsSent # Django 默认 DATA_UPLOAD_MAX_NUMBER_FIELDS=1000,当一次 POST/GET 的字段(含重复 key)过多会直接抛异常。 DATA_UPLOAD_MAX_NUMBER_FIELDS = env.int('DATA_UPLOAD_MAX_NUMBER_FIELDS', default=20000) +DATA_UPLOAD_MAX_MEMORY_SIZE = env.int('DATA_UPLOAD_MAX_MEMORY_SIZE', default=200 * 1024 * 1024) +FILE_UPLOAD_MAX_MEMORY_SIZE = env.int('FILE_UPLOAD_MAX_MEMORY_SIZE', default=10 * 1024 * 1024) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ diff --git a/flower/test_app_version_api.py b/flower/test_app_version_api.py index 71e43a6..468f1d9 100644 --- a/flower/test_app_version_api.py +++ b/flower/test_app_version_api.py @@ -119,6 +119,39 @@ class AppVersionAPITest(TestCase): self.assertFalse(first.is_current) self.assertTrue(second.is_current) + def test_package_file_does_not_override_download_url(self): + app_version = AppVersion.objects.create( + major=3, + minor=0, + build=300, + package_file="app_versions/broken-upload.apk", + download_url="https://example.com/manual-url.apk", + is_current=True, + ) + + response = self.client.get("/api/app-version/") + + app_version.refresh_from_db() + self.assertEqual(app_version.download_url, "https://example.com/manual-url.apk") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["download_url"], "https://example.com/manual-url.apk") + + def test_package_file_sets_download_url_when_url_is_empty(self): + app_version = AppVersion.objects.create( + major=3, + minor=1, + build=310, + package_file="app_versions/uploaded.apk", + is_current=True, + ) + + response = self.client.get("/api/app-version/") + + app_version.refresh_from_db() + self.assertTrue(app_version.download_url.endswith("/app_versions/uploaded.apk")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["download_url"], app_version.download_url) + def test_set_app_version_command_updates_cached_api_payload_with_defaults(self): output = StringIO() call_command( diff --git a/mission/handlers.py b/mission/handlers.py index 7a157ee..9ec24a3 100644 --- a/mission/handlers.py +++ b/mission/handlers.py @@ -9,9 +9,9 @@ logger = logging.getLogger(__name__) def _build_mission_payload(mission) -> dict: - participant_names = list( - mission.get_participants().values_list("employee__name", flat=True) - ) + participants = list(mission.get_participants().values_list("employee_id", "employee__name")) + participant_ids = [employee_id for employee_id, _ in participants] + participant_names = [employee_name for _, employee_name in participants] content_type = getattr(mission.content_type, "model", None) return { "mission_id": mission.id, @@ -24,6 +24,7 @@ def _build_mission_payload(mission) -> dict: "is_cancelled": mission.is_cancelled, "creator_id": mission.creator_id, "creator_name": getattr(mission.creator, "name", ""), + "participant_ids": participant_ids, "participant_names": participant_names, "participant_names_display": "、".join(participant_names) if participant_names else "无", "content_type": content_type or "", diff --git a/notifier/backends.py b/notifier/backends.py index 433df7f..77019a0 100644 --- a/notifier/backends.py +++ b/notifier/backends.py @@ -2,6 +2,7 @@ import logging import json from api_v1.utils.wecom_webhook import send_wecom_webhook_message +from notifier.jpush import send_jpush_payload from notifier.models import NotifierChannelEnum from notifier.message_api import send_message_api_news_to_agents, send_message_api_text_message from notifier.serializers import ( @@ -132,3 +133,144 @@ class MessageAPINotifierBackend(BaseNotifierBackend): } logger.info("[notifier.backends] message api news sent: notifier_id=%s result=%s", notifier.id, result) return result + + +class JPushNotifierBackend(BaseNotifierBackend): + channel = NotifierChannelEnum.JPUSH + + def _load_rendered_payload(self, *, content: str) -> dict: + content = (content or "").strip() + if not content: + raise ValueError("极光推送模板渲染结果不能为空") + try: + payload = json.loads(content) + except json.JSONDecodeError as exc: + raise ValueError(f"极光推送模板渲染结果不是合法 JSON: {exc}") from exc + if not isinstance(payload, dict): + raise ValueError("极光推送模板渲染结果必须是 JSON 对象") + return payload + + def _resolve_aliases(self, *, notifier, rendered_payload: dict, context: dict) -> list[str]: + aliases = rendered_payload.get("aliases") or notifier.get_config_value("aliases") + if aliases: + return self._normalize_aliases(aliases) + + recipient_source = str( + rendered_payload.get("recipient_source") + or notifier.get_config_value("recipient_source", "participants") + or "participants" + ) + employee_ids = [] + if recipient_source == "participants": + employee_ids = context.get("participant_ids") or [] + elif recipient_source == "creator": + employee_ids = [context.get("creator_id")] + elif recipient_source == "participants_and_creator": + employee_ids = [*(context.get("participant_ids") or []), context.get("creator_id")] + else: + raise ValueError(f"不支持的极光接收人来源: {recipient_source}") + + alias_prefix = str(notifier.get_config_value("alias_prefix", "emp_") or "emp_") + resolved = [] + for employee_id in employee_ids: + if employee_id is None or employee_id == "": + continue + resolved.append(f"{alias_prefix}{int(employee_id)}") + return self._normalize_aliases(resolved) + + def _normalize_aliases(self, values) -> list[str]: + aliases = [] + for value in values or []: + alias = str(value or "").strip() + if not alias: + raise ValueError("极光 aliases 中不能包含空值") + if len(alias.encode("utf-8")) > 40: + raise ValueError(f"极光 alias 超过 40 字节限制: {alias}") + aliases.append(alias) + aliases = list(dict.fromkeys(aliases)) + if not aliases: + raise ValueError("极光推送接收 alias 不能为空") + if len(aliases) > 1000: + raise ValueError("极光单次推送 alias 不能超过 1000 个") + return aliases + + def _build_payload(self, *, notifier, rendered_payload: dict, context: dict) -> dict: + aliases = self._resolve_aliases( + notifier=notifier, + rendered_payload=rendered_payload, + context=context, + ) + platform = rendered_payload.get("platform") or notifier.get_config_value("platform", "all") + title = str(rendered_payload.get("title") or notifier.get_config_value("title", "任务提醒")).strip() + alert = str(rendered_payload.get("alert") or rendered_payload.get("content") or "").strip() + if not alert: + raise ValueError("极光推送 alert 不能为空") + + extras = rendered_payload.get("extras") or {} + if not isinstance(extras, dict): + raise ValueError("极光推送 extras 必须是 JSON 对象") + + notification = { + "alert": alert, + "android": { + "alert": alert, + "title": title, + "extras": extras, + }, + "ios": { + "alert": alert, + "sound": rendered_payload.get("ios_sound") or notifier.get_config_value("ios_sound", "default"), + "extras": extras, + }, + } + android_channel_id = rendered_payload.get("android_channel_id") or notifier.get_config_value("android_channel_id") + if android_channel_id: + notification["android"]["channel_id"] = str(android_channel_id) + android_intent = rendered_payload.get("android_intent") or notifier.get_config_value("android_intent") + if android_intent: + notification["android"]["intent"] = {"url": str(android_intent)} + android_category = rendered_payload.get("android_category") or notifier.get_config_value("android_category") + if android_category: + notification["android"]["category"] = str(android_category) + + payload = { + "platform": platform, + "audience": {"alias": aliases}, + "notification": notification, + "options": { + "apns_production": bool(notifier.get_config_value("apns_production", False)), + }, + } + time_to_live = rendered_payload.get("time_to_live", notifier.get_config_value("time_to_live")) + if time_to_live is not None: + payload["options"]["time_to_live"] = int(time_to_live) + + message = rendered_payload.get("message") + if message: + if not isinstance(message, dict): + raise ValueError("极光推送 message 必须是 JSON 对象") + payload["message"] = message + return payload + + def notify(self, *, notifier, content: str, context: dict) -> dict: + rendered_payload = self._load_rendered_payload(content=content) + payload = self._build_payload( + notifier=notifier, + rendered_payload=rendered_payload, + context=context, + ) + response = send_jpush_payload( + app_key=notifier.get_config_value("app_key"), + master_secret=notifier.get_config_value("master_secret"), + payload=payload, + timeout_seconds=float(notifier.get_config_value("timeout_seconds", 10.0) or 10.0), + ) + result = { + "channel": self.channel, + "aliases": payload["audience"]["alias"], + "msg_id": response.raw.get("msg_id"), + "sendno": response.raw.get("sendno"), + "raw": response.raw, + } + logger.info("[notifier.backends] jpush sent: notifier_id=%s result=%s", notifier.id, result) + return result diff --git a/notifier/jpush.py b/notifier/jpush.py new file mode 100644 index 0000000..9c460f9 --- /dev/null +++ b/notifier/jpush.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +JPUSH_PUSH_URL = "https://api.jpush.cn/v3/push" + + +@dataclass(frozen=True) +class JPushResponse: + status_code: int + raw: dict + + @property + def ok(self) -> bool: + return 200 <= int(self.status_code) < 300 and "error" not in self.raw + + +def _build_basic_auth_header(*, app_key: str, master_secret: str) -> str: + token = f"{app_key}:{master_secret}".encode("utf-8") + return "Basic " + base64.b64encode(token).decode("ascii") + + +def send_jpush_payload( + *, + app_key: str, + master_secret: str, + payload: dict, + timeout_seconds: float = 10.0, +) -> JPushResponse: + app_key = str(app_key or "").strip() + master_secret = str(master_secret or "").strip() + if not app_key: + raise ValueError("JPush app_key 未配置") + if not master_secret: + raise ValueError("JPush master_secret 未配置") + if not isinstance(payload, dict) or not payload: + raise ValueError("JPush payload 不能为空") + + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = Request( + url=JPUSH_PUSH_URL, + data=data, + method="POST", + headers={ + "Authorization": _build_basic_auth_header( + app_key=app_key, + master_secret=master_secret, + ), + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + try: + with urlopen(req, timeout=float(timeout_seconds)) as resp: + body = resp.read().decode("utf-8", errors="replace") + status_code = int(getattr(resp, "status", 200) or 200) + except HTTPError as exc: + body = "" + try: + body = exc.read().decode("utf-8", errors="replace") + except Exception: + pass + try: + raw = json.loads(body) if body else {} + except Exception: + raw = {"error": {"message": body}} + raise RuntimeError(f"JPush HTTPError: status={exc.code}, body={raw}") from exc + except URLError as exc: + raise RuntimeError(f"JPush URLError: {exc}") from exc + + try: + raw = json.loads(body) if body else {} + except Exception as exc: + raise RuntimeError(f"JPush 响应不是合法 JSON: {body}") from exc + + return JPushResponse(status_code=status_code, raw=raw) diff --git a/notifier/migrations/0004_alter_notifier_channel.py b/notifier/migrations/0004_alter_notifier_channel.py new file mode 100644 index 0000000..5952553 --- /dev/null +++ b/notifier/migrations/0004_alter_notifier_channel.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("notifier", "0003_alter_notifier_channel_alter_notifierroute_event_key"), + ] + + operations = [ + migrations.AlterField( + model_name="notifier", + name="channel", + field=models.CharField( + choices=[ + ("wecom_webhook", "企业微信机器人"), + ("message_api", "消息发送 API"), + ("jpush", "极光推送"), + ], + default="wecom_webhook", + max_length=50, + verbose_name="通知渠道", + ), + ), + ] diff --git a/notifier/models.py b/notifier/models.py index 4931ee5..df8eed2 100644 --- a/notifier/models.py +++ b/notifier/models.py @@ -8,6 +8,7 @@ from flower.common import ModelBase class NotifierChannelEnum(models.TextChoices): WECOM_WEBHOOK = "wecom_webhook", "企业微信机器人" MESSAGE_API = "message_api", "消息发送 API" + JPUSH = "jpush", "极光推送" class NotificationEventKeyEnum(models.TextChoices): @@ -41,7 +42,7 @@ class Notifier(ModelBase): description = models.TextField(blank=True, null=True, verbose_name="备注描述") def get_template_name(self) -> str: - suffix = "json" if self.channel == NotifierChannelEnum.MESSAGE_API else "md" + suffix = "json" if self.channel in {NotifierChannelEnum.MESSAGE_API, NotifierChannelEnum.JPUSH} else "md" return f"notifier/events/{self.template_key}.{suffix}" def get_config_value(self, key: str, default=None): diff --git a/notifier/registry.py b/notifier/registry.py index 398a4a2..db63f53 100644 --- a/notifier/registry.py +++ b/notifier/registry.py @@ -1,10 +1,11 @@ -from notifier.backends import MessageAPINotifierBackend, WeComWebhookNotifierBackend +from notifier.backends import JPushNotifierBackend, MessageAPINotifierBackend, WeComWebhookNotifierBackend from notifier.models import NotifierChannelEnum BACKEND_REGISTRY = { NotifierChannelEnum.WECOM_WEBHOOK: WeComWebhookNotifierBackend, NotifierChannelEnum.MESSAGE_API: MessageAPINotifierBackend, + NotifierChannelEnum.JPUSH: JPushNotifierBackend, } diff --git a/notifier/tasks.py b/notifier/tasks.py index e5cf50d..e593d83 100644 --- a/notifier/tasks.py +++ b/notifier/tasks.py @@ -16,9 +16,9 @@ logger = logging.getLogger(__name__) def _build_unreplied_mission_payload(*, mission: mission_models.Mission, notified_at) -> dict[str, Any]: - participant_names = list( - mission.get_participants().values_list("employee__name", flat=True) - ) + participants = list(mission.get_participants().values_list("employee_id", "employee__name")) + participant_ids = [employee_id for employee_id, _ in participants] + participant_names = [employee_name for _, employee_name in participants] content_type = getattr(mission.content_type, "model", None) next_count = mission.unreplied_notify_sent_count + 1 payload = { @@ -32,6 +32,7 @@ def _build_unreplied_mission_payload(*, mission: mission_models.Mission, notifie "is_cancelled": mission.is_cancelled, "creator_id": mission.creator_id, "creator_name": getattr(mission.creator, "name", ""), + "participant_ids": participant_ids, "participant_names": participant_names, "participant_names_display": "、".join(participant_names) if participant_names else "无", "content_type": content_type or "", diff --git a/notifier/templates/notifier/events/mission_created_jpush.json b/notifier/templates/notifier/events/mission_created_jpush.json new file mode 100644 index 0000000..d9b717c --- /dev/null +++ b/notifier/templates/notifier/events/mission_created_jpush.json @@ -0,0 +1,10 @@ +{ + "title": "新任务", + "alert": "你有一个新任务:{{ description|truncatechars:40|escapejs }}", + "extras": { + "event_key": "mission.created", + "mission_id": "{{ mission_id }}", + "merchant_id": "{{ merchant_id }}", + "route": "/missions/{{ mission_id }}" + } +} diff --git a/notifier/tests.py b/notifier/tests.py index ef1f337..ff08f9b 100644 --- a/notifier/tests.py +++ b/notifier/tests.py @@ -555,6 +555,68 @@ class NotifierServiceTestCase(TestCase): notifier=notifier, payload={"mission_id": 12}, ) + + @patch("notifier.backends.send_jpush_payload") + def test_send_notification_with_notifier_uses_jpush_backend(self, mock_send): + mock_send.return_value.raw = {"sendno": "123", "msg_id": "456"} + + notifier = Notifier.objects.create( + merchant=self.merchant, + name="任务创建极光推送", + channel=NotifierChannelEnum.JPUSH, + template_key="mission_created_jpush", + config={ + "app_key": "app-key-1", + "master_secret": "master-secret-1", + "alias_prefix": "emp_", + "recipient_source": "participants", + "platform": "all", + "apns_production": True, + "android_channel_id": "mission", + }, + ) + + result = send_notification_with_notifier( + notifier=notifier, + payload={ + "mission_id": 12, + "merchant_id": self.merchant.id, + "description": "检查打印质量", + "participant_ids": [21, 22, 21], + }, + ) + + self.assertEqual(result["status"], "sent") + self.assertEqual(result["channel"], NotifierChannelEnum.JPUSH) + self.assertEqual(result["aliases"], ["emp_21", "emp_22"]) + mock_send.assert_called_once() + self.assertEqual(mock_send.call_args.kwargs["app_key"], "app-key-1") + self.assertEqual(mock_send.call_args.kwargs["master_secret"], "master-secret-1") + payload = mock_send.call_args.kwargs["payload"] + self.assertEqual(payload["audience"], {"alias": ["emp_21", "emp_22"]}) + self.assertEqual(payload["notification"]["android"]["channel_id"], "mission") + self.assertEqual(payload["notification"]["ios"]["sound"], "default") + self.assertTrue(payload["options"]["apns_production"]) + self.assertEqual(payload["notification"]["android"]["extras"]["mission_id"], "12") + + @patch("notifier.backends.send_jpush_payload") + def test_send_notification_with_notifier_rejects_jpush_without_aliases(self, mock_send): + notifier = Notifier.objects.create( + merchant=self.merchant, + name="无接收人极光推送", + channel=NotifierChannelEnum.JPUSH, + template_key="mission_created_jpush", + config={"app_key": "app-key-1", "master_secret": "master-secret-1"}, + ) + + with self.assertRaisesMessage(ValueError, "alias 不能为空"): + send_notification_with_notifier( + notifier=notifier, + payload={"mission_id": 12, "description": "检查打印质量"}, + ) + mock_send.assert_not_called() + + class MessageAPIServiceTestCase(TestCase): @patch("notifier.message_api.urlopen") def test_send_message_api_text_message_calls_message_api_endpoint(self, mock_urlopen): diff --git a/settlement/services.py b/settlement/services.py index b8ff5e2..d1d4d6d 100644 --- a/settlement/services.py +++ b/settlement/services.py @@ -1,14 +1,27 @@ """日结模块统计服务函数""" import logging -from datetime import datetime, date +from datetime import datetime, date, time, timedelta +from decimal import Decimal, InvalidOperation -from django.db.models import Q, Sum, Case, When, Value, CharField, IntegerField +from django.db.models import Prefetch +from django.db.models import Q, Sum, Case, When, Value, CharField, IntegerField, Count +from django.db.models.fields.json import KeyTextTransform from django.db.models.functions import Concat +from django.utils.dateparse import parse_datetime +from django.utils import timezone from printing.models import PlateOrder +from stateflow.models import StateFlowRecord, StateLogParameterRecord logger = logging.getLogger(__name__) +DEFAULT_DESIGNER_SUMMARY_STATE_NAMES = ("画图完成",) +DEFAULT_DESIGNER_TASK_STATE_NAME_SUFFIX = "完成" +DEFAULT_DESIGNER_PARAM_KEY = "设计师名称" +DEFAULT_DESIGNER_TASK_QUANTITY_PARAM_KEY = "完成数量" +DEFAULT_DESIGNER_TASK_TIME_PARAM_KEY = "完成时间" +DEFAULT_EMPTY_TASK_QUANTITY = Decimal("1") + def get_plate_order_summary_by_customer( merchant_id: int, @@ -43,6 +56,427 @@ def get_plate_order_summary_by_customer( return _format_plate_order_summary(aggregated_data) +def get_plate_order_summary_by_designer( + merchant_id: int, + settlement_date: date, + user=None, + state_names: list[str] | tuple[str, ...] | None = None, + designer_param_key: str = DEFAULT_DESIGNER_PARAM_KEY, +) -> list[dict]: + """ + 获取按设计师分组的开版订单统计。 + + 设计师来源于 stateflow 参数记录,默认只统计“画图完成”节点下的 + ``parameters["设计师名称"]``。同一设计师在同一开版单同一类型下出现多次只计 1 单。 + """ + logger.info( + f'[settlement.services] 获取开版订单设计师统计: ' + f'merchant_id={merchant_id}, date={settlement_date}, states={state_names}' + ) + + settlement_date = _validate_and_normalize_summary_args( + merchant_id=merchant_id, + settlement_date=settlement_date, + ) + normalized_state_names = _normalize_designer_summary_state_names(state_names) + if not designer_param_key or not str(designer_param_key).strip(): + raise ValueError('designer_param_key 不能为空') + + month_start_dt, next_day_dt, today_start_dt = _get_summary_datetime_ranges( + settlement_date + ) + queryset = _get_designer_plate_order_queryset( + merchant_id=merchant_id, + user=user, + state_names=normalized_state_names, + designer_param_key=designer_param_key, + month_start_dt=month_start_dt, + next_day_dt=next_day_dt, + ) + aggregated_data = _aggregate_plate_orders_by_designer_and_type( + queryset=queryset, + today_start_dt=today_start_dt, + next_day_dt=next_day_dt, + ) + return _format_plate_order_designer_summary(aggregated_data) + + +def get_designer_workflow_task_summary( + merchant_id: int, + settlement_date: date, + user=None, + state_names: list[str] | tuple[str, ...] | None = None, + designer_names: list[str] | tuple[str, ...] | None = None, + designer_param_key: str = DEFAULT_DESIGNER_PARAM_KEY, + quantity_param_key: str = DEFAULT_DESIGNER_TASK_QUANTITY_PARAM_KEY, + time_param_key: str = DEFAULT_DESIGNER_TASK_TIME_PARAM_KEY, +) -> list[dict]: + """ + 获取设计师工序任务量统计。 + + 统计单位是未撤销的 StateFlowRecord。每条状态流转记录最多计入一次, + 即使其下存在多条 StateLogParameterRecord。 + """ + logger.info( + f'[settlement.services] 获取设计师工序任务量统计: ' + f'merchant_id={merchant_id}, date={settlement_date}, states={state_names}' + ) + + settlement_date = _validate_and_normalize_summary_args( + merchant_id=merchant_id, + settlement_date=settlement_date, + ) + normalized_state_names = _normalize_optional_state_names(state_names) + normalized_designer_names = _normalize_optional_names(designer_names) + for key, label in [ + (designer_param_key, 'designer_param_key'), + (quantity_param_key, 'quantity_param_key'), + (time_param_key, 'time_param_key'), + ]: + if not key or not str(key).strip(): + raise ValueError(f'{label} 不能为空') + + month_start_dt, next_day_dt, today_start_dt = _get_summary_datetime_ranges( + settlement_date + ) + queryset = _get_designer_workflow_task_queryset( + merchant_id=merchant_id, + user=user, + state_names=normalized_state_names, + designer_param_key=designer_param_key, + ) + summary = _aggregate_designer_workflow_tasks( + queryset=queryset, + month_start_dt=month_start_dt, + today_start_dt=today_start_dt, + next_day_dt=next_day_dt, + designer_names=normalized_designer_names, + designer_param_key=designer_param_key, + quantity_param_key=quantity_param_key, + time_param_key=time_param_key, + ) + return _format_designer_workflow_task_summary(summary) + + +def _normalize_optional_names( + names: list[str] | tuple[str, ...] | None, +) -> tuple[str, ...] | None: + if names is None: + return None + normalized = tuple(name.strip() for name in names if isinstance(name, str) and name.strip()) + if not normalized: + raise ValueError('designer_names 不能为空') + return normalized + + +def _normalize_optional_state_names( + state_names: list[str] | tuple[str, ...] | None, +) -> tuple[str, ...] | None: + if state_names is None: + return None + normalized = tuple( + name.strip() for name in state_names if isinstance(name, str) and name.strip() + ) + if not normalized: + raise ValueError('state_names 不能为空') + return normalized + + +def _normalize_designer_summary_state_names( + state_names: list[str] | tuple[str, ...] | None, +) -> tuple[str, ...]: + """标准化设计师统计节点名。""" + if state_names is None: + return DEFAULT_DESIGNER_SUMMARY_STATE_NAMES + + normalized = tuple( + name.strip() for name in state_names if isinstance(name, str) and name.strip() + ) + if not normalized: + raise ValueError('state_names 不能为空') + return normalized + + +def _get_summary_datetime_ranges(settlement_date: date): + """返回月初、次日、当天起始的当前时区 aware datetime。""" + current_tz = timezone.get_current_timezone() + month_start = settlement_date.replace(day=1) + month_start_dt = timezone.make_aware( + datetime.combine(month_start, time.min), current_tz + ) + today_start_dt = timezone.make_aware( + datetime.combine(settlement_date, time.min), current_tz + ) + next_day_dt = timezone.make_aware( + datetime.combine(settlement_date + timedelta(days=1), time.min), current_tz + ) + return month_start_dt, next_day_dt, today_start_dt + + +def _get_designer_workflow_task_queryset( + *, + merchant_id: int, + user=None, + state_names: tuple[str, ...] | None, + designer_param_key: str, +): + queryset = StateFlowRecord.objects.filter( + is_cancelled=False, + business_object__plate_order__merchant_id=merchant_id, + business_object__plate_order__plate_type__isnull=False, + business_object__plate_order__production_method__isnull=False, + parameter_records__parameters__has_key=designer_param_key, + ).select_related( + 'state', + 'business_object__plate_order', + 'business_object__plate_order__customer', + ).prefetch_related( + Prefetch( + 'parameter_records', + queryset=StateLogParameterRecord.objects.order_by('created_at', 'id'), + to_attr='_prefetched_parameter_records', + ) + ).distinct() + + if state_names is None: + queryset = queryset.filter(state__name__endswith=DEFAULT_DESIGNER_TASK_STATE_NAME_SUFFIX) + else: + queryset = queryset.filter(state__name__in=state_names) + + if user and not user.is_superuser: + emp = getattr(user, 'employee', None) + if emp: + queryset = queryset.filter( + Q(business_object__plate_order__customer__created_by=emp) | + Q(business_object__plate_order__customer__visible_employees=emp) + ).distinct() + + return queryset.order_by('id') + + +def _aggregate_designer_workflow_tasks( + *, + queryset, + month_start_dt: datetime, + today_start_dt: datetime, + next_day_dt: datetime, + designer_names: tuple[str, ...] | None, + designer_param_key: str, + quantity_param_key: str, + time_param_key: str, +) -> dict[tuple[str, str, str], dict[str, Decimal]]: + summary: dict[tuple[str, str, str], dict[str, Decimal]] = {} + designer_name_filter = set(designer_names) if designer_names is not None else None + + for state_log in queryset: + parameters = _merge_state_log_parameters(state_log) + designer_name = str(parameters.get(designer_param_key) or '').strip() + if not designer_name: + continue + if designer_name_filter is not None and designer_name not in designer_name_filter: + continue + + effective_time = _resolve_task_effective_time( + parameters.get(time_param_key), + fallback=state_log.completed_at, + ) + if effective_time is None or effective_time < month_start_dt or effective_time >= next_day_dt: + continue + + plate_order = state_log.business_object.plate_order + task_quantity = _parse_task_quantity(parameters.get(quantity_param_key)) + key = ( + designer_name, + state_log.state.name, + f'{plate_order.plate_type}-{plate_order.production_method}', + ) + if key not in summary: + summary[key] = {'today': Decimal('0'), 'current_month': Decimal('0')} + + summary[key]['current_month'] += task_quantity + if today_start_dt <= effective_time < next_day_dt: + summary[key]['today'] += task_quantity + + return summary + + +def _merge_state_log_parameters(state_log: StateFlowRecord) -> dict: + records = getattr(state_log, '_prefetched_parameter_records', None) + if records is None: + records = state_log.parameter_records.all().order_by('created_at', 'id') + + merged = {} + for record in records: + if isinstance(record.parameters, dict): + merged.update(record.parameters) + return merged + + +def _parse_task_quantity(value) -> Decimal: + try: + quantity = Decimal(str(value).strip()) + except (InvalidOperation, ValueError, TypeError, AttributeError): + return DEFAULT_EMPTY_TASK_QUANTITY + if quantity > 0: + return quantity + return DEFAULT_EMPTY_TASK_QUANTITY + + +def _resolve_task_effective_time(value, *, fallback): + parsed = None + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + raw_value = value.strip() + parsed = parse_datetime(raw_value) + if parsed is None: + for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d'): + try: + parsed = datetime.strptime(raw_value, fmt) + break + except ValueError: + continue + + effective = parsed or fallback + if effective is None: + return None + if timezone.is_naive(effective): + effective = timezone.make_aware(effective, timezone.get_current_timezone()) + return effective.astimezone(timezone.get_current_timezone()) + + +def _format_decimal_quantity(value: Decimal): + if value == value.to_integral_value(): + return int(value) + return str(value.normalize()) + + +def _format_designer_workflow_task_summary( + summary: dict[tuple[str, str, str], dict[str, Decimal]], +) -> list[dict]: + result: dict[str, dict] = {} + for (designer_name, state_name, type_name), counts in sorted(summary.items()): + if designer_name not in result: + result[designer_name] = { + 'designer_name': designer_name, + 'task_count': [], + 'today': Decimal('0'), + 'current_month': Decimal('0'), + } + result[designer_name]['task_count'].append({ + 'state_name': state_name, + 'type': type_name, + 'today': _format_decimal_quantity(counts['today']), + 'current_month': _format_decimal_quantity(counts['current_month']), + }) + result[designer_name]['today'] += counts['today'] + result[designer_name]['current_month'] += counts['current_month'] + + formatted = [] + for item in result.values(): + item['today'] = _format_decimal_quantity(item['today']) + item['current_month'] = _format_decimal_quantity(item['current_month']) + formatted.append(item) + return formatted + + +def _get_designer_plate_order_queryset( + *, + merchant_id: int, + user=None, + state_names: tuple[str, ...], + designer_param_key: str, + month_start_dt: datetime, + next_day_dt: datetime, +): + """获取设计师统计的 stateflow 参数记录基础查询集。""" + queryset = StateLogParameterRecord.objects.filter( + parameters__has_key=designer_param_key, + state_log__is_cancelled=False, + state_log__state__name__in=state_names, + state_log__business_object__plate_order__merchant_id=merchant_id, + state_log__business_object__plate_order__plate_type__isnull=False, + state_log__business_object__plate_order__production_method__isnull=False, + state_log__business_object__plate_order__plate_date__gte=month_start_dt, + state_log__business_object__plate_order__plate_date__lt=next_day_dt, + ).annotate( + designer_name=KeyTextTransform(designer_param_key, 'parameters'), + type=Concat( + 'state_log__business_object__plate_order__plate_type', + Value('-'), + 'state_log__business_object__plate_order__production_method', + output_field=CharField(), + ), + ).exclude( + designer_name__isnull=True, + ).exclude( + designer_name='', + ) + + if user and not user.is_superuser: + emp = getattr(user, 'employee', None) + if emp: + queryset = queryset.filter( + Q(state_log__business_object__plate_order__customer__created_by=emp) | + Q(state_log__business_object__plate_order__customer__visible_employees=emp) + ).distinct() + + return queryset + + +def _aggregate_plate_orders_by_designer_and_type( + *, + queryset, + today_start_dt: datetime, + next_day_dt: datetime, +): + """按设计师和类型聚合开版订单数量。""" + plate_order_path = 'state_log__business_object__plate_order' + return queryset.values( + 'designer_name', + 'type', + ).annotate( + today=Count( + plate_order_path, + filter=Q( + state_log__business_object__plate_order__plate_date__gte=today_start_dt, + state_log__business_object__plate_order__plate_date__lt=next_day_dt, + ), + distinct=True, + ), + current_month=Count( + plate_order_path, + distinct=True, + ), + ).order_by('designer_name', 'type') + + +def _format_plate_order_designer_summary(aggregated_data): + """格式化设计师聚合结果为 API 返回格式。""" + result = {} + + for item in aggregated_data: + designer_name = item['designer_name'] + if designer_name not in result: + result[designer_name] = { + 'designer_name': designer_name, + 'plate_order_count': [], + } + + result[designer_name]['plate_order_count'].append({ + 'type': item['type'], + 'today': item['today'], + 'current_month': item['current_month'], + }) + + for designer_name in result: + result[designer_name]['plate_order_count'] = _filter_zero_data( + result[designer_name]['plate_order_count'] + ) + + return [v for v in result.values() if v['plate_order_count']] + + def _validate_and_normalize_summary_args( merchant_id: int, settlement_date: date, diff --git a/settlement/test_services.py b/settlement/test_services.py index eac1b79..ef19bd6 100644 --- a/settlement/test_services.py +++ b/settlement/test_services.py @@ -8,6 +8,7 @@ from django.utils import timezone from basic_info import models as basic_models from printing import models as printing_models from settlement import services +from stateflow import models as stateflow_models User = get_user_model() @@ -508,3 +509,308 @@ class GetPlateOrderSummaryByCustomerTestCase(SettlementServiceTestCase): settlement_date='2026-02-08', user=self.user ) + + +class GetPlateOrderSummaryByDesignerTestCase(SettlementServiceTestCase): + def setUp(self): + super().setUp() + self.process = stateflow_models.Process.objects.create(name='开版流程') + self.drawing_done = stateflow_models.State.objects.create(name='画图完成') + self.color_done = stateflow_models.State.objects.create(name='调色完成') + + def _create_plate_order(self, *, customer=None, plate_date=None, plate_type='首版', production_method='定位'): + plate_order = printing_models.PlateOrder.objects.create( + merchant=self.merchant, + customer=customer or self.customer1, + plate_type=plate_type, + production_method=production_method, + plate_date=timezone.make_aware(datetime.combine(plate_date or self.settlement_date, datetime.min.time())), + ) + business_object = stateflow_models.BusinessObject.objects.create( + name=f'PlateOrder-{plate_order.id}', + process=self.process, + description='', + ) + plate_order.business_object = business_object + plate_order.save(update_fields=['business_object']) + return plate_order + + def _add_designer_param(self, plate_order, *, designer_name, state=None, cancelled=False): + state_log = stateflow_models.StateFlowRecord.objects.create( + business_object=plate_order.business_object, + state=state or self.drawing_done, + completed_by=self.user, + is_cancelled=cancelled, + ) + return stateflow_models.StateLogParameterRecord.objects.create( + state_log=state_log, + parameters={'设计师名称': designer_name}, + ) + + def test_returns_summary_for_drawing_done_designer(self): + plate_order = self._create_plate_order() + self._add_designer_param(plate_order, designer_name='设计师A') + + result = services.get_plate_order_summary_by_designer( + self.merchant.id, + self.settlement_date, + self.user, + ) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]['designer_name'], '设计师A') + self.assertEqual(result[0]['plate_order_count'][0]['type'], '首版-定位') + self.assertEqual(result[0]['plate_order_count'][0]['today'], 1) + self.assertEqual(result[0]['plate_order_count'][0]['current_month'], 1) + + def test_defaults_to_drawing_done_only(self): + plate_order = self._create_plate_order() + self._add_designer_param(plate_order, designer_name='设计师A', state=self.color_done) + + result = services.get_plate_order_summary_by_designer( + self.merchant.id, + self.settlement_date, + self.user, + ) + + self.assertEqual(result, []) + + +class GetDesignerWorkflowTaskSummaryTestCase(SettlementServiceTestCase): + def setUp(self): + super().setUp() + self.process = stateflow_models.Process.objects.create(name='开版流程') + self.drawing_done = stateflow_models.State.objects.create(name='画图完成') + self.color_done = stateflow_models.State.objects.create(name='调色完成') + self.drawing_in_progress = stateflow_models.State.objects.create(name='画图中') + + def _create_plate_order(self, *, customer=None, plate_type='首版', production_method='定位'): + plate_order = printing_models.PlateOrder.objects.create( + merchant=self.merchant, + customer=customer or self.customer1, + plate_type=plate_type, + production_method=production_method, + plate_date=timezone.make_aware(datetime.combine(self.settlement_date, datetime.min.time())), + ) + business_object = stateflow_models.BusinessObject.objects.create( + name=f'PlateOrder-{plate_order.id}', + process=self.process, + description='', + ) + plate_order.business_object = business_object + plate_order.save(update_fields=['business_object']) + return plate_order + + def _create_state_log(self, plate_order, *, state=None, cancelled=False): + return stateflow_models.StateFlowRecord.objects.create( + business_object=plate_order.business_object, + state=state or self.drawing_done, + completed_by=self.user, + is_cancelled=cancelled, + ) + + def _add_params(self, state_log, params): + return stateflow_models.StateLogParameterRecord.objects.create( + state_log=state_log, + parameters=params, + ) + + def _add_designer_param(self, plate_order, *, designer_name, state=None, cancelled=False): + state_log = self._create_state_log( + plate_order, + state=state or self.drawing_done, + cancelled=cancelled, + ) + return self._add_params(state_log, { + '设计师名称': designer_name, + '完成时间': '2026-02-08 10:00:00', + }) + + def test_sums_task_quantity_by_state_and_type(self): + first = self._create_plate_order() + second = self._create_plate_order() + first_log = self._create_state_log(first, state=self.drawing_done) + second_log = self._create_state_log(second, state=self.color_done) + self._add_params(first_log, { + '设计师名称': '左威', + '完成数量': '3', + '完成时间': '2026-02-08 10:00:00', + }) + self._add_params(second_log, { + '设计师名称': '左威', + '完成数量': '2', + '完成时间': '2026-02-08 11:00:00', + }) + + result = services.get_designer_workflow_task_summary( + self.merchant.id, + self.settlement_date, + self.user, + state_names=['画图完成', '调色完成'], + ) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]['designer_name'], '左威') + self.assertEqual(result[0]['today'], 5) + self.assertEqual(result[0]['current_month'], 5) + by_state = {item['state_name']: item for item in result[0]['task_count']} + self.assertEqual(by_state['画图完成']['today'], 3) + self.assertEqual(by_state['调色完成']['today'], 2) + + def test_default_state_filter_includes_only_done_suffix(self): + for state, quantity in [ + (self.drawing_done, '3'), + (self.color_done, '2'), + (self.drawing_in_progress, '10'), + ]: + plate_order = self._create_plate_order() + state_log = self._create_state_log(plate_order, state=state) + self._add_params(state_log, { + '设计师名称': '左威', + '完成数量': quantity, + '完成时间': '2026-02-08 10:00:00', + }) + + result = services.get_designer_workflow_task_summary( + self.merchant.id, + self.settlement_date, + self.user, + ) + + self.assertEqual(result[0]['today'], 5) + states = {item['state_name'] for item in result[0]['task_count']} + self.assertEqual(states, {'画图完成', '调色完成'}) + + def test_invalid_quantity_defaults_to_one_and_does_not_dedupe_plate_order(self): + plate_order = self._create_plate_order() + values = [None, '', '0', '-2', 'bad'] + for index, value in enumerate(values): + state_log = self._create_state_log(plate_order) + self._add_params(state_log, { + '设计师名称': '左威', + '完成数量': value, + '完成时间': f'2026-02-08 10:0{index}:00', + }) + + result = services.get_designer_workflow_task_summary( + self.merchant.id, + self.settlement_date, + self.user, + ) + + self.assertEqual(result[0]['today'], 5) + self.assertEqual(result[0]['current_month'], 5) + + def test_multiple_parameter_records_for_one_state_log_count_once(self): + plate_order = self._create_plate_order() + state_log = self._create_state_log(plate_order) + self._add_params(state_log, {'设计师名称': '左威'}) + self._add_params(state_log, { + '完成数量': '4', + '完成时间': '2026-02-08 10:00:00', + }) + + result = services.get_designer_workflow_task_summary( + self.merchant.id, + self.settlement_date, + self.user, + ) + + self.assertEqual(result[0]['today'], 4) + self.assertEqual(result[0]['current_month'], 4) + + def test_uses_completed_at_when_time_param_missing(self): + plate_order = self._create_plate_order() + state_log = self._create_state_log(plate_order) + completed_at = timezone.make_aware(datetime(2026, 2, 8, 12, 0, 0)) + stateflow_models.StateFlowRecord.objects.filter(id=state_log.id).update(completed_at=completed_at) + state_log.refresh_from_db() + self._add_params(state_log, { + '设计师名称': '左威', + '完成数量': '2', + }) + + result = services.get_designer_workflow_task_summary( + self.merchant.id, + self.settlement_date, + self.user, + ) + + self.assertEqual(result[0]['today'], 2) + + def test_filters_cancelled_and_designer_names(self): + first = self._create_plate_order() + second = self._create_plate_order() + cancelled_log = self._create_state_log(first, cancelled=True) + kept_log = self._create_state_log(second) + self._add_params(cancelled_log, { + '设计师名称': '左威', + '完成数量': '10', + '完成时间': '2026-02-08 10:00:00', + }) + self._add_params(kept_log, { + '设计师名称': '王五', + '完成数量': '3', + '完成时间': '2026-02-08 10:00:00', + }) + + result = services.get_designer_workflow_task_summary( + self.merchant.id, + self.settlement_date, + self.user, + designer_names=['王五'], + ) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]['designer_name'], '王五') + self.assertEqual(result[0]['today'], 3) + + def test_accepts_custom_state_names(self): + plate_order = self._create_plate_order() + self._add_designer_param(plate_order, designer_name='设计师A', state=self.color_done) + + result = services.get_plate_order_summary_by_designer( + self.merchant.id, + self.settlement_date, + self.user, + state_names=['画图完成', '调色完成'], + ) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]['designer_name'], '设计师A') + + def test_distincts_same_order_same_designer(self): + plate_order = self._create_plate_order() + self._add_designer_param(plate_order, designer_name='设计师A') + self._add_designer_param(plate_order, designer_name='设计师A') + + result = services.get_plate_order_summary_by_designer( + self.merchant.id, + self.settlement_date, + self.user, + ) + + self.assertEqual(result[0]['plate_order_count'][0]['today'], 1) + self.assertEqual(result[0]['plate_order_count'][0]['current_month'], 1) + + def test_applies_customer_visibility(self): + other_employee = basic_models.Employee.objects.create( + sys_user=User.objects.create_user('designer-other', 'pass123'), + merchant=self.merchant, + name='其他员工' + ) + invisible_customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='不可见客户', + created_by=other_employee, + ) + plate_order = self._create_plate_order(customer=invisible_customer) + self._add_designer_param(plate_order, designer_name='设计师A') + + result = services.get_plate_order_summary_by_designer( + self.merchant.id, + self.settlement_date, + self.user, + ) + + self.assertEqual(result, [])