forked from erp-dev/erp
feat: prod clean
This commit is contained in:
@@ -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*
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ wheels/
|
||||
# Virtual environments
|
||||
.venv
|
||||
data-bak/
|
||||
.env.slave
|
||||
|
||||
20
Dockerfile.prod
Normal file
20
Dockerfile.prod
Normal file
@@ -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"]
|
||||
@@ -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()
|
||||
|
||||
|
||||
181
api_v2/test_shipment_delivery_photo_api.py
Normal file
181
api_v2/test_shipment_delivery_photo_api.py
Normal file
@@ -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())
|
||||
@@ -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/<int:category_id>/', 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/<int:printing_order_id>/', MissionByPrintingOrderView.as_view(), name='api_v2_mission_by_printing_order'),
|
||||
path('missions/<int:mission_id>/', MissionDetailView.as_view(), name='api_v2_mission_detail'),
|
||||
path('missions/<int:mission_id>/replies/', MissionReplyListCreateView.as_view(), name='api_v2_mission_reply_list_create'),
|
||||
path('missions/<int:mission_id>/reopen/', MissionReopenView.as_view(), name='api_v2_mission_reopen'),
|
||||
path('missions/<int:mission_id>/cancel/', MissionCancelView.as_view(), name='api_v2_mission_cancel'),
|
||||
path('missions/<int:mission_id>/set-urgent/', MissionSetUrgentView.as_view(), name='api_v2_mission_set_urgent'),
|
||||
path('mission-replies/<int:reply_id>/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/<int:photo_id>/', ShipmentDeliveryPhotoDetailView.as_view(), name='api_v2_shipment_delivery_photo_detail'),
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
144
api_v2/views/shipment_delivery_photo.py
Normal file
144
api_v2/views/shipment_delivery_photo.py
Normal file
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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/<printing_order_id>/`
|
||||
- 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=<printing_order_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/<mission_id>/`
|
||||
@@ -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`
|
||||
- 已取消任务、或已有有效结束回应的任务不允许继续回应
|
||||
|
||||
|
||||
103
docs/api_v2_shipment_delivery_photo_api.md
Normal file
103
docs/api_v2_shipment_delivery_photo_api.md
Normal file
@@ -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/<photo_id>/`
|
||||
- Method: `GET`
|
||||
|
||||
成功响应:`ShipmentDeliveryPhoto`
|
||||
|
||||
## 更新送达照片
|
||||
|
||||
- URL: `/api/v2/shipment-delivery-photos/<photo_id>/`
|
||||
- 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/<photo_id>/`
|
||||
- Method: `DELETE`
|
||||
|
||||
成功响应:`204 No Content`
|
||||
@@ -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 配置
|
||||
|
||||
21
mission/migrations/0006_mission_and_reply_extra.py
Normal file
21
mission/migrations/0006_mission_and_reply_extra.py
Normal file
@@ -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="扩展字段"),
|
||||
),
|
||||
]
|
||||
@@ -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="是否被撤销")
|
||||
|
||||
@@ -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
|
||||
|
||||
20
requirements.prod.txt
Normal file
20
requirements.prod.txt
Normal file
@@ -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
|
||||
36
shipment/migrations/0024_shipmentdeliveryphoto.py
Normal file
36
shipment/migrations/0024_shipmentdeliveryphoto.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user