forked from erp-dev/erp
feat: added type field into warehouse model
This commit is contained in:
@@ -102,11 +102,27 @@ class SupplierSerializer(BaseSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class EmployeeSerializer(BaseSerializer):
|
class EmployeeSerializer(BaseSerializer):
|
||||||
|
job_type = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = basic_models.Employee
|
model = basic_models.Employee
|
||||||
fields = '__all__'
|
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):
|
class CustomerSerializer(BaseSerializer):
|
||||||
created_by = serializers.PrimaryKeyRelatedField(
|
created_by = serializers.PrimaryKeyRelatedField(
|
||||||
queryset=basic_models.Employee.objects.all(),
|
queryset=basic_models.Employee.objects.all(),
|
||||||
|
|||||||
304
api_man/tests.py
304
api_man/tests.py
@@ -1,3 +1,305 @@
|
|||||||
from django.test import TestCase
|
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'], '打纸工') # 应该保持不变
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ supplier_router.register(prefix='', viewset=views.SupplierViewSet)
|
|||||||
employee_router = routers.DefaultRouter()
|
employee_router = routers.DefaultRouter()
|
||||||
employee_router.register(prefix='', viewset=views.EmployeeViewSet)
|
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 = routers.DefaultRouter()
|
||||||
customer_router.register(prefix='', viewset=views.CustomerViewSet)
|
customer_router.register(prefix='', viewset=views.CustomerViewSet)
|
||||||
|
|
||||||
@@ -43,6 +46,7 @@ urlpatterns = [
|
|||||||
path('product-categories/', include(product_category_router.urls)),
|
path('product-categories/', include(product_category_router.urls)),
|
||||||
path('suppliers/', include(supplier_router.urls)),
|
path('suppliers/', include(supplier_router.urls)),
|
||||||
path('employees/', include(employee_router.urls)),
|
path('employees/', include(employee_router.urls)),
|
||||||
|
path('employee-types/', include(employee_type_router.urls)),
|
||||||
path('customers/', include(customer_router.urls)),
|
path('customers/', include(customer_router.urls)),
|
||||||
path('vehicle-types/', include(vehicle_type_router.urls)),
|
path('vehicle-types/', include(vehicle_type_router.urls)),
|
||||||
path('bank-accounts/', include(bank_account_router.urls)),
|
path('bank-accounts/', include(bank_account_router.urls)),
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ class EmployeeViewSet(BaseViewSet):
|
|||||||
serializer_class = serializers.EmployeeSerializer
|
serializer_class = serializers.EmployeeSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeTypeViewSet(BaseViewSet):
|
||||||
|
queryset = serializers.basic_models.EmployeeType.objects
|
||||||
|
serializer_class = serializers.EmployeeTypeSerializer
|
||||||
|
|
||||||
|
|
||||||
class CustomerViewSet(BaseViewSet):
|
class CustomerViewSet(BaseViewSet):
|
||||||
queryset = serializers.basic_models.Customer.objects
|
queryset = serializers.basic_models.Customer.objects
|
||||||
serializer_class = serializers.CustomerSerializer
|
serializer_class = serializers.CustomerSerializer
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class PlateOrderDesignCodeMixin:
|
class PlateOrderDesignCodeMixin:
|
||||||
"""确保 design_code 为空时使用主键补全"""
|
"""确保 design_code 为空时使用主键"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_design_code(design_code: str | None, instance_id: int | None) -> str | None:
|
def _normalize_design_code(design_code: str | None, instance_id: int | None) -> str | None:
|
||||||
@@ -222,22 +222,32 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
|
|||||||
customer_name = serializers.CharField(source="customer.name", read_only=True)
|
customer_name = serializers.CharField(source="customer.name", read_only=True)
|
||||||
salesperson_name = serializers.CharField(source="salesperson.name", read_only=True)
|
salesperson_name = serializers.CharField(source="salesperson.name", read_only=True)
|
||||||
merchandiser_name = serializers.CharField(source="merchandiser.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 = serializers.CharField(read_only=True)
|
||||||
progress_percentage = serializers.IntegerField(read_only=True)
|
progress_percentage = serializers.IntegerField(read_only=True)
|
||||||
process_name = serializers.SerializerMethodField()
|
process_name = serializers.SerializerMethodField()
|
||||||
|
plate_image_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PlateOrder
|
model = models.PlateOrder
|
||||||
|
|
||||||
fields = [
|
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',
|
'urgency_level', 'is_invalid',
|
||||||
'customer', 'customer_name', 'area',
|
'customer', 'customer_name', 'area', 'default_address',
|
||||||
'salesperson', 'salesperson_name',
|
'salesperson', 'salesperson_name',
|
||||||
'merchandiser', 'merchandiser_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',
|
'required_completion_date', 'completion_date',
|
||||||
'is_ordered', 'process', 'process_name', 'business_object_id',
|
'approval_result', 'is_ordered', 'customer_feedback',
|
||||||
'status', 'progress_percentage',
|
'process', 'process_name',
|
||||||
|
'status', 'status_id', 'is_completed', 'has_started',
|
||||||
|
'progress_percentage', 'business_object_id',
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
@@ -245,7 +255,17 @@ class PlateOrderListSerializer(PlateOrderDesignCodeMixin, serializers.ModelSeria
|
|||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_process_name(self, obj):
|
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) ->str | None:
|
||||||
"""获取流程名称"""
|
"""获取流程名称"""
|
||||||
if obj.process:
|
if obj.process:
|
||||||
try:
|
try:
|
||||||
@@ -263,6 +283,7 @@ class PlateOrderDetailSerializer(PlateOrderDesignCodeMixin, serializers.ModelSer
|
|||||||
customer_phone = serializers.CharField(source="customer.mobile", read_only=True)
|
customer_phone = serializers.CharField(source="customer.mobile", read_only=True)
|
||||||
salesperson_name = serializers.CharField(source="salesperson.name", read_only=True)
|
salesperson_name = serializers.CharField(source="salesperson.name", read_only=True)
|
||||||
merchandiser_name = serializers.CharField(source="merchandiser.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 = serializers.CharField(read_only=True)
|
||||||
status_id = serializers.IntegerField(read_only=True)
|
status_id = serializers.IntegerField(read_only=True)
|
||||||
is_completed = serializers.BooleanField(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',
|
'customer', 'customer_name', 'customer_phone', 'area', 'default_address',
|
||||||
'salesperson', 'salesperson_name',
|
'salesperson', 'salesperson_name',
|
||||||
'merchandiser', 'merchandiser_name',
|
'merchandiser', 'merchandiser_name',
|
||||||
|
'designer', 'designer_name',
|
||||||
'style_name', 'fabric', 'fabric_source', 'width', 'production_method',
|
'style_name', 'fabric', 'fabric_source', 'width', 'production_method',
|
||||||
'is_mark_frame', 'drawing_rating', 'color_matching_rating',
|
'is_mark_frame', 'drawing_rating', 'color_matching_rating',
|
||||||
'sample_rating', 'difficulty_rating',
|
'sample_rating', 'difficulty_rating',
|
||||||
@@ -333,7 +355,7 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer):
|
|||||||
"plate_image", "plate_notes", "reprint_reason",
|
"plate_image", "plate_notes", "reprint_reason",
|
||||||
"urgency_level", "is_invalid",
|
"urgency_level", "is_invalid",
|
||||||
"customer", "area", "default_address",
|
"customer", "area", "default_address",
|
||||||
"salesperson", "merchandiser",
|
"salesperson", "merchandiser", "designer",
|
||||||
"style_name", "fabric", "fabric_source", "width", "production_method",
|
"style_name", "fabric", "fabric_source", "width", "production_method",
|
||||||
"is_mark_frame", "drawing_rating", "color_matching_rating",
|
"is_mark_frame", "drawing_rating", "color_matching_rating",
|
||||||
"sample_rating", "difficulty_rating",
|
"sample_rating", "difficulty_rating",
|
||||||
@@ -362,6 +384,12 @@ class PlateOrderCreateUpdateSerializer(serializers.ModelSerializer):
|
|||||||
raise serializers.ValidationError("跟单员不存在")
|
raise serializers.ValidationError("跟单员不存在")
|
||||||
return value
|
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):
|
def validate_sample_meter(self, value):
|
||||||
"""验证样品米数"""
|
"""验证样品米数"""
|
||||||
if value is not None and value < 0:
|
if value is not None and value < 0:
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ class PrintingOrderAPITestCase(TestCase):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
name='测试员工',
|
name='测试员工',
|
||||||
mobile='13800138000',
|
mobile='13800138000',
|
||||||
job_type=basic_models.EmployeeTypeEnum.PRINTER,
|
|
||||||
status=basic_models.EmployeeStatusEnum.ACTIVE
|
status=basic_models.EmployeeStatusEnum.ACTIVE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class PlateOrderAPITestCase(TestCase):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
name='测试员工',
|
name='测试员工',
|
||||||
mobile='13800138000',
|
mobile='13800138000',
|
||||||
job_type=basic_models.EmployeeTypeEnum.PRINTER,
|
|
||||||
status=basic_models.EmployeeStatusEnum.ACTIVE
|
status=basic_models.EmployeeStatusEnum.ACTIVE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,7 +46,6 @@ class PlateOrderAPITestCase(TestCase):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
name='测试业务员',
|
name='测试业务员',
|
||||||
mobile='13800138001',
|
mobile='13800138001',
|
||||||
job_type=basic_models.EmployeeTypeEnum.PRINTER,
|
|
||||||
status=basic_models.EmployeeStatusEnum.ACTIVE
|
status=basic_models.EmployeeStatusEnum.ACTIVE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +54,14 @@ class PlateOrderAPITestCase(TestCase):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
name='测试跟单员',
|
name='测试跟单员',
|
||||||
mobile='13800138002',
|
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
|
status=basic_models.EmployeeStatusEnum.ACTIVE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -189,7 +194,7 @@ class PlateOrderAPITestCase(TestCase):
|
|||||||
self.assertIn('salesperson_name', response.data)
|
self.assertIn('salesperson_name', response.data)
|
||||||
|
|
||||||
def test_design_code_fallback_in_detail(self):
|
def test_design_code_fallback_in_detail(self):
|
||||||
"""design_code 为空时返回补零后的主键"""
|
"""design_code 为空时返回主键ID"""
|
||||||
plate_order = printing_models.PlateOrder.objects.create(
|
plate_order = printing_models.PlateOrder.objects.create(
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
design_code='',
|
design_code='',
|
||||||
@@ -200,10 +205,10 @@ class PlateOrderAPITestCase(TestCase):
|
|||||||
|
|
||||||
response = self.client.get(f'/api/v1/plate-orders/{plate_order.id}/')
|
response = self.client.get(f'/api/v1/plate-orders/{plate_order.id}/')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
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):
|
def test_design_code_fallback_in_list(self):
|
||||||
"""列表接口也应返回补零后的设计编号"""
|
"""列表接口也应返回主键ID"""
|
||||||
printing_models.PlateOrder.objects.create(
|
printing_models.PlateOrder.objects.create(
|
||||||
customer=self.customer,
|
customer=self.customer,
|
||||||
design_code='',
|
design_code='',
|
||||||
@@ -215,7 +220,7 @@ class PlateOrderAPITestCase(TestCase):
|
|||||||
response = self.client.get('/api/v1/plate-orders/')
|
response = self.client.get('/api/v1/plate-orders/')
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
results = response.data['results'] if isinstance(response.data, dict) else response.data
|
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):
|
def test_fabric_source_field_in_detail(self):
|
||||||
"""验证布料来源字段在返回中存在"""
|
"""验证布料来源字段在返回中存在"""
|
||||||
@@ -777,7 +782,22 @@ class PlateOrderInvalidateAPITestCase(TestCase):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
name='测试员工',
|
name='测试员工',
|
||||||
mobile='13800138000',
|
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
|
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/')
|
response = self.client.post(f'/api/v1/plate-orders/{plate_order.id}/activate/')
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn('未作废', response.data['detail'])
|
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)
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ class PlateOrderFileUploadTestCase(TestCase):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
name='测试员工',
|
name='测试员工',
|
||||||
mobile='13800138000',
|
mobile='13800138000',
|
||||||
job_type=basic_models.EmployeeTypeEnum.PRINTER,
|
|
||||||
status=basic_models.EmployeeStatusEnum.ACTIVE
|
status=basic_models.EmployeeStatusEnum.ACTIVE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
name='测试员工',
|
name='测试员工',
|
||||||
mobile='13800138000',
|
mobile='13800138000',
|
||||||
job_type=basic_models.EmployeeTypeEnum.PRINTER,
|
|
||||||
status=basic_models.EmployeeStatusEnum.ACTIVE
|
status=basic_models.EmployeeStatusEnum.ACTIVE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -979,9 +978,9 @@ class PrintingJobAPITestCase(TestCase):
|
|||||||
self.assertIsNotNone(timeline[0]['completed_at'])
|
self.assertIsNotNone(timeline[0]['completed_at'])
|
||||||
self.assertIsNotNone(timeline[0]['completed_by'])
|
self.assertIsNotNone(timeline[0]['completed_by'])
|
||||||
|
|
||||||
# 第二个状态应该是进行中
|
# 第二个状态应该是未开始(因为我们废除了 in_progress 的概念)
|
||||||
self.assertEqual(timeline[1]['state_name'], self.state2.name)
|
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'])
|
self.assertIsNone(timeline[1]['completed_at'])
|
||||||
|
|
||||||
# 第三个状态应该是未开始
|
# 第三个状态应该是未开始
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ class PrintingJobServiceTest(TestCase):
|
|||||||
self.assertIn('is_completed', status_info)
|
self.assertIn('is_completed', status_info)
|
||||||
self.assertIn('has_started', 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['is_completed'])
|
||||||
self.assertFalse(status_info['has_started'])
|
self.assertFalse(status_info['has_started'])
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class AdminBase(admin.ModelAdmin):
|
|||||||
|
|
||||||
def get_readonly_fields(self, request, obj = ...):
|
def get_readonly_fields(self, request, obj = ...):
|
||||||
readonly_fields = list(super().get_readonly_fields(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')
|
readonly_fields.append('merchant')
|
||||||
return readonly_fields
|
return readonly_fields
|
||||||
|
|
||||||
@@ -82,8 +82,8 @@ class BankAccountAdmin(AdminBase):
|
|||||||
|
|
||||||
@admin.register(models.WareHouse)
|
@admin.register(models.WareHouse)
|
||||||
class WareHouseAdmin(AdminBase):
|
class WareHouseAdmin(AdminBase):
|
||||||
list_display = ('name', 'location', 'area', 'mobile', 'mode')
|
list_display = ('name', 'type', 'location', 'area', 'mobile', 'mode')
|
||||||
list_filter = ('area', 'mode')
|
list_filter = ('area', 'mode', 'type')
|
||||||
search_fields = ('name', 'location', 'mobile')
|
search_fields = ('name', 'location', 'mobile')
|
||||||
|
|
||||||
|
|
||||||
@@ -104,13 +104,27 @@ class CustomerAdmin(AdminBase):
|
|||||||
|
|
||||||
@admin.register(models.Employee)
|
@admin.register(models.Employee)
|
||||||
class EmployeeAdmin(AdminBase):
|
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')
|
search_fields = ('name', 'mobile')
|
||||||
list_filter = (
|
list_filter = (
|
||||||
'job_type',
|
'position',
|
||||||
'status',
|
'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=...):
|
def get_readonly_fields(self, request, obj=...):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
18
basic_info/migrations/0010_warehouse_type.py
Normal file
18
basic_info/migrations/0010_warehouse_type.py
Normal file
@@ -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='仓库类别'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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='职位'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -19,6 +19,12 @@ class WareHouseModeEnum(models.IntegerChoices):
|
|||||||
UNRESTRICTED = 3, '宽进宽出'
|
UNRESTRICTED = 3, '宽进宽出'
|
||||||
|
|
||||||
|
|
||||||
|
class WarehouseTypeEnum(models.IntegerChoices):
|
||||||
|
"""仓库类别枚举"""
|
||||||
|
WHOLE = 1, '整仓'
|
||||||
|
SCATTERED = 2, '散仓'
|
||||||
|
|
||||||
|
|
||||||
class MerchantTypeEnum(models.IntegerChoices):
|
class MerchantTypeEnum(models.IntegerChoices):
|
||||||
"""商户类型枚举"""
|
"""商户类型枚举"""
|
||||||
STORE = 1, '布行'
|
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):
|
class QuickInput(ModelBase):
|
||||||
id = models.BigAutoField(primary_key=True)
|
id = models.BigAutoField(primary_key=True)
|
||||||
name = models.CharField(max_length=100, verbose_name='名')
|
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='所属商户')
|
merchant = models.ForeignKey('Merchant', on_delete=models.PROTECT, related_name='warehouses', verbose_name='所属商户')
|
||||||
name = models.CharField(max_length=100, verbose_name='仓库名称')
|
name = models.CharField(max_length=100, verbose_name='仓库名称')
|
||||||
location = models.CharField(max_length=200, blank=True, null=True, 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(
|
mode = models.IntegerField(
|
||||||
choices=WareHouseModeEnum.choices,
|
choices=WareHouseModeEnum.choices,
|
||||||
default=WareHouseModeEnum.RESTRICT_IN,
|
default=WareHouseModeEnum.RESTRICT_IN,
|
||||||
@@ -311,10 +339,12 @@ class Employee(ModelBase):
|
|||||||
verbose_name='系统用户',
|
verbose_name='系统用户',
|
||||||
)
|
)
|
||||||
name = models.CharField(max_length=100, verbose_name='员工姓名')
|
name = models.CharField(max_length=100, verbose_name='员工姓名')
|
||||||
job_type = models.CharField(
|
position = models.ForeignKey(
|
||||||
max_length=20,
|
'EmployeeType',
|
||||||
choices=EmployeeTypeEnum.choices,
|
on_delete=models.PROTECT,
|
||||||
default=EmployeeTypeEnum.PRINTER,
|
related_name='employees',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
verbose_name='职位',
|
verbose_name='职位',
|
||||||
)
|
)
|
||||||
mobile = models.CharField(max_length=20, blank=True, null=True, verbose_name='手机')
|
mobile = models.CharField(max_length=20, blank=True, null=True, verbose_name='手机')
|
||||||
@@ -327,6 +357,17 @@ class Employee(ModelBase):
|
|||||||
verbose_name='员工状态',
|
verbose_name='员工状态',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def job_type(self) -> str:
|
||||||
|
"""
|
||||||
|
返回职位名称(向后兼容)
|
||||||
|
|
||||||
|
如果有关联的职位类型,返回其 title,否则返回空字符串
|
||||||
|
"""
|
||||||
|
if self.position:
|
||||||
|
return self.position.title
|
||||||
|
return ''
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,166 @@
|
|||||||
from django.test import TestCase
|
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)
|
||||||
|
|||||||
419
docs/EMPLOYEE_TYPE_REFACTOR.md
Normal file
419
docs/EMPLOYEE_TYPE_REFACTOR.md
Normal file
@@ -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`
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ GET /api/v1/plate-orders/
|
|||||||
| customer_phone | string | 否 | 客户电话(模糊查询) |
|
| customer_phone | string | 否 | 客户电话(模糊查询) |
|
||||||
| salesperson | integer | 否 | 业务员ID |
|
| salesperson | integer | 否 | 业务员ID |
|
||||||
| merchandiser | integer | 否 | 跟单员ID |
|
| merchandiser | integer | 否 | 跟单员ID |
|
||||||
|
| designer | integer | 否 | 设计师ID |
|
||||||
| plate_type | string | 否 | 版型(模糊查询) |
|
| plate_type | string | 否 | 版型(模糊查询) |
|
||||||
| urgency_level | string | 否 | 紧急程度(模糊查询) |
|
| urgency_level | string | 否 | 紧急程度(模糊查询) |
|
||||||
| is_invalid | boolean | 否 | 是否作废(true/false) |
|
| is_invalid | boolean | 否 | 是否作废(true/false) |
|
||||||
@@ -69,6 +70,8 @@ GET /api/v1/plate-orders/
|
|||||||
"salesperson_name": "张三",
|
"salesperson_name": "张三",
|
||||||
"merchandiser": 4,
|
"merchandiser": 4,
|
||||||
"merchandiser_name": "李四",
|
"merchandiser_name": "李四",
|
||||||
|
"designer": 8,
|
||||||
|
"designer_name": "王五",
|
||||||
"style_name": "花朵印染",
|
"style_name": "花朵印染",
|
||||||
"fabric": "棉布",
|
"fabric": "棉布",
|
||||||
"width": "150cm",
|
"width": "150cm",
|
||||||
@@ -124,6 +127,8 @@ GET /api/v1/plate-orders/{id}/
|
|||||||
"salesperson_name": "张三",
|
"salesperson_name": "张三",
|
||||||
"merchandiser": 4,
|
"merchandiser": 4,
|
||||||
"merchandiser_name": "李四",
|
"merchandiser_name": "李四",
|
||||||
|
"designer": 8,
|
||||||
|
"designer_name": "王五",
|
||||||
"style_name": "花朵印染",
|
"style_name": "花朵印染",
|
||||||
"fabric": "棉布",
|
"fabric": "棉布",
|
||||||
"width": "150cm",
|
"width": "150cm",
|
||||||
@@ -175,6 +180,7 @@ Content-Type: application/json
|
|||||||
"urgency_level": "正常",
|
"urgency_level": "正常",
|
||||||
"salesperson": 3,
|
"salesperson": 3,
|
||||||
"merchandiser": 4,
|
"merchandiser": 4,
|
||||||
|
"designer": 8,
|
||||||
"required_completion_date": "2025-11-25",
|
"required_completion_date": "2025-11-25",
|
||||||
"is_mark_frame": false,
|
"is_mark_frame": false,
|
||||||
"plate_method": "机器",
|
"plate_method": "机器",
|
||||||
@@ -199,6 +205,7 @@ Content-Type: application/json
|
|||||||
- `default_address`: 默认地址
|
- `default_address`: 默认地址
|
||||||
- `salesperson`: 业务员ID
|
- `salesperson`: 业务员ID
|
||||||
- `merchandiser`: 跟单员ID
|
- `merchandiser`: 跟单员ID
|
||||||
|
- `designer`: 设计师ID
|
||||||
- `style_name`: 款式名称
|
- `style_name`: 款式名称
|
||||||
- `fabric`: 面料
|
- `fabric`: 面料
|
||||||
- `fabric_source`: 布料来源(可空字符串,如“客户提供”)
|
- `fabric_source`: 布料来源(可空字符串,如“客户提供”)
|
||||||
@@ -579,6 +586,7 @@ GET /api/v1/plate-orders/{id}/timeline/
|
|||||||
| default_address | string | 默认地址 |
|
| default_address | string | 默认地址 |
|
||||||
| salesperson | FK | 业务员(外键) |
|
| salesperson | FK | 业务员(外键) |
|
||||||
| merchandiser | FK | 跟单员(外键) |
|
| merchandiser | FK | 跟单员(外键) |
|
||||||
|
| designer | FK | 设计师(外键) |
|
||||||
| process | integer | 流程ID(无外键约束) |
|
| process | integer | 流程ID(无外键约束) |
|
||||||
| style_name | string | 款式名称 |
|
| style_name | string | 款式名称 |
|
||||||
| fabric | string | 面料 |
|
| fabric | string | 面料 |
|
||||||
|
|||||||
146
docs/WAREHOUSE_TYPE.md
Normal file
146
docs/WAREHOUSE_TYPE.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
@@ -34,17 +34,18 @@ class PlateOrderAdmin(admin.ModelAdmin):
|
|||||||
'customer__name',
|
'customer__name',
|
||||||
'salesperson__name',
|
'salesperson__name',
|
||||||
'merchandiser__name',
|
'merchandiser__name',
|
||||||
|
'designer__name',
|
||||||
)
|
)
|
||||||
readonly_fields = ('progress_display', 'status_display')
|
readonly_fields = ('progress_display', 'status_display')
|
||||||
actions = ['advance_to_next_state_action', 'step_back_one_state_action', 'reset_progress_action']
|
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 = [
|
fieldsets = [
|
||||||
('基本信息', {
|
('基本信息', {
|
||||||
'fields': ['design_code', 'plate_type', 'plate_date', 'urgency_level']
|
'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']
|
'fields': ['style_name', 'fabric', 'fabric_source', 'width', 'production_method']
|
||||||
|
|||||||
20
printing/migrations/0015_plateorder_designer.py
Normal file
20
printing/migrations/0015_plateorder_designer.py
Normal file
@@ -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='设计师'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -40,6 +40,14 @@ class PlateOrder(ModelBase):
|
|||||||
related_name='plate_orders_as_merchandiser',
|
related_name='plate_orders_as_merchandiser',
|
||||||
verbose_name='跟单员'
|
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(
|
customer = models.ForeignKey(
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ class PlateOrderModelTestCase(TestCase):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
name='销售员',
|
name='销售员',
|
||||||
mobile='13800138000',
|
mobile='13800138000',
|
||||||
job_type=basic_models.EmployeeTypeEnum.PRINTER,
|
|
||||||
status=basic_models.EmployeeStatusEnum.ACTIVE
|
status=basic_models.EmployeeStatusEnum.ACTIVE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,7 +46,6 @@ class PlateOrderModelTestCase(TestCase):
|
|||||||
merchant=self.merchant,
|
merchant=self.merchant,
|
||||||
name='跟单员',
|
name='跟单员',
|
||||||
mobile='13800138001',
|
mobile='13800138001',
|
||||||
job_type=basic_models.EmployeeTypeEnum.ROLLING,
|
|
||||||
status=basic_models.EmployeeStatusEnum.ACTIVE
|
status=basic_models.EmployeeStatusEnum.ACTIVE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user