diff --git a/api_v2/test_mes_api.py b/api_v2/test_mes_api.py new file mode 100644 index 0000000..46c2ec7 --- /dev/null +++ b/api_v2/test_mes_api.py @@ -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]) diff --git a/api_v2/tests.py b/api_v2/tests.py index 662652a..db682c4 100644 --- a/api_v2/tests.py +++ b/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) diff --git a/api_v2/urls.py b/api_v2/urls.py index abe05ad..b00bb2f 100644 --- a/api_v2/urls.py +++ b/api_v2/urls.py @@ -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//', 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//', 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//cancel/', MissionCancelView.as_view(), name='api_v2_mission_cancel'), path('missions//set-urgent/', MissionSetUrgentView.as_view(), name='api_v2_mission_set_urgent'), path('mission-replies//reject/', MissionReplyRejectView.as_view(), name='api_v2_mission_reply_reject'), + path('mes/device-categories/', DeviceCategoryListCreateView.as_view(), name='api_v2_mes_device_category_list_create'), + path('mes/device-categories//', 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//', 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//', 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//', ShipmentDeliveryPhotoDetailView.as_view(), name='api_v2_shipment_delivery_photo_detail'), ] diff --git a/api_v2/views/__init__.py b/api_v2/views/__init__.py index 120216c..623cb62 100644 --- a/api_v2/views/__init__.py +++ b/api_v2/views/__init__.py @@ -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', ] diff --git a/api_v2/views/ai.py b/api_v2/views/ai.py index 30b83e5..3fe0bc6 100644 --- a/api_v2/views/ai.py +++ b/api_v2/views/ai.py @@ -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= + """ + + 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=&start_date=&end_date= + """ + + 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 diff --git a/api_v2/views/mes.py b/api_v2/views/mes.py new file mode 100644 index 0000000..9d40873 --- /dev/null +++ b/api_v2/views/mes.py @@ -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) diff --git a/docs/api_v2_agent_api.md b/docs/api_v2_agent_api.md index b96622d..6550ba6 100644 --- a/docs/api_v2_agent_api.md +++ b/docs/api_v2_agent_api.md @@ -11,99 +11,103 @@ ### 认证方式 ``` -Authorization: +# 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` 的日期范围过滤生产安排 +- 也就是说,它查的是“这个时间范围内创建的生产安排” 说明: diff --git a/docs/api_v2_ai_api.md b/docs/api_v2_ai_api.md index 86fcbeb..97a0852 100644 --- a/docs/api_v2_ai_api.md +++ b/docs/api_v2_ai_api.md @@ -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` 由后端部署时通过同名环境变量配置。未配置时所有请求均返回 `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//` +- 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`。 diff --git a/docs/api_v2_mes_api.md b/docs/api_v2_mes_api.md new file mode 100644 index 0000000..33df15d --- /dev/null +++ b/docs/api_v2_mes_api.md @@ -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` diff --git a/docs/mes_module_structure.md b/docs/mes_module_structure.md new file mode 100644 index 0000000..7749999 --- /dev/null +++ b/docs/mes_module_structure.md @@ -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` 是否符合业务上“一个指派对应一个目标对象”的表达方式 \ No newline at end of file diff --git a/flower-app_latest.tar.gz b/flower-app_latest.tar.gz new file mode 100644 index 0000000..76cfc05 Binary files /dev/null and b/flower-app_latest.tar.gz differ diff --git a/flower/settings.py b/flower/settings.py index 978592c..a206a2e 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -147,6 +147,7 @@ INSTALLED_APPS = [ 'settlement', 'mission', 'notifier', + 'mes', ] MIDDLEWARE = [ diff --git a/mes/apps.py b/mes/apps.py new file mode 100644 index 0000000..e4e2b11 --- /dev/null +++ b/mes/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class MesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'mes' + verbose_name = 'MES' diff --git a/mes/migrations/0001_initial.py b/mes/migrations/0001_initial.py new file mode 100644 index 0000000..5627a97 --- /dev/null +++ b/mes/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/mes/migrations/0002_device_capacity_unit_device_peak_capacity_and_more.py b/mes/migrations/0002_device_capacity_unit_device_peak_capacity_and_more.py new file mode 100644 index 0000000..8e08cb1 --- /dev/null +++ b/mes/migrations/0002_device_capacity_unit_device_peak_capacity_and_more.py @@ -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')], + }, + ), + ] diff --git a/mes/migrations/0003_productionassignment_production_quantity_and_more.py b/mes/migrations/0003_productionassignment_production_quantity_and_more.py new file mode 100644 index 0000000..e571351 --- /dev/null +++ b/mes/migrations/0003_productionassignment_production_quantity_and_more.py @@ -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'), + ), + ] diff --git a/mes/migrations/__init__.py b/mes/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mes/models.py b/mes/models.py new file mode 100644 index 0000000..13a3efb --- /dev/null +++ b/mes/models.py @@ -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']), + ] + diff --git a/mes/services.py b/mes/services.py new file mode 100644 index 0000000..f8517a4 --- /dev/null +++ b/mes/services.py @@ -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() diff --git a/mes/tests.py b/mes/tests.py new file mode 100644 index 0000000..400753a --- /dev/null +++ b/mes/tests.py @@ -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, + )