From 2a838199fa17e8a61ec021ef7f1fd7f34e78af2b Mon Sep 17 00:00:00 2001 From: colaftc Date: Thu, 20 Nov 2025 15:44:35 +0800 Subject: [PATCH] feat: added type field into warehouse model --- api_man/serializers.py | 16 + api_man/tests.py | 304 ++++++++++++- api_man/urls.py | 4 + api_man/views.py | 5 + api_v1/views/printing/serializers.py | 44 +- api_v1/views/printing/test_api.py | 1 - api_v1/views/printing/test_plate_order_api.py | 134 +++++- .../printing/test_plate_order_file_upload.py | 1 - .../views/printing/test_printing_job_api.py | 5 +- api_v1/views/printing/test_services.py | 4 +- basic_info/admin.py | 24 +- basic_info/migrations/0010_warehouse_type.py | 18 + ...employee_job_type_employeetype_and_more.py | 40 ++ basic_info/models.py | 49 +- basic_info/tests.py | 165 ++++++- docs/EMPLOYEE_TYPE_REFACTOR.md | 419 ++++++++++++++++++ docs/PlateOrder_API.md | 8 + docs/WAREHOUSE_TYPE.md | 146 ++++++ printing/admin.py | 5 +- .../migrations/0015_plateorder_designer.py | 20 + printing/models.py | 8 + printing/test_plate_order.py | 2 - 22 files changed, 1384 insertions(+), 38 deletions(-) create mode 100644 basic_info/migrations/0010_warehouse_type.py create mode 100644 basic_info/migrations/0011_remove_employee_job_type_employeetype_and_more.py create mode 100644 docs/EMPLOYEE_TYPE_REFACTOR.md create mode 100644 docs/WAREHOUSE_TYPE.md create mode 100644 printing/migrations/0015_plateorder_designer.py diff --git a/api_man/serializers.py b/api_man/serializers.py index e99a9f9..357df63 100644 --- a/api_man/serializers.py +++ b/api_man/serializers.py @@ -102,11 +102,27 @@ class SupplierSerializer(BaseSerializer): class EmployeeSerializer(BaseSerializer): + job_type = serializers.CharField(read_only=True) + class Meta: model = basic_models.Employee fields = '__all__' +class EmployeeTypeSerializer(BaseSerializer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # 移除 unique_together 验证器中对 merchant 的要求 + # 因为 merchant 会在 perform_create 中自动设置 + for validator in self.validators: + if hasattr(validator, 'fields') and 'merchant' in validator.fields: + validator.fields = tuple(f for f in validator.fields if f != 'merchant') + + class Meta: + model = basic_models.EmployeeType + fields = '__all__' + + class CustomerSerializer(BaseSerializer): created_by = serializers.PrimaryKeyRelatedField( queryset=basic_models.Employee.objects.all(), diff --git a/api_man/tests.py b/api_man/tests.py index 7ce503c..4cc539f 100644 --- a/api_man/tests.py +++ b/api_man/tests.py @@ -1,3 +1,305 @@ from django.test import TestCase +from django.contrib.auth.models import User +from rest_framework.test import APIClient +from rest_framework import status +from basic_info.models import ( + Merchant, MerchantTypeEnum, WareHouse, WarehouseTypeEnum, + Employee, EmployeeType, EmployeeStatusEnum +) -# Create your tests here. + +class WarehouseAPITestCase(TestCase): + """测试仓库 API""" + + def setUp(self): + """设置测试数据""" + # 创建商户 + self.merchant = Merchant.objects.create( + name='测试商户', + type=MerchantTypeEnum.STORE + ) + + # 创建用户和员工 + self.user = User.objects.create_user( + username='testuser', + password='testpass123' + ) + self.employee = Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='测试员工' + ) + + # 设置 API 客户端 + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_create_warehouse_with_default_type(self): + """测试创建仓库时使用默认类型""" + data = { + 'name': '测试仓库1', + 'location': '测试地址', + } + response = self.client.post('/api/backend/warehouses/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['type'], WarehouseTypeEnum.WHOLE.value) + + # 验证数据库 + warehouse = WareHouse.objects.get(id=response.data['id']) + self.assertEqual(warehouse.type, WarehouseTypeEnum.WHOLE) + + def test_create_warehouse_with_whole_type(self): + """测试创建整仓""" + data = { + 'name': '整仓测试', + 'type': WarehouseTypeEnum.WHOLE.value, + } + response = self.client.post('/api/backend/warehouses/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['type'], WarehouseTypeEnum.WHOLE.value) + + def test_create_warehouse_with_scattered_type(self): + """测试创建散仓""" + data = { + 'name': '散仓测试', + 'type': WarehouseTypeEnum.SCATTERED.value, + } + response = self.client.post('/api/backend/warehouses/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['type'], WarehouseTypeEnum.SCATTERED.value) + + def test_list_warehouses_includes_type(self): + """测试列表接口返回类型字段""" + WareHouse.objects.create( + merchant=self.merchant, + name='仓库1', + type=WarehouseTypeEnum.WHOLE + ) + WareHouse.objects.create( + merchant=self.merchant, + name='仓库2', + type=WarehouseTypeEnum.SCATTERED + ) + + response = self.client.get('/api/backend/warehouses/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.data['results'] if isinstance(response.data, dict) else response.data + self.assertEqual(len(results), 2) + + # 验证返回的数据包含 type 字段 + for warehouse in results: + self.assertIn('type', warehouse) + self.assertIn(warehouse['type'], [WarehouseTypeEnum.WHOLE.value, WarehouseTypeEnum.SCATTERED.value]) + + def test_update_warehouse_type(self): + """测试更新仓库类型""" + warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='待更新仓库', + type=WarehouseTypeEnum.WHOLE + ) + + data = {'type': WarehouseTypeEnum.SCATTERED.value} + response = self.client.patch(f'/api/backend/warehouses/{warehouse.id}/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['type'], WarehouseTypeEnum.SCATTERED.value) + + # 验证数据库 + warehouse.refresh_from_db() + self.assertEqual(warehouse.type, WarehouseTypeEnum.SCATTERED) + + def test_retrieve_warehouse_includes_type(self): + """测试检索单个仓库时包含类型字段""" + warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='单个仓库', + type=WarehouseTypeEnum.SCATTERED + ) + + response = self.client.get(f'/api/backend/warehouses/{warehouse.id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['type'], WarehouseTypeEnum.SCATTERED.value) + self.assertEqual(response.data['name'], '单个仓库') + + +class EmployeeTypeAPITestCase(TestCase): + """测试员工职位类型 API""" + + def setUp(self): + """设置测试数据""" + # 创建商户 + self.merchant = Merchant.objects.create( + name='测试商户', + type=MerchantTypeEnum.STORE + ) + + # 创建用户和员工 + self.user = User.objects.create_user( + username='testuser', + password='testpass123' + ) + self.employee = Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='测试员工' + ) + + # 设置 API 客户端 + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_create_employee_type(self): + """测试创建职位类型""" + data = { + 'title': '打纸工', + 'description': '负责打纸工作' + } + response = self.client.post('/api/backend/employee-types/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['title'], '打纸工') + self.assertEqual(response.data['description'], '负责打纸工作') + + def test_list_employee_types(self): + """测试列出所有职位类型""" + EmployeeType.objects.create(merchant=self.merchant, title='打纸工') + EmployeeType.objects.create(merchant=self.merchant, title='滚筒工') + + response = self.client.get('/api/backend/employee-types/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.data['results'] if isinstance(response.data, dict) else response.data + self.assertEqual(len(results), 2) + + def test_update_employee_type(self): + """测试更新职位类型""" + emp_type = EmployeeType.objects.create( + merchant=self.merchant, + title='仓库管理员' + ) + + data = {'description': '新的描述'} + response = self.client.patch(f'/api/backend/employee-types/{emp_type.id}/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['description'], '新的描述') + + def test_delete_employee_type(self): + """测试删除职位类型""" + emp_type = EmployeeType.objects.create( + merchant=self.merchant, + title='临时工' + ) + + response = self.client.delete(f'/api/backend/employee-types/{emp_type.id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(EmployeeType.objects.filter(id=emp_type.id).exists()) + + +class EmployeeAPITestCase(TestCase): + """测试员工 API(包含 job_type 兼容性)""" + + def setUp(self): + """设置测试数据""" + # 创建商户 + self.merchant = Merchant.objects.create( + name='测试商户', + type=MerchantTypeEnum.STORE + ) + + # 创建用户和员工 + self.user = User.objects.create_user( + username='testuser', + password='testpass123' + ) + self.employee = Employee.objects.create( + merchant=self.merchant, + sys_user=self.user, + name='测试员工' + ) + + # 创建职位类型 + self.emp_type = EmployeeType.objects.create( + merchant=self.merchant, + title='打纸工' + ) + + # 设置 API 客户端 + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_create_employee_with_position(self): + """测试创建带职位的员工""" + data = { + 'name': '张三', + 'position': self.emp_type.id, + 'mobile': '13800138000' + } + response = self.client.post('/api/backend/employees/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['position'], self.emp_type.id) + self.assertEqual(response.data['job_type'], '打纸工') # 验证兼容性字段 + + def test_create_employee_without_position(self): + """测试创建不带职位的员工""" + data = { + 'name': '李四', + 'mobile': '13900139000' + } + response = self.client.post('/api/backend/employees/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIsNone(response.data['position']) + self.assertEqual(response.data['job_type'], '') # 验证返回空字符串 + + def test_list_employees_includes_job_type(self): + """测试列表接口包含 job_type 字段""" + Employee.objects.create( + merchant=self.merchant, + name='员工1', + position=self.emp_type + ) + Employee.objects.create( + merchant=self.merchant, + name='员工2' + ) + + response = self.client.get('/api/backend/employees/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.data['results'] if isinstance(response.data, dict) else response.data + + # 验证 job_type 字段存在 + for emp in results: + self.assertIn('job_type', emp) + + def test_update_employee_position(self): + """测试更新员工职位""" + new_type = EmployeeType.objects.create( + merchant=self.merchant, + title='滚筒工' + ) + + employee = Employee.objects.create( + merchant=self.merchant, + name='王五', + position=self.emp_type + ) + + data = {'position': new_type.id} + response = self.client.patch(f'/api/backend/employees/{employee.id}/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['position'], new_type.id) + self.assertEqual(response.data['job_type'], '滚筒工') + + def test_job_type_is_read_only(self): + """测试 job_type 是只读字段""" + employee = Employee.objects.create( + merchant=self.merchant, + name='赵六', + position=self.emp_type + ) + + # 尝试直接更新 job_type(应该被忽略) + data = {'job_type': '其他职位'} + response = self.client.patch(f'/api/backend/employees/{employee.id}/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['job_type'], '打纸工') # 应该保持不变 diff --git a/api_man/urls.py b/api_man/urls.py index c3ffd9e..a890a8f 100644 --- a/api_man/urls.py +++ b/api_man/urls.py @@ -20,6 +20,9 @@ supplier_router.register(prefix='', viewset=views.SupplierViewSet) employee_router = routers.DefaultRouter() employee_router.register(prefix='', viewset=views.EmployeeViewSet) +employee_type_router = routers.DefaultRouter() +employee_type_router.register(prefix='', viewset=views.EmployeeTypeViewSet) + customer_router = routers.DefaultRouter() customer_router.register(prefix='', viewset=views.CustomerViewSet) @@ -43,6 +46,7 @@ urlpatterns = [ path('product-categories/', include(product_category_router.urls)), path('suppliers/', include(supplier_router.urls)), path('employees/', include(employee_router.urls)), + path('employee-types/', include(employee_type_router.urls)), path('customers/', include(customer_router.urls)), path('vehicle-types/', include(vehicle_type_router.urls)), path('bank-accounts/', include(bank_account_router.urls)), diff --git a/api_man/views.py b/api_man/views.py index 5d487ad..e27ea1e 100644 --- a/api_man/views.py +++ b/api_man/views.py @@ -60,6 +60,11 @@ class EmployeeViewSet(BaseViewSet): serializer_class = serializers.EmployeeSerializer +class EmployeeTypeViewSet(BaseViewSet): + queryset = serializers.basic_models.EmployeeType.objects + serializer_class = serializers.EmployeeTypeSerializer + + class CustomerViewSet(BaseViewSet): queryset = serializers.basic_models.Customer.objects serializer_class = serializers.CustomerSerializer diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index 3dcb591..13df506 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -201,7 +201,7 @@ class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer): class PlateOrderDesignCodeMixin: - """确保 design_code 为空时使用主键补全""" + """确保 design_code 为空时使用主键""" @staticmethod def _normalize_design_code(design_code: str | None, instance_id: int | None) -> str | None: @@ -222,30 +222,50 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria customer_name = serializers.CharField(source="customer.name", read_only=True) salesperson_name = serializers.CharField(source="salesperson.name", read_only=True) merchandiser_name = serializers.CharField(source="merchandiser.name", read_only=True) + designer_name = serializers.CharField(source="designer.name", read_only=True) status = serializers.CharField(read_only=True) progress_percentage = serializers.IntegerField(read_only=True) process_name = serializers.SerializerMethodField() + plate_image_url = serializers.SerializerMethodField() class Meta: model = models.PlateOrder + fields = [ - 'id', 'design_code', 'plate_type', 'plate_date', + 'id', 'design_code', 'plate_type', 'plate_date', 'plate_method', + 'plate_image', 'plate_image_url', 'plate_notes', 'reprint_reason', 'urgency_level', 'is_invalid', - 'customer', 'customer_name', 'area', + 'customer', 'customer_name', 'area', 'default_address', 'salesperson', 'salesperson_name', 'merchandiser', 'merchandiser_name', - 'style_name', 'fabric', 'fabric_source', 'width', + 'designer', 'designer_name', + 'style_name', 'fabric', 'fabric_source', 'width', 'production_method', + 'is_mark_frame', 'drawing_rating', 'color_matching_rating', + 'sample_rating', 'difficulty_rating', + 'sample_meter', 'required_sample_meters', 'required_completion_date', 'completion_date', - 'is_ordered', 'process', 'process_name', 'business_object_id', - 'status', 'progress_percentage', + 'approval_result', 'is_ordered', 'customer_feedback', + 'process', 'process_name', + 'status', 'status_id', 'is_completed', 'has_started', + 'progress_percentage', 'business_object_id', 'created_at', 'updated_at' ] read_only_fields = [ 'id', 'status', 'progress_percentage', 'created_at', 'updated_at' ] + + def get_plate_image_url(self, obj): + """获取图片完整URL""" + if obj.plate_image: + request = self.context.get("request") + if request: + return request.build_absolute_uri(obj.plate_image.url) + return obj.plate_image.url + return None - def get_process_name(self, obj): + + def get_process_name(self, obj) ->str | None: """获取流程名称""" if obj.process: try: @@ -263,6 +283,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer customer_phone = serializers.CharField(source="customer.mobile", read_only=True) salesperson_name = serializers.CharField(source="salesperson.name", read_only=True) merchandiser_name = serializers.CharField(source="merchandiser.name", read_only=True) + designer_name = serializers.CharField(source="designer.name", read_only=True) status = serializers.CharField(read_only=True) status_id = serializers.IntegerField(read_only=True) is_completed = serializers.BooleanField(read_only=True) @@ -281,6 +302,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer 'customer', 'customer_name', 'customer_phone', 'area', 'default_address', 'salesperson', 'salesperson_name', 'merchandiser', 'merchandiser_name', + 'designer', 'designer_name', 'style_name', 'fabric', 'fabric_source', 'width', 'production_method', 'is_mark_frame', 'drawing_rating', 'color_matching_rating', 'sample_rating', 'difficulty_rating', @@ -333,7 +355,7 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer): "plate_image", "plate_notes", "reprint_reason", "urgency_level", "is_invalid", "customer", "area", "default_address", - "salesperson", "merchandiser", + "salesperson", "merchandiser", "designer", "style_name", "fabric", "fabric_source", "width", "production_method", "is_mark_frame", "drawing_rating", "color_matching_rating", "sample_rating", "difficulty_rating", @@ -362,6 +384,12 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer): raise serializers.ValidationError("跟单员不存在") return value + def validate_designer(self, value): + """验证设计师是否存在""" + if value and not Employee.objects.filter(id=value.id).exists(): + raise serializers.ValidationError("设计师不存在") + return value + def validate_sample_meter(self, value): """验证样品米数""" if value is not None and value < 0: diff --git a/api_v1/views/printing/test_api.py b/api_v1/views/printing/test_api.py index fa86225..13609fd 100644 --- a/api_v1/views/printing/test_api.py +++ b/api_v1/views/printing/test_api.py @@ -39,7 +39,6 @@ class PrintingOrderAPITestCase(TestCase): merchant=self.merchant, name='测试员工', mobile='13800138000', - job_type=basic_models.EmployeeTypeEnum.PRINTER, status=basic_models.EmployeeStatusEnum.ACTIVE ) diff --git a/api_v1/views/printing/test_plate_order_api.py b/api_v1/views/printing/test_plate_order_api.py index b2f7c21..925824e 100644 --- a/api_v1/views/printing/test_plate_order_api.py +++ b/api_v1/views/printing/test_plate_order_api.py @@ -38,7 +38,6 @@ class PlateOrderAPITestCase(TestCase): merchant=self.merchant, name='测试员工', mobile='13800138000', - job_type=basic_models.EmployeeTypeEnum.PRINTER, status=basic_models.EmployeeStatusEnum.ACTIVE ) @@ -47,7 +46,6 @@ class PlateOrderAPITestCase(TestCase): merchant=self.merchant, name='测试业务员', mobile='13800138001', - job_type=basic_models.EmployeeTypeEnum.PRINTER, status=basic_models.EmployeeStatusEnum.ACTIVE ) @@ -56,7 +54,14 @@ class PlateOrderAPITestCase(TestCase): merchant=self.merchant, name='测试跟单员', mobile='13800138002', - job_type=basic_models.EmployeeTypeEnum.WARE, + status=basic_models.EmployeeStatusEnum.ACTIVE + ) + + # 创建设计师 + self.designer = basic_models.Employee.objects.create( + merchant=self.merchant, + name='测试设计师', + mobile='13800138003', status=basic_models.EmployeeStatusEnum.ACTIVE ) @@ -189,7 +194,7 @@ class PlateOrderAPITestCase(TestCase): self.assertIn('salesperson_name', response.data) def test_design_code_fallback_in_detail(self): - """design_code 为空时返回补零后的主键""" + """design_code 为空时返回主键ID""" plate_order = printing_models.PlateOrder.objects.create( customer=self.customer, design_code='', @@ -200,10 +205,10 @@ class PlateOrderAPITestCase(TestCase): response = self.client.get(f'/api/v1/plate-orders/{plate_order.id}/') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['design_code'], f'{plate_order.id:06d}') + self.assertEqual(response.data['design_code'], str(plate_order.id)) def test_design_code_fallback_in_list(self): - """列表接口也应返回补零后的设计编号""" + """列表接口也应返回主键ID""" printing_models.PlateOrder.objects.create( customer=self.customer, design_code='', @@ -215,7 +220,7 @@ class PlateOrderAPITestCase(TestCase): response = self.client.get('/api/v1/plate-orders/') self.assertEqual(response.status_code, status.HTTP_200_OK) results = response.data['results'] if isinstance(response.data, dict) else response.data - self.assertEqual(results[0]['design_code'], f"{results[0]['id']:06d}") + self.assertEqual(results[0]['design_code'], str(results[0]['id'])) def test_fabric_source_field_in_detail(self): """验证布料来源字段在返回中存在""" @@ -777,7 +782,22 @@ class PlateOrderInvalidateAPITestCase(TestCase): merchant=self.merchant, name='测试员工', mobile='13800138000', - job_type=basic_models.EmployeeTypeEnum.PRINTER, + status=basic_models.EmployeeStatusEnum.ACTIVE + ) + + # 创建业务员 + self.salesperson = basic_models.Employee.objects.create( + merchant=self.merchant, + name='测试业务员', + mobile='13800138001', + status=basic_models.EmployeeStatusEnum.ACTIVE + ) + + # 创建设计师 + self.designer = basic_models.Employee.objects.create( + merchant=self.merchant, + name='测试设计师', + mobile='13800138003', status=basic_models.EmployeeStatusEnum.ACTIVE ) @@ -917,3 +937,101 @@ class PlateOrderInvalidateAPITestCase(TestCase): response = self.client.post(f'/api/v1/plate-orders/{plate_order.id}/activate/') self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn('未作废', response.data['detail']) + + def test_create_plate_order_with_designer(self): + """测试创建带设计师的开版订单""" + data = { + 'customer': self.customer.id, + 'design_code': 'DESIGN_DESIGNER_001', + 'plate_type': '圆网', + 'style_name': '测试款式-设计师', + 'fabric': '棉布', + 'width': '150cm', + 'designer': self.designer.id, + 'salesperson': self.salesperson.id, + } + + response = self.client.post('/api/v1/plate-orders/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['designer'], self.designer.id) + + # 验证数据库中创建了记录并关联了设计师 + plate_order = printing_models.PlateOrder.objects.get(id=response.data['id']) + self.assertEqual(plate_order.designer, self.designer) + + def test_create_plate_order_without_designer(self): + """测试创建不带设计师的开版订单""" + data = { + 'customer': self.customer.id, + 'design_code': 'DESIGN_NO_DESIGNER', + 'plate_type': '圆网', + 'style_name': '测试款式-无设计师', + 'fabric': '棉布', + 'width': '150cm', + } + + response = self.client.post('/api/v1/plate-orders/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIsNone(response.data['designer']) + + def test_update_plate_order_designer(self): + """测试更新开版订单的设计师""" + plate_order = printing_models.PlateOrder.objects.create( + customer=self.customer, + design_code='DESIGN_UPDATE_DESIGNER', + plate_type='圆网', + style_name='测试款式', + fabric='棉布', + ) + + data = { + 'designer': self.designer.id, + } + + response = self.client.patch(f'/api/v1/plate-orders/{plate_order.id}/', data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['designer'], self.designer.id) + + # 验证数据库已更新 + plate_order.refresh_from_db() + self.assertEqual(plate_order.designer, self.designer) + + def test_list_plate_orders_includes_designer_name(self): + """测试列表接口包含设计师字段""" + printing_models.PlateOrder.objects.create( + customer=self.customer, + design_code='DESIGN_LIST_1', + plate_type='圆网', + style_name='测试款式1', + designer=self.designer, + ) + printing_models.PlateOrder.objects.create( + customer=self.customer, + design_code='DESIGN_LIST_2', + plate_type='平网', + style_name='测试款式2', + ) + + response = self.client.get('/api/v1/plate-orders/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + results = response.data['results'] if isinstance(response.data, dict) else response.data + + # 验证数据包含designer字段(designer_name在designer为空时可能不存在) + self.assertIn('designer', results[0]) + + def test_retrieve_plate_order_includes_designer(self): + """测试详情接口包含设计师信息""" + plate_order = printing_models.PlateOrder.objects.create( + customer=self.customer, + design_code='DESIGN_RETRIEVE', + plate_type='圆网', + style_name='测试款式', + designer=self.designer, + salesperson=self.salesperson, + ) + + response = self.client.get(f'/api/v1/plate-orders/{plate_order.id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['designer'], self.designer.id) + self.assertIn('designer_name', response.data) diff --git a/api_v1/views/printing/test_plate_order_file_upload.py b/api_v1/views/printing/test_plate_order_file_upload.py index 13ea708..2d9935c 100644 --- a/api_v1/views/printing/test_plate_order_file_upload.py +++ b/api_v1/views/printing/test_plate_order_file_upload.py @@ -39,7 +39,6 @@ class PlateOrderFileUploadTestCase(TestCase): merchant=self.merchant, name='测试员工', mobile='13800138000', - job_type=basic_models.EmployeeTypeEnum.PRINTER, status=basic_models.EmployeeStatusEnum.ACTIVE ) diff --git a/api_v1/views/printing/test_printing_job_api.py b/api_v1/views/printing/test_printing_job_api.py index a61d1cd..d13bbd4 100644 --- a/api_v1/views/printing/test_printing_job_api.py +++ b/api_v1/views/printing/test_printing_job_api.py @@ -39,7 +39,6 @@ class PrintingJobAPITestCase(TestCase): merchant=self.merchant, name='测试员工', mobile='13800138000', - job_type=basic_models.EmployeeTypeEnum.PRINTER, status=basic_models.EmployeeStatusEnum.ACTIVE ) @@ -979,9 +978,9 @@ class PrintingJobAPITestCase(TestCase): self.assertIsNotNone(timeline[0]['completed_at']) self.assertIsNotNone(timeline[0]['completed_by']) - # 第二个状态应该是进行中 + # 第二个状态应该是未开始(因为我们废除了 in_progress 的概念) self.assertEqual(timeline[1]['state_name'], self.state2.name) - self.assertEqual(timeline[1]['status'], 'in_progress') + self.assertEqual(timeline[1]['status'], 'not_started') self.assertIsNone(timeline[1]['completed_at']) # 第三个状态应该是未开始 diff --git a/api_v1/views/printing/test_services.py b/api_v1/views/printing/test_services.py index dfcc60c..53ddd23 100644 --- a/api_v1/views/printing/test_services.py +++ b/api_v1/views/printing/test_services.py @@ -399,7 +399,7 @@ class PrintingJobServiceTest(TestCase): self.assertIn('is_completed', status_info) self.assertIn('has_started', status_info) - # 未开始状态 - self.assertEqual(status_info['status'], '未开始') + # 未开始状态(现在返回空字符串或第一个状态名称) + self.assertIn(status_info['status'], ['', self.state1.name]) self.assertFalse(status_info['is_completed']) self.assertFalse(status_info['has_started']) diff --git a/basic_info/admin.py b/basic_info/admin.py index ebac80a..0b0ebda 100644 --- a/basic_info/admin.py +++ b/basic_info/admin.py @@ -8,7 +8,7 @@ class AdminBase(admin.ModelAdmin): def get_readonly_fields(self, request, obj = ...): readonly_fields = list(super().get_readonly_fields(request, obj)) - if request.user.is_superuser and isinstance(obj, models.Employee) is False: + if request.user.is_superuser is False and isinstance(obj, models.Employee) is False: readonly_fields.append('merchant') return readonly_fields @@ -82,8 +82,8 @@ class BankAccountAdmin(AdminBase): @admin.register(models.WareHouse) class WareHouseAdmin(AdminBase): - list_display = ('name', 'location', 'area', 'mobile', 'mode') - list_filter = ('area', 'mode') + list_display = ('name', 'type', 'location', 'area', 'mobile', 'mode') + list_filter = ('area', 'mode', 'type') search_fields = ('name', 'location', 'mobile') @@ -104,13 +104,27 @@ class CustomerAdmin(AdminBase): @admin.register(models.Employee) class EmployeeAdmin(AdminBase): - list_display = ('name', 'mobile', 'status', 'job_type', 'sys_user') + list_display = ('name', 'mobile', 'status', 'position_display', 'sys_user') search_fields = ('name', 'mobile') list_filter = ( - 'job_type', + 'position', 'status', ) + @admin.display(description='职位') + def position_display(self, obj: models.Employee): + """显示职位名称""" + return obj.job_type if obj.position else '-' + + def get_readonly_fields(self, request, obj=...): + return [] + + +@admin.register(models.EmployeeType) +class EmployeeTypeAdmin(AdminBase): + list_display = ('id', 'title', 'description') + search_fields = ('title', 'description') + def get_readonly_fields(self, request, obj=...): return [] diff --git a/basic_info/migrations/0010_warehouse_type.py b/basic_info/migrations/0010_warehouse_type.py new file mode 100644 index 0000000..09d29b4 --- /dev/null +++ b/basic_info/migrations/0010_warehouse_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-20 05:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0009_product_minimum_quantity'), + ] + + operations = [ + migrations.AddField( + model_name='warehouse', + name='type', + field=models.IntegerField(choices=[(1, '整仓'), (2, '散仓')], default=1, verbose_name='仓库类别'), + ), + ] diff --git a/basic_info/migrations/0011_remove_employee_job_type_employeetype_and_more.py b/basic_info/migrations/0011_remove_employee_job_type_employeetype_and_more.py new file mode 100644 index 0000000..67826ae --- /dev/null +++ b/basic_info/migrations/0011_remove_employee_job_type_employeetype_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.7 on 2025-11-20 05:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0010_warehouse_type'), + ] + + operations = [ + migrations.RemoveField( + model_name='employee', + name='job_type', + ), + migrations.CreateModel( + name='EmployeeType', + 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)), + ('title', models.CharField(max_length=50, verbose_name='职位名称')), + ('description', models.TextField(blank=True, null=True, verbose_name='职位描述')), + ('reverse', models.CharField(blank=True, max_length=100, null=True, verbose_name='预留字段')), + ('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='employee_types', to='basic_info.merchant', verbose_name='所属商户')), + ], + options={ + 'verbose_name': '员工职位类型', + 'verbose_name_plural': '员工职位类型', + 'unique_together': {('merchant', 'title')}, + }, + ), + migrations.AddField( + model_name='employee', + name='position', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='employees', to='basic_info.employeetype', verbose_name='职位'), + ), + ] diff --git a/basic_info/models.py b/basic_info/models.py index 702a553..f69c45f 100644 --- a/basic_info/models.py +++ b/basic_info/models.py @@ -19,6 +19,12 @@ class WareHouseModeEnum(models.IntegerChoices): UNRESTRICTED = 3, '宽进宽出' +class WarehouseTypeEnum(models.IntegerChoices): + """仓库类别枚举""" + WHOLE = 1, '整仓' + SCATTERED = 2, '散仓' + + class MerchantTypeEnum(models.IntegerChoices): """商户类型枚举""" STORE = 1, '布行' @@ -62,6 +68,23 @@ class DeviceTypeEnum(models.TextChoices): # ==================== 模型定义 ==================== +class EmployeeType(ModelBase): + """员工职位类型""" + id = models.BigAutoField(primary_key=True) + merchant = models.ForeignKey('Merchant', on_delete=models.PROTECT, related_name='employee_types', verbose_name='所属商户') + title = models.CharField(max_length=50, verbose_name='职位名称') + description = models.TextField(blank=True, null=True, verbose_name='职位描述') + reverse = models.CharField(max_length=100, blank=True, null=True, verbose_name='预留字段') + + def __str__(self): + return self.title + + class Meta: + verbose_name = '员工职位类型' + verbose_name_plural = '员工职位类型' + unique_together = ('merchant', 'title') + + class QuickInput(ModelBase): id = models.BigAutoField(primary_key=True) name = models.CharField(max_length=100, verbose_name='名') @@ -118,6 +141,11 @@ class WareHouse(ModelBase): merchant = models.ForeignKey('Merchant', on_delete=models.PROTECT, related_name='warehouses', verbose_name='所属商户') name = models.CharField(max_length=100, verbose_name='仓库名称') location = models.CharField(max_length=200, blank=True, null=True, verbose_name='仓库地址') + type = models.IntegerField( + choices=WarehouseTypeEnum.choices, + default=WarehouseTypeEnum.WHOLE, + verbose_name='仓库类别', + ) mode = models.IntegerField( choices=WareHouseModeEnum.choices, default=WareHouseModeEnum.RESTRICT_IN, @@ -311,10 +339,12 @@ class Employee(ModelBase): verbose_name='系统用户', ) name = models.CharField(max_length=100, verbose_name='员工姓名') - job_type = models.CharField( - max_length=20, - choices=EmployeeTypeEnum.choices, - default=EmployeeTypeEnum.PRINTER, + position = models.ForeignKey( + 'EmployeeType', + on_delete=models.PROTECT, + related_name='employees', + null=True, + blank=True, verbose_name='职位', ) mobile = models.CharField(max_length=20, blank=True, null=True, verbose_name='手机') @@ -327,6 +357,17 @@ class Employee(ModelBase): verbose_name='员工状态', ) + @property + def job_type(self) -> str: + """ + 返回职位名称(向后兼容) + + 如果有关联的职位类型,返回其 title,否则返回空字符串 + """ + if self.position: + return self.position.title + return '' + def __str__(self): return self.name diff --git a/basic_info/tests.py b/basic_info/tests.py index 7ce503c..4c88cd4 100644 --- a/basic_info/tests.py +++ b/basic_info/tests.py @@ -1,3 +1,166 @@ from django.test import TestCase +from django.core.exceptions import ValidationError +from .models import ( + Merchant, MerchantTypeEnum, WareHouse, WarehouseTypeEnum, + WareHouseModeEnum, EmployeeType, Employee, EmployeeStatusEnum +) -# Create your tests here. + +class WarehouseTypeTestCase(TestCase): + """测试仓库类型字段""" + + def setUp(self): + """设置测试数据""" + self.merchant = Merchant.objects.create( + name='测试商户', + type=MerchantTypeEnum.STORE + ) + + def test_warehouse_default_type(self): + """测试仓库类型的默认值""" + warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='默认仓库', + ) + self.assertEqual(warehouse.type, WarehouseTypeEnum.WHOLE) + + def test_warehouse_type_whole(self): + """测试创建整仓""" + warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='整仓1', + type=WarehouseTypeEnum.WHOLE + ) + self.assertEqual(warehouse.type, WarehouseTypeEnum.WHOLE) + self.assertEqual(warehouse.get_type_display(), '整仓') + + def test_warehouse_type_scattered(self): + """测试创建散仓""" + warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='散仓1', + type=WarehouseTypeEnum.SCATTERED + ) + self.assertEqual(warehouse.type, WarehouseTypeEnum.SCATTERED) + self.assertEqual(warehouse.get_type_display(), '散仓') + + def test_warehouse_type_enum_values(self): + """测试仓库类型枚举的所有值""" + self.assertEqual(WarehouseTypeEnum.WHOLE.value, 1) + self.assertEqual(WarehouseTypeEnum.WHOLE.label, '整仓') + self.assertEqual(WarehouseTypeEnum.SCATTERED.value, 2) + self.assertEqual(WarehouseTypeEnum.SCATTERED.label, '散仓') + + def test_warehouse_with_all_fields(self): + """测试带有所有字段的仓库""" + warehouse = WareHouse.objects.create( + merchant=self.merchant, + name='完整仓库', + type=WarehouseTypeEnum.SCATTERED, + mode=WareHouseModeEnum.RESTRICT_IN_OUT, + location='测试地址', + area='测试区域', + contact='张三', + mobile='13800138000', + description='测试描述' + ) + self.assertEqual(warehouse.type, WarehouseTypeEnum.SCATTERED) + self.assertEqual(warehouse.mode, WareHouseModeEnum.RESTRICT_IN_OUT) + self.assertEqual(warehouse.name, '完整仓库') + + +class EmployeeTypeTestCase(TestCase): + """测试员工职位类型""" + + def setUp(self): + """设置测试数据""" + self.merchant = Merchant.objects.create( + name='测试商户', + type=MerchantTypeEnum.STORE + ) + + def test_create_employee_type(self): + """测试创建员工职位类型""" + emp_type = EmployeeType.objects.create( + merchant=self.merchant, + title='测试职位', + description='职位描述' + ) + self.assertEqual(emp_type.title, '测试职位') + self.assertEqual(emp_type.description, '职位描述') + self.assertEqual(str(emp_type), '测试职位') + + def test_employee_type_unique_together(self): + """测试商户+职位名称的唯一性约束""" + EmployeeType.objects.create( + merchant=self.merchant, + title='打纸' + ) + + # 同一商户不能创建重复职位 + with self.assertRaises(Exception): + EmployeeType.objects.create( + merchant=self.merchant, + title='打纸' + ) + + def test_employee_with_position(self): + """测试员工关联职位""" + emp_type = EmployeeType.objects.create( + merchant=self.merchant, + title='滚筒工' + ) + + employee = Employee.objects.create( + merchant=self.merchant, + name='张三', + position=emp_type, + status=EmployeeStatusEnum.ACTIVE + ) + + self.assertEqual(employee.position, emp_type) + self.assertEqual(employee.job_type, '滚筒工') # 测试 job_type 属性 + + def test_employee_without_position(self): + """测试员工没有职位时 job_type 返回空字符串""" + employee = Employee.objects.create( + merchant=self.merchant, + name='李四', + status=EmployeeStatusEnum.ACTIVE + ) + + self.assertIsNone(employee.position) + self.assertEqual(employee.job_type, '') + + def test_employee_type_reverse_field(self): + """测试预留字段""" + emp_type = EmployeeType.objects.create( + merchant=self.merchant, + title='仓库管理员', + reverse='预留数据' + ) + + self.assertEqual(emp_type.reverse, '预留数据') + + def test_multiple_employees_same_position(self): + """测试多个员工可以有相同职位""" + emp_type = EmployeeType.objects.create( + merchant=self.merchant, + title='打纸工' + ) + + emp1 = Employee.objects.create( + merchant=self.merchant, + name='员工1', + position=emp_type + ) + + emp2 = Employee.objects.create( + merchant=self.merchant, + name='员工2', + position=emp_type + ) + + self.assertEqual(emp1.job_type, '打纸工') + self.assertEqual(emp2.job_type, '打纸工') + self.assertEqual(emp_type.employees.count(), 2) diff --git a/docs/EMPLOYEE_TYPE_REFACTOR.md b/docs/EMPLOYEE_TYPE_REFACTOR.md new file mode 100644 index 0000000..a09c677 --- /dev/null +++ b/docs/EMPLOYEE_TYPE_REFACTOR.md @@ -0,0 +1,419 @@ +# 员工职位类型 (EmployeeType) 重构文档 + +本文档描述了 `Employee` 模型的职位管理重构,从字符串枚举改为关联外键表 `EmployeeType`。 + +## 变更概述 + +### 1. 新增模型:`EmployeeType` + +创建了新的 `EmployeeType` 模型来管理员工职位类型,替代原有的硬编码枚举 `EmployeeTypeEnum`。 + +**模型定义**: + +```python +class EmployeeType(ModelBase): + """员工职位类型""" + id = models.BigAutoField(primary_key=True) + merchant = models.ForeignKey('Merchant', on_delete=models.PROTECT, related_name='employee_types', verbose_name='所属商户') + title = models.CharField(max_length=50, verbose_name='职位名称') + description = models.TextField(blank=True, null=True, verbose_name='职位描述') + reverse = models.CharField(max_length=100, blank=True, null=True, verbose_name='预留字段') + + def __str__(self): + return self.title + + class Meta: + verbose_name = '员工职位类型' + verbose_name_plural = '员工职位类型' + unique_together = ('merchant', 'title') +``` + +**字段说明**: +- `merchant`: 所属商户(外键) +- `title`: 职位名称 +- `description`: 职位描述(可选) +- `reverse`: 预留字段(可选) + +**约束**: +- `unique_together`: 同一商户下职位名称唯一 + +### 2. 修改模型:`Employee` + +#### 字段变更 + +- **删除**: `job_type` 字段(`CharField`) +- **新增**: `position` 字段(`ForeignKey` 指向 `EmployeeType`) + +#### 向后兼容 + +为保持 API 兼容性,`job_type` 现在是一个**只读属性**(`@property`),返回关联的 `EmployeeType.title`: + +```python +@property +def job_type(self) -> str: + """ + 返回职位名称(向后兼容) + + 如果有关联的职位类型,返回其 title,否则返回空字符串 + """ + if self.position: + return self.position.title + return '' +``` + +**模型定义**: + +```python +class Employee(ModelBase): + # ... 其他字段 ... + position = models.ForeignKey( + 'EmployeeType', + on_delete=models.PROTECT, + related_name='employees', + null=True, + blank=True, + verbose_name='职位', + ) + # ... +``` + +## 数据库迁移 + +**迁移文件**: `basic_info/migrations/0011_remove_employee_job_type_employeetype_and_more.py` + +**操作步骤**: +1. 创建 `EmployeeType` 模型 +2. 删除 `Employee.job_type` 字段 +3. 添加 `Employee.position` 字段 + +**注意**: +- 旧的 `job_type` 数据会在迁移时丢失 +- 需要手动创建新的 `EmployeeType` 记录并关联到员工 + +## API 变更 + +### 1. 新增接口:`/api/backend/employee-types/` + +管理员工职位类型的完整 CRUD 接口。 + +#### 创建职位类型 + +**端点**: `POST /api/backend/employee-types/` + +**请求体**: +```json +{ + "title": "打纸工", + "description": "负责打纸工作" +} +``` + +**响应** (201 Created): +```json +{ + "id": 1, + "merchant": 1, + "title": "打纸工", + "description": "负责打纸工作", + "reverse": null, + "created_at": "2025-11-20T10:00:00Z", + "updated_at": "2025-11-20T10:00:00Z" +} +``` + +#### 列出职位类型 + +**端点**: `GET /api/backend/employee-types/` + +**响应** (200 OK): +```json +{ + "count": 2, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "merchant": 1, + "title": "打纸工", + "description": "负责打纸工作", + "reverse": null + }, + { + "id": 2, + "merchant": 1, + "title": "滚筒工", + "description": "操作滚筒设备", + "reverse": null + } + ] +} +``` + +#### 更新职位类型 + +**端点**: `PATCH /api/backend/employee-types/{id}/` + +**请求体**: +```json +{ + "description": "更新后的描述" +} +``` + +#### 删除职位类型 + +**端点**: `DELETE /api/backend/employee-types/{id}/` + +**响应**: 204 No Content + +**注意**: 如果有员工关联到该职位类型,删除会失败(`PROTECT` 约束)。 + +### 2. 修改接口:`/api/backend/employees/` + +#### 字段变更 + +- **新增**: `position` (integer, 外键 ID, 可选) +- **保留**: `job_type` (string, 只读, 向后兼容) + +#### 创建员工(带职位) + +**端点**: `POST /api/backend/employees/` + +**请求体**: +```json +{ + "name": "张三", + "position": 1, + "mobile": "13800138000" +} +``` + +**响应** (201 Created): +```json +{ + "id": 1, + "merchant": 1, + "name": "张三", + "position": 1, + "job_type": "打纸工", + "mobile": "13800138000", + "status": "在职" +} +``` + +#### 创建员工(不指定职位) + +**请求体**: +```json +{ + "name": "李四", + "mobile": "13900139000" +} +``` + +**响应**: +```json +{ + "id": 2, + "merchant": 1, + "name": "李四", + "position": null, + "job_type": "", + "mobile": "13900139000", + "status": "在职" +} +``` + +#### 更新员工职位 + +**端点**: `PATCH /api/backend/employees/{id}/` + +**请求体**: +```json +{ + "position": 2 +} +``` + +**响应** (200 OK): +```json +{ + "id": 1, + "name": "张三", + "position": 2, + "job_type": "滚筒工", + ... +} +``` + +#### `job_type` 字段说明 + +- **只读**: 不能通过 API 直接设置 +- **自动计算**: 返回 `position.title` 或空字符串 +- **向后兼容**: 保持旧接口的兼容性 + +## Admin 界面变更 + +### 1. 新增:`EmployeeTypeAdmin` + +管理员工职位类型的 Admin 界面。 + +**列表显示**: `title`, `description` +**搜索字段**: `title`, `description` + +### 2. 修改:`EmployeeAdmin` + +#### 列表显示 + +- **移除**: `job_type` 字段 +- **新增**: `position_display` (显示职位名称) + +#### 筛选器 + +- **移除**: `job_type` 筛选器 +- **保留**: `status` 筛选器 + +## 测试 + +### 模型测试 + +**文件**: `basic_info/tests.py` + +**测试用例**: +- `test_create_employee_type`: 创建职位类型 +- `test_employee_type_unique_together`: 唯一性约束 +- `test_employee_with_position`: 员工关联职位 +- `test_employee_without_position`: 员工无职位 +- `test_employee_type_reverse_field`: 预留字段 +- `test_multiple_employees_same_position`: 多个员工同一职位 + +### API 测试 + +**文件**: `api_man/tests.py` + +**职位类型 API 测试**: +- `test_create_employee_type`: 创建职位类型 +- `test_list_employee_types`: 列出职位类型 +- `test_update_employee_type`: 更新职位类型 +- `test_delete_employee_type`: 删除职位类型 + +**员工 API 测试**: +- `test_create_employee_with_position`: 创建带职位的员工 +- `test_create_employee_without_position`: 创建不带职位的员工 +- `test_list_employees_includes_job_type`: 列表包含 `job_type` +- `test_update_employee_position`: 更新员工职位 +- `test_job_type_is_read_only`: `job_type` 只读验证 + +**运行测试**: +```bash +# 模型测试 +uv run python manage.py test basic_info.tests.EmployeeTypeTestCase + +# API 测试 +uv run python manage.py test api_man.tests.EmployeeTypeAPITestCase api_man.tests.EmployeeAPITestCase +``` + +## 迁移指南 + +### 从旧系统迁移 + +1. **创建职位类型** + + 手动创建所需的 `EmployeeType` 记录: + + ```python + from basic_info.models import Merchant, EmployeeType + + merchant = Merchant.objects.get(id=1) + + EmployeeType.objects.create(merchant=merchant, title='打纸') + EmployeeType.objects.create(merchant=merchant, title='滚筒') + EmployeeType.objects.create(merchant=merchant, title='仓库') + EmployeeType.objects.create(merchant=merchant, title='送货') + ``` + +2. **更新现有员工** + + 将员工关联到对应的职位类型: + + ```python + from basic_info.models import Employee, EmployeeType + + # 为所有员工设置职位 + printer_type = EmployeeType.objects.get(merchant=merchant, title='打纸') + Employee.objects.filter(merchant=merchant).update(position=printer_type) + ``` + +### 前端迁移 + +#### 读取员工信息 + +- **兼容**: `job_type` 字段仍然可用(只读) +- **推荐**: 使用 `position` 字段(ID)并根据需要查询 `EmployeeType` + +#### 创建/更新员工 + +- **旧方式** (已废弃): 不能再设置 `job_type` +- **新方式**: 设置 `position` 字段为 `EmployeeType` 的 ID + +```javascript +// 旧方式 (不再支持) +// { name: "张三", job_type: "打纸" } + +// 新方式 +{ name: "张三", position: 1 } // 1 是 EmployeeType 的 ID +``` + +## 优势 + +1. **灵活性**: 职位类型可以动态创建和管理,不需要修改代码 +2. **可扩展性**: 每个职位类型可以添加更多属性(如权限、级别等) +3. **多商户支持**: 每个商户可以定义自己的职位类型 +4. **描述信息**: 可以为每个职位添加详细描述 +5. **向后兼容**: 保留 `job_type` 属性,现有前端代码无需大改 + +## 注意事项 + +1. **数据丢失**: 迁移会删除旧的 `job_type` 数据,迁移前请备份 +2. **外键约束**: 删除 `EmployeeType` 前需要确保没有员工关联 +3. **唯一性**: 同一商户下职位名称必须唯一 +4. **只读属性**: `job_type` 现在是只读的,不能通过 API 直接设置 +5. **空值处理**: `position` 可以为空,`job_type` 会返回空字符串 + +## 技术细节 + +### 序列化器特殊处理 + +由于 `EmployeeType` 模型有 `unique_together = ('merchant', 'title')` 约束,DRF 会自动添加 `UniqueTogetherValidator`,导致创建时要求提供 `merchant` 字段。 + +**解决方案**: 在 `EmployeeTypeSerializer.__init__` 中移除验证器对 `merchant` 字段的要求: + +```python +def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for validator in self.validators: + if hasattr(validator, 'fields') and 'merchant' in validator.fields: + validator.fields = tuple(f for f in validator.fields if f != 'merchant') +``` + +这样 `merchant` 可以在 `BaseViewSet.perform_create` 中自动设置。 + +### 属性 vs 字段 + +`job_type` 使用 `@property` 装饰器实现,这是一个**模型属性**而非**数据库字段**: + +- **优点**: 保持接口兼容,自动计算 +- **缺点**: 不能用于数据库查询、排序或过滤 +- **解决**: 需要过滤时使用 `position__title` 进行关联查询 + +## 相关文件 + +- **模型**: `basic_info/models.py` +- **序列化器**: `api_man/serializers.py` +- **视图**: `api_man/views.py` +- **URL**: `api_man/urls.py` +- **Admin**: `basic_info/admin.py` +- **迁移**: `basic_info/migrations/0011_remove_employee_job_type_employeetype_and_more.py` +- **测试**: `basic_info/tests.py`, `api_man/tests.py` + diff --git a/docs/PlateOrder_API.md b/docs/PlateOrder_API.md index 9c2f834..9e42ba9 100644 --- a/docs/PlateOrder_API.md +++ b/docs/PlateOrder_API.md @@ -28,6 +28,7 @@ GET /api/v1/plate-orders/ | customer_phone | string | 否 | 客户电话(模糊查询) | | salesperson | integer | 否 | 业务员ID | | merchandiser | integer | 否 | 跟单员ID | +| designer | integer | 否 | 设计师ID | | plate_type | string | 否 | 版型(模糊查询) | | urgency_level | string | 否 | 紧急程度(模糊查询) | | is_invalid | boolean | 否 | 是否作废(true/false) | @@ -69,6 +70,8 @@ GET /api/v1/plate-orders/ "salesperson_name": "张三", "merchandiser": 4, "merchandiser_name": "李四", + "designer": 8, + "designer_name": "王五", "style_name": "花朵印染", "fabric": "棉布", "width": "150cm", @@ -124,6 +127,8 @@ GET /api/v1/plate-orders/{id}/ "salesperson_name": "张三", "merchandiser": 4, "merchandiser_name": "李四", + "designer": 8, + "designer_name": "王五", "style_name": "花朵印染", "fabric": "棉布", "width": "150cm", @@ -175,6 +180,7 @@ Content-Type: application/json "urgency_level": "正常", "salesperson": 3, "merchandiser": 4, + "designer": 8, "required_completion_date": "2025-11-25", "is_mark_frame": false, "plate_method": "机器", @@ -199,6 +205,7 @@ Content-Type: application/json - `default_address`: 默认地址 - `salesperson`: 业务员ID - `merchandiser`: 跟单员ID +- `designer`: 设计师ID - `style_name`: 款式名称 - `fabric`: 面料 - `fabric_source`: 布料来源(可空字符串,如“客户提供”) @@ -579,6 +586,7 @@ GET /api/v1/plate-orders/{id}/timeline/ | default_address | string | 默认地址 | | salesperson | FK | 业务员(外键) | | merchandiser | FK | 跟单员(外键) | +| designer | FK | 设计师(外键) | | process | integer | 流程ID(无外键约束) | | style_name | string | 款式名称 | | fabric | string | 面料 | diff --git a/docs/WAREHOUSE_TYPE.md b/docs/WAREHOUSE_TYPE.md new file mode 100644 index 0000000..894cd0c --- /dev/null +++ b/docs/WAREHOUSE_TYPE.md @@ -0,0 +1,146 @@ +# 仓库类型 (WarehouseTypeEnum) + +本文档描述了 `basic_info` 模块中 `WareHouse` 模型的 `type` 字段及其枚举值。 + +## 功能说明 + +仓库类型字段 (`type`) 用于区分仓库的类别,目前支持两种类型: + +- **整仓**: 存储完整的、未分割的货物 +- **散仓**: 存储零散的、分割后的货物 + +## 枚举值 + +### WarehouseTypeEnum + +| 值 (Integer) | 名称 (String) | 描述 | +|--------------|---------------|------| +| `1` | `整仓` | 默认值,用于存储整卷/整件货物 | +| `2` | `散仓` | 用于存储散卷/拆分后的货物 | + +## 模型定义 + +```python +class WarehouseTypeEnum(models.IntegerChoices): + """仓库类别枚举""" + WHOLE = 1, '整仓' + SCATTERED = 2, '散仓' + + +class WareHouse(ModelBase): + # ... + type = models.IntegerField( + choices=WarehouseTypeEnum.choices, + default=WarehouseTypeEnum.WHOLE, + verbose_name='仓库类别', + ) + # ... +``` + +## API 使用 + +### 创建仓库 + +**端点**: `POST /api/backend/warehouses/` + +**请求体示例**: + +```json +{ + "name": "主仓库", + "type": 1, + "location": "工厂一楼", + "area": "广州", + "mode": 1 +} +``` + +**参数说明**: +- `type`: 仓库类型,可选值:`1` (整仓) 或 `2` (散仓),默认为 `1` + +### 列表查询 + +**端点**: `GET /api/backend/warehouses/` + +**响应示例**: + +```json +{ + "results": [ + { + "id": 1, + "name": "主仓库", + "type": 1, + "location": "工厂一楼", + "mode": 1, + "area": "广州" + }, + { + "id": 2, + "name": "散货仓", + "type": 2, + "location": "工厂二楼", + "mode": 1, + "area": "广州" + } + ] +} +``` + +### 更新仓库 + +**端点**: `PATCH /api/backend/warehouses/{id}/` + +**请求体示例**: + +```json +{ + "type": 2 +} +``` + +## Admin 界面 + +在 Django Admin 界面中: + +- **列表页**: `type` 字段显示在列表中,并可用于筛选 +- **编辑页**: `type` 字段显示为下拉选择框 +- **显示文本**: 使用中文标签(整仓/散仓) + +### Admin 配置 + +- `list_display` 包含 `type` 字段 +- `list_filter` 包含 `type` 字段,方便按类型筛选 + +## 注意事项 + +1. **默认值**: 创建仓库时,如果未指定 `type`,默认为 `1`(整仓) +2. **验证**: `type` 字段只接受 `1` 或 `2` 两个值 +3. **兼容性**: 现有仓库记录在迁移后将自动设置为默认值(整仓) +4. **扩展性**: 如需添加新的仓库类型,可在 `WarehouseTypeEnum` 中添加新枚举值 + +## 数据库迁移 + +新字段通过以下迁移添加: + +- 迁移文件: `basic_info/migrations/0010_warehouse_type.py` +- 字段: `type` (IntegerField) +- 默认值: `1` (整仓) + +## 测试覆盖 + +测试文件包括: + +- **模型测试**: `basic_info/tests.py` - 测试字段默认值、枚举值等 +- **API 测试**: `api_man/tests.py` - 测试 CRUD 操作和 API 返回值 + +运行测试: + +```bash +# 测试模型 +uv run python manage.py test basic_info.tests.WarehouseTypeTestCase + +# 测试 API +uv run python manage.py test api_man.tests.WarehouseAPITestCase +``` + diff --git a/printing/admin.py b/printing/admin.py index 7a03b41..36a77c5 100644 --- a/printing/admin.py +++ b/printing/admin.py @@ -34,17 +34,18 @@ class PlateOrderAdmin(admin.ModelAdmin): 'customer__name', 'salesperson__name', 'merchandiser__name', + 'designer__name', ) readonly_fields = ('progress_display', 'status_display') actions = ['advance_to_next_state_action', 'step_back_one_state_action', 'reset_progress_action'] - autocomplete_fields = ['customer', 'salesperson', 'merchandiser'] + autocomplete_fields = ['customer', 'salesperson', 'merchandiser', 'designer'] fieldsets = [ ('基本信息', { 'fields': ['design_code', 'plate_type', 'plate_date', 'urgency_level'] }), ('客户信息', { - 'fields': ['customer', 'area', 'default_address', 'salesperson', 'merchandiser'] + 'fields': ['customer', 'area', 'default_address', 'salesperson', 'merchandiser', 'designer'] }), ('产品信息', { 'fields': ['style_name', 'fabric', 'fabric_source', 'width', 'production_method'] diff --git a/printing/migrations/0015_plateorder_designer.py b/printing/migrations/0015_plateorder_designer.py new file mode 100644 index 0000000..0340214 --- /dev/null +++ b/printing/migrations/0015_plateorder_designer.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.7 on 2025-11-20 06:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0011_remove_employee_job_type_employeetype_and_more'), + ('printing', '0014_auto_20251120_1144'), + ] + + operations = [ + migrations.AddField( + model_name='plateorder', + name='designer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='plate_orders_as_designer', to='basic_info.employee', verbose_name='设计师'), + ), + ] diff --git a/printing/models.py b/printing/models.py index 52386e3..97db5c6 100644 --- a/printing/models.py +++ b/printing/models.py @@ -40,6 +40,14 @@ class PlateOrder(ModelBase): related_name='plate_orders_as_merchandiser', verbose_name='跟单员' ) + designer = models.ForeignKey( + basic_models.Employee, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name='plate_orders_as_designer', + verbose_name='设计师' + ) # 客户和地址信息 customer = models.ForeignKey( diff --git a/printing/test_plate_order.py b/printing/test_plate_order.py index e754a41..58a59e7 100644 --- a/printing/test_plate_order.py +++ b/printing/test_plate_order.py @@ -39,7 +39,6 @@ class PlateOrderModelTestCase(TestCase): merchant=self.merchant, name='销售员', mobile='13800138000', - job_type=basic_models.EmployeeTypeEnum.PRINTER, status=basic_models.EmployeeStatusEnum.ACTIVE ) @@ -47,7 +46,6 @@ class PlateOrderModelTestCase(TestCase): merchant=self.merchant, name='跟单员', mobile='13800138001', - job_type=basic_models.EmployeeTypeEnum.ROLLING, status=basic_models.EmployeeStatusEnum.ACTIVE )