forked from erp-dev/erp
feat: mes module
This commit is contained in:
403
api_v2/test_mes_api.py
Normal file
403
api_v2/test_mes_api.py
Normal file
@@ -0,0 +1,403 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
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 mes import models as mes_models
|
||||
from printing.models import PrintingOrder
|
||||
|
||||
|
||||
class MesV2APITest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.merchant = basic_models.Merchant.objects.create(name="MES商户", type=basic_models.MerchantTypeEnum.STORE)
|
||||
self.other_merchant = basic_models.Merchant.objects.create(name="其他商户", type=basic_models.MerchantTypeEnum.STORE)
|
||||
self.user = get_user_model().objects.create_user(username="mes-api-user", password="pass12345")
|
||||
self.other_user = get_user_model().objects.create_user(username="mes-api-other-user", password="pass12345")
|
||||
self.employee = basic_models.Employee.objects.create(merchant=self.merchant, sys_user=self.user, name="MES员工")
|
||||
self.other_employee = basic_models.Employee.objects.create(merchant=self.other_merchant, sys_user=self.other_user, name="其他员工")
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def test_create_device_category(self):
|
||||
resp = self.client.post("/api/v2/mes/device-categories/", {"name": "打印机"}, format="json")
|
||||
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
self.assertEqual(resp.data["name"], "打印机")
|
||||
self.assertEqual(resp.data["merchant"], self.merchant.id)
|
||||
self.assertEqual(resp.data["created_by"]["id"], self.user.id)
|
||||
self.assertEqual(resp.data["operator"]["id"], self.employee.id)
|
||||
|
||||
def test_list_device_categories_supports_name_filter(self):
|
||||
mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="打印机",
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="滚筒机",
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
|
||||
resp = self.client.get("/api/v2/mes/device-categories/?name=打印")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual([item["name"] for item in resp.data], ["打印机"])
|
||||
|
||||
def test_update_device_category(self):
|
||||
category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="打印机",
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
|
||||
resp = self.client.patch(f"/api/v2/mes/device-categories/{category.id}/", {"name": "热转印机"}, format="json")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data["name"], "热转印机")
|
||||
|
||||
def test_delete_device_category_rejects_when_devices_exist(self):
|
||||
category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="打印机",
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
mes_models.Device.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name="设备A",
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
|
||||
resp = self.client.delete(f"/api/v2/mes/device-categories/{category.id}/")
|
||||
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn("不能删除", resp.data["detail"])
|
||||
|
||||
def test_create_device(self):
|
||||
category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="打印机",
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/v2/mes/devices/",
|
||||
{
|
||||
"name": "设备A",
|
||||
"category": category.id,
|
||||
"peak_capacity": 120,
|
||||
"capacity_unit": 1,
|
||||
"extra": {"capacity": {"per_hour": 120}},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
self.assertEqual(resp.data["name"], "设备A")
|
||||
self.assertEqual(resp.data["category"], category.id)
|
||||
self.assertEqual(resp.data["category_name"], "打印机")
|
||||
self.assertEqual(resp.data['peak_capacity'], 120)
|
||||
self.assertEqual(resp.data['capacity_unit'], 1)
|
||||
self.assertEqual(resp.data['capacity_unit_label'], '米')
|
||||
self.assertEqual(resp.data["extra"], {"capacity": {"per_hour": 120}})
|
||||
|
||||
def test_create_device_rejects_cross_merchant_category(self):
|
||||
category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
name="外部分类",
|
||||
created_by=self.other_user,
|
||||
operator=self.other_employee,
|
||||
)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/v2/mes/devices/",
|
||||
{"name": "设备A", "category": category.id, "peak_capacity": 120},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn("category", resp.data)
|
||||
|
||||
def test_list_devices_supports_category_filter(self):
|
||||
category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="打印机",
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
other_category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="滚筒机",
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
target = mes_models.Device.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name="设备A",
|
||||
peak_capacity=120,
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
mes_models.Device.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=other_category,
|
||||
name="设备B",
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
|
||||
resp = self.client.get(f"/api/v2/mes/devices/?category={category.id}")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual([item["id"] for item in resp.data], [target.id])
|
||||
|
||||
def test_update_device_supports_clearing_extra(self):
|
||||
category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="打印机",
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
device = mes_models.Device.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name="设备A",
|
||||
peak_capacity=120,
|
||||
extra={"power": 220},
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
|
||||
resp = self.client.patch(
|
||||
f"/api/v2/mes/devices/{device.id}/",
|
||||
{"extra": None, "capacity_unit": 2, "peak_capacity": 150},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.data['peak_capacity'], 150)
|
||||
self.assertEqual(resp.data['capacity_unit'], 2)
|
||||
self.assertEqual(resp.data['capacity_unit_label'], '码')
|
||||
self.assertIsNone(resp.data["extra"])
|
||||
|
||||
def test_list_devices_supports_extra_json_filter(self):
|
||||
category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="打印机",
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
target = mes_models.Device.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name="设备A",
|
||||
peak_capacity=120,
|
||||
extra={"capacity": {"per_hour": 120}},
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
mes_models.Device.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name="设备B",
|
||||
peak_capacity=80,
|
||||
extra={"capacity": {"per_hour": 80}},
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
|
||||
resp = self.client.get('/api/v2/mes/devices/?extra_path=capacity.per_hour&extra_value=120')
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual([item['id'] for item in resp.data], [target.id])
|
||||
|
||||
def test_list_devices_rejects_incomplete_extra_json_filter(self):
|
||||
resp = self.client.get('/api/v2/mes/devices/?extra_path=capacity.per_hour')
|
||||
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn('detail', resp.data)
|
||||
|
||||
def test_cross_merchant_device_detail_returns_404(self):
|
||||
category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
name="外部分类",
|
||||
created_by=self.other_user,
|
||||
operator=self.other_employee,
|
||||
)
|
||||
device = mes_models.Device.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
category=category,
|
||||
name="外部设备",
|
||||
peak_capacity=120,
|
||||
created_by=self.other_user,
|
||||
operator=self.other_employee,
|
||||
)
|
||||
|
||||
resp = self.client.get(f"/api/v2/mes/devices/{device.id}/")
|
||||
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_create_production_assignment(self):
|
||||
customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试客户',
|
||||
mobile='13800138002',
|
||||
)
|
||||
printing_order = PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
fabric='测试面料',
|
||||
width='150cm',
|
||||
created_by=self.user,
|
||||
)
|
||||
category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
device = mes_models.Device.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
peak_capacity=120,
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
content_type = ContentType.objects.get_for_model(PrintingOrder)
|
||||
|
||||
resp = self.client.post(
|
||||
'/api/v2/mes/production-assignments/',
|
||||
{
|
||||
'device': device.id,
|
||||
'content_type': content_type.id,
|
||||
'object_id': printing_order.id,
|
||||
'assigner': self.employee.id,
|
||||
'production_quantity': 300,
|
||||
'extra': {'batch': 'A1'},
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
self.assertEqual(resp.data['device'], device.id)
|
||||
self.assertEqual(resp.data['production_quantity'], 300)
|
||||
self.assertEqual(resp.data['status'], mes_models.ProductionAssignmentStatusEnum.DRAFT)
|
||||
self.assertEqual(resp.data['status_label'], 'Draft')
|
||||
|
||||
def test_update_production_assignment_rejects_cancel_after_accepted(self):
|
||||
customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试客户2',
|
||||
mobile='13800138003',
|
||||
)
|
||||
printing_order = PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
fabric='测试面料',
|
||||
width='150cm',
|
||||
created_by=self.user,
|
||||
)
|
||||
category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
device = mes_models.Device.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
peak_capacity=120,
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
content_type = ContentType.objects.get_for_model(PrintingOrder)
|
||||
assignment = mes_models.ProductionAssignment.objects.create(
|
||||
merchant=self.merchant,
|
||||
device=device,
|
||||
content_type=content_type,
|
||||
object_id=printing_order.id,
|
||||
assigner=self.employee,
|
||||
assignee=self.employee,
|
||||
production_quantity=300,
|
||||
status=mes_models.ProductionAssignmentStatusEnum.ACCEPTED,
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
|
||||
resp = self.client.patch(
|
||||
f'/api/v2/mes/production-assignments/{assignment.id}/',
|
||||
{'status': mes_models.ProductionAssignmentStatusEnum.CANCELLED},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertEqual(resp.data['detail'], '当前状态不允许变更为目标状态')
|
||||
|
||||
def test_list_production_assignments_supports_status_filter(self):
|
||||
customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试客户3',
|
||||
mobile='13800138004',
|
||||
)
|
||||
printing_order = PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
fabric='测试面料',
|
||||
width='150cm',
|
||||
created_by=self.user,
|
||||
)
|
||||
category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
device = mes_models.Device.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
peak_capacity=120,
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
content_type = ContentType.objects.get_for_model(PrintingOrder)
|
||||
target = mes_models.ProductionAssignment.objects.create(
|
||||
merchant=self.merchant,
|
||||
device=device,
|
||||
content_type=content_type,
|
||||
object_id=printing_order.id,
|
||||
assigner=self.employee,
|
||||
production_quantity=300,
|
||||
status=mes_models.ProductionAssignmentStatusEnum.PUBLISHED,
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
mes_models.ProductionAssignment.objects.create(
|
||||
merchant=self.merchant,
|
||||
device=device,
|
||||
content_type=content_type,
|
||||
object_id=printing_order.id,
|
||||
assigner=self.employee,
|
||||
production_quantity=100,
|
||||
status=mes_models.ProductionAssignmentStatusEnum.DRAFT,
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
|
||||
resp = self.client.get(
|
||||
f'/api/v2/mes/production-assignments/?status={mes_models.ProductionAssignmentStatusEnum.PUBLISHED}'
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual([item['id'] for item in resp.data], [target.id])
|
||||
220
api_v2/tests.py
220
api_v2/tests.py
@@ -15,6 +15,7 @@ from api_v2.views.printing import PrintingJobByCustomerView
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from stateflow import models as stateflow_models
|
||||
from django.test.utils import override_settings
|
||||
from mes import models as mes_models
|
||||
|
||||
|
||||
class QuickCreateEmployeeUserAPITest(TestCase):
|
||||
@@ -2292,3 +2293,222 @@ class AgentTransportVehicleAPITest(TestCase):
|
||||
url = f'/api/v2/ai/transport-vehicles/{self.vehicle_a.license_plate}/'
|
||||
response = self.client.get(url, {'merchant_id': self.merchant.id})
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
|
||||
@override_settings(AGENT_ACCESS_KEY='agent-test-key')
|
||||
class AgentMesAPITest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.device_list_url = '/api/v2/ai/mes/devices/'
|
||||
self.assignment_list_url = '/api/v2/ai/mes/production-assignments/'
|
||||
|
||||
self.merchant = basic_models.Merchant.objects.create(
|
||||
name='MES Agent 商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
self.other_merchant = basic_models.Merchant.objects.create(
|
||||
name='其他 MES 商户',
|
||||
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||
)
|
||||
self.user = get_user_model().objects.create_user(username='agent-mes-user', password='pass12345')
|
||||
self.other_user = get_user_model().objects.create_user(username='agent-mes-other-user', password='pass12345')
|
||||
self.employee = basic_models.Employee.objects.create(merchant=self.merchant, sys_user=self.user, name='MES员工')
|
||||
self.other_employee = basic_models.Employee.objects.create(merchant=self.other_merchant, sys_user=self.other_user, name='其他MES员工')
|
||||
|
||||
self.category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
self.device_a = mes_models.Device.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=self.category,
|
||||
name='设备A',
|
||||
peak_capacity=120,
|
||||
capacity_unit=mes_models.CapacityUnitEnum.METER,
|
||||
extra={'capacity': {'per_hour': 120}},
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
self.device_b = mes_models.Device.objects.create(
|
||||
merchant=self.merchant,
|
||||
category=self.category,
|
||||
name='设备B',
|
||||
peak_capacity=90,
|
||||
capacity_unit=mes_models.CapacityUnitEnum.YARD,
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
self.other_category = mes_models.DeviceCategory.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
name='他商户分类',
|
||||
created_by=self.other_user,
|
||||
operator=self.other_employee,
|
||||
)
|
||||
self.other_device = mes_models.Device.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
category=self.other_category,
|
||||
name='他商户设备',
|
||||
peak_capacity=60,
|
||||
created_by=self.other_user,
|
||||
operator=self.other_employee,
|
||||
)
|
||||
|
||||
self.customer = basic_models.Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试客户',
|
||||
mobile='13800138011',
|
||||
)
|
||||
self.other_customer = basic_models.Customer.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
name='他商户客户',
|
||||
mobile='13800138012',
|
||||
)
|
||||
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.other_user,
|
||||
)
|
||||
self.content_type = ContentType.objects.get_for_model(printing_models.PrintingOrder)
|
||||
|
||||
self.assignment_published = mes_models.ProductionAssignment.objects.create(
|
||||
merchant=self.merchant,
|
||||
device=self.device_a,
|
||||
content_type=self.content_type,
|
||||
object_id=self.printing_order.id,
|
||||
assigner=self.employee,
|
||||
assignee=self.employee,
|
||||
production_quantity=300,
|
||||
status=mes_models.ProductionAssignmentStatusEnum.PUBLISHED,
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
self.assignment_draft = mes_models.ProductionAssignment.objects.create(
|
||||
merchant=self.merchant,
|
||||
device=self.device_b,
|
||||
content_type=self.content_type,
|
||||
object_id=self.printing_order.id,
|
||||
assigner=self.employee,
|
||||
production_quantity=100,
|
||||
status=mes_models.ProductionAssignmentStatusEnum.DRAFT,
|
||||
created_by=self.user,
|
||||
operator=self.employee,
|
||||
)
|
||||
self.other_assignment = mes_models.ProductionAssignment.objects.create(
|
||||
merchant=self.other_merchant,
|
||||
device=self.other_device,
|
||||
content_type=self.content_type,
|
||||
object_id=self.other_printing_order.id,
|
||||
assigner=self.other_employee,
|
||||
production_quantity=50,
|
||||
status=mes_models.ProductionAssignmentStatusEnum.PUBLISHED,
|
||||
created_by=self.other_user,
|
||||
operator=self.other_employee,
|
||||
)
|
||||
|
||||
mes_models.ProductionAssignment.objects.filter(id=self.assignment_published.id).update(
|
||||
created_at=timezone.make_aware(datetime.datetime(2026, 5, 1, 10, 0, 0))
|
||||
)
|
||||
mes_models.ProductionAssignment.objects.filter(id=self.assignment_draft.id).update(
|
||||
created_at=timezone.make_aware(datetime.datetime(2026, 5, 3, 12, 0, 0))
|
||||
)
|
||||
mes_models.ProductionAssignment.objects.filter(id=self.other_assignment.id).update(
|
||||
created_at=timezone.make_aware(datetime.datetime(2026, 5, 2, 9, 0, 0))
|
||||
)
|
||||
|
||||
def _headers(self, key='agent-test-key'):
|
||||
return {'HTTP_AUTHORIZATION': key}
|
||||
|
||||
def test_list_mes_devices_success(self):
|
||||
response = self.client.get(
|
||||
self.device_list_url,
|
||||
{'merchant_id': self.merchant.id},
|
||||
**self._headers(),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['count'], 2)
|
||||
self.assertEqual(response.data['results'][0]['category']['name'], '打印机')
|
||||
|
||||
def test_list_mes_devices_isolates_by_merchant(self):
|
||||
response = self.client.get(
|
||||
self.device_list_url,
|
||||
{'merchant_id': self.other_merchant.id},
|
||||
**self._headers(),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.other_device.id)
|
||||
|
||||
def test_list_mes_devices_requires_access_key(self):
|
||||
response = self.client.get(self.device_list_url, {'merchant_id': self.merchant.id})
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_list_mes_production_assignments_filters_by_date_range(self):
|
||||
response = self.client.get(
|
||||
self.assignment_list_url,
|
||||
{
|
||||
'merchant_id': self.merchant.id,
|
||||
'start_date': '2026-05-01',
|
||||
'end_date': '2026-05-02',
|
||||
},
|
||||
**self._headers(),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.assignment_published.id)
|
||||
self.assertEqual(response.data['results'][0]['device']['id'], self.device_a.id)
|
||||
|
||||
def test_list_mes_production_assignments_supports_status_and_device_filter(self):
|
||||
response = self.client.get(
|
||||
self.assignment_list_url,
|
||||
{
|
||||
'merchant_id': self.merchant.id,
|
||||
'start_date': '2026-05-01',
|
||||
'end_date': '2026-05-31',
|
||||
'device_id': self.device_b.id,
|
||||
'status': mes_models.ProductionAssignmentStatusEnum.DRAFT,
|
||||
},
|
||||
**self._headers(),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
self.assertEqual(response.data['results'][0]['id'], self.assignment_draft.id)
|
||||
|
||||
def test_list_mes_production_assignments_rejects_invalid_date_range(self):
|
||||
response = self.client.get(
|
||||
self.assignment_list_url,
|
||||
{
|
||||
'merchant_id': self.merchant.id,
|
||||
'start_date': '2026-05-31',
|
||||
'end_date': '2026-05-01',
|
||||
},
|
||||
**self._headers(),
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('end_date', response.data)
|
||||
|
||||
def test_list_mes_production_assignments_requires_access_key(self):
|
||||
response = self.client.get(
|
||||
self.assignment_list_url,
|
||||
{
|
||||
'merchant_id': self.merchant.id,
|
||||
'start_date': '2026-05-01',
|
||||
'end_date': '2026-05-31',
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
@@ -26,11 +26,19 @@ from api_v2.views import (
|
||||
MissionReplyListCreateView,
|
||||
MissionReplyRejectView,
|
||||
MissionSetUrgentView,
|
||||
DeviceCategoryListCreateView,
|
||||
DeviceCategoryDetailView,
|
||||
DeviceListCreateView,
|
||||
DeviceDetailView,
|
||||
ProductionAssignmentListCreateView,
|
||||
ProductionAssignmentDetailView,
|
||||
ShipmentDeliveryPhotoDetailView,
|
||||
ShipmentDeliveryPhotoListCreateView,
|
||||
AgentUnshippedShipmentListView,
|
||||
AgentTransportVehicleListView,
|
||||
AgentTransportVehicleDetailView,
|
||||
AgentMesDeviceListView,
|
||||
AgentMesProductionAssignmentListView,
|
||||
)
|
||||
from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView
|
||||
|
||||
@@ -55,6 +63,8 @@ urlpatterns = [
|
||||
path('ai/shipments/unshipped/', AgentUnshippedShipmentListView.as_view(), name='api_v2_ai_unshipped_shipment_list'),
|
||||
path('ai/transport-vehicles/', AgentTransportVehicleListView.as_view(), name='api_v2_ai_transport_vehicle_list'),
|
||||
path('ai/transport-vehicles/<str:license_plate>/', AgentTransportVehicleDetailView.as_view(), name='api_v2_ai_transport_vehicle_detail'),
|
||||
path('ai/mes/devices/', AgentMesDeviceListView.as_view(), name='api_v2_ai_mes_device_list'),
|
||||
path('ai/mes/production-assignments/', AgentMesProductionAssignmentListView.as_view(), name='api_v2_ai_mes_production_assignment_list'),
|
||||
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'),
|
||||
@@ -65,6 +75,12 @@ urlpatterns = [
|
||||
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('mes/device-categories/', DeviceCategoryListCreateView.as_view(), name='api_v2_mes_device_category_list_create'),
|
||||
path('mes/device-categories/<int:category_id>/', DeviceCategoryDetailView.as_view(), name='api_v2_mes_device_category_detail'),
|
||||
path('mes/devices/', DeviceListCreateView.as_view(), name='api_v2_mes_device_list_create'),
|
||||
path('mes/devices/<int:device_id>/', DeviceDetailView.as_view(), name='api_v2_mes_device_detail'),
|
||||
path('mes/production-assignments/', ProductionAssignmentListCreateView.as_view(), name='api_v2_mes_production_assignment_list_create'),
|
||||
path('mes/production-assignments/<int:assignment_id>/', ProductionAssignmentDetailView.as_view(), name='api_v2_mes_production_assignment_detail'),
|
||||
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'),
|
||||
]
|
||||
|
||||
@@ -31,7 +31,21 @@ from .mission import (
|
||||
MissionReplyRejectView,
|
||||
MissionSetUrgentView,
|
||||
)
|
||||
from .ai import AgentUnshippedShipmentListView, AgentTransportVehicleListView, AgentTransportVehicleDetailView
|
||||
from .mes import (
|
||||
DeviceCategoryDetailView,
|
||||
DeviceCategoryListCreateView,
|
||||
DeviceDetailView,
|
||||
DeviceListCreateView,
|
||||
ProductionAssignmentDetailView,
|
||||
ProductionAssignmentListCreateView,
|
||||
)
|
||||
from .ai import (
|
||||
AgentUnshippedShipmentListView,
|
||||
AgentTransportVehicleListView,
|
||||
AgentTransportVehicleDetailView,
|
||||
AgentMesDeviceListView,
|
||||
AgentMesProductionAssignmentListView,
|
||||
)
|
||||
from .shipment_delivery_photo import ShipmentDeliveryPhotoDetailView, ShipmentDeliveryPhotoListCreateView
|
||||
|
||||
__all__ = [
|
||||
@@ -61,9 +75,17 @@ __all__ = [
|
||||
'MissionSetUrgentView',
|
||||
'ContentTypeListView',
|
||||
'MissionByPrintingOrderView',
|
||||
'DeviceCategoryListCreateView',
|
||||
'DeviceCategoryDetailView',
|
||||
'DeviceListCreateView',
|
||||
'DeviceDetailView',
|
||||
'ProductionAssignmentListCreateView',
|
||||
'ProductionAssignmentDetailView',
|
||||
'AgentUnshippedShipmentListView',
|
||||
'AgentTransportVehicleListView',
|
||||
'AgentTransportVehicleDetailView',
|
||||
'AgentMesDeviceListView',
|
||||
'AgentMesProductionAssignmentListView',
|
||||
'ShipmentDeliveryPhotoListCreateView',
|
||||
'ShipmentDeliveryPhotoDetailView',
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ from rest_framework.views import APIView
|
||||
|
||||
from basic_info import models as basic_models
|
||||
from flower.viewsets import LimitedLimitOffsetPagination
|
||||
from mes import models as mes_models
|
||||
from printing.models import PrintingJob
|
||||
from shipment.models import SalesItem, Shipment
|
||||
|
||||
@@ -330,3 +331,175 @@ class AgentTransportVehicleDetailView(APIView):
|
||||
raise Http404
|
||||
|
||||
return Response(AgentTransportVehicleSerializer(vehicle).data)
|
||||
|
||||
|
||||
class AgentMesDeviceCategorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = mes_models.DeviceCategory
|
||||
fields = ["id", "merchant", "name", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class AgentMesDeviceSerializer(serializers.ModelSerializer):
|
||||
category = AgentMesDeviceCategorySerializer(read_only=True)
|
||||
capacity_unit_label = serializers.CharField(source='get_capacity_unit_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = mes_models.Device
|
||||
fields = [
|
||||
"id",
|
||||
"merchant",
|
||||
"category",
|
||||
"name",
|
||||
"peak_capacity",
|
||||
"capacity_unit",
|
||||
"capacity_unit_label",
|
||||
"extra",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class AgentMesDeviceListView(mixins.ListModelMixin, GenericAPIView):
|
||||
"""
|
||||
Agent 专用:查询商户下所有 MES 设备,并返回设备分类信息。
|
||||
|
||||
GET /api/v2/ai/mes/devices/?merchant_id=<id>
|
||||
"""
|
||||
|
||||
authentication_classes = [AgentAccessKeyAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = AgentMesDeviceSerializer
|
||||
pagination_class = LimitedLimitOffsetPagination
|
||||
|
||||
def get(self, request):
|
||||
query_serializer = AgentMerchantQuerySerializer(data=request.query_params)
|
||||
query_serializer.is_valid(raise_exception=True)
|
||||
self.merchant = query_serializer.validated_data["merchant_id"]
|
||||
return self.list(request)
|
||||
|
||||
def get_queryset(self):
|
||||
merchant = getattr(self, "merchant", None)
|
||||
if merchant is None:
|
||||
query_serializer = AgentMerchantQuerySerializer(data=self.request.query_params)
|
||||
query_serializer.is_valid(raise_exception=True)
|
||||
merchant = query_serializer.validated_data["merchant_id"]
|
||||
return mes_models.Device.objects.filter(merchant=merchant).select_related("category").order_by("id")
|
||||
|
||||
|
||||
class AgentMesProductionAssignmentQuerySerializer(AgentMerchantQuerySerializer):
|
||||
start_date = serializers.DateField()
|
||||
end_date = serializers.DateField()
|
||||
device_id = serializers.IntegerField(required=False, min_value=1)
|
||||
status = serializers.ChoiceField(required=False, choices=mes_models.ProductionAssignmentStatusEnum.choices)
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
if attrs["start_date"] > attrs["end_date"]:
|
||||
raise serializers.ValidationError({"end_date": "结束日期不能早于开始日期"})
|
||||
|
||||
merchant = attrs["merchant_id"]
|
||||
device_id = attrs.get("device_id")
|
||||
if device_id is not None:
|
||||
device = mes_models.Device.objects.filter(id=device_id, merchant=merchant).first()
|
||||
if device is None:
|
||||
raise serializers.ValidationError({"device_id": "设备不存在"})
|
||||
attrs["device"] = device
|
||||
return attrs
|
||||
|
||||
|
||||
class AgentMesProductionAssignmentEmployeeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = basic_models.Employee
|
||||
fields = ["id", "name", "merchant_id"]
|
||||
|
||||
|
||||
class AgentMesProductionAssignmentDeviceSerializer(serializers.ModelSerializer):
|
||||
category = AgentMesDeviceCategorySerializer(read_only=True)
|
||||
capacity_unit_label = serializers.CharField(source='get_capacity_unit_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = mes_models.Device
|
||||
fields = [
|
||||
"id",
|
||||
"merchant",
|
||||
"category",
|
||||
"name",
|
||||
"peak_capacity",
|
||||
"capacity_unit",
|
||||
"capacity_unit_label",
|
||||
"extra",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class AgentMesProductionAssignmentSerializer(serializers.ModelSerializer):
|
||||
device = AgentMesProductionAssignmentDeviceSerializer(read_only=True)
|
||||
assigner = AgentMesProductionAssignmentEmployeeSerializer(read_only=True)
|
||||
assignee = AgentMesProductionAssignmentEmployeeSerializer(read_only=True)
|
||||
status_label = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = mes_models.ProductionAssignment
|
||||
fields = [
|
||||
"id",
|
||||
"merchant",
|
||||
"device",
|
||||
"content_type",
|
||||
"object_id",
|
||||
"assigner",
|
||||
"assignee",
|
||||
"production_quantity",
|
||||
"status",
|
||||
"status_label",
|
||||
"extra",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
|
||||
class AgentMesProductionAssignmentListView(mixins.ListModelMixin, GenericAPIView):
|
||||
"""
|
||||
Agent 专用:查询指定日期范围内创建的生产安排。
|
||||
|
||||
GET /api/v2/ai/mes/production-assignments/?merchant_id=<id>&start_date=<YYYY-MM-DD>&end_date=<YYYY-MM-DD>
|
||||
"""
|
||||
|
||||
authentication_classes = [AgentAccessKeyAuthentication]
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = AgentMesProductionAssignmentSerializer
|
||||
pagination_class = LimitedLimitOffsetPagination
|
||||
|
||||
def get(self, request):
|
||||
query_serializer = AgentMesProductionAssignmentQuerySerializer(data=request.query_params)
|
||||
query_serializer.is_valid(raise_exception=True)
|
||||
self.validated_query = query_serializer.validated_data
|
||||
return self.list(request)
|
||||
|
||||
def get_queryset(self):
|
||||
validated_query = getattr(self, "validated_query", None)
|
||||
if validated_query is None:
|
||||
query_serializer = AgentMesProductionAssignmentQuerySerializer(data=self.request.query_params)
|
||||
query_serializer.is_valid(raise_exception=True)
|
||||
validated_query = query_serializer.validated_data
|
||||
|
||||
merchant = validated_query["merchant_id"]
|
||||
queryset = (
|
||||
mes_models.ProductionAssignment.objects.filter(
|
||||
merchant=merchant,
|
||||
created_at__date__gte=validated_query["start_date"],
|
||||
created_at__date__lte=validated_query["end_date"],
|
||||
)
|
||||
.select_related("device", "device__category", "assigner", "assignee", "content_type")
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
|
||||
device = validated_query.get("device")
|
||||
if device is not None:
|
||||
queryset = queryset.filter(device=device)
|
||||
|
||||
status_value = validated_query.get("status")
|
||||
if status_value is not None:
|
||||
queryset = queryset.filter(status=status_value)
|
||||
|
||||
return queryset
|
||||
|
||||
467
api_v2/views/mes.py
Normal file
467
api_v2/views/mes.py
Normal file
@@ -0,0 +1,467 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import ProtectedError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import permissions, serializers, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from mes.models import CapacityUnitEnum, ProductionAssignment, ProductionAssignmentStatusEnum
|
||||
from mes import services as mes_services
|
||||
from mes.models import Device, DeviceCategory
|
||||
|
||||
|
||||
EXTRA_JSON_PATH_SEGMENT_RE = re.compile(r'^[A-Za-z0-9_-]+$')
|
||||
|
||||
|
||||
def _get_employee(request):
|
||||
employee = getattr(request.user, "employee", None)
|
||||
if employee is None:
|
||||
raise serializers.ValidationError("当前用户未关联员工")
|
||||
return employee
|
||||
|
||||
|
||||
def _employee_payload(employee):
|
||||
if employee is None:
|
||||
return None
|
||||
return {
|
||||
"id": employee.id,
|
||||
"name": employee.name,
|
||||
"merchant_id": employee.merchant_id,
|
||||
}
|
||||
|
||||
|
||||
def _user_payload(user):
|
||||
if user is None:
|
||||
return None
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
}
|
||||
|
||||
|
||||
def _parse_extra_filter(extra_path: str | None, extra_value: str | None):
|
||||
if not extra_path and extra_value is None:
|
||||
return None, mes_services.UNSET
|
||||
if not extra_path or extra_value is None:
|
||||
raise serializers.ValidationError({'detail': 'extra_path 和 extra_value 必须同时传入'})
|
||||
|
||||
normalized_segments = [segment.strip() for segment in extra_path.split('.') if segment.strip()]
|
||||
if not normalized_segments:
|
||||
raise serializers.ValidationError({'detail': 'extra_path 不合法'})
|
||||
for segment in normalized_segments:
|
||||
if not EXTRA_JSON_PATH_SEGMENT_RE.match(segment):
|
||||
raise serializers.ValidationError({'detail': 'extra_path 不合法'})
|
||||
|
||||
try:
|
||||
parsed_value = json.loads(extra_value)
|
||||
except json.JSONDecodeError:
|
||||
parsed_value = extra_value
|
||||
return normalized_segments, parsed_value
|
||||
|
||||
|
||||
class DeviceCategoryWriteSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(allow_blank=False, max_length=100)
|
||||
|
||||
|
||||
class DeviceWriteSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(required=False, allow_blank=False, max_length=100)
|
||||
category = serializers.IntegerField(required=False, min_value=1)
|
||||
peak_capacity = serializers.IntegerField(required=False, min_value=1)
|
||||
capacity_unit = serializers.ChoiceField(required=False, choices=CapacityUnitEnum.choices)
|
||||
extra = serializers.JSONField(required=False, allow_null=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.is_create = kwargs.pop("is_create", False)
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.is_create:
|
||||
self.fields["name"].required = True
|
||||
self.fields["category"].required = True
|
||||
self.fields["peak_capacity"].required = True
|
||||
|
||||
def validate_category(self, value):
|
||||
employee = self.context["employee"]
|
||||
try:
|
||||
return DeviceCategory.objects.get(id=value, merchant=employee.merchant)
|
||||
except DeviceCategory.DoesNotExist as exc:
|
||||
raise serializers.ValidationError("设备分类不存在") from exc
|
||||
|
||||
|
||||
class DeviceCategorySerializer(serializers.ModelSerializer):
|
||||
created_by = serializers.SerializerMethodField()
|
||||
operator = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = DeviceCategory
|
||||
fields = [
|
||||
"id",
|
||||
"merchant",
|
||||
"name",
|
||||
"created_by",
|
||||
"operator",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_created_by(self, obj):
|
||||
return _user_payload(obj.created_by)
|
||||
|
||||
def get_operator(self, obj):
|
||||
return _employee_payload(obj.operator)
|
||||
|
||||
|
||||
class DeviceSerializer(serializers.ModelSerializer):
|
||||
created_by = serializers.SerializerMethodField()
|
||||
operator = serializers.SerializerMethodField()
|
||||
category_name = serializers.CharField(source="category.name", read_only=True)
|
||||
category = serializers.IntegerField(source="category_id", read_only=True)
|
||||
capacity_unit_label = serializers.CharField(source='get_capacity_unit_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
"id",
|
||||
"merchant",
|
||||
"category",
|
||||
"category_name",
|
||||
"name",
|
||||
"peak_capacity",
|
||||
"capacity_unit",
|
||||
"capacity_unit_label",
|
||||
"extra",
|
||||
"created_by",
|
||||
"operator",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_created_by(self, obj):
|
||||
return _user_payload(obj.created_by)
|
||||
|
||||
def get_operator(self, obj):
|
||||
return _employee_payload(obj.operator)
|
||||
|
||||
|
||||
class ProductionAssignmentWriteSerializer(serializers.Serializer):
|
||||
device = serializers.IntegerField(required=False, min_value=1)
|
||||
content_type = serializers.IntegerField(required=False, min_value=1)
|
||||
object_id = serializers.IntegerField(required=False, min_value=1)
|
||||
assigner = serializers.IntegerField(required=False, min_value=1)
|
||||
assignee = serializers.IntegerField(required=False, min_value=1, allow_null=True)
|
||||
production_quantity = serializers.IntegerField(required=False, min_value=1)
|
||||
status = serializers.ChoiceField(required=False, choices=ProductionAssignmentStatusEnum.choices)
|
||||
extra = serializers.JSONField(required=False, allow_null=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.is_create = kwargs.pop("is_create", False)
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.is_create:
|
||||
self.fields["device"].required = True
|
||||
self.fields["content_type"].required = True
|
||||
self.fields["object_id"].required = True
|
||||
self.fields["assigner"].required = True
|
||||
self.fields["production_quantity"].required = True
|
||||
|
||||
def validate_device(self, value):
|
||||
employee = self.context["employee"]
|
||||
try:
|
||||
return Device.objects.get(id=value, merchant=employee.merchant)
|
||||
except Device.DoesNotExist as exc:
|
||||
raise serializers.ValidationError("设备不存在") from exc
|
||||
|
||||
def validate_content_type(self, value):
|
||||
try:
|
||||
return ContentType.objects.get(id=value)
|
||||
except ContentType.DoesNotExist as exc:
|
||||
raise serializers.ValidationError("关联对象类型不存在") from exc
|
||||
|
||||
def validate_assigner(self, value):
|
||||
employee = self.context["employee"]
|
||||
try:
|
||||
return employee.merchant.employees.get(id=value)
|
||||
except Exception as exc:
|
||||
raise serializers.ValidationError("指派者不存在") from exc
|
||||
|
||||
def validate_assignee(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
employee = self.context["employee"]
|
||||
try:
|
||||
return employee.merchant.employees.get(id=value)
|
||||
except Exception as exc:
|
||||
raise serializers.ValidationError("被指派人不存在") from exc
|
||||
|
||||
|
||||
class ProductionAssignmentSerializer(serializers.ModelSerializer):
|
||||
device_name = serializers.CharField(source='device.name', read_only=True)
|
||||
assigner = serializers.SerializerMethodField()
|
||||
assignee = serializers.SerializerMethodField()
|
||||
created_by = serializers.SerializerMethodField()
|
||||
operator = serializers.SerializerMethodField()
|
||||
status_label = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProductionAssignment
|
||||
fields = [
|
||||
'id',
|
||||
'merchant',
|
||||
'device',
|
||||
'device_name',
|
||||
'content_type',
|
||||
'object_id',
|
||||
'assigner',
|
||||
'assignee',
|
||||
'production_quantity',
|
||||
'status',
|
||||
'status_label',
|
||||
'extra',
|
||||
'created_by',
|
||||
'operator',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_assigner(self, obj):
|
||||
return _employee_payload(obj.assigner)
|
||||
|
||||
def get_assignee(self, obj):
|
||||
return _employee_payload(obj.assignee)
|
||||
|
||||
def get_created_by(self, obj):
|
||||
return _user_payload(obj.created_by)
|
||||
|
||||
def get_operator(self, obj):
|
||||
return _employee_payload(obj.operator)
|
||||
|
||||
|
||||
class DeviceCategoryListCreateView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
employee = _get_employee(request)
|
||||
queryset = mes_services.list_device_categories_for_merchant(merchant=employee.merchant)
|
||||
name = request.query_params.get("name")
|
||||
if name:
|
||||
queryset = queryset.filter(name__icontains=name.strip())
|
||||
return Response(DeviceCategorySerializer(queryset, many=True).data)
|
||||
|
||||
def post(self, request):
|
||||
employee = _get_employee(request)
|
||||
serializer = DeviceCategoryWriteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
category = mes_services.create_device_category(
|
||||
merchant=employee.merchant,
|
||||
name=serializer.validated_data["name"],
|
||||
created_by=request.user,
|
||||
operator=employee,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(DeviceCategorySerializer(category).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class DeviceCategoryDetailView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_object(self, request, category_id):
|
||||
employee = _get_employee(request)
|
||||
return get_object_or_404(
|
||||
mes_services.list_device_categories_for_merchant(merchant=employee.merchant),
|
||||
id=category_id,
|
||||
)
|
||||
|
||||
def get(self, request, category_id):
|
||||
category = self.get_object(request, category_id)
|
||||
return Response(DeviceCategorySerializer(category).data)
|
||||
|
||||
def patch(self, request, category_id):
|
||||
employee = _get_employee(request)
|
||||
category = self.get_object(request, category_id)
|
||||
serializer = DeviceCategoryWriteSerializer(data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
category = mes_services.update_device_category(
|
||||
category=category,
|
||||
operator=employee,
|
||||
name=serializer.validated_data.get("name", mes_services.UNSET),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(DeviceCategorySerializer(category).data)
|
||||
|
||||
def delete(self, request, category_id):
|
||||
employee = _get_employee(request)
|
||||
category = self.get_object(request, category_id)
|
||||
try:
|
||||
mes_services.delete_device_category(category=category, operator=employee)
|
||||
except ProtectedError:
|
||||
return Response({"detail": "设备分类已被设备引用,不能删除"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class DeviceListCreateView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
employee = _get_employee(request)
|
||||
category_id = request.query_params.get("category")
|
||||
extra_json_path, extra_json_value = _parse_extra_filter(
|
||||
request.query_params.get("extra_path"),
|
||||
request.query_params.get("extra_value"),
|
||||
)
|
||||
queryset = mes_services.list_devices_for_merchant(
|
||||
merchant=employee.merchant,
|
||||
category_id=int(category_id) if category_id else None,
|
||||
extra_json_path=extra_json_path,
|
||||
extra_json_value=extra_json_value,
|
||||
)
|
||||
name = request.query_params.get("name")
|
||||
if name:
|
||||
queryset = queryset.filter(name__icontains=name.strip())
|
||||
return Response(DeviceSerializer(queryset, many=True).data)
|
||||
|
||||
def post(self, request):
|
||||
employee = _get_employee(request)
|
||||
serializer = DeviceWriteSerializer(data=request.data, is_create=True, context={"employee": employee})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
device = mes_services.create_device(
|
||||
merchant=employee.merchant,
|
||||
category=serializer.validated_data["category"],
|
||||
name=serializer.validated_data["name"],
|
||||
created_by=request.user,
|
||||
operator=employee,
|
||||
peak_capacity=serializer.validated_data['peak_capacity'],
|
||||
capacity_unit=serializer.validated_data.get('capacity_unit', CapacityUnitEnum.METER),
|
||||
extra=serializer.validated_data.get("extra"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(DeviceSerializer(device).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class DeviceDetailView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_object(self, request, device_id):
|
||||
employee = _get_employee(request)
|
||||
return get_object_or_404(
|
||||
mes_services.list_devices_for_merchant(merchant=employee.merchant),
|
||||
id=device_id,
|
||||
)
|
||||
|
||||
def get(self, request, device_id):
|
||||
device = self.get_object(request, device_id)
|
||||
return Response(DeviceSerializer(device).data)
|
||||
|
||||
def patch(self, request, device_id):
|
||||
employee = _get_employee(request)
|
||||
device = self.get_object(request, device_id)
|
||||
serializer = DeviceWriteSerializer(data=request.data, partial=True, context={"employee": employee})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
device = mes_services.update_device(
|
||||
device=device,
|
||||
operator=employee,
|
||||
name=serializer.validated_data.get("name", mes_services.UNSET),
|
||||
category=serializer.validated_data.get("category", mes_services.UNSET),
|
||||
peak_capacity=serializer.validated_data.get('peak_capacity', mes_services.UNSET),
|
||||
capacity_unit=serializer.validated_data.get('capacity_unit', mes_services.UNSET),
|
||||
extra=serializer.validated_data.get("extra", mes_services.UNSET),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(DeviceSerializer(device).data)
|
||||
|
||||
def delete(self, request, device_id):
|
||||
employee = _get_employee(request)
|
||||
device = self.get_object(request, device_id)
|
||||
mes_services.delete_device(device=device, operator=employee)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ProductionAssignmentListCreateView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
employee = _get_employee(request)
|
||||
device_id = request.query_params.get('device')
|
||||
content_type_id = request.query_params.get('content_type')
|
||||
object_id = request.query_params.get('object_id')
|
||||
status_value = request.query_params.get('status')
|
||||
queryset = mes_services.list_production_assignments_for_merchant(
|
||||
merchant=employee.merchant,
|
||||
device_id=int(device_id) if device_id else None,
|
||||
content_type_id=int(content_type_id) if content_type_id else None,
|
||||
object_id=int(object_id) if object_id else None,
|
||||
status=int(status_value) if status_value else None,
|
||||
)
|
||||
return Response(ProductionAssignmentSerializer(queryset, many=True).data)
|
||||
|
||||
def post(self, request):
|
||||
employee = _get_employee(request)
|
||||
serializer = ProductionAssignmentWriteSerializer(data=request.data, is_create=True, context={'employee': employee})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
assignment = mes_services.create_production_assignment(
|
||||
merchant=employee.merchant,
|
||||
device=serializer.validated_data['device'],
|
||||
content_type=serializer.validated_data['content_type'],
|
||||
object_id=serializer.validated_data['object_id'],
|
||||
assigner=serializer.validated_data['assigner'],
|
||||
assignee=serializer.validated_data.get('assignee'),
|
||||
production_quantity=serializer.validated_data['production_quantity'],
|
||||
status=serializer.validated_data.get('status', ProductionAssignmentStatusEnum.DRAFT),
|
||||
created_by=request.user,
|
||||
operator=employee,
|
||||
extra=serializer.validated_data.get('extra'),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(ProductionAssignmentSerializer(assignment).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class ProductionAssignmentDetailView(APIView):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_object(self, request, assignment_id):
|
||||
employee = _get_employee(request)
|
||||
return get_object_or_404(
|
||||
mes_services.list_production_assignments_for_merchant(merchant=employee.merchant),
|
||||
id=assignment_id,
|
||||
)
|
||||
|
||||
def get(self, request, assignment_id):
|
||||
assignment = self.get_object(request, assignment_id)
|
||||
return Response(ProductionAssignmentSerializer(assignment).data)
|
||||
|
||||
def patch(self, request, assignment_id):
|
||||
employee = _get_employee(request)
|
||||
assignment = self.get_object(request, assignment_id)
|
||||
serializer = ProductionAssignmentWriteSerializer(data=request.data, partial=True, context={'employee': employee})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
try:
|
||||
assignment = mes_services.update_production_assignment(
|
||||
assignment=assignment,
|
||||
operator=employee,
|
||||
device=serializer.validated_data.get('device', mes_services.UNSET),
|
||||
assignee=serializer.validated_data.get('assignee', mes_services.UNSET),
|
||||
production_quantity=serializer.validated_data.get('production_quantity', mes_services.UNSET),
|
||||
status=serializer.validated_data.get('status', mes_services.UNSET),
|
||||
extra=serializer.validated_data.get('extra', mes_services.UNSET),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(ProductionAssignmentSerializer(assignment).data)
|
||||
|
||||
def delete(self, request, assignment_id):
|
||||
employee = _get_employee(request)
|
||||
assignment = self.get_object(request, assignment_id)
|
||||
mes_services.delete_production_assignment(assignment=assignment, operator=employee)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -11,99 +11,103 @@
|
||||
### 认证方式
|
||||
|
||||
```
|
||||
Authorization: <AGENT_ACCESS_KEY>
|
||||
# API v2 Agent 接口
|
||||
|
||||
本文档描述 `api/v2/ai/...` 下供内部 agent 调用的简化鉴权接口。
|
||||
|
||||
## 鉴权方式
|
||||
|
||||
- 使用请求头 `Authorization`
|
||||
- 直接传入固定密钥
|
||||
- 不使用 `Bearer` 前缀
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl -X GET \
|
||||
'https://api.example.com/api/v2/ai/mes/devices/?merchant_id=1' \
|
||||
-H 'Authorization: your-agent-access-key'
|
||||
```
|
||||
|
||||
`AGENT_ACCESS_KEY` 由后端部署时通过同名环境变量配置。未配置时所有请求均返回 `401`。
|
||||
配置项:
|
||||
|
||||
- [`AGENT_ACCESS_KEY`](/home/f/coding/flower/flower/settings.py)
|
||||
|
||||
---
|
||||
|
||||
## 数据结构
|
||||
## 查询未进入送货单的出货单
|
||||
|
||||
### TransportVehicle
|
||||
- **URL**: `/api/v2/ai/shipments/unshipped/`
|
||||
- **Method**: `GET`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"merchant_id": 10,
|
||||
"name": "大卡车",
|
||||
"license_plate": "粤A12345",
|
||||
"material_capacities": [
|
||||
{"id": 1, "material_name": "坯布", "capacity": 500},
|
||||
{"id": 2, "material_name": "成品", "capacity": 300}
|
||||
],
|
||||
"created_at": "2026-04-01T08:00:00+08:00",
|
||||
"updated_at": "2026-04-01T08:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
### Shipment(出货单)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 100,
|
||||
"merchant_id": 10,
|
||||
"customer": 5,
|
||||
"customer_name": "客户A",
|
||||
"fabric": "棉布 40S",
|
||||
"order_description": "滚筒预警",
|
||||
"shipment_date": "2026-04-14",
|
||||
"address": "广州市天河区",
|
||||
"contact_name": "张三",
|
||||
"contact_phone": "13800000000",
|
||||
"area": "华东",
|
||||
"remark": "备注",
|
||||
"status": "pending",
|
||||
"status_display": "待出货",
|
||||
"external_id": null,
|
||||
"geo_coordinates": {"lat": 23.1291, "lng": 113.2644},
|
||||
"delivery_id": null,
|
||||
"delivery": null,
|
||||
"sales_items": [
|
||||
{
|
||||
"id": 201,
|
||||
"name": "销售品甲",
|
||||
"quantity": "50.00",
|
||||
"unit": 1,
|
||||
"unit_display": "件",
|
||||
"position": "A-01",
|
||||
"remark": "",
|
||||
"printing_job_id": 88,
|
||||
"printing_job_width": "150cm"
|
||||
},
|
||||
{
|
||||
"id": 202,
|
||||
"name": "销售品乙",
|
||||
"quantity": "10.00",
|
||||
"unit": 1,
|
||||
"unit_display": "件",
|
||||
"position": "",
|
||||
"remark": "",
|
||||
"printing_job_id": null,
|
||||
"printing_job_width": null
|
||||
}
|
||||
],
|
||||
"created_at": "2026-04-14T10:00:00+08:00",
|
||||
"updated_at": "2026-04-14T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运输车辆列表
|
||||
|
||||
- URL: `/api/v2/ai/transport-vehicles/`
|
||||
- Method: `GET`
|
||||
|
||||
查询参数:
|
||||
### 查询参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `merchant_id` | int | 是 | 商户 ID |
|
||||
| `limit` | int | 否 | 分页每页数量(默认由服务端决定) |
|
||||
| `offset` | int | 否 | 分页偏移量 |
|
||||
| `area` | string | 是 | 地区,精确匹配 |
|
||||
| `limit` | int | 否 | 分页大小 |
|
||||
| `offset` | int | 否 | 分页偏移 |
|
||||
|
||||
响应为分页结构,`results` 中每条为 `TransportVehicle`,包含该车辆的所有物料容量。
|
||||
### 错误响应
|
||||
|
||||
#### 401 Unauthorized
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "AGENT_ACCESS_KEY 无效"
|
||||
}
|
||||
```
|
||||
|
||||
#### 400 Bad Request
|
||||
|
||||
```json
|
||||
{
|
||||
"merchant_id": ["This field is required."]
|
||||
}
|
||||
```
|
||||
|
||||
## 查询 MES 设备列表
|
||||
|
||||
- **URL**: `/api/v2/ai/mes/devices/`
|
||||
- **Method**: `GET`
|
||||
|
||||
### 查询参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `merchant_id` | int | 是 | 商户 ID |
|
||||
| `limit` | int | 否 | 分页大小 |
|
||||
| `offset` | int | 否 | 分页偏移 |
|
||||
|
||||
### 说明
|
||||
|
||||
- 返回当前商户下全部 MES 设备
|
||||
- 每条记录包含所属设备分类信息
|
||||
- 使用与其他 agent 接口相同的固定密钥鉴权方式,不走 JWT
|
||||
|
||||
## 查询指定日期范围内的 MES 生产安排
|
||||
|
||||
- **URL**: `/api/v2/ai/mes/production-assignments/`
|
||||
- **Method**: `GET`
|
||||
|
||||
### 查询参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `merchant_id` | int | 是 | 商户 ID |
|
||||
| `start_date` | date | 是 | 开始日期,格式 `YYYY-MM-DD` |
|
||||
| `end_date` | date | 是 | 结束日期,格式 `YYYY-MM-DD` |
|
||||
| `device_id` | int | 否 | 设备 ID,可选筛选 |
|
||||
| `status` | int | 否 | 状态值,可选筛选 |
|
||||
| `limit` | int | 否 | 分页大小 |
|
||||
| `offset` | int | 否 | 分页偏移 |
|
||||
|
||||
### 当前实现说明
|
||||
|
||||
- 当前 MES 模型没有单独的排产日期字段
|
||||
- 所以本接口当前是按 `created_at` 的日期范围过滤生产安排
|
||||
- 也就是说,它查的是“这个时间范围内创建的生产安排”
|
||||
|
||||
说明:
|
||||
|
||||
|
||||
@@ -24,70 +24,266 @@ curl -X GET \
|
||||
|
||||
## 查询未进入送货单的出货单
|
||||
|
||||
- **URL**: `/api/v2/ai/shipments/unshipped/`
|
||||
- **Method**: `GET`
|
||||
# API v2 Agent 接口文档
|
||||
|
||||
### 查询参数
|
||||
本文档面向 AI Agent 及外部自动化调用方,描述 `/api/v2/ai/` 前缀下的所有接口。
|
||||
|
||||
## 基本约定
|
||||
|
||||
- Base URL: `/api/v2/ai`
|
||||
- 认证:所有接口使用固定 API Key,通过 `Authorization` 请求头直接传入,无 `Bearer` 前缀
|
||||
- 多商户隔离:每个接口均需传入 `merchant_id` 查询参数,后端以此确定数据范围
|
||||
|
||||
### 认证方式
|
||||
|
||||
```text
|
||||
Authorization: <AGENT_ACCESS_KEY>
|
||||
```
|
||||
|
||||
`AGENT_ACCESS_KEY` 由后端部署时通过同名环境变量配置。未配置时所有请求均返回 `401`。
|
||||
|
||||
---
|
||||
|
||||
## 数据结构
|
||||
|
||||
### TransportVehicle
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"merchant_id": 10,
|
||||
"name": "大卡车",
|
||||
"license_plate": "粤A12345",
|
||||
"material_capacities": [
|
||||
{"id": 1, "material_name": "坯布", "capacity": 500},
|
||||
{"id": 2, "material_name": "成品", "capacity": 300}
|
||||
],
|
||||
"created_at": "2026-04-01T08:00:00+08:00",
|
||||
"updated_at": "2026-04-01T08:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
### Shipment
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 100,
|
||||
"merchant_id": 10,
|
||||
"customer": 5,
|
||||
"customer_name": "客户A",
|
||||
"fabric": "棉布 40S",
|
||||
"order_description": "滚筒预警",
|
||||
"shipment_date": "2026-04-14",
|
||||
"address": "广州市天河区",
|
||||
"contact_name": "张三",
|
||||
"contact_phone": "13800000000",
|
||||
"area": "华东",
|
||||
"remark": "备注",
|
||||
"status": "pending",
|
||||
"status_display": "待出货",
|
||||
"external_id": null,
|
||||
"geo_coordinates": {"lat": 23.1291, "lng": 113.2644},
|
||||
"delivery_id": null,
|
||||
"delivery": null,
|
||||
"sales_items": [
|
||||
{
|
||||
"id": 201,
|
||||
"name": "销售品甲",
|
||||
"quantity": "50.00",
|
||||
"unit": 1,
|
||||
"unit_display": "件",
|
||||
"position": "A-01",
|
||||
"remark": "",
|
||||
"printing_job_id": 88,
|
||||
"printing_job_width": "150cm"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-04-14T10:00:00+08:00",
|
||||
"updated_at": "2026-04-14T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
### MesDevice
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"merchant": 10,
|
||||
"category": {
|
||||
"id": 2,
|
||||
"merchant": 10,
|
||||
"name": "打印机",
|
||||
"created_at": "2026-05-01T08:00:00+08:00",
|
||||
"updated_at": "2026-05-01T08:00:00+08:00"
|
||||
},
|
||||
"name": "A-01",
|
||||
"peak_capacity": 120,
|
||||
"capacity_unit": 1,
|
||||
"capacity_unit_label": "米",
|
||||
"extra": {"capacity": {"per_hour": 120}},
|
||||
"created_at": "2026-05-01T08:00:00+08:00",
|
||||
"updated_at": "2026-05-01T08:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
### MesProductionAssignment
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 100,
|
||||
"merchant": 10,
|
||||
"device": {
|
||||
"id": 1,
|
||||
"merchant": 10,
|
||||
"category": {
|
||||
"id": 2,
|
||||
"merchant": 10,
|
||||
"name": "打印机",
|
||||
"created_at": "2026-05-01T08:00:00+08:00",
|
||||
"updated_at": "2026-05-01T08:00:00+08:00"
|
||||
},
|
||||
"name": "A-01",
|
||||
"peak_capacity": 120,
|
||||
"capacity_unit": 1,
|
||||
"capacity_unit_label": "米",
|
||||
"extra": null,
|
||||
"created_at": "2026-05-01T08:00:00+08:00",
|
||||
"updated_at": "2026-05-01T08:00:00+08:00"
|
||||
},
|
||||
"content_type": 45,
|
||||
"object_id": 9001,
|
||||
"assigner": {"id": 20, "name": "张三", "merchant_id": 10},
|
||||
"assignee": {"id": 21, "name": "李四", "merchant_id": 10},
|
||||
"production_quantity": 300,
|
||||
"status": 2,
|
||||
"status_label": "已发布",
|
||||
"extra": {"batch": "A1"},
|
||||
"created_at": "2026-05-02T10:00:00+08:00",
|
||||
"updated_at": "2026-05-02T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运输车辆列表
|
||||
|
||||
- URL: `/api/v2/ai/transport-vehicles/`
|
||||
- Method: `GET`
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `merchant_id` | int | 是 | 商户 ID |
|
||||
| `area` | string | 是 | 地区,精确匹配 |
|
||||
| `limit` | int | 否 | 分页大小 |
|
||||
| `offset` | int | 否 | 分页偏移 |
|
||||
| `limit` | int | 否 | 分页每页数量 |
|
||||
| `offset` | int | 否 | 分页偏移量 |
|
||||
|
||||
### 业务定义
|
||||
说明:
|
||||
|
||||
这里的“未出货”定义为:
|
||||
- `material_capacities` 表示该车辆对不同物料的最大装载量
|
||||
- 只返回 `merchant_id` 对应商户的车辆
|
||||
|
||||
- `Shipment.delivery_id is null`
|
||||
响应为分页结构,`results` 中每条为 `TransportVehicle`。
|
||||
|
||||
也就是该出货单尚未进入送货单。
|
||||
## 运输车辆详情
|
||||
|
||||
### 响应示例
|
||||
- URL: `/api/v2/ai/transport-vehicles/<license_plate>/`
|
||||
- Method: `GET`
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 1,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 101,
|
||||
"merchant_id": 1,
|
||||
"customer": 12,
|
||||
"customer_name": "客户A",
|
||||
"shipment_date": "2026-04-14",
|
||||
"address": "",
|
||||
"contact_name": "张三",
|
||||
"contact_phone": "13800000000",
|
||||
"area": "华东",
|
||||
"remark": "目标记录",
|
||||
"status": 1,
|
||||
"status_display": "草稿(未发布)",
|
||||
"external_id": null,
|
||||
"delivery": null,
|
||||
"created_at": "2026-04-14T10:00:00Z",
|
||||
"updated_at": "2026-04-14T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
路径参数:
|
||||
|
||||
### 错误响应
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `license_plate` | 车牌号码 |
|
||||
|
||||
#### 401 Unauthorized
|
||||
查询参数:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "AGENT_ACCESS_KEY 无效"
|
||||
}
|
||||
```
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `merchant_id` | int | 是 | 商户 ID |
|
||||
|
||||
#### 400 Bad Request
|
||||
说明:
|
||||
|
||||
```json
|
||||
{
|
||||
"merchant_id": ["This field is required."]
|
||||
}
|
||||
```
|
||||
- 同一商户内 `license_plate` 唯一
|
||||
- 不同商户可能存在相同车牌号,`merchant_id` 是必须的
|
||||
- 车辆不存在时返回 `404`
|
||||
|
||||
## 未出货出货单列表
|
||||
|
||||
- URL: `/api/v2/ai/shipments/unshipped/`
|
||||
- Method: `GET`
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `merchant_id` | int | 是 | 商户 ID |
|
||||
| `area` | string | 是 | 地区筛选,精确匹配 |
|
||||
| `limit` | int | 否 | 分页每页数量 |
|
||||
| `offset` | int | 否 | 分页偏移量 |
|
||||
|
||||
说明:
|
||||
|
||||
- “未出货”定义:`delivery` 为空,即尚未关联送货单
|
||||
- `delivery_id` 未关联时返回 `null`
|
||||
- 结果按 `created_at` 降序排列
|
||||
- 只返回 `merchant_id` 对应商户的数据
|
||||
|
||||
响应为分页结构,`results` 中每条为 `Shipment`。
|
||||
|
||||
## MES 设备列表
|
||||
|
||||
- URL: `/api/v2/ai/mes/devices/`
|
||||
- Method: `GET`
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `merchant_id` | int | 是 | 商户 ID |
|
||||
| `limit` | int | 否 | 分页每页数量 |
|
||||
| `offset` | int | 否 | 分页偏移量 |
|
||||
|
||||
说明:
|
||||
|
||||
- 使用与其他 agent API 相同的 `AGENT_ACCESS_KEY` 鉴权,不走 JWT
|
||||
- 返回当前商户下全部 MES 设备
|
||||
- 每条设备记录都带完整的设备分类信息
|
||||
|
||||
响应为分页结构,`results` 中每条为 `MesDevice`。
|
||||
|
||||
## MES 生产安排列表
|
||||
|
||||
- URL: `/api/v2/ai/mes/production-assignments/`
|
||||
- Method: `GET`
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `merchant_id` | int | 是 | 商户 ID |
|
||||
| `start_date` | date | 是 | 开始日期,格式 `YYYY-MM-DD` |
|
||||
| `end_date` | date | 是 | 结束日期,格式 `YYYY-MM-DD` |
|
||||
| `device_id` | int | 否 | 设备 ID,可选筛选 |
|
||||
| `status` | int | 否 | 生产安排状态,可选筛选 |
|
||||
| `limit` | int | 否 | 分页每页数量 |
|
||||
| `offset` | int | 否 | 分页偏移量 |
|
||||
|
||||
说明:
|
||||
|
||||
- 当前模型没有单独的“计划生产日期”字段
|
||||
- 因此该接口当前按 `created_at` 所在日期做范围过滤
|
||||
- 也就是“查询指定日期范围内创建的生产安排”
|
||||
- `device_id` 和 `status` 都是可选筛选条件
|
||||
|
||||
状态值:
|
||||
|
||||
| 值 | 显示值 |
|
||||
|------|------|
|
||||
| `1` | `Draft` |
|
||||
| `2` | `已发布` |
|
||||
| `3` | `已接收` |
|
||||
| `4` | `已取消` |
|
||||
| `5` | `已完工` |
|
||||
|
||||
响应为分页结构,`results` 中每条为 `MesProductionAssignment`。
|
||||
|
||||
378
docs/api_v2_mes_api.md
Normal file
378
docs/api_v2_mes_api.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# API v2 MES 模块接口文档
|
||||
|
||||
本文档面向前端和业务评审,描述当前已经开放的 `MES` 公开接口,以及每个接口当前真实可用的字段。
|
||||
|
||||
## 基本约定
|
||||
|
||||
- Base URL: `/api/v2/mes`
|
||||
- 认证:所有接口都需要登录。
|
||||
- 人员身份:后端使用 `request.user.employee` 作为当前员工身份。
|
||||
- 多商户隔离:所有接口只允许访问当前员工所属商户的数据。
|
||||
- 创建审计:`created_by` 由当前登录用户自动写入。
|
||||
- 操作审计:`operator` 由当前登录员工自动写入。
|
||||
- `extra`:自由结构 JSON 字段,后端不解释业务内容。
|
||||
|
||||
## 枚举定义
|
||||
|
||||
### 产能单位 `capacity_unit`
|
||||
|
||||
| 值 | 显示值 |
|
||||
|------|------|
|
||||
| `1` | `米` |
|
||||
| `2` | `码` |
|
||||
|
||||
### 生产指派状态 `status`
|
||||
|
||||
| 值 | 显示值 |
|
||||
|------|------|
|
||||
| `1` | `Draft` |
|
||||
| `2` | `已发布` |
|
||||
| `3` | `已接收` |
|
||||
| `4` | `已取消` |
|
||||
| `5` | `已完工` |
|
||||
|
||||
## 返回对象
|
||||
|
||||
### DeviceCategory 对象
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"merchant": 10,
|
||||
"name": "打印机",
|
||||
"created_by": {
|
||||
"id": 5,
|
||||
"username": "admin"
|
||||
},
|
||||
"operator": {
|
||||
"id": 20,
|
||||
"name": "张三",
|
||||
"merchant_id": 10
|
||||
},
|
||||
"created_at": "2026-05-08T12:00:00+08:00",
|
||||
"updated_at": "2026-05-08T12:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | integer | 主键 |
|
||||
| `merchant` | integer | 所属商户 ID |
|
||||
| `name` | string | 设备分类名称 |
|
||||
| `created_by` | object | 创建用户信息,包含 `id`、`username` |
|
||||
| `operator` | object | 操作员工信息,包含 `id`、`name`、`merchant_id` |
|
||||
| `created_at` | datetime string | 创建时间 |
|
||||
| `updated_at` | datetime string | 更新时间 |
|
||||
|
||||
### Device 对象
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 100,
|
||||
"merchant": 10,
|
||||
"category": 1,
|
||||
"category_name": "打印机",
|
||||
"name": "A-01",
|
||||
"peak_capacity": 120,
|
||||
"capacity_unit": 1,
|
||||
"capacity_unit_label": "米",
|
||||
"extra": {
|
||||
"capacity": {
|
||||
"per_hour": 120
|
||||
}
|
||||
},
|
||||
"created_by": {
|
||||
"id": 5,
|
||||
"username": "admin"
|
||||
},
|
||||
"operator": {
|
||||
"id": 20,
|
||||
"name": "张三",
|
||||
"merchant_id": 10
|
||||
},
|
||||
"created_at": "2026-05-08T12:00:00+08:00",
|
||||
"updated_at": "2026-05-08T12:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | integer | 主键 |
|
||||
| `merchant` | integer | 所属商户 ID |
|
||||
| `category` | integer | 设备分类 ID |
|
||||
| `category_name` | string | 设备分类名称 |
|
||||
| `name` | string | 设备名称 |
|
||||
| `peak_capacity` | integer | 峰值产能,必须大于 `0` |
|
||||
| `capacity_unit` | integer | 产能单位枚举值 |
|
||||
| `capacity_unit_label` | string | 产能单位显示值 |
|
||||
| `extra` | object/null | 自由结构 JSON 扩展字段 |
|
||||
| `created_by` | object | 创建用户信息,包含 `id`、`username` |
|
||||
| `operator` | object | 操作员工信息,包含 `id`、`name`、`merchant_id` |
|
||||
| `created_at` | datetime string | 创建时间 |
|
||||
| `updated_at` | datetime string | 更新时间 |
|
||||
|
||||
### ProductionAssignment 对象
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 200,
|
||||
"merchant": 10,
|
||||
"device": 100,
|
||||
"device_name": "A-01",
|
||||
"content_type": 45,
|
||||
"object_id": 9001,
|
||||
"assigner": {
|
||||
"id": 20,
|
||||
"name": "张三",
|
||||
"merchant_id": 10
|
||||
},
|
||||
"assignee": {
|
||||
"id": 21,
|
||||
"name": "李四",
|
||||
"merchant_id": 10
|
||||
},
|
||||
"production_quantity": 300,
|
||||
"status": 1,
|
||||
"status_label": "Draft",
|
||||
"extra": {
|
||||
"batch": "A1"
|
||||
},
|
||||
"created_by": {
|
||||
"id": 5,
|
||||
"username": "admin"
|
||||
},
|
||||
"operator": {
|
||||
"id": 20,
|
||||
"name": "张三",
|
||||
"merchant_id": 10
|
||||
},
|
||||
"created_at": "2026-05-08T12:00:00+08:00",
|
||||
"updated_at": "2026-05-08T12:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | integer | 主键 |
|
||||
| `merchant` | integer | 所属商户 ID |
|
||||
| `device` | integer | 设备 ID |
|
||||
| `device_name` | string | 设备名称 |
|
||||
| `content_type` | integer | 关联业务对象类型 ID |
|
||||
| `object_id` | integer | 关联业务对象主键 |
|
||||
| `assigner` | object | 指派者员工信息,包含 `id`、`name`、`merchant_id` |
|
||||
| `assignee` | object/null | 被指派人员工信息,包含 `id`、`name`、`merchant_id`,可空 |
|
||||
| `production_quantity` | integer | 生产数量,必须大于 `0`,单位跟随设备 `capacity_unit` |
|
||||
| `status` | integer | 状态枚举值 |
|
||||
| `status_label` | string | 状态显示值 |
|
||||
| `extra` | object/null | 自由结构 JSON 扩展字段 |
|
||||
| `created_by` | object | 创建用户信息,包含 `id`、`username` |
|
||||
| `operator` | object | 操作员工信息,包含 `id`、`name`、`merchant_id` |
|
||||
| `created_at` | datetime string | 创建时间 |
|
||||
| `updated_at` | datetime string | 更新时间 |
|
||||
|
||||
## 设备分类接口
|
||||
|
||||
### 设备分类列表
|
||||
|
||||
- URL: `/api/v2/mes/device-categories/`
|
||||
- Method: `GET`
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 否 | 按名称模糊匹配 |
|
||||
|
||||
### 创建设备分类
|
||||
|
||||
- URL: `/api/v2/mes/device-categories/`
|
||||
- Method: `POST`
|
||||
|
||||
请求字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 是 | 设备分类名称 |
|
||||
|
||||
### 设备分类详情
|
||||
|
||||
- URL: `/api/v2/mes/device-categories/{category_id}/`
|
||||
- Method: `GET`
|
||||
|
||||
### 更新设备分类
|
||||
|
||||
- URL: `/api/v2/mes/device-categories/{category_id}/`
|
||||
- Method: `PATCH`
|
||||
|
||||
请求字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 否 | 设备分类名称 |
|
||||
|
||||
### 删除设备分类
|
||||
|
||||
- URL: `/api/v2/mes/device-categories/{category_id}/`
|
||||
- Method: `DELETE`
|
||||
|
||||
错误示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "设备分类已被设备引用,不能删除"
|
||||
}
|
||||
```
|
||||
|
||||
## 设备接口
|
||||
|
||||
### 设备列表
|
||||
|
||||
- URL: `/api/v2/mes/devices/`
|
||||
- Method: `GET`
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 否 | 按设备名称模糊匹配 |
|
||||
| `category` | integer | 否 | 按设备分类 ID 过滤 |
|
||||
| `extra_path` | string | 否 | `extra` 中的 JSON 路径,使用 `.` 分隔,例如 `capacity.per_hour` |
|
||||
| `extra_value` | string | 否 | 与 `extra_path` 配套使用,支持 JSON 字面量字符串,例如 `120`、`"A1"`、`true` |
|
||||
|
||||
说明:
|
||||
|
||||
- `extra_path` 和 `extra_value` 必须同时传入。
|
||||
- 当前仅支持精确匹配。
|
||||
- 后端不解释 `extra` 的业务语义,只按路径和值匹配。
|
||||
|
||||
### 创建设备
|
||||
|
||||
- URL: `/api/v2/mes/devices/`
|
||||
- Method: `POST`
|
||||
|
||||
请求字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 是 | 设备名称 |
|
||||
| `category` | integer | 是 | 设备分类 ID |
|
||||
| `peak_capacity` | integer | 是 | 峰值产能,必须大于 `0` |
|
||||
| `capacity_unit` | integer | 否 | 产能单位,省略时默认 `1=米` |
|
||||
| `extra` | object/null | 否 | 自由结构 JSON 扩展字段 |
|
||||
|
||||
### 设备详情
|
||||
|
||||
- URL: `/api/v2/mes/devices/{device_id}/`
|
||||
- Method: `GET`
|
||||
|
||||
### 更新设备
|
||||
|
||||
- URL: `/api/v2/mes/devices/{device_id}/`
|
||||
- Method: `PATCH`
|
||||
|
||||
请求字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 否 | 设备名称 |
|
||||
| `category` | integer | 否 | 设备分类 ID |
|
||||
| `peak_capacity` | integer | 否 | 峰值产能,必须大于 `0` |
|
||||
| `capacity_unit` | integer | 否 | 产能单位 |
|
||||
| `extra` | object/null | 否 | 传 `null` 可清空扩展字段 |
|
||||
|
||||
补充说明:
|
||||
|
||||
- 模型层历史迁移曾使用一次性兼容默认值处理旧数据,但前端不应依赖省略 `peak_capacity` 让系统自动补值。
|
||||
|
||||
### 删除设备
|
||||
|
||||
- URL: `/api/v2/mes/devices/{device_id}/`
|
||||
- Method: `DELETE`
|
||||
|
||||
- 成功返回 `204 No Content`
|
||||
|
||||
## 生产指派接口
|
||||
|
||||
### 生产指派列表
|
||||
|
||||
- URL: `/api/v2/mes/production-assignments/`
|
||||
- Method: `GET`
|
||||
|
||||
查询参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `device` | integer | 否 | 按设备 ID 过滤 |
|
||||
| `content_type` | integer | 否 | 按关联对象类型 ID 过滤 |
|
||||
| `object_id` | integer | 否 | 按关联对象 ID 过滤 |
|
||||
| `status` | integer | 否 | 按状态过滤 |
|
||||
|
||||
### 创建生产指派
|
||||
|
||||
- URL: `/api/v2/mes/production-assignments/`
|
||||
- Method: `POST`
|
||||
|
||||
请求字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `device` | integer | 是 | 设备 ID |
|
||||
| `content_type` | integer | 是 | 关联业务对象类型 ID |
|
||||
| `object_id` | integer | 是 | 关联业务对象主键 |
|
||||
| `assigner` | integer | 是 | 指派者员工 ID |
|
||||
| `assignee` | integer/null | 否 | 被指派人员工 ID,可空 |
|
||||
| `production_quantity` | integer | 是 | 生产数量,必须大于 `0` |
|
||||
| `status` | integer | 否 | 状态,省略时默认 `1=Draft` |
|
||||
| `extra` | object/null | 否 | 自由结构 JSON 扩展字段 |
|
||||
|
||||
说明:
|
||||
|
||||
- `device`、`assigner`、`assignee` 必须属于当前商户。
|
||||
- `production_quantity` 单位跟随设备 `capacity_unit`。
|
||||
|
||||
### 生产指派详情
|
||||
|
||||
- URL: `/api/v2/mes/production-assignments/{assignment_id}/`
|
||||
- Method: `GET`
|
||||
|
||||
### 更新生产指派
|
||||
|
||||
- URL: `/api/v2/mes/production-assignments/{assignment_id}/`
|
||||
- Method: `PATCH`
|
||||
|
||||
请求字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `device` | integer | 否 | 设备 ID |
|
||||
| `assignee` | integer/null | 否 | 被指派人员工 ID,可空 |
|
||||
| `production_quantity` | integer | 否 | 生产数量,必须大于 `0` |
|
||||
| `status` | integer | 否 | 目标状态 |
|
||||
| `extra` | object/null | 否 | 扩展字段,传 `null` 可清空 |
|
||||
|
||||
状态流转说明:
|
||||
|
||||
- 主流程:`Draft -> 已发布 -> 已接收 -> 已完工`
|
||||
- `Draft` 可以改为 `已取消`
|
||||
- `已发布` 可以改为 `已取消`
|
||||
- `已接收` 和 `已完工` 不能改为 `已取消`
|
||||
- `已取消` 和 `已完工` 视为终态,不能回退
|
||||
|
||||
错误示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "当前状态不允许变更为目标状态"
|
||||
}
|
||||
```
|
||||
|
||||
### 删除生产指派
|
||||
|
||||
- URL: `/api/v2/mes/production-assignments/{assignment_id}/`
|
||||
- Method: `DELETE`
|
||||
185
docs/mes_module_structure.md
Normal file
185
docs/mes_module_structure.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# MES 模块结构说明
|
||||
|
||||
本文档用于业务评审和联调前核对,描述当前 `MES` 模块已经落地的代码结构、数据对象、公开边界和暂未进入本轮范围的能力。
|
||||
|
||||
## 当前模块目标
|
||||
|
||||
当前 `MES` 模块的落点是两个基础能力:
|
||||
|
||||
- 设备分类管理
|
||||
- 设备管理
|
||||
- 生产指派管理
|
||||
|
||||
它们共同服务于后续更深入的排产、派工、产能统计,但当前阶段还没有实现复杂的汇总、排程算法和业务编排。
|
||||
|
||||
## 目录结构
|
||||
|
||||
当前 `mes` app 目录如下:
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `mes/apps.py` | Django app 注册入口 |
|
||||
| `mes/models.py` | MES 核心模型定义,包含枚举、设备分类、设备、生产指派 |
|
||||
| `mes/services.py` | 服务层,封装 CRUD、校验、状态流转和商户隔离规则 |
|
||||
| `mes/tests.py` | service 层测试 |
|
||||
| `mes/migrations/` | 数据库迁移文件 |
|
||||
|
||||
对外 API 入口位于:
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `api_v2/views/mes.py` | MES 对外 API 视图、序列化和请求参数解析 |
|
||||
| `api_v2/urls.py` | MES API 路由注册 |
|
||||
| `api_v2/test_mes_api.py` | API 层测试 |
|
||||
| `docs/api_v2_mes_api.md` | 当前公开 API 文档 |
|
||||
|
||||
## 数据对象
|
||||
|
||||
### 1. DeviceCategory
|
||||
|
||||
用途:设备分类,例如打印机、滚筒机等。
|
||||
|
||||
当前字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | bigint | 主键 |
|
||||
| `merchant` | FK | 所属商户 |
|
||||
| `name` | string | 分类名称,同商户下唯一 |
|
||||
| `created_by` | FK | 创建用户 |
|
||||
| `operator` | FK | 当前操作员工 |
|
||||
| `created_at` | datetime | 创建时间 |
|
||||
| `updated_at` | datetime | 更新时间 |
|
||||
|
||||
当前规则:
|
||||
|
||||
- `operator` 必须属于当前商户。
|
||||
- `merchant + name` 唯一。
|
||||
|
||||
### 2. Device
|
||||
|
||||
用途:具体生产设备。
|
||||
|
||||
当前字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | bigint | 主键 |
|
||||
| `merchant` | FK | 所属商户 |
|
||||
| `category` | FK | 设备分类 |
|
||||
| `name` | string | 设备名称,同商户下唯一 |
|
||||
| `peak_capacity` | integer | 峰值产能,必须大于 0 |
|
||||
| `capacity_unit` | integer enum | 产能单位,当前 `1=米`、`2=码` |
|
||||
| `extra` | json/null | 扩展参数,后端不解释内容 |
|
||||
| `created_by` | FK | 创建用户 |
|
||||
| `operator` | FK | 当前操作员工 |
|
||||
| `created_at` | datetime | 创建时间 |
|
||||
| `updated_at` | datetime | 更新时间 |
|
||||
|
||||
当前规则:
|
||||
|
||||
- `category` 必须属于当前商户。
|
||||
- `operator` 必须属于当前商户。
|
||||
- `peak_capacity > 0`。
|
||||
- 支持按 `extra` 指定 JSON 路径和值做精确筛选。
|
||||
|
||||
### 3. ProductionAssignment
|
||||
|
||||
用途:把某个业务对象指派到某台设备,并记录指派数量和状态。
|
||||
|
||||
当前字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | bigint | 主键 |
|
||||
| `merchant` | FK | 所属商户 |
|
||||
| `device` | FK | 被指派设备 |
|
||||
| `content_type` | FK | 关联业务对象类型 |
|
||||
| `object_id` | bigint | 关联业务对象 ID |
|
||||
| `content_object` | GenericForeignKey | 运行时关联对象,不单独出现在 API 请求体中 |
|
||||
| `assigner` | FK | 指派者 |
|
||||
| `assignee` | FK/null | 被指派人,可空 |
|
||||
| `production_quantity` | integer | 生产数量,必须大于 0,单位跟随设备 `capacity_unit` |
|
||||
| `status` | integer enum | 状态,当前 `1=Draft`、`2=已发布`、`3=已接收`、`4=已取消`、`5=已完工` |
|
||||
| `extra` | json/null | 扩展参数,后端不解释内容 |
|
||||
| `created_by` | FK | 创建用户 |
|
||||
| `operator` | FK | 当前操作员工 |
|
||||
| `created_at` | datetime | 创建时间 |
|
||||
| `updated_at` | datetime | 更新时间 |
|
||||
|
||||
当前规则:
|
||||
|
||||
- `device`、`assigner`、`assignee`、`operator` 必须属于当前商户。
|
||||
- `content_type + object_id` 指向的对象必须存在。
|
||||
- 如果目标对象带 `merchant_id`,则必须与当前商户一致。
|
||||
- `production_quantity > 0`。
|
||||
|
||||
## 当前状态机
|
||||
|
||||
`ProductionAssignment.status` 当前是简单状态机:
|
||||
|
||||
| 状态值 | 显示值 | 含义 |
|
||||
|------|------|------|
|
||||
| `1` | `Draft` | 草稿 |
|
||||
| `2` | `已发布` | 已正式发出指派 |
|
||||
| `3` | `已接收` | 执行方已接收 |
|
||||
| `4` | `已取消` | 指派取消 |
|
||||
| `5` | `已完工` | 指派完成 |
|
||||
|
||||
当前允许流转:
|
||||
|
||||
- `Draft -> Draft / 已发布 / 已取消`
|
||||
- `已发布 -> 已发布 / 已接收 / 已取消`
|
||||
- `已接收 -> 已接收 / 已完工`
|
||||
- `已取消 -> 已取消`
|
||||
- `已完工 -> 已完工`
|
||||
|
||||
当前明确禁止:
|
||||
|
||||
- `已接收 -> 已取消`
|
||||
- `已完工 -> 已取消`
|
||||
- 任意终态回退到前置状态
|
||||
|
||||
## 服务层职责
|
||||
|
||||
`mes/services.py` 当前负责:
|
||||
|
||||
- 统一商户隔离校验
|
||||
- 统一名称、数量、状态值校验
|
||||
- 设备分类 CRUD
|
||||
- 设备 CRUD
|
||||
- 生产指派 CRUD
|
||||
- 生产指派状态流转校验
|
||||
- 设备 `extra` JSON 路径筛选
|
||||
|
||||
这意味着当前 API 层主要是参数适配和错误转译,核心业务校验已经集中到 service 层。
|
||||
|
||||
## 当前公开 API 边界
|
||||
|
||||
当前已经公开到 `api_v2` 的接口包括:
|
||||
|
||||
- 设备分类:列表、创建、详情、更新、删除
|
||||
- 设备:列表、创建、详情、更新、删除
|
||||
- 生产指派:列表、创建、详情、更新、删除
|
||||
|
||||
当前 API 文档见:`docs/api_v2_mes_api.md`
|
||||
|
||||
## 当前未进入本轮范围的能力
|
||||
|
||||
以下能力尚未在本轮实现,业务评审时需要明确它们仍属于后续扩展:
|
||||
|
||||
- 设备产能利用率汇总
|
||||
- 指派数量聚合统计
|
||||
- 更复杂的排产/排队/冲突检测
|
||||
- 基于业务对象类型的定制校验规则
|
||||
- 更细粒度的状态副作用,例如发布后自动通知、接收后自动开工等
|
||||
|
||||
## 评审建议
|
||||
|
||||
如果你要先去业务部门开会和做实验,建议重点确认这几件事:
|
||||
|
||||
- `production_quantity` 的业务口径是否始终跟随设备 `capacity_unit`
|
||||
- `ProductionAssignment` 的五个状态是否够用
|
||||
- `assignee` 是否允许为空,以及在哪个环节允许为空
|
||||
- `extra` 里是否会出现需要前端按路径筛选的高频字段
|
||||
- `content_type + object_id` 是否符合业务上“一个指派对应一个目标对象”的表达方式
|
||||
BIN
flower-app_latest.tar.gz
Normal file
BIN
flower-app_latest.tar.gz
Normal file
Binary file not shown.
@@ -147,6 +147,7 @@ INSTALLED_APPS = [
|
||||
'settlement',
|
||||
'mission',
|
||||
'notifier',
|
||||
'mes',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
7
mes/apps.py
Normal file
7
mes/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'mes'
|
||||
verbose_name = 'MES'
|
||||
62
mes/migrations/0001_initial.py
Normal file
62
mes/migrations/0001_initial.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.2.8 on 2026-05-08 07:40
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('basic_info', '0026_transportvehicle_and_capacity'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DeviceCategory',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100, verbose_name='设备分类名称')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_mes_device_categories', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
|
||||
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='mes_device_categories', to='basic_info.merchant', verbose_name='所属商户')),
|
||||
('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='operated_mes_device_categories', to='basic_info.employee', verbose_name='操作人')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '设备分类',
|
||||
'verbose_name_plural': '设备分类',
|
||||
'ordering': ['id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Device',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100, verbose_name='设备名称')),
|
||||
('extra', models.JSONField(blank=True, null=True, verbose_name='扩展参数')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_mes_devices', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
|
||||
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='mes_devices', to='basic_info.merchant', verbose_name='所属商户')),
|
||||
('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='operated_mes_devices', to='basic_info.employee', verbose_name='操作人')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='mes.devicecategory', verbose_name='设备分类')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '设备',
|
||||
'verbose_name_plural': '设备',
|
||||
'ordering': ['id'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='devicecategory',
|
||||
constraint=models.UniqueConstraint(fields=('merchant', 'name'), name='unique_mes_device_category_name_per_merchant'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='device',
|
||||
constraint=models.UniqueConstraint(fields=('merchant', 'name'), name='unique_mes_device_name_per_merchant'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 5.2.8 on 2026-05-08 08:43
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('basic_info', '0026_transportvehicle_and_capacity'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('mes', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='capacity_unit',
|
||||
field=models.IntegerField(choices=[(1, '米'), (2, '码')], default=1, verbose_name='产能单位'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='peak_capacity',
|
||||
field=models.PositiveIntegerField(default=100, verbose_name='峰值产能'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductionAssignment',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('object_id', models.PositiveBigIntegerField(db_index=True, verbose_name='关联对象ID')),
|
||||
('extra', models.JSONField(blank=True, null=True, verbose_name='扩展参数')),
|
||||
('assignee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='received_mes_production_assignments', to='basic_info.employee', verbose_name='被指派人')),
|
||||
('assigner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assigned_mes_production_assignments', to='basic_info.employee', verbose_name='指派者')),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='mes_production_assignments', to='contenttypes.contenttype', verbose_name='关联对象类型')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_mes_production_assignments', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
|
||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='production_assignments', to='mes.device', verbose_name='设备')),
|
||||
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='mes_production_assignments', to='basic_info.merchant', verbose_name='所属商户')),
|
||||
('operator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='operated_mes_production_assignments', to='basic_info.employee', verbose_name='操作人')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '生产指派',
|
||||
'verbose_name_plural': '生产指派',
|
||||
'ordering': ['-created_at', '-id'],
|
||||
'indexes': [models.Index(fields=['merchant', 'content_type', 'object_id'], name='mes_product_merchan_a3ff6f_idx'), models.Index(fields=['merchant', 'device', 'created_at'], name='mes_product_merchan_ae938e_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.2.8 on 2026-05-08 09:06
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('basic_info', '0026_transportvehicle_and_capacity'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('mes', '0002_device_capacity_unit_device_peak_capacity_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='productionassignment',
|
||||
name='production_quantity',
|
||||
field=models.PositiveIntegerField(default=100, verbose_name='生产数量'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='productionassignment',
|
||||
name='status',
|
||||
field=models.IntegerField(choices=[(1, 'Draft'), (2, '已发布'), (3, '已接收'), (4, '已取消'), (5, '已完工')], default=1, verbose_name='状态'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productionassignment',
|
||||
index=models.Index(fields=['merchant', 'status', 'created_at'], name='mes_product_merchan_3ae0e6_idx'),
|
||||
),
|
||||
]
|
||||
0
mes/migrations/__init__.py
Normal file
0
mes/migrations/__init__.py
Normal file
211
mes/models.py
Normal file
211
mes/models.py
Normal file
@@ -0,0 +1,211 @@
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from flower.common import ModelBase
|
||||
|
||||
|
||||
class CapacityUnitEnum(models.IntegerChoices):
|
||||
METER = 1, '米'
|
||||
YARD = 2, '码'
|
||||
|
||||
|
||||
class ProductionAssignmentStatusEnum(models.IntegerChoices):
|
||||
DRAFT = 1, 'Draft'
|
||||
PUBLISHED = 2, '已发布'
|
||||
ACCEPTED = 3, '已接收'
|
||||
CANCELLED = 4, '已取消'
|
||||
COMPLETED = 5, '已完工'
|
||||
|
||||
|
||||
class DeviceCategory(ModelBase):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
merchant = models.ForeignKey(
|
||||
'basic_info.Merchant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='mes_device_categories',
|
||||
verbose_name='所属商户',
|
||||
)
|
||||
name = models.CharField(max_length=100, verbose_name='设备分类名称')
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='created_mes_device_categories',
|
||||
verbose_name='创建人',
|
||||
)
|
||||
operator = models.ForeignKey(
|
||||
'basic_info.Employee',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='operated_mes_device_categories',
|
||||
verbose_name='操作人',
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
if self.operator_id and self.merchant_id and self.operator.merchant_id != self.merchant_id:
|
||||
raise ValidationError({'operator': '操作人不属于当前商户'})
|
||||
|
||||
class Meta:
|
||||
verbose_name = '设备分类'
|
||||
verbose_name_plural = '设备分类'
|
||||
ordering = ['id']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['merchant', 'name'], name='unique_mes_device_category_name_per_merchant'),
|
||||
]
|
||||
|
||||
|
||||
class Device(ModelBase):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
merchant = models.ForeignKey(
|
||||
'basic_info.Merchant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='mes_devices',
|
||||
verbose_name='所属商户',
|
||||
)
|
||||
category = models.ForeignKey(
|
||||
DeviceCategory,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='devices',
|
||||
verbose_name='设备分类',
|
||||
)
|
||||
name = models.CharField(max_length=100, verbose_name='设备名称')
|
||||
peak_capacity = models.PositiveIntegerField(default=100, verbose_name='峰值产能')
|
||||
capacity_unit = models.IntegerField(
|
||||
choices=CapacityUnitEnum.choices,
|
||||
default=CapacityUnitEnum.METER,
|
||||
verbose_name='产能单位',
|
||||
)
|
||||
extra = models.JSONField(null=True, blank=True, verbose_name='扩展参数')
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='created_mes_devices',
|
||||
verbose_name='创建人',
|
||||
)
|
||||
operator = models.ForeignKey(
|
||||
'basic_info.Employee',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='operated_mes_devices',
|
||||
verbose_name='操作人',
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
errors = {}
|
||||
if self.operator_id and self.merchant_id and self.operator.merchant_id != self.merchant_id:
|
||||
errors['operator'] = '操作人不属于当前商户'
|
||||
if self.category_id and self.merchant_id and self.category.merchant_id != self.merchant_id:
|
||||
errors['category'] = '设备分类不属于当前商户'
|
||||
if self.peak_capacity <= 0:
|
||||
errors['peak_capacity'] = '峰值产能必须大于0'
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '设备'
|
||||
verbose_name_plural = '设备'
|
||||
ordering = ['id']
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['merchant', 'name'], name='unique_mes_device_name_per_merchant'),
|
||||
]
|
||||
|
||||
|
||||
class ProductionAssignment(ModelBase):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
merchant = models.ForeignKey(
|
||||
'basic_info.Merchant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='mes_production_assignments',
|
||||
verbose_name='所属商户',
|
||||
)
|
||||
device = models.ForeignKey(
|
||||
Device,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='production_assignments',
|
||||
verbose_name='设备',
|
||||
)
|
||||
content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='mes_production_assignments',
|
||||
verbose_name='关联对象类型',
|
||||
)
|
||||
object_id = models.PositiveBigIntegerField(db_index=True, verbose_name='关联对象ID')
|
||||
content_object = GenericForeignKey('content_type', 'object_id')
|
||||
assigner = models.ForeignKey(
|
||||
'basic_info.Employee',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='assigned_mes_production_assignments',
|
||||
verbose_name='指派者',
|
||||
)
|
||||
assignee = models.ForeignKey(
|
||||
'basic_info.Employee',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='received_mes_production_assignments',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='被指派人',
|
||||
)
|
||||
production_quantity = models.PositiveIntegerField(verbose_name='生产数量')
|
||||
status = models.IntegerField(
|
||||
choices=ProductionAssignmentStatusEnum.choices,
|
||||
default=ProductionAssignmentStatusEnum.DRAFT,
|
||||
verbose_name='状态',
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='created_mes_production_assignments',
|
||||
verbose_name='创建人',
|
||||
)
|
||||
operator = models.ForeignKey(
|
||||
'basic_info.Employee',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='operated_mes_production_assignments',
|
||||
verbose_name='操作人',
|
||||
)
|
||||
extra = models.JSONField(null=True, blank=True, verbose_name='扩展参数')
|
||||
|
||||
def __str__(self):
|
||||
return f'ProductionAssignment #{self.id}'
|
||||
|
||||
def clean(self):
|
||||
errors = {}
|
||||
if self.device_id and self.merchant_id and self.device.merchant_id != self.merchant_id:
|
||||
errors['device'] = '设备不属于当前商户'
|
||||
if self.assigner_id and self.merchant_id and self.assigner.merchant_id != self.merchant_id:
|
||||
errors['assigner'] = '指派者不属于当前商户'
|
||||
if self.assignee_id and self.merchant_id and self.assignee.merchant_id != self.merchant_id:
|
||||
errors['assignee'] = '被指派人不属于当前商户'
|
||||
if self.operator_id and self.merchant_id and self.operator.merchant_id != self.merchant_id:
|
||||
errors['operator'] = '操作人不属于当前商户'
|
||||
if self.production_quantity is not None and self.production_quantity <= 0:
|
||||
errors['production_quantity'] = '生产数量必须大于0'
|
||||
if self.content_type_id and self.object_id:
|
||||
try:
|
||||
content_object = self.content_type.get_object_for_this_type(pk=self.object_id)
|
||||
except Exception as exc:
|
||||
errors['object_id'] = '关联对象不存在'
|
||||
else:
|
||||
content_object_merchant_id = getattr(content_object, 'merchant_id', None)
|
||||
if content_object_merchant_id is not None and self.merchant_id and content_object_merchant_id != self.merchant_id:
|
||||
errors['object_id'] = '关联对象不属于当前商户'
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
class Meta:
|
||||
verbose_name = '生产指派'
|
||||
verbose_name_plural = '生产指派'
|
||||
ordering = ['-created_at', '-id']
|
||||
indexes = [
|
||||
models.Index(fields=['merchant', 'content_type', 'object_id']),
|
||||
models.Index(fields=['merchant', 'device', 'created_at']),
|
||||
models.Index(fields=['merchant', 'status', 'created_at']),
|
||||
]
|
||||
|
||||
303
mes/services.py
Normal file
303
mes/services.py
Normal file
@@ -0,0 +1,303 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
|
||||
from mes.models import (
|
||||
CapacityUnitEnum,
|
||||
Device,
|
||||
DeviceCategory,
|
||||
ProductionAssignment,
|
||||
ProductionAssignmentStatusEnum,
|
||||
)
|
||||
|
||||
|
||||
UNSET = object()
|
||||
|
||||
ALLOWED_PRODUCTION_ASSIGNMENT_STATUS_TRANSITIONS = {
|
||||
ProductionAssignmentStatusEnum.DRAFT: {
|
||||
ProductionAssignmentStatusEnum.DRAFT,
|
||||
ProductionAssignmentStatusEnum.PUBLISHED,
|
||||
ProductionAssignmentStatusEnum.CANCELLED,
|
||||
},
|
||||
ProductionAssignmentStatusEnum.PUBLISHED: {
|
||||
ProductionAssignmentStatusEnum.PUBLISHED,
|
||||
ProductionAssignmentStatusEnum.ACCEPTED,
|
||||
ProductionAssignmentStatusEnum.CANCELLED,
|
||||
},
|
||||
ProductionAssignmentStatusEnum.ACCEPTED: {
|
||||
ProductionAssignmentStatusEnum.ACCEPTED,
|
||||
ProductionAssignmentStatusEnum.COMPLETED,
|
||||
},
|
||||
ProductionAssignmentStatusEnum.CANCELLED: {
|
||||
ProductionAssignmentStatusEnum.CANCELLED,
|
||||
},
|
||||
ProductionAssignmentStatusEnum.COMPLETED: {
|
||||
ProductionAssignmentStatusEnum.COMPLETED,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_name(name: str, field_name: str = 'name') -> str:
|
||||
normalized = (name or '').strip()
|
||||
if not normalized:
|
||||
raise ValueError(f'{field_name}不能为空')
|
||||
return normalized
|
||||
|
||||
|
||||
def _assert_operator_belongs_to_merchant(*, operator, merchant) -> None:
|
||||
if operator is None:
|
||||
raise ValueError('操作人不能为空')
|
||||
if operator.merchant_id != merchant.id:
|
||||
raise ValueError('操作人不属于当前商户')
|
||||
|
||||
|
||||
def _assert_category_belongs_to_merchant(*, category: DeviceCategory, merchant) -> None:
|
||||
if category.merchant_id != merchant.id:
|
||||
raise ValueError('设备分类不属于当前商户')
|
||||
|
||||
|
||||
def _assert_employee_belongs_to_merchant(*, employee, merchant, role: str) -> None:
|
||||
if employee is None:
|
||||
raise ValueError(f'{role}不能为空')
|
||||
if employee.merchant_id != merchant.id:
|
||||
raise ValueError(f'{role}不属于当前商户')
|
||||
|
||||
|
||||
def _validate_peak_capacity(peak_capacity: int) -> int:
|
||||
if peak_capacity is None:
|
||||
raise ValueError('峰值产能不能为空')
|
||||
if int(peak_capacity) <= 0:
|
||||
raise ValueError('峰值产能必须大于0')
|
||||
return int(peak_capacity)
|
||||
|
||||
|
||||
def _validate_capacity_unit(capacity_unit: int) -> int:
|
||||
valid_values = {choice[0] for choice in CapacityUnitEnum.choices}
|
||||
if capacity_unit not in valid_values:
|
||||
raise ValueError('产能单位不合法')
|
||||
return capacity_unit
|
||||
|
||||
|
||||
def _validate_production_quantity(production_quantity: int) -> int:
|
||||
if production_quantity is None:
|
||||
raise ValueError('生产数量不能为空')
|
||||
if int(production_quantity) <= 0:
|
||||
raise ValueError('生产数量必须大于0')
|
||||
return int(production_quantity)
|
||||
|
||||
|
||||
def _validate_production_assignment_status(status: int) -> int:
|
||||
valid_values = {choice[0] for choice in ProductionAssignmentStatusEnum.choices}
|
||||
if status not in valid_values:
|
||||
raise ValueError('生产指派状态不合法')
|
||||
return status
|
||||
|
||||
|
||||
def _validate_production_assignment_status_transition(*, current_status: int, next_status: int) -> int:
|
||||
next_status = _validate_production_assignment_status(next_status)
|
||||
allowed_statuses = ALLOWED_PRODUCTION_ASSIGNMENT_STATUS_TRANSITIONS.get(current_status, {current_status})
|
||||
if next_status not in allowed_statuses:
|
||||
raise ValueError('当前状态不允许变更为目标状态')
|
||||
return next_status
|
||||
|
||||
|
||||
def _validate_content_object_merchant(*, content_type, object_id: int, merchant) -> None:
|
||||
try:
|
||||
content_object = content_type.get_object_for_this_type(pk=object_id)
|
||||
except Exception as exc:
|
||||
raise ValueError('关联对象不存在') from exc
|
||||
content_object_merchant_id = getattr(content_object, 'merchant_id', None)
|
||||
if content_object_merchant_id is not None and content_object_merchant_id != merchant.id:
|
||||
raise ValueError('关联对象不属于当前商户')
|
||||
|
||||
|
||||
def _save_with_validation(instance):
|
||||
try:
|
||||
instance.full_clean()
|
||||
instance.save()
|
||||
except ValidationError as exc:
|
||||
if hasattr(exc, 'message_dict'):
|
||||
first_error = next(iter(exc.message_dict.values()))
|
||||
if isinstance(first_error, list) and first_error:
|
||||
raise ValueError(first_error[0]) from exc
|
||||
raise ValueError(str(exc)) from exc
|
||||
except IntegrityError as exc:
|
||||
raise ValueError('名称已存在') from exc
|
||||
return instance
|
||||
|
||||
|
||||
def list_device_categories_for_merchant(*, merchant):
|
||||
return DeviceCategory.objects.filter(merchant=merchant).select_related('merchant', 'created_by', 'operator')
|
||||
|
||||
|
||||
def get_device_category_for_merchant(*, merchant, category_id: int) -> DeviceCategory:
|
||||
return list_device_categories_for_merchant(merchant=merchant).get(id=category_id)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_device_category(*, merchant, name: str, created_by, operator) -> DeviceCategory:
|
||||
_assert_operator_belongs_to_merchant(operator=operator, merchant=merchant)
|
||||
category = DeviceCategory(
|
||||
merchant=merchant,
|
||||
name=_normalize_name(name, '设备分类名称'),
|
||||
created_by=created_by,
|
||||
operator=operator,
|
||||
)
|
||||
return _save_with_validation(category)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_device_category(*, category: DeviceCategory, operator, name=UNSET) -> DeviceCategory:
|
||||
_assert_operator_belongs_to_merchant(operator=operator, merchant=category.merchant)
|
||||
category = DeviceCategory.objects.select_for_update().get(pk=category.pk)
|
||||
category.operator = operator
|
||||
if name is not UNSET:
|
||||
category.name = _normalize_name(name, '设备分类名称')
|
||||
return _save_with_validation(category)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_device_category(*, category: DeviceCategory, operator) -> None:
|
||||
_assert_operator_belongs_to_merchant(operator=operator, merchant=category.merchant)
|
||||
category = DeviceCategory.objects.select_for_update().get(pk=category.pk)
|
||||
category.delete()
|
||||
|
||||
|
||||
def list_devices_for_merchant(*, merchant, category_id: int | None = None, extra_json_path: list[str] | None = None,
|
||||
extra_json_value=UNSET):
|
||||
queryset = Device.objects.filter(merchant=merchant).select_related('merchant', 'category', 'created_by', 'operator')
|
||||
if category_id is not None:
|
||||
queryset = queryset.filter(category_id=category_id)
|
||||
if extra_json_path:
|
||||
lookup = '__'.join(['extra', *extra_json_path])
|
||||
queryset = queryset.filter(**{lookup: extra_json_value})
|
||||
return queryset
|
||||
|
||||
|
||||
def get_device_for_merchant(*, merchant, device_id: int) -> Device:
|
||||
return list_devices_for_merchant(merchant=merchant).get(id=device_id)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_device(*, merchant, category: DeviceCategory, name: str, created_by, operator, peak_capacity: int,
|
||||
capacity_unit: int = CapacityUnitEnum.METER, extra=None) -> Device:
|
||||
_assert_operator_belongs_to_merchant(operator=operator, merchant=merchant)
|
||||
_assert_category_belongs_to_merchant(category=category, merchant=merchant)
|
||||
device = Device(
|
||||
merchant=merchant,
|
||||
category=category,
|
||||
name=_normalize_name(name, '设备名称'),
|
||||
peak_capacity=_validate_peak_capacity(peak_capacity),
|
||||
capacity_unit=_validate_capacity_unit(capacity_unit),
|
||||
extra=extra,
|
||||
created_by=created_by,
|
||||
operator=operator,
|
||||
)
|
||||
return _save_with_validation(device)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_device(*, device: Device, operator, name=UNSET, category=UNSET, peak_capacity=UNSET,
|
||||
capacity_unit=UNSET, extra=UNSET):
|
||||
device = Device.objects.select_for_update().get(pk=device.pk)
|
||||
_assert_operator_belongs_to_merchant(operator=operator, merchant=device.merchant)
|
||||
device.operator = operator
|
||||
if name is not UNSET:
|
||||
device.name = _normalize_name(name, '设备名称')
|
||||
if category is not UNSET:
|
||||
_assert_category_belongs_to_merchant(category=category, merchant=device.merchant)
|
||||
device.category = category
|
||||
if peak_capacity is not UNSET:
|
||||
device.peak_capacity = _validate_peak_capacity(peak_capacity)
|
||||
if capacity_unit is not UNSET:
|
||||
device.capacity_unit = _validate_capacity_unit(capacity_unit)
|
||||
if extra is not UNSET:
|
||||
device.extra = extra
|
||||
return _save_with_validation(device)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_device(*, device: Device, operator) -> None:
|
||||
_assert_operator_belongs_to_merchant(operator=operator, merchant=device.merchant)
|
||||
device = Device.objects.select_for_update().get(pk=device.pk)
|
||||
device.delete()
|
||||
|
||||
|
||||
def list_production_assignments_for_merchant(*, merchant, device_id: int | None = None, content_type_id: int | None = None,
|
||||
object_id: int | None = None, status: int | None = None):
|
||||
queryset = ProductionAssignment.objects.filter(merchant=merchant).select_related(
|
||||
'merchant', 'device', 'content_type', 'assigner', 'assignee', 'created_by', 'operator'
|
||||
)
|
||||
if device_id is not None:
|
||||
queryset = queryset.filter(device_id=device_id)
|
||||
if content_type_id is not None:
|
||||
queryset = queryset.filter(content_type_id=content_type_id)
|
||||
if object_id is not None:
|
||||
queryset = queryset.filter(object_id=object_id)
|
||||
if status is not None:
|
||||
queryset = queryset.filter(status=status)
|
||||
return queryset
|
||||
|
||||
|
||||
def get_production_assignment_for_merchant(*, merchant, assignment_id: int) -> ProductionAssignment:
|
||||
return list_production_assignments_for_merchant(merchant=merchant).get(id=assignment_id)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_production_assignment(*, merchant, device: Device, content_type: ContentType, object_id: int,
|
||||
assigner, production_quantity: int, assignee=None, created_by=None, operator=None,
|
||||
status: int = ProductionAssignmentStatusEnum.DRAFT, extra=None) -> ProductionAssignment:
|
||||
_assert_employee_belongs_to_merchant(employee=assigner, merchant=merchant, role='指派者')
|
||||
if assignee is not None:
|
||||
_assert_employee_belongs_to_merchant(employee=assignee, merchant=merchant, role='被指派人')
|
||||
_assert_employee_belongs_to_merchant(employee=operator, merchant=merchant, role='操作人')
|
||||
if device.merchant_id != merchant.id:
|
||||
raise ValueError('设备不属于当前商户')
|
||||
_validate_content_object_merchant(content_type=content_type, object_id=object_id, merchant=merchant)
|
||||
assignment = ProductionAssignment(
|
||||
merchant=merchant,
|
||||
device=device,
|
||||
content_type=content_type,
|
||||
object_id=object_id,
|
||||
assigner=assigner,
|
||||
assignee=assignee,
|
||||
production_quantity=_validate_production_quantity(production_quantity),
|
||||
status=_validate_production_assignment_status(status),
|
||||
created_by=created_by,
|
||||
operator=operator,
|
||||
extra=extra,
|
||||
)
|
||||
return _save_with_validation(assignment)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def update_production_assignment(*, assignment: ProductionAssignment, operator, device=UNSET, assignee=UNSET,
|
||||
production_quantity=UNSET, status=UNSET, extra=UNSET):
|
||||
assignment = ProductionAssignment.objects.select_for_update().get(pk=assignment.pk)
|
||||
_assert_employee_belongs_to_merchant(employee=operator, merchant=assignment.merchant, role='操作人')
|
||||
assignment.operator = operator
|
||||
if device is not UNSET:
|
||||
if device.merchant_id != assignment.merchant_id:
|
||||
raise ValueError('设备不属于当前商户')
|
||||
assignment.device = device
|
||||
if assignee is not UNSET:
|
||||
if assignee is not None:
|
||||
_assert_employee_belongs_to_merchant(employee=assignee, merchant=assignment.merchant, role='被指派人')
|
||||
assignment.assignee = assignee
|
||||
if production_quantity is not UNSET:
|
||||
assignment.production_quantity = _validate_production_quantity(production_quantity)
|
||||
if status is not UNSET:
|
||||
assignment.status = _validate_production_assignment_status_transition(
|
||||
current_status=assignment.status,
|
||||
next_status=status,
|
||||
)
|
||||
if extra is not UNSET:
|
||||
assignment.extra = extra
|
||||
return _save_with_validation(assignment)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete_production_assignment(*, assignment: ProductionAssignment, operator) -> None:
|
||||
_assert_employee_belongs_to_merchant(employee=operator, merchant=assignment.merchant, role='操作人')
|
||||
assignment = ProductionAssignment.objects.select_for_update().get(pk=assignment.pk)
|
||||
assignment.delete()
|
||||
368
mes/tests.py
Normal file
368
mes/tests.py
Normal file
@@ -0,0 +1,368 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import ProtectedError
|
||||
from django.test import TestCase
|
||||
|
||||
from basic_info.models import Customer, Employee, Merchant, MerchantTypeEnum
|
||||
from mes.models import CapacityUnitEnum, Device, DeviceCategory, ProductionAssignment, ProductionAssignmentStatusEnum
|
||||
from mes import services
|
||||
from printing.models import PrintingOrder
|
||||
|
||||
|
||||
class MesServiceTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(username='mes-user', password='pass12345')
|
||||
self.other_user = get_user_model().objects.create_user(username='mes-other-user', password='pass12345')
|
||||
self.merchant = Merchant.objects.create(name='MES商户', type=MerchantTypeEnum.STORE)
|
||||
self.other_merchant = Merchant.objects.create(name='其他商户', type=MerchantTypeEnum.STORE)
|
||||
self.operator = Employee.objects.create(merchant=self.merchant, sys_user=self.user, name='操作员')
|
||||
self.other_operator = Employee.objects.create(merchant=self.other_merchant, sys_user=self.other_user, name='其他操作员')
|
||||
|
||||
def test_create_device_category(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
|
||||
self.assertEqual(category.merchant, self.merchant)
|
||||
self.assertEqual(category.created_by, self.user)
|
||||
self.assertEqual(category.operator, self.operator)
|
||||
|
||||
def test_create_device_category_rejects_cross_merchant_operator(self):
|
||||
with self.assertRaisesMessage(ValueError, '操作人不属于当前商户'):
|
||||
services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.other_operator,
|
||||
)
|
||||
|
||||
def test_create_device(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
|
||||
device = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
capacity_unit=CapacityUnitEnum.METER,
|
||||
extra={'capacity': {'per_hour': 120}},
|
||||
)
|
||||
|
||||
self.assertEqual(device.category, category)
|
||||
self.assertEqual(device.peak_capacity, 120)
|
||||
self.assertEqual(device.capacity_unit, CapacityUnitEnum.METER)
|
||||
self.assertEqual(device.extra, {'capacity': {'per_hour': 120}})
|
||||
|
||||
def test_create_device_rejects_cross_merchant_category(self):
|
||||
other_category = services.create_device_category(
|
||||
merchant=self.other_merchant,
|
||||
name='其他分类',
|
||||
created_by=self.other_user,
|
||||
operator=self.other_operator,
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(ValueError, '设备分类不属于当前商户'):
|
||||
services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=other_category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
|
||||
def test_update_device(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
another_category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='滚筒机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
device = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
|
||||
updated = services.update_device(
|
||||
device=device,
|
||||
operator=self.operator,
|
||||
name='设备B',
|
||||
category=another_category,
|
||||
peak_capacity=180,
|
||||
capacity_unit=CapacityUnitEnum.YARD,
|
||||
extra={'power': 220},
|
||||
)
|
||||
|
||||
self.assertEqual(updated.name, '设备B')
|
||||
self.assertEqual(updated.category, another_category)
|
||||
self.assertEqual(updated.peak_capacity, 180)
|
||||
self.assertEqual(updated.capacity_unit, CapacityUnitEnum.YARD)
|
||||
self.assertEqual(updated.extra, {'power': 220})
|
||||
|
||||
def test_update_device_allows_clearing_extra(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
device = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
extra={'power': 220},
|
||||
)
|
||||
|
||||
updated = services.update_device(device=device, operator=self.operator, extra=None)
|
||||
|
||||
self.assertIsNone(updated.extra)
|
||||
|
||||
def test_delete_device_category_is_protected_when_devices_exist(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
|
||||
with self.assertRaises(ProtectedError):
|
||||
services.delete_device_category(category=category, operator=self.operator)
|
||||
|
||||
def test_list_devices_for_merchant_filters_by_category(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
another_category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='滚筒机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
target = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=another_category,
|
||||
name='设备B',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=80,
|
||||
)
|
||||
|
||||
devices = list(services.list_devices_for_merchant(merchant=self.merchant, category_id=category.id))
|
||||
|
||||
self.assertEqual([device.id for device in devices], [target.id])
|
||||
|
||||
def test_list_devices_for_merchant_filters_by_extra_json_path(self):
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
target = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
extra={'capacity': {'per_hour': 120}},
|
||||
)
|
||||
services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备B',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=80,
|
||||
extra={'capacity': {'per_hour': 80}},
|
||||
)
|
||||
|
||||
devices = list(
|
||||
services.list_devices_for_merchant(
|
||||
merchant=self.merchant,
|
||||
extra_json_path=['capacity', 'per_hour'],
|
||||
extra_json_value=120,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual([device.id for device in devices], [target.id])
|
||||
|
||||
def test_create_production_assignment(self):
|
||||
customer = Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试客户',
|
||||
mobile='13800138000',
|
||||
)
|
||||
printing_order = PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
fabric='测试面料',
|
||||
width='150cm',
|
||||
created_by=self.user,
|
||||
)
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
device = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
content_type = ContentType.objects.get_for_model(PrintingOrder)
|
||||
|
||||
assignment = services.create_production_assignment(
|
||||
merchant=self.merchant,
|
||||
device=device,
|
||||
content_type=content_type,
|
||||
object_id=printing_order.id,
|
||||
assigner=self.operator,
|
||||
production_quantity=300,
|
||||
assignee=None,
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
extra={'batch': 'A1'},
|
||||
)
|
||||
|
||||
self.assertEqual(assignment.device, device)
|
||||
self.assertEqual(assignment.assigner, self.operator)
|
||||
self.assertIsNone(assignment.assignee)
|
||||
self.assertEqual(assignment.production_quantity, 300)
|
||||
self.assertEqual(assignment.status, ProductionAssignmentStatusEnum.DRAFT)
|
||||
self.assertEqual(assignment.extra, {'batch': 'A1'})
|
||||
|
||||
def test_create_production_assignment_rejects_cross_merchant_assignee(self):
|
||||
customer = Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试客户',
|
||||
mobile='13800138000',
|
||||
)
|
||||
printing_order = PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
fabric='测试面料',
|
||||
width='150cm',
|
||||
created_by=self.user,
|
||||
)
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
device = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
content_type = ContentType.objects.get_for_model(PrintingOrder)
|
||||
|
||||
with self.assertRaisesMessage(ValueError, '被指派人不属于当前商户'):
|
||||
services.create_production_assignment(
|
||||
merchant=self.merchant,
|
||||
device=device,
|
||||
content_type=content_type,
|
||||
object_id=printing_order.id,
|
||||
assigner=self.operator,
|
||||
production_quantity=300,
|
||||
assignee=self.other_operator,
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
|
||||
def test_update_production_assignment_rejects_invalid_status_transition(self):
|
||||
customer = Customer.objects.create(
|
||||
merchant=self.merchant,
|
||||
name='测试客户',
|
||||
mobile='13800138001',
|
||||
)
|
||||
printing_order = PrintingOrder.objects.create(
|
||||
merchant=self.merchant,
|
||||
customer=customer,
|
||||
fabric='测试面料',
|
||||
width='150cm',
|
||||
created_by=self.user,
|
||||
)
|
||||
category = services.create_device_category(
|
||||
merchant=self.merchant,
|
||||
name='打印机',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
)
|
||||
device = services.create_device(
|
||||
merchant=self.merchant,
|
||||
category=category,
|
||||
name='设备A',
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
peak_capacity=120,
|
||||
)
|
||||
content_type = ContentType.objects.get_for_model(PrintingOrder)
|
||||
assignment = services.create_production_assignment(
|
||||
merchant=self.merchant,
|
||||
device=device,
|
||||
content_type=content_type,
|
||||
object_id=printing_order.id,
|
||||
assigner=self.operator,
|
||||
production_quantity=300,
|
||||
created_by=self.user,
|
||||
operator=self.operator,
|
||||
status=ProductionAssignmentStatusEnum.ACCEPTED,
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(ValueError, '当前状态不允许变更为目标状态'):
|
||||
services.update_production_assignment(
|
||||
assignment=assignment,
|
||||
operator=self.operator,
|
||||
status=ProductionAssignmentStatusEnum.CANCELLED,
|
||||
)
|
||||
Reference in New Issue
Block a user