From d7b388e935815da1bfb10dea50a581c6674a82fd Mon Sep 17 00:00:00 2001 From: colaftc Date: Fri, 1 May 2026 22:43:22 +0800 Subject: [PATCH] feat: prod clean --- .dockerignore | 22 ++ .gitignore | 1 + Dockerfile.prod | 20 ++ api_v2/test_mission_api.py | 195 ++++++++++++++++ api_v2/test_shipment_delivery_photo_api.py | 181 +++++++++++++++ api_v2/urls.py | 6 + api_v2/views/__init__.py | 5 + api_v2/views/mission.py | 208 +++++++++++++++++- api_v2/views/shipment_delivery_photo.py | 144 ++++++++++++ docker-compose.prod.yml | 151 +++---------- docs/api_v2_mission_api.md | 153 +++++++++++++ docs/api_v2_shipment_delivery_photo_api.md | 103 +++++++++ flower/settings.py | 9 +- .../0006_mission_and_reply_extra.py | 21 ++ mission/models.py | 2 + mission/services.py | 8 + requirements.prod.txt | 20 ++ .../migrations/0024_shipmentdeliveryphoto.py | 36 +++ shipment/models.py | 47 ++++ 19 files changed, 1198 insertions(+), 134 deletions(-) create mode 100644 Dockerfile.prod create mode 100644 api_v2/test_shipment_delivery_photo_api.py create mode 100644 api_v2/views/shipment_delivery_photo.py create mode 100644 docs/api_v2_shipment_delivery_photo_api.md create mode 100644 mission/migrations/0006_mission_and_reply_extra.py create mode 100644 requirements.prod.txt create mode 100644 shipment/migrations/0024_shipmentdeliveryphoto.py diff --git a/.dockerignore b/.dockerignore index e6ed237..4fe1dff 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,8 @@ .idea .vscode .cursor +.codex +.skills __pycache__/ *.py[cod] @@ -11,13 +13,33 @@ __pycache__/ .ruff_cache .coverage htmlcov/ +.venv/ +.uv-env/ + +# 编辑器/本地工具产物 +.tmp_docx/ + +# 文档与本地报告 +docs/ +*.md +!env.example # 本地环境变量(不要打进发布镜像) .env +.env.* +!.env.example # 本地备份文件(体积大且可能包含敏感数据) data-bak/ *.sql +*.tar +*.tar.gz + +# 运行日志与临时文件 +*.log +celerybeat-schedule +celerybeat-schedule-shm +celerybeat-schedule-wal # Celery Beat 本地持久化调度文件(不要进镜像,也不要污染构建上下文) celerybeat-schedule* diff --git a/.gitignore b/.gitignore index 4872903..217694a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ wheels/ # Virtual environments .venv data-bak/ +.env.slave diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..757dc26 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,20 @@ +FROM python:3.14-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_ROOT_USER_ACTION=ignore + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.prod.txt /app/ + +RUN pip install --upgrade pip \ + && pip install -r requirements.prod.txt + +COPY . /app + +CMD ["python", "-m", "uvicorn", "flower.asgi:application", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/api_v2/test_mission_api.py b/api_v2/test_mission_api.py index 3d75c9a..0ba9602 100644 --- a/api_v2/test_mission_api.py +++ b/api_v2/test_mission_api.py @@ -1,10 +1,13 @@ +from django.core.cache import cache from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from rest_framework.test import APIClient from basic_info import models as basic_models from mission import models as mission_models +from printing import models as printing_models def grant_permission(user, codename): @@ -14,6 +17,7 @@ def grant_permission(user, codename): class MissionV2APITest(TestCase): def setUp(self): + cache.clear() self.client = APIClient() self.merchant = basic_models.Merchant.objects.create( name="任务商户", @@ -49,6 +53,31 @@ class MissionV2APITest(TestCase): merchant=self.other_merchant, name="其他员工", ) + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name="任务客户", + mobile="13800138000", + ) + self.other_customer = basic_models.Customer.objects.create( + merchant=self.other_merchant, + name="其他客户", + mobile="13800138001", + ) + self.printing_order = printing_models.PrintingOrder.objects.create( + merchant=self.merchant, + customer=self.customer, + fabric="测试面料", + width="150cm", + created_by=self.user, + ) + self.other_printing_order = printing_models.PrintingOrder.objects.create( + merchant=self.other_merchant, + customer=self.other_customer, + fabric="其他面料", + width="160cm", + created_by=self.user, + ) + self.printing_order_content_type = ContentType.objects.get_for_model(printing_models.PrintingOrder) self.client.force_authenticate(user=self.user) def test_create_mission_uses_default_status_and_current_employee(self): @@ -56,6 +85,7 @@ class MissionV2APITest(TestCase): "/api/v2/missions/", { "description": "跟进客户问题", + "extra": {"source": "api-test", "priority": 1}, "participant_ids": [self.participant.id], }, format="json", @@ -71,8 +101,10 @@ class MissionV2APITest(TestCase): self.assertFalse(mission.is_cancelled) self.assertFalse(mission.notify_if_unreplied) self.assertEqual(mission.unreplied_notify_max_count, 5) + self.assertEqual(mission.extra, {"source": "api-test", "priority": 1}) self.assertEqual(resp.data["category"], self.default_category.id) self.assertEqual(resp.data["category_name"], "通用") + self.assertEqual(resp.data["extra"], {"source": "api-test", "priority": 1}) self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.participant.id]) def test_create_mission_supports_unreplied_notification_fields(self): @@ -165,6 +197,7 @@ class MissionV2APITest(TestCase): { "description": "更新后的任务", "category": self.followup_category.id, + "extra": {"channel": "wechat"}, "participant_ids": [self.participant.id], }, format="json", @@ -174,10 +207,26 @@ class MissionV2APITest(TestCase): mission.refresh_from_db() self.assertEqual(mission.description, "更新后的任务") self.assertEqual(mission.category, self.followup_category) + self.assertEqual(mission.extra, {"channel": "wechat"}) self.assertEqual(resp.data["category"], self.followup_category.id) self.assertEqual(resp.data["category_name"], "跟进") + self.assertEqual(resp.data["extra"], {"channel": "wechat"}) self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.participant.id]) + def test_create_mission_reply_supports_extra(self): + mission = self._create_mission() + + resp = self.client.post( + f"/api/v2/missions/{mission.id}/replies/", + {"content": "已记录", "extra": {"attachment_ids": [1, 2]}}, + format="json", + ) + + self.assertEqual(resp.status_code, 201) + reply = mission_models.MissionReply.objects.get(id=resp.data["id"]) + self.assertEqual(reply.extra, {"attachment_ids": [1, 2]}) + self.assertEqual(resp.data["extra"], {"attachment_ids": [1, 2]}) + def test_patch_mission_updates_unreplied_notification_fields(self): mission = self._create_mission() @@ -211,6 +260,152 @@ class MissionV2APITest(TestCase): self.assertEqual(resp.status_code, 200) self.assertEqual([item["id"] for item in resp.data], [visible.id]) + def test_list_missions_by_printing_order_returns_nested_replies_and_extra(self): + mission = mission_models.Mission.objects.create( + merchant=self.merchant, + category=self.default_category, + creator=self.employee, + description="印花订单任务", + content_type=self.printing_order_content_type, + content_id=self.printing_order.id, + extra={"source": "printing-order"}, + ) + mission_models.MissionReply.objects.create( + merchant=self.merchant, + mission=mission, + responder=self.employee, + content="已跟进", + extra={"attachment_ids": [101]}, + ) + mission_models.Mission.objects.create( + merchant=self.merchant, + category=self.followup_category, + creator=self.employee, + description="其他订单任务", + content_type=self.printing_order_content_type, + content_id=self.other_printing_order.id, + ) + + resp = self.client.get(f"/api/v2/missions/by-printing-order/{self.printing_order.id}/") + + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(resp.data), 1) + self.assertEqual(resp.data[0]["id"], mission.id) + self.assertEqual(resp.data[0]["extra"], {"source": "printing-order"}) + self.assertEqual(len(resp.data[0]["replies"]), 1) + self.assertEqual(resp.data[0]["replies"][0]["content"], "已跟进") + self.assertEqual(resp.data[0]["replies"][0]["extra"], {"attachment_ids": [101]}) + + def test_list_missions_by_printing_order_supports_category_filter(self): + mission_models.Mission.objects.create( + merchant=self.merchant, + category=self.default_category, + creator=self.employee, + description="通用任务", + content_type=self.printing_order_content_type, + content_id=self.printing_order.id, + ) + followup_mission = mission_models.Mission.objects.create( + merchant=self.merchant, + category=self.followup_category, + creator=self.employee, + description="跟进任务", + content_type=self.printing_order_content_type, + content_id=self.printing_order.id, + ) + + resp = self.client.get( + f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?category_ids={self.followup_category.id}" + ) + + self.assertEqual(resp.status_code, 200) + self.assertEqual([item["id"] for item in resp.data], [followup_mission.id]) + + def test_list_missions_by_printing_order_supports_lightweight_mode(self): + mission = mission_models.Mission.objects.create( + merchant=self.merchant, + category=self.default_category, + creator=self.employee, + description="轻量任务", + content_type=self.printing_order_content_type, + content_id=self.printing_order.id, + ) + mission_models.MissionParticipant.objects.create( + merchant=self.merchant, + mission=mission, + employee=self.participant, + ) + mission_models.MissionReply.objects.create( + merchant=self.merchant, + mission=mission, + responder=self.employee, + content="已处理", + ends_task=True, + ) + + resp = self.client.get( + f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?include_details=false" + ) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(resp.data), 1) + self.assertEqual(resp.data[0]["id"], mission.id) + self.assertNotIn("participants", resp.data[0]) + self.assertNotIn("has_ending_reply", resp.data[0]) + self.assertNotIn("can_reply", resp.data[0]) + self.assertNotIn("replies", resp.data[0]) + + def test_list_missions_by_printing_order_rejects_invalid_include_details(self): + resp = self.client.get( + f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?include_details=maybe" + ) + + self.assertEqual(resp.status_code, 400) + self.assertIn("include_details", resp.data) + + def test_list_missions_by_printing_order_uses_separate_cache_by_detail_mode(self): + mission = mission_models.Mission.objects.create( + merchant=self.merchant, + category=self.default_category, + creator=self.employee, + description="缓存任务", + content_type=self.printing_order_content_type, + content_id=self.printing_order.id, + ) + mission_models.MissionReply.objects.create( + merchant=self.merchant, + mission=mission, + responder=self.employee, + content="首次回复", + ) + + detailed_resp = self.client.get(f"/api/v2/missions/by-printing-order/{self.printing_order.id}/") + lightweight_resp = self.client.get( + f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?include_details=false" + ) + + mission_models.MissionReply.objects.create( + merchant=self.merchant, + mission=mission, + responder=self.employee, + content="后续回复", + ) + + detailed_cached_resp = self.client.get(f"/api/v2/missions/by-printing-order/{self.printing_order.id}/") + lightweight_cached_resp = self.client.get( + f"/api/v2/missions/by-printing-order/{self.printing_order.id}/?include_details=false" + ) + + self.assertEqual(len(detailed_resp.data[0]["replies"]), 1) + self.assertEqual(len(detailed_cached_resp.data[0]["replies"]), 1) + self.assertNotIn("replies", lightweight_resp.data[0]) + self.assertNotIn("replies", lightweight_cached_resp.data[0]) + + def test_list_missions_by_printing_order_rejects_cross_merchant_order(self): + resp = self.client.get(f"/api/v2/missions/by-printing-order/{self.other_printing_order.id}/") + + self.assertEqual(resp.status_code, 404) + def test_delete_mission_is_not_allowed(self): mission = self._create_mission() diff --git a/api_v2/test_shipment_delivery_photo_api.py b/api_v2/test_shipment_delivery_photo_api.py new file mode 100644 index 0000000..d8e4d69 --- /dev/null +++ b/api_v2/test_shipment_delivery_photo_api.py @@ -0,0 +1,181 @@ +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework.test import APIClient + +from basic_info import models as basic_models +from shipment import models as shipment_models + + +def tiny_gif(name='photo.gif'): + return SimpleUploadedFile( + name, + ( + b'GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!' + b'\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00' + b'\x00\x02\x02D\x01\x00;' + ), + content_type='image/gif', + ) + + +class ShipmentDeliveryPhotoV2APITest(TestCase): + def setUp(self): + self.client = APIClient() + self.merchant = basic_models.Merchant.objects.create( + name='送货照片商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + self.other_merchant = basic_models.Merchant.objects.create( + name='其他送货照片商户', + type=basic_models.MerchantTypeEnum.FACTORY, + ) + self.user = get_user_model().objects.create_user(username='shipment-photo-user', password='pass12345') + self.employee = basic_models.Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='送货照片员工', + ) + self.customer = basic_models.Customer.objects.create( + merchant=self.merchant, + name='照片客户', + mobile='13800138000', + ) + self.other_customer = basic_models.Customer.objects.create( + merchant=self.other_merchant, + name='其他照片客户', + mobile='13800138001', + ) + self.delivery = shipment_models.ShipmentDelivery.objects.create( + merchant=self.merchant, + driver_name='张司机', + vehicle_trip='TRIP-001', + created_by=self.user, + ) + self.other_delivery = shipment_models.ShipmentDelivery.objects.create( + merchant=self.other_merchant, + driver_name='李司机', + vehicle_trip='TRIP-002', + created_by=self.user, + ) + self.shipment = shipment_models.Shipment.objects.create( + merchant=self.merchant, + customer=self.customer, + shipment_date='2026-04-28', + created_by=self.user, + delivery=self.delivery, + ) + self.shipment2 = shipment_models.Shipment.objects.create( + merchant=self.merchant, + customer=self.customer, + shipment_date='2026-04-29', + created_by=self.user, + delivery=self.delivery, + ) + self.other_shipment = shipment_models.Shipment.objects.create( + merchant=self.other_merchant, + customer=self.other_customer, + shipment_date='2026-04-30', + created_by=self.user, + delivery=self.other_delivery, + ) + self.client.force_authenticate(user=self.user) + + def test_create_shipment_delivery_photo_success(self): + resp = self.client.post( + '/api/v2/shipment-delivery-photos/', + { + 'shipment': str(self.shipment.id), + 'delivery': str(self.delivery.id), + 'remark': '已送达', + 'photo': tiny_gif(), + }, + format='multipart', + ) + + self.assertEqual(resp.status_code, 201) + photo = shipment_models.ShipmentDeliveryPhoto.objects.get(id=resp.data['id']) + self.assertEqual(photo.shipment_id, self.shipment.id) + self.assertEqual(photo.delivery_id, self.delivery.id) + self.assertEqual(photo.created_by_id, self.user.id) + self.assertEqual(resp.data['shipment_id'], self.shipment.id) + self.assertEqual(resp.data['delivery_id'], self.delivery.id) + self.assertEqual(resp.data['remark'], '已送达') + self.assertTrue(resp.data['photo']) + + def test_create_shipment_delivery_photo_rejects_delivery_mismatch(self): + other_delivery_same_merchant = shipment_models.ShipmentDelivery.objects.create( + merchant=self.merchant, + driver_name='王司机', + vehicle_trip='TRIP-003', + created_by=self.user, + ) + + resp = self.client.post( + '/api/v2/shipment-delivery-photos/', + { + 'shipment': str(self.shipment.id), + 'delivery': str(other_delivery_same_merchant.id), + 'photo': tiny_gif('mismatch.gif'), + }, + format='multipart', + ) + + self.assertEqual(resp.status_code, 400) + self.assertIn('delivery', resp.data) + + def test_list_shipment_delivery_photos_supports_shipment_and_delivery_filters(self): + photo1 = shipment_models.ShipmentDeliveryPhoto.objects.create( + shipment=self.shipment, + delivery=self.delivery, + remark='第一张', + photo=tiny_gif('one.gif'), + created_by=self.user, + ) + shipment_models.ShipmentDeliveryPhoto.objects.create( + shipment=self.shipment2, + delivery=self.delivery, + remark='第二张', + photo=tiny_gif('two.gif'), + created_by=self.user, + ) + + resp = self.client.get( + f'/api/v2/shipment-delivery-photos/?shipment_id={self.shipment.id}&delivery_id={self.delivery.id}' + ) + + self.assertEqual(resp.status_code, 200) + self.assertEqual([item['id'] for item in resp.data], [photo1.id]) + + def test_patch_shipment_delivery_photo_updates_remark(self): + photo = shipment_models.ShipmentDeliveryPhoto.objects.create( + shipment=self.shipment, + delivery=self.delivery, + remark='初始备注', + photo=tiny_gif('patch.gif'), + created_by=self.user, + ) + + resp = self.client.patch( + f'/api/v2/shipment-delivery-photos/{photo.id}/', + {'remark': '更新备注'}, + format='multipart', + ) + + self.assertEqual(resp.status_code, 200) + photo.refresh_from_db() + self.assertEqual(photo.remark, '更新备注') + + def test_delete_shipment_delivery_photo_success(self): + photo = shipment_models.ShipmentDeliveryPhoto.objects.create( + shipment=self.shipment, + delivery=self.delivery, + remark='待删除', + photo=tiny_gif('delete.gif'), + created_by=self.user, + ) + + resp = self.client.delete(f'/api/v2/shipment-delivery-photos/{photo.id}/') + + self.assertEqual(resp.status_code, 204) + self.assertFalse(shipment_models.ShipmentDeliveryPhoto.objects.filter(id=photo.id).exists()) \ No newline at end of file diff --git a/api_v2/urls.py b/api_v2/urls.py index a9e5bb5..abe05ad 100644 --- a/api_v2/urls.py +++ b/api_v2/urls.py @@ -17,6 +17,7 @@ from api_v2.views import ( PrintingOrderBatchAdvanceRecordsView, BusinessObjectCloneView, MissionCancelView, + MissionByPrintingOrderView, MissionCategoryDetailView, MissionCategoryListCreateView, MissionDetailView, @@ -25,6 +26,8 @@ from api_v2.views import ( MissionReplyListCreateView, MissionReplyRejectView, MissionSetUrgentView, + ShipmentDeliveryPhotoDetailView, + ShipmentDeliveryPhotoListCreateView, AgentUnshippedShipmentListView, AgentTransportVehicleListView, AgentTransportVehicleDetailView, @@ -55,10 +58,13 @@ urlpatterns = [ path('mission-categories/', MissionCategoryListCreateView.as_view(), name='api_v2_mission_category_list_create'), path('mission-categories//', MissionCategoryDetailView.as_view(), name='api_v2_mission_category_detail'), path('missions/', MissionListCreateView.as_view(), name='api_v2_mission_list_create'), + path('missions/by-printing-order//', MissionByPrintingOrderView.as_view(), name='api_v2_mission_by_printing_order'), path('missions//', MissionDetailView.as_view(), name='api_v2_mission_detail'), path('missions//replies/', MissionReplyListCreateView.as_view(), name='api_v2_mission_reply_list_create'), path('missions//reopen/', MissionReopenView.as_view(), name='api_v2_mission_reopen'), path('missions//cancel/', MissionCancelView.as_view(), name='api_v2_mission_cancel'), path('missions//set-urgent/', MissionSetUrgentView.as_view(), name='api_v2_mission_set_urgent'), path('mission-replies//reject/', MissionReplyRejectView.as_view(), name='api_v2_mission_reply_reject'), + path('shipment-delivery-photos/', ShipmentDeliveryPhotoListCreateView.as_view(), name='api_v2_shipment_delivery_photo_list_create'), + path('shipment-delivery-photos//', ShipmentDeliveryPhotoDetailView.as_view(), name='api_v2_shipment_delivery_photo_detail'), ] diff --git a/api_v2/views/__init__.py b/api_v2/views/__init__.py index c0d2b79..120216c 100644 --- a/api_v2/views/__init__.py +++ b/api_v2/views/__init__.py @@ -20,6 +20,7 @@ from .printing import ( from .stateflow import BusinessObjectCloneView from .mission import ( ContentTypeListView, + MissionByPrintingOrderView, MissionCancelView, MissionCategoryDetailView, MissionCategoryListCreateView, @@ -31,6 +32,7 @@ from .mission import ( MissionSetUrgentView, ) from .ai import AgentUnshippedShipmentListView, AgentTransportVehicleListView, AgentTransportVehicleDetailView +from .shipment_delivery_photo import ShipmentDeliveryPhotoDetailView, ShipmentDeliveryPhotoListCreateView __all__ = [ 'HealthCheckView', @@ -58,7 +60,10 @@ __all__ = [ 'MissionReplyRejectView', 'MissionSetUrgentView', 'ContentTypeListView', + 'MissionByPrintingOrderView', 'AgentUnshippedShipmentListView', 'AgentTransportVehicleListView', 'AgentTransportVehicleDetailView', + 'ShipmentDeliveryPhotoListCreateView', + 'ShipmentDeliveryPhotoDetailView', ] diff --git a/api_v2/views/mission.py b/api_v2/views/mission.py index 43ee7ca..9653470 100644 --- a/api_v2/views/mission.py +++ b/api_v2/views/mission.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.db import IntegrityError -from django.db.models import Q +from django.db.models import Exists, OuterRef, Prefetch, Q from django.db.models import ProtectedError from django.shortcuts import get_object_or_404 from rest_framework import permissions, serializers, status @@ -12,6 +13,7 @@ from mission import services as mission_services STATUS_FIELDS = {"is_urgent", "is_completed", "is_cancelled", "cancelled_by", "cancelled_at", "rejected_by", "rejected_at"} +MISSION_BY_PRINTING_ORDER_CACHE_TIMEOUT = 180 def _get_employee(request): @@ -36,6 +38,7 @@ class MissionWriteSerializer(serializers.Serializer): category = serializers.IntegerField(required=False, min_value=1) content_type = serializers.IntegerField(required=False, allow_null=True, min_value=1) content_id = serializers.IntegerField(required=False, allow_null=True, min_value=1) + extra = serializers.JSONField(required=False, allow_null=True) notify_if_unreplied = serializers.BooleanField(required=False) unreplied_notify_interval_minutes = serializers.IntegerField(required=False, allow_null=True, min_value=1) unreplied_notify_max_count = serializers.IntegerField(required=False, min_value=1) @@ -96,6 +99,7 @@ class MissionWriteSerializer(serializers.Serializer): class MissionReplyCreateSerializer(serializers.Serializer): content = serializers.CharField(allow_blank=False) ends_task = serializers.BooleanField(required=False, default=False) + extra = serializers.JSONField(required=False, allow_null=True) class MissionUrgentSerializer(serializers.Serializer): @@ -129,8 +133,8 @@ class MissionSerializer(serializers.ModelSerializer): content_type_label = serializers.SerializerMethodField() category = serializers.IntegerField(source="category_id", read_only=True) category_name = serializers.CharField(source="category.name", read_only=True) - has_ending_reply = serializers.BooleanField(read_only=True) - can_reply = serializers.BooleanField(read_only=True) + has_ending_reply = serializers.SerializerMethodField() + can_reply = serializers.SerializerMethodField() class Meta: model = mission_models.Mission @@ -155,6 +159,7 @@ class MissionSerializer(serializers.ModelSerializer): "content_type", "content_type_label", "content_id", + "extra", "has_ending_reply", "can_reply", "created_at", @@ -169,6 +174,9 @@ class MissionSerializer(serializers.ModelSerializer): return _employee_payload(obj.cancelled_by) def get_participants(self, obj): + prefetched_participants = getattr(obj, "_prefetched_objects_cache", {}).get("participants") + if prefetched_participants is not None: + return [_employee_payload(participant.employee) for participant in prefetched_participants] return [ _employee_payload(participant.employee) for participant in obj.participants.select_related("employee", "employee__merchant") @@ -179,6 +187,62 @@ class MissionSerializer(serializers.ModelSerializer): return None return f"{obj.content_type.app_label}.{obj.content_type.model}" + def get_has_ending_reply(self, obj): + annotated_value = getattr(obj, "has_ending_reply_value", None) + if annotated_value is not None: + return annotated_value + return obj.has_ending_reply + + def get_can_reply(self, obj): + return not obj.is_cancelled and not self.get_has_ending_reply(obj) + + +class MissionLiteSerializer(serializers.ModelSerializer): + creator = serializers.SerializerMethodField() + cancelled_by = serializers.SerializerMethodField() + content_type_label = serializers.SerializerMethodField() + category = serializers.IntegerField(source="category_id", read_only=True) + category_name = serializers.CharField(source="category.name", read_only=True) + + class Meta: + model = mission_models.Mission + fields = [ + "id", + "merchant", + "description", + "category", + "category_name", + "is_urgent", + "is_completed", + "is_cancelled", + "notify_if_unreplied", + "unreplied_notify_interval_minutes", + "unreplied_notify_max_count", + "unreplied_notify_sent_count", + "unreplied_last_notified_at", + "cancelled_at", + "creator", + "cancelled_by", + "content_type", + "content_type_label", + "content_id", + "extra", + "created_at", + "updated_at", + ] + read_only_fields = fields + + def get_creator(self, obj): + return _employee_payload(obj.creator) + + def get_cancelled_by(self, obj): + return _employee_payload(obj.cancelled_by) + + def get_content_type_label(self, obj): + if obj.content_type_id is None: + return None + return f"{obj.content_type.app_label}.{obj.content_type.model}" + class MissionCategorySerializer(serializers.ModelSerializer): class Meta: @@ -205,6 +269,7 @@ class MissionReplySerializer(serializers.ModelSerializer): "merchant", "responder", "content", + "extra", "replied_at", "ends_task", "is_rejected", @@ -222,9 +287,78 @@ class MissionReplySerializer(serializers.ModelSerializer): return _employee_payload(obj.rejected_by) -def _mission_queryset_for_employee(employee): - return ( +class MissionWithRepliesSerializer(MissionSerializer): + replies = serializers.SerializerMethodField() + + class Meta(MissionSerializer.Meta): + fields = [*MissionSerializer.Meta.fields, "replies"] + read_only_fields = fields + + def get_replies(self, obj): + replies = getattr(obj, "prefetched_replies", None) + if replies is None: + replies = ( + obj.replies.select_related( + "responder", + "responder__merchant", + "rejected_by", + "rejected_by__merchant", + ) + .order_by("replied_at", "id") + ) + return MissionReplySerializer(replies, many=True).data + + +def _parse_int_list_query_param(request, param_name: str) -> list[int] | None: + raw_values = request.query_params.getlist(param_name) + if not raw_values: + single_value = request.query_params.get(param_name) + if single_value is None: + return None + raw_values = [single_value] + + tokens = [] + for raw_value in raw_values: + if raw_value is None: + continue + parts = [part.strip() for part in str(raw_value).split(",")] + tokens.extend(part for part in parts if part) + + if not tokens: + return None + + try: + values = [int(token) for token in tokens] + except (TypeError, ValueError) as exc: + raise serializers.ValidationError({param_name: "必须是整数 ID 列表"}) from exc + + if any(value <= 0 for value in values): + raise serializers.ValidationError({param_name: "必须是正整数 ID 列表"}) + return list(dict.fromkeys(values)) + + +def _parse_bool_query_param(request, param_name: str, default: bool) -> bool: + raw_value = request.query_params.get(param_name) + if raw_value is None: + return default + + value = str(raw_value).strip().lower() + if value in {"1", "true", "yes", "y", "on"}: + return True + if value in {"0", "false", "no", "n", "off"}: + return False + raise serializers.ValidationError({param_name: "必须是布尔值"}) + + +def _mission_queryset_for_employee(employee, *, include_details: bool = True): + has_ending_reply_subquery = mission_models.MissionReply.objects.filter( + mission_id=OuterRef("pk"), + ends_task=True, + is_rejected=False, + ) + queryset = ( mission_models.Mission.objects.filter(merchant=employee.merchant) + .annotate(has_ending_reply_value=Exists(has_ending_reply_subquery)) .select_related( "merchant", "category", @@ -234,9 +368,18 @@ def _mission_queryset_for_employee(employee): "cancelled_by__merchant", "content_type", ) - .prefetch_related("participants__employee", "participants__employee__merchant") .order_by("-created_at", "-id") ) + if include_details: + queryset = queryset.prefetch_related("participants__employee", "participants__employee__merchant") + return queryset + + +def _mission_by_printing_order_cache_key(*, merchant_id: int, printing_order_id: int, category_ids: list[int] | None, + include_details: bool) -> str: + category_key = "all" if category_ids is None else ",".join(str(category_id) for category_id in category_ids) + detail_key = "detail" if include_details else "lite" + return f"mission:by-printing-order:{merchant_id}:{printing_order_id}:{category_key}:{detail_key}" def _mission_category_queryset_for_employee(employee): @@ -334,6 +477,7 @@ class MissionListCreateView(APIView): category=data.get("category"), content_type=data.get("content_type"), content_id=data.get("content_id"), + extra=data.get("extra"), participant_ids=data.get("participant_ids"), notify_if_unreplied=data.get("notify_if_unreplied", False), unreplied_notify_interval_minutes=data.get("unreplied_notify_interval_minutes"), @@ -344,6 +488,56 @@ class MissionListCreateView(APIView): return Response(MissionSerializer(mission).data, status=status.HTTP_201_CREATED) +class MissionByPrintingOrderView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, printing_order_id): + employee = _get_employee(request) + + from printing.models import PrintingOrder + + printing_order = get_object_or_404( + PrintingOrder.objects.filter(merchant=employee.merchant), + id=printing_order_id, + ) + category_ids = _parse_int_list_query_param(request, "category_ids") + include_details = _parse_bool_query_param(request, "include_details", default=True) + cache_key = _mission_by_printing_order_cache_key( + merchant_id=employee.merchant_id, + printing_order_id=printing_order.id, + category_ids=category_ids, + include_details=include_details, + ) + cached_data = cache.get(cache_key) + if cached_data is not None: + return Response(cached_data) + + printing_order_content_type = ContentType.objects.get_for_model(PrintingOrder) + + queryset = _mission_queryset_for_employee(employee, include_details=include_details).filter( + content_type=printing_order_content_type, + content_id=printing_order.id, + ) + if category_ids is not None: + queryset = queryset.filter(category_id__in=category_ids) + if include_details: + replies_queryset = mission_models.MissionReply.objects.select_related( + "responder", + "responder__merchant", + "rejected_by", + "rejected_by__merchant", + ).order_by("replied_at", "id") + queryset = queryset.prefetch_related( + Prefetch("replies", queryset=replies_queryset, to_attr="prefetched_replies") + ) + + serializer_class = MissionWithRepliesSerializer if include_details else MissionLiteSerializer + data = serializer_class(queryset, many=True).data + cache.set(cache_key, data, timeout=MISSION_BY_PRINTING_ORDER_CACHE_TIMEOUT) + + return Response(data) + + class MissionDetailView(APIView): permission_classes = [permissions.IsAuthenticated] @@ -373,6 +567,7 @@ class MissionDetailView(APIView): category=data.get("category"), content_type=data.get("content_type"), content_id=data.get("content_id"), + extra=(data["extra"] if "extra" in data else mission_services.UNSET), update_content_object=("content_type" in request.data or "content_id" in request.data), participant_ids=data.get("participant_ids"), notify_if_unreplied=( @@ -427,6 +622,7 @@ class MissionReplyListCreateView(APIView): responder=employee, content=serializer.validated_data["content"], ends_task=serializer.validated_data.get("ends_task", False), + extra=serializer.validated_data.get("extra"), ) except ValueError as exc: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) diff --git a/api_v2/views/shipment_delivery_photo.py b/api_v2/views/shipment_delivery_photo.py new file mode 100644 index 0000000..9986ad3 --- /dev/null +++ b/api_v2/views/shipment_delivery_photo.py @@ -0,0 +1,144 @@ +from rest_framework import permissions, serializers, status +from rest_framework.parsers import FormParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from shipment import models as shipment_models + + +def _get_employee(request): + employee = getattr(request.user, 'employee', None) + if employee is None: + raise serializers.ValidationError('当前用户未关联员工') + return employee + + +class ShipmentDeliveryPhotoWriteSerializer(serializers.ModelSerializer): + shipment = serializers.PrimaryKeyRelatedField(queryset=shipment_models.Shipment.objects.all()) + delivery = serializers.PrimaryKeyRelatedField(queryset=shipment_models.ShipmentDelivery.objects.all()) + + class Meta: + model = shipment_models.ShipmentDeliveryPhoto + fields = ['shipment', 'delivery', 'photo', 'remark'] + + def validate(self, attrs): + employee = self.context['employee'] + instance = self.instance + shipment = attrs.get('shipment', getattr(instance, 'shipment', None)) + delivery = attrs.get('delivery', getattr(instance, 'delivery', None)) + + if shipment is None: + raise serializers.ValidationError({'shipment': '出货单不能为空'}) + if delivery is None: + raise serializers.ValidationError({'delivery': '送货单不能为空'}) + if shipment.merchant_id != employee.merchant_id: + raise serializers.ValidationError({'shipment': '出货单不属于当前商户'}) + if delivery.merchant_id != employee.merchant_id: + raise serializers.ValidationError({'delivery': '送货单不属于当前商户'}) + if shipment.delivery_id != delivery.id: + raise serializers.ValidationError({'delivery': '送货单与出货单当前绑定关系不一致'}) + return attrs + + +class ShipmentDeliveryPhotoReadSerializer(serializers.ModelSerializer): + created_by = serializers.IntegerField(source='created_by_id', read_only=True, allow_null=True) + shipment_id = serializers.IntegerField(read_only=True) + delivery_id = serializers.IntegerField(read_only=True) + + class Meta: + model = shipment_models.ShipmentDeliveryPhoto + fields = [ + 'id', + 'shipment_id', + 'delivery_id', + 'photo', + 'remark', + 'created_by', + 'created_at', + 'updated_at', + ] + read_only_fields = fields + + +class ShipmentDeliveryPhotoListCreateView(APIView): + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + def _queryset(self, employee): + return shipment_models.ShipmentDeliveryPhoto.objects.select_related( + 'shipment', 'delivery', 'created_by' + ).filter( + shipment__merchant=employee.merchant, + delivery__merchant=employee.merchant, + ) + + def get(self, request): + employee = _get_employee(request) + queryset = self._queryset(employee) + + shipment_id = request.query_params.get('shipment_id') + delivery_id = request.query_params.get('delivery_id') + if shipment_id: + queryset = queryset.filter(shipment_id=shipment_id) + if delivery_id: + queryset = queryset.filter(delivery_id=delivery_id) + + return Response(ShipmentDeliveryPhotoReadSerializer(queryset, many=True, context={'request': request}).data) + + def post(self, request): + employee = _get_employee(request) + serializer = ShipmentDeliveryPhotoWriteSerializer(data=request.data, context={'employee': employee}) + serializer.is_valid(raise_exception=True) + photo = serializer.save(created_by=request.user) + return Response( + ShipmentDeliveryPhotoReadSerializer(photo, context={'request': request}).data, + status=status.HTTP_201_CREATED, + ) + + +class ShipmentDeliveryPhotoDetailView(APIView): + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + def get_object(self, request, photo_id): + employee = _get_employee(request) + return shipment_models.ShipmentDeliveryPhoto.objects.select_related( + 'shipment', 'delivery', 'created_by' + ).filter( + shipment__merchant=employee.merchant, + delivery__merchant=employee.merchant, + id=photo_id, + ).first() + + def get(self, request, photo_id): + photo = self.get_object(request, photo_id) + if photo is None: + return Response({'detail': '未找到送达照片'}, status=status.HTTP_404_NOT_FOUND) + return Response(ShipmentDeliveryPhotoReadSerializer(photo, context={'request': request}).data) + + def patch(self, request, photo_id): + photo = self.get_object(request, photo_id) + if photo is None: + return Response({'detail': '未找到送达照片'}, status=status.HTTP_404_NOT_FOUND) + employee = _get_employee(request) + serializer = ShipmentDeliveryPhotoWriteSerializer(photo, data=request.data, partial=True, context={'employee': employee}) + serializer.is_valid(raise_exception=True) + photo = serializer.save() + return Response(ShipmentDeliveryPhotoReadSerializer(photo, context={'request': request}).data) + + def put(self, request, photo_id): + photo = self.get_object(request, photo_id) + if photo is None: + return Response({'detail': '未找到送达照片'}, status=status.HTTP_404_NOT_FOUND) + employee = _get_employee(request) + serializer = ShipmentDeliveryPhotoWriteSerializer(photo, data=request.data, context={'employee': employee}) + serializer.is_valid(raise_exception=True) + photo = serializer.save() + return Response(ShipmentDeliveryPhotoReadSerializer(photo, context={'request': request}).data) + + def delete(self, request, photo_id): + photo = self.get_object(request, photo_id) + if photo is None: + return Response({'detail': '未找到送达照片'}, status=status.HTTP_404_NOT_FOUND) + photo.delete() + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index af8b535..900eeb5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,42 +1,38 @@ -version: "3.8" +x-app-image: &app-image + image: ${FLOWER_IMAGE:-flower-app:latest} + build: + context: . + dockerfile: Dockerfile.prod + +x-app-runtime: &app-runtime + env_file: + - ${APP_ENV_FILE:-.env} + environment: + DJANGO_SETTINGS_MODULE: flower.settings + PYTHONPATH: /app + CACHE_HOST: redis + CACHE_PORT: 6379 + CELERY_RESULT_HOST: redis + CELERY_RESULT_PORT: 6379 + CELERY_BROKER_HOST: rabbitmq + CELERY_BROKER_PORT: 5672 + restart: unless-stopped services: - postgres: - image: postgres:16-alpine - container_name: postgres - environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB: ${POSTGRES_DB:-flower} - # 仅绑定到本机,避免把数据库暴露到公网 - ports: - - "127.0.0.1:${POSTGRES_PORT:-5432}:5432" - volumes: - - db_data:/var/lib/postgresql/data - restart: unless-stopped - - pgbouncer: - image: edoburu/pgbouncer:latest - container_name: pgbouncer - ports: - - "127.0.0.1:6432:6432" - volumes: - - ./deploy/pgbouncer/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini - - ./deploy/pgbouncer/userlist.txt:/etc/pgbouncer/userlist.txt - depends_on: - - postgres - restart: unless-stopped - redis: image: redis:7-alpine - container_name: redis + container_name: flower_redis + command: + - redis-server + - --appendonly + - yes volumes: - redis_data:/data restart: unless-stopped rabbitmq: image: rabbitmq:3-management-alpine - container_name: rabbitmq + container_name: flower_rabbitmq environment: RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER:-guest} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS:-guest} @@ -45,9 +41,11 @@ services: restart: unless-stopped web: - image: ${FLOWER_IMAGE:-flower-app:latest} + <<: [*app-image, *app-runtime] container_name: flower_web command: + - python + - -m - uvicorn - flower.asgi:application - --host @@ -58,37 +56,16 @@ services: - "${UVICORN_WORKERS:-1}" ports: - "${WEB_PORT:-8100}:8000" - environment: - DJANGO_SETTINGS_MODULE: flower.settings - PYTHONPATH: /app - DEBUG: "${DEBUG:-0}" - ALLOWED_HOSTS: "${ALLOWED_HOSTS:-yuwenerp.yuwen.cloud}" - DB_HOST: ${DB_HOST:-pgbouncer} - DB_PORT: "${DB_PORT:-6432}" - DB_NAME: "${DB_NAME:-flower}" - DB_USER: "${DB_USER:-postgres}" - DB_PASSWORD: "${DB_PASSWORD:-postgres}" - CACHE_HOST: ${CACHE_HOST:-redis} - CACHE_PORT: "${CACHE_PORT:-6379}" - # Celery Broker(RabbitMQ)连接:容器内用服务名访问 - CELERY_BROKER_HOST: ${CELERY_BROKER_HOST:-rabbitmq} - CELERY_BROKER_PORT: "${CELERY_BROKER_PORT:-5672}" - CELERY_BROKER_USER: "${CELERY_BROKER_USER:-guest}" - CELERY_BROKER_PASSWORD: "${CELERY_BROKER_PASSWORD:-guest}" - # Celery Result Backend(Redis)连接:容器内用服务名访问 - CELERY_RESULT_HOST: ${CELERY_RESULT_HOST:-redis} - CELERY_RESULT_PORT: "${CELERY_RESULT_PORT:-6379}" - CELERY_RESULT_DB: "${CELERY_RESULT_DB:-1}" depends_on: - - pgbouncer - redis - rabbitmq - restart: unless-stopped celery_worker: - image: ${FLOWER_IMAGE:-flower-app:latest} + <<: [*app-image, *app-runtime] container_name: celery_worker command: + - python + - -m - celery - -A - flower @@ -97,78 +74,12 @@ services: - info - --concurrency=${CELERY_CONCURRENCY:-2} - --pool=solo - environment: - PYTHONPATH: /app - DB_HOST: ${DB_HOST:-pgbouncer} - DB_PORT: "${DB_PORT:-6432}" - DB_NAME: "${DB_NAME:-flower}" - DB_USER: "${DB_USER:-postgres}" - DB_PASSWORD: "${DB_PASSWORD:-postgres}" - # 缓存(Redis)连接:容器内用服务名访问 - CACHE_HOST: ${CACHE_HOST:-redis} - CACHE_PORT: "${CACHE_PORT:-6379}" - # Celery 连接配置:容器内用服务名访问 - CELERY_BROKER_HOST: ${CELERY_BROKER_HOST:-rabbitmq} - CELERY_BROKER_PORT: "${CELERY_BROKER_PORT:-5672}" - CELERY_BROKER_USER: "${CELERY_BROKER_USER:-guest}" - CELERY_BROKER_PASSWORD: "${CELERY_BROKER_PASSWORD:-guest}" - CELERY_RESULT_HOST: ${CELERY_RESULT_HOST:-redis} - CELERY_RESULT_PORT: "${CELERY_RESULT_PORT:-6379}" - CELERY_RESULT_DB: "${CELERY_RESULT_DB:-1}" - # 数据库备份输出目录(api_v1.tasks.backup_database 默认输出到 BASE_DIR/data-bak) volumes: - # 默认落在 compose 同级目录的 ./data-bak,方便宿主机直接查看/拷贝 - # 也可通过 BACKUP_DIR 指定绝对路径,如 /srv/flower/data-bak - ${BACKUP_DIR:-./data-bak}:/app/data-bak depends_on: - - pgbouncer - redis - rabbitmq - restart: unless-stopped - - celery_beat: - image: ${FLOWER_IMAGE:-flower-app:latest} - container_name: celery_beat - command: - - celery - - -A - - flower - - beat - - -l - - info - - --schedule - - /var/lib/celery/celerybeat-schedule - environment: - DJANGO_SETTINGS_MODULE: flower.settings - PYTHONPATH: /app - DB_HOST: ${DB_HOST:-pgbouncer} - DB_PORT: "${DB_PORT:-6432}" - DB_NAME: "${DB_NAME:-flower}" - DB_USER: "${DB_USER:-postgres}" - DB_PASSWORD: "${DB_PASSWORD:-postgres}" - # 缓存(Redis)连接:容器内用服务名访问 - CACHE_HOST: ${CACHE_HOST:-redis} - CACHE_PORT: "${CACHE_PORT:-6379}" - # Celery 连接配置:容器内用服务名访问 - CELERY_BROKER_HOST: ${CELERY_BROKER_HOST:-rabbitmq} - CELERY_BROKER_PORT: "${CELERY_BROKER_PORT:-5672}" - CELERY_BROKER_USER: "${CELERY_BROKER_USER:-guest}" - CELERY_BROKER_PASSWORD: "${CELERY_BROKER_PASSWORD:-guest}" - CELERY_RESULT_HOST: ${CELERY_RESULT_HOST:-redis} - CELERY_RESULT_PORT: "${CELERY_RESULT_PORT:-6379}" - CELERY_RESULT_DB: "${CELERY_RESULT_DB:-1}" - # Celery Beat 的持久化调度文件,避免重启后重复/错过调度 - volumes: - - celery_beat_data:/var/lib/celery - depends_on: - - postgres - - redis - - rabbitmq - restart: unless-stopped volumes: - db_data: redis_data: rabbitmq_data: - celery_beat_data: - diff --git a/docs/api_v2_mission_api.md b/docs/api_v2_mission_api.md index 86394b0..1553384 100644 --- a/docs/api_v2_mission_api.md +++ b/docs/api_v2_mission_api.md @@ -135,6 +135,10 @@ "content_type": null, "content_type_label": null, "content_id": null, + "extra": { + "source": "wechat", + "ticket_no": "TK-001" + }, "has_ending_reply": false, "can_reply": true, "created_at": "2026-04-10T12:00:00+08:00", @@ -155,6 +159,9 @@ "merchant_id": 10 }, "content": "已处理", + "extra": { + "attachment_ids": [1001, 1002] + }, "replied_at": "2026-04-10T12:10:00+08:00", "ends_task": true, "is_rejected": false, @@ -165,6 +172,106 @@ } ``` +### MissionWithReplies + +在 `Mission` 结构基础上,额外返回 `replies` 字段: + +```json +{ + "id": 1, + "merchant": 10, + "description": "跟进客户问题", + "category": 1, + "category_name": "通用", + "is_urgent": false, + "is_completed": false, + "is_cancelled": false, + "notify_if_unreplied": false, + "unreplied_notify_interval_minutes": null, + "unreplied_notify_max_count": 5, + "unreplied_notify_sent_count": 0, + "unreplied_last_notified_at": null, + "cancelled_at": null, + "creator": { + "id": 20, + "name": "张三", + "merchant_id": 10 + }, + "cancelled_by": null, + "participants": [], + "content_type": 33, + "content_type_label": "printing.printingorder", + "content_id": 123, + "extra": { + "source": "printing-order" + }, + "has_ending_reply": false, + "can_reply": true, + "created_at": "2026-04-10T12:00:00+08:00", + "updated_at": "2026-04-10T12:00:00+08:00", + "replies": [ + { + "id": 100, + "mission": 1, + "merchant": 10, + "responder": { + "id": 20, + "name": "张三", + "merchant_id": 10 + }, + "content": "已处理", + "extra": { + "attachment_ids": [1001, 1002] + }, + "replied_at": "2026-04-10T12:10:00+08:00", + "ends_task": false, + "is_rejected": false, + "rejected_by": null, + "rejected_at": null, + "created_at": "2026-04-10T12:10:00+08:00", + "updated_at": "2026-04-10T12:10:00+08:00" + } + ] +} +``` + +### MissionLite + +用于按印花订单查询任务时的轻量响应结构,不返回参与者、结束回应状态和回复列表: + +```json +{ + "id": 1, + "merchant": 10, + "description": "跟进客户问题", + "category": 1, + "category_name": "通用", + "is_urgent": false, + "is_completed": false, + "is_cancelled": false, + "notify_if_unreplied": false, + "unreplied_notify_interval_minutes": null, + "unreplied_notify_max_count": 5, + "unreplied_notify_sent_count": 0, + "unreplied_last_notified_at": null, + "cancelled_at": null, + "creator": { + "id": 20, + "name": "张三", + "merchant_id": 10 + }, + "cancelled_by": null, + "content_type": 33, + "content_type_label": "printing.printingorder", + "content_id": 123, + "extra": { + "source": "printing-order" + }, + "created_at": "2026-04-10T12:00:00+08:00", + "updated_at": "2026-04-10T12:00:00+08:00" +} +``` + ## 任务分类列表 - URL: `/api/v2/mission-categories/` @@ -255,6 +362,7 @@ | `category` | int | 否 | 任务分类 ID;不传时默认使用当前商户下名称为“通用”的分类,不存在则自动创建 | | `content_type` | int/null | 否 | Django ContentType ID;必须与 `content_id` 同时提供或同时省略 | | `content_id` | int/null | 否 | 关联业务对象 ID;必须与 `content_type` 同时提供或同时省略 | +| `extra` | object/null | 否 | 任务扩展字段,原样保存为 JSON;可传 `null` | | `notify_if_unreplied` | boolean | 否 | 是否开启“未回复持续提醒”,默认 `false` | | `unreplied_notify_interval_minutes` | int/null | 否 | 未回复提醒间隔(分钟);开启未回复提醒时必填 | | `unreplied_notify_max_count` | int | 否 | 最大提醒次数,默认 `5` | @@ -275,6 +383,10 @@ { "description": "跟进客户问题", "category": 1, + "extra": { + "source": "wechat", + "ticket_no": "TK-001" + }, "notify_if_unreplied": true, "unreplied_notify_interval_minutes": 30, "unreplied_notify_max_count": 5, @@ -293,6 +405,44 @@ 跨商户访问返回 `404` +## 按印花订单查询任务及回复 + +- URL: `/api/v2/missions/by-printing-order//` +- Method: `GET` + +查询参数: + +| 参数 | 类型 | 说明 | +|------|------|------| +| `category_ids` | int[] / comma-separated string | 可选任务分类筛选。支持重复参数 `?category_ids=1&category_ids=2`,也支持逗号分隔 `?category_ids=1,2` | +| `include_details` | boolean | 是否返回完整详情,默认 `true`。传 `false` 时返回轻量结构,不带 `participants`、`has_ending_reply`、`can_reply`、`replies` | + +说明: + +- 只查询 `content_type=printing.printingorder` 且 `content_id=` 的任务 +- 只返回当前登录员工所属商户下的任务 +- `include_details=true` 时返回 `MissionWithReplies[]` +- `include_details=false` 时返回 `MissionLite[]` +- 完整模式下每个任务会内嵌其全部回复,按 `replied_at`、`id` 升序返回 +- `Mission.extra` 与 `MissionReply.extra` 都会原样返回 +- 该接口启用了 180 秒的低层缓存;缓存键会区分当前商户、`printing_order_id`、`category_ids`、`include_details` +- 在缓存有效期内,如果任务、参与者或回复刚发生变化,接口结果最多可能延迟约 3 分钟刷新 +- 若该 `printing_order_id` 不属于当前商户,返回 `404` + +请求示例: + +```http +GET /api/v2/missions/by-printing-order/123/ +GET /api/v2/missions/by-printing-order/123/?category_ids=1,2 +GET /api/v2/missions/by-printing-order/123/?include_details=false +GET /api/v2/missions/by-printing-order/123/?category_ids=2&include_details=false +``` + +成功响应: + +- `include_details=true`:`MissionWithReplies[]` +- `include_details=false`:`MissionLite[]` + ## 更新任务 - URL: `/api/v2/missions//` @@ -306,6 +456,7 @@ | `category` | int | 任务分类 ID | | `content_type` | int/null | 关联对象类型;必须与 `content_id` 同时提供 | | `content_id` | int/null | 关联对象 ID;必须与 `content_type` 同时提供 | +| `extra` | object/null | 任务扩展字段,原样保存为 JSON;可传 `null` | | `notify_if_unreplied` | boolean | 是否开启“未回复持续提醒” | | `unreplied_notify_interval_minutes` | int/null | 未回复提醒间隔(分钟) | | `unreplied_notify_max_count` | int | 最大提醒次数 | @@ -360,10 +511,12 @@ HTTP 状态码:`405 Method Not Allowed` |------|------|------|------| | `content` | string | 是 | 回应内容 | | `ends_task` | boolean | 否 | 是否结束任务,默认 `false` | +| `extra` | object/null | 否 | 回应扩展字段,原样保存为 JSON;可传 `null` | 说明: - `responder` 使用当前登录用户的 employee +- `extra` 由后端原样存储并原样返回,不做结构校验 - 如果 `ends_task=true`,后端会同步设置 `Mission.is_completed=true` - 已取消任务、或已有有效结束回应的任务不允许继续回应 diff --git a/docs/api_v2_shipment_delivery_photo_api.md b/docs/api_v2_shipment_delivery_photo_api.md new file mode 100644 index 0000000..1fc0aca --- /dev/null +++ b/docs/api_v2_shipment_delivery_photo_api.md @@ -0,0 +1,103 @@ +# API v2 Shipment Delivery Photo 文档 + +本文档说明司机按出货单上传送达照片的 API。 + +## 数据结构 + +```json +{ + "id": 1, + "shipment_id": 100, + "delivery_id": 20, + "photo": "https://image.yuwen.cloud/media/shipment_delivery_photos/demo.jpg", + "remark": "已送达并签收", + "created_by": 8, + "created_at": "2026-04-28T20:00:00+08:00", + "updated_at": "2026-04-28T20:00:00+08:00" +} +``` + +说明: + +- `photo` 使用项目现有七牛存储。 +- `shipment_id` 与 `delivery_id` 都是必填。 +- 同一个 `shipment_id + delivery_id` 组合允许多次写入,不做唯一性限制。 +- 后端会校验 `shipment.delivery_id == delivery_id`,不一致时拒绝写入。 + +## 列表查询 + +- URL: `/api/v2/shipment-delivery-photos/` +- Method: `GET` + +查询参数: + +| 参数 | 类型 | 说明 | +|------|------|------| +| `shipment_id` | int | 可选,按出货单筛选 | +| `delivery_id` | int | 可选,按送货单筛选 | + +说明: + +- 支持只传 `shipment_id` +- 支持只传 `delivery_id` +- 支持同时传两者,按 AND 过滤 + +成功响应:`ShipmentDeliveryPhoto[]` + +## 创建送达照片 + +- URL: `/api/v2/shipment-delivery-photos/` +- Method: `POST` +- Content-Type: `multipart/form-data` + +请求参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `shipment` | int | 是 | 出货单 ID | +| `delivery` | int | 是 | 送货单 ID | +| `photo` | file | 是 | 送达照片 | +| `remark` | string | 否 | 备注 | + +说明: + +- `created_by` 由当前登录用户自动写入 +- `shipment` 与 `delivery` 必须属于当前商户 +- `delivery` 必须与 `shipment.delivery_id` 一致 + +成功响应:`201 Created`,返回 `ShipmentDeliveryPhoto` + +## 详情查询 + +- URL: `/api/v2/shipment-delivery-photos//` +- Method: `GET` + +成功响应:`ShipmentDeliveryPhoto` + +## 更新送达照片 + +- URL: `/api/v2/shipment-delivery-photos//` +- Method: `PATCH` / `PUT` +- Content-Type: `multipart/form-data` + +允许更新: + +| 参数 | 类型 | 说明 | +|------|------|------| +| `shipment` | int | 出货单 ID | +| `delivery` | int | 送货单 ID | +| `photo` | file | 照片 | +| `remark` | string | 备注 | + +说明: + +- 更新时仍会执行 `shipment.delivery_id == delivery_id` 一致性校验 +- `PATCH` 支持部分更新 +- `PUT` 需要传完整字段 + +## 删除送达照片 + +- URL: `/api/v2/shipment-delivery-photos//` +- Method: `DELETE` + +成功响应:`204 No Content` \ No newline at end of file diff --git a/flower/settings.py b/flower/settings.py index 022289c..978592c 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -90,14 +90,7 @@ AGENT_ACCESS_KEY = env('AGENT_ACCESS_KEY', default='') # CORS 配置 -CORS_ALLOW_ALL_ORIGINS = DEBUG # 开发环境允许所有源,生产环境需要配置白名单 -CORS_ALLOWED_ORIGINS = env.list('CORS_ALLOWED_ORIGINS', default=[ - 'http://localhost:8000', - 'http://127.0.0.1:5173', - 'http://127.0.0.1:5174', - 'http://127.0.0.1:5179', - 'https://yuwenerp.yuwen.cloud', -]) +CORS_ALLOW_ALL_ORIGINS = True # 开发环境允许所有源,生产环境需要配置白名单 CORS_ALLOW_CREDENTIALS = True # 允许携带凭证(如 Cookie、认证头) # SSE 需要的特殊 CORS 配置 diff --git a/mission/migrations/0006_mission_and_reply_extra.py b/mission/migrations/0006_mission_and_reply_extra.py new file mode 100644 index 0000000..da3965b --- /dev/null +++ b/mission/migrations/0006_mission_and_reply_extra.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("mission", "0005_mission_unreplied_notification_fields"), + ] + + operations = [ + migrations.AddField( + model_name="mission", + name="extra", + field=models.JSONField(blank=True, null=True, verbose_name="扩展字段"), + ), + migrations.AddField( + model_name="missionreply", + name="extra", + field=models.JSONField(blank=True, null=True, verbose_name="扩展字段"), + ), + ] \ No newline at end of file diff --git a/mission/models.py b/mission/models.py index ce1443c..f5bacf7 100644 --- a/mission/models.py +++ b/mission/models.py @@ -87,6 +87,7 @@ class Mission(ModelBase): verbose_name="关联对象类型", ) content_id = models.PositiveBigIntegerField(null=True, blank=True, db_index=True, verbose_name="关联对象ID") + extra = models.JSONField(null=True, blank=True, verbose_name="扩展字段") content_object = GenericForeignKey("content_type", "content_id") @property @@ -184,6 +185,7 @@ class MissionReply(ModelBase): verbose_name="回应者", ) content = models.TextField(verbose_name="回应内容") + extra = models.JSONField(null=True, blank=True, verbose_name="扩展字段") replied_at = models.DateTimeField(default=timezone.now, db_index=True, verbose_name="回应时间") ends_task = models.BooleanField(default=False, db_index=True, verbose_name="是否结束任务") is_rejected = models.BooleanField(default=False, db_index=True, verbose_name="是否被撤销") diff --git a/mission/services.py b/mission/services.py index dab11ed..7dff5b5 100644 --- a/mission/services.py +++ b/mission/services.py @@ -141,6 +141,7 @@ def create_mission( notify_if_unreplied: bool = False, unreplied_notify_interval_minutes: int | None = None, unreplied_notify_max_count: int = 5, + extra=None, ) -> Mission: if creator is None: raise ValueError("任务创建者不能为空") @@ -160,6 +161,7 @@ def create_mission( category=category or _get_default_mission_category(merchant=merchant), content_type=content_type, content_id=content_id, + extra=extra, notify_if_unreplied=notify_if_unreplied, unreplied_notify_interval_minutes=unreplied_notify_interval_minutes, unreplied_notify_max_count=unreplied_notify_max_count, @@ -189,6 +191,7 @@ def update_mission( notify_if_unreplied=UNSET, unreplied_notify_interval_minutes=UNSET, unreplied_notify_max_count=UNSET, + extra=UNSET, ) -> Mission: mission = Mission.objects.select_for_update().get(pk=mission.pk) _assert_employee_belongs_to_mission(updated_by, mission, "更新任务的员工") @@ -210,6 +213,9 @@ def update_mission( mission.content_type = content_type mission.content_id = content_id update_fields.extend(["content_type", "content_id"]) + if extra is not UNSET: + mission.extra = extra + update_fields.append("extra") notify_changed = notify_if_unreplied is not UNSET interval_changed = unreplied_notify_interval_minutes is not UNSET @@ -272,6 +278,7 @@ def create_mission_reply( responder, content: str, ends_task: bool = False, + extra=None, ) -> MissionReply: mission = Mission.objects.select_for_update().get(pk=mission.pk) _assert_employee_belongs_to_mission(responder, mission, "回应者") @@ -284,6 +291,7 @@ def create_mission_reply( responder=responder, content=content, ends_task=ends_task, + extra=extra, ) if ends_task and not mission.is_completed: mission.is_completed = True diff --git a/requirements.prod.txt b/requirements.prod.txt new file mode 100644 index 0000000..f48857d --- /dev/null +++ b/requirements.prod.txt @@ -0,0 +1,20 @@ +celery>=5.5.3 +django==5.2.8 +django-cors-headers>=4.9.0 +django-environ>=0.12.0 +django-filter>=25.2 +django-ninja>=1.4.5 +django-qiniu-storage>=2.3.1 +django-simpleui>=2025.6.24 +djangorestframework>=3.16.1 +djangorestframework-simplejwt>=5.5.1 +drf-spectacular>=0.29.0 +markdown>=3.10 +pillow>=12.0.0 +uvicorn>=0.38.0 +watchfiles>=0.22.0 +psycopg[binary]>=3.2.12 +redis>=5.0.0 +aiohttp>=3.13.2 +tencentcloud-sdk-python>=3.0.0 +python-docx>=1.2.0 \ No newline at end of file diff --git a/shipment/migrations/0024_shipmentdeliveryphoto.py b/shipment/migrations/0024_shipmentdeliveryphoto.py new file mode 100644 index 0000000..eed0a32 --- /dev/null +++ b/shipment/migrations/0024_shipmentdeliveryphoto.py @@ -0,0 +1,36 @@ +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shipment', '0023_shipmentdelivery_shipment_order_ids'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ShipmentDeliveryPhoto', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('photo', models.ImageField(upload_to='shipment_delivery_photos/', verbose_name='送达照片')), + ('remark', models.CharField(blank=True, default='', max_length=200, verbose_name='备注')), + ('created_by', models.ForeignKey(null=True, on_delete=models.SET_NULL, related_name='created_shipment_delivery_photos', to=settings.AUTH_USER_MODEL, verbose_name='创建人')), + ('delivery', models.ForeignKey(on_delete=models.CASCADE, related_name='photos', to='shipment.shipmentdelivery', verbose_name='送货单')), + ('shipment', models.ForeignKey(on_delete=models.CASCADE, related_name='delivery_photos', to='shipment.shipment', verbose_name='出货单')), + ], + options={ + 'verbose_name': '送达照片', + 'verbose_name_plural': '送达照片', + 'db_table': 'shipment_delivery_photo', + 'ordering': ['-created_at', '-id'], + }, + ), + migrations.AddIndex( + model_name='shipmentdeliveryphoto', + index=models.Index(fields=['shipment', 'delivery'], name='shipment_de_shipment_355c1f_idx'), + ), + ] \ No newline at end of file diff --git a/shipment/models.py b/shipment/models.py index fdaaf09..885ccf2 100644 --- a/shipment/models.py +++ b/shipment/models.py @@ -618,6 +618,53 @@ class ShipmentDelivery(ModelBase): verbose_name = '送货单' verbose_name_plural = '送货单' ordering = ['-created_at', '-id'] + + +class ShipmentDeliveryPhoto(ModelBase): + """司机送达照片,按出货单上传并冗余记录所属送货单。""" + + shipment = models.ForeignKey( + Shipment, + on_delete=models.CASCADE, + related_name='delivery_photos', + verbose_name='出货单', + ) + + delivery = models.ForeignKey( + ShipmentDelivery, + on_delete=models.CASCADE, + related_name='photos', + verbose_name='送货单', + ) + + photo = models.ImageField( + upload_to='shipment_delivery_photos/', + verbose_name='送达照片', + ) + + remark = models.CharField( + max_length=200, + blank=True, + default='', + verbose_name='备注', + ) + + created_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='created_shipment_delivery_photos', + verbose_name='创建人', + ) + + class Meta: + db_table = 'shipment_delivery_photo' + verbose_name = '送达照片' + verbose_name_plural = '送达照片' + ordering = ['-created_at', '-id'] + indexes = [ + models.Index(fields=['shipment', 'delivery']), + ] permissions = [ ('cancel_shipmentdelivery', 'Can cancel shipment delivery'), ]