forked from erp-dev/erp
feat: added frontend_pages models(basic_info) and api(v2), support employees query pages that they can visit by themself token
This commit is contained in:
@@ -138,8 +138,6 @@ class PrintingOrderListSerializer(serializers.ModelSerializer):
|
|||||||
process_name = serializers.CharField(source='process.name', read_only=True)
|
process_name = serializers.CharField(source='process.name', read_only=True)
|
||||||
progress = serializers.IntegerField(read_only=True)
|
progress = serializers.IntegerField(read_only=True)
|
||||||
jobs_status_summary = serializers.SerializerMethodField()
|
jobs_status_summary = serializers.SerializerMethodField()
|
||||||
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
|
|
||||||
created_by_name = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingOrder
|
model = models.PrintingOrder
|
||||||
@@ -149,17 +147,9 @@ class PrintingOrderListSerializer(serializers.ModelSerializer):
|
|||||||
'is_fabric_received', 'outgoing_date', 'is_invalid', 'new_curve',
|
'is_fabric_received', 'outgoing_date', 'is_invalid', 'new_curve',
|
||||||
'process', 'process_name', 'progress', 'position', 'print_count',
|
'process', 'process_name', 'progress', 'position', 'print_count',
|
||||||
'jobs_status_summary',
|
'jobs_status_summary',
|
||||||
'created_by', 'created_by_name',
|
|
||||||
'created_at', 'updated_at',
|
'created_at', 'updated_at',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count', 'created_by']
|
read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count']
|
||||||
|
|
||||||
def get_created_by_name(self, obj):
|
|
||||||
user = getattr(obj, 'created_by', None)
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
emp = getattr(user, 'employee', None)
|
|
||||||
return getattr(emp, 'name', None)
|
|
||||||
|
|
||||||
def get_jobs_status_summary(self, obj):
|
def get_jobs_status_summary(self, obj):
|
||||||
"""
|
"""
|
||||||
@@ -196,8 +186,6 @@ class PrintingOrderDetailSerializer(serializers.ModelSerializer):
|
|||||||
customer_area = serializers.CharField(source='customer.area', read_only=True)
|
customer_area = serializers.CharField(source='customer.area', read_only=True)
|
||||||
process_name = serializers.CharField(source='process.name', read_only=True)
|
process_name = serializers.CharField(source='process.name', read_only=True)
|
||||||
progress = serializers.IntegerField(read_only=True)
|
progress = serializers.IntegerField(read_only=True)
|
||||||
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
|
|
||||||
created_by_name = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingOrder
|
model = models.PrintingOrder
|
||||||
@@ -208,17 +196,9 @@ class PrintingOrderDetailSerializer(serializers.ModelSerializer):
|
|||||||
'curve', 'new_curve', 'position',
|
'curve', 'new_curve', 'position',
|
||||||
'printing_warn', 'rolling_warn', 'production_warn',
|
'printing_warn', 'rolling_warn', 'production_warn',
|
||||||
'is_invalid', 'process', 'process_name', 'progress', 'print_count',
|
'is_invalid', 'process', 'process_name', 'progress', 'print_count',
|
||||||
'created_by', 'created_by_name',
|
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count', 'created_by']
|
read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count']
|
||||||
|
|
||||||
def get_created_by_name(self, obj):
|
|
||||||
user = getattr(obj, 'created_by', None)
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
emp = getattr(user, 'employee', None)
|
|
||||||
return getattr(emp, 'name', None)
|
|
||||||
|
|
||||||
|
|
||||||
class PrintingOrderCreateUpdateSerializer(serializers.ModelSerializer):
|
class PrintingOrderCreateUpdateSerializer(serializers.ModelSerializer):
|
||||||
@@ -278,16 +258,10 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
product_name = serializers.CharField(source='product.name', read_only=True)
|
product_name = serializers.CharField(source='product.name', read_only=True)
|
||||||
product_image_url = serializers.SerializerMethodField()
|
product_image_url = serializers.SerializerMethodField()
|
||||||
status = serializers.CharField(read_only=True)
|
status = serializers.CharField(read_only=True)
|
||||||
status_id = serializers.IntegerField(read_only=True)
|
|
||||||
work_state_display = serializers.SerializerMethodField()
|
work_state_display = serializers.SerializerMethodField()
|
||||||
is_completed = serializers.BooleanField(read_only=True)
|
is_completed = serializers.BooleanField(read_only=True)
|
||||||
has_started = serializers.BooleanField(read_only=True)
|
|
||||||
progress_percentage = serializers.FloatField(read_only=True)
|
|
||||||
last_completed_state = serializers.CharField(read_only=True)
|
|
||||||
business_object_id = serializers.SerializerMethodField()
|
business_object_id = serializers.SerializerMethodField()
|
||||||
batch_advance_records = serializers.SerializerMethodField()
|
batch_advance_records = serializers.SerializerMethodField()
|
||||||
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
|
|
||||||
created_by_name = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
@@ -296,19 +270,11 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
'product_image_url',
|
'product_image_url',
|
||||||
'quantity', 'unit', 'size', 'pieces', 'description',
|
'quantity', 'unit', 'size', 'pieces', 'description',
|
||||||
'work_state', 'work_state_display',
|
'work_state', 'work_state_display',
|
||||||
'status', 'status_id', 'is_completed', 'has_started',
|
'status', 'is_completed', 'business_object_id',
|
||||||
'progress_percentage', 'last_completed_state',
|
|
||||||
'business_object_id',
|
|
||||||
'batch_advance_records',
|
'batch_advance_records',
|
||||||
'created_by', 'created_by_name',
|
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = ['id', 'created_at', 'updated_at', 'status', 'is_completed', 'business_object_id']
|
||||||
'id', 'created_at', 'updated_at',
|
|
||||||
'status', 'status_id', 'is_completed', 'has_started',
|
|
||||||
'progress_percentage', 'last_completed_state', 'business_object_id',
|
|
||||||
'created_by'
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_business_object_id(self, obj):
|
def get_business_object_id(self, obj):
|
||||||
"""安全地获取 business_object_id"""
|
"""安全地获取 business_object_id"""
|
||||||
@@ -354,13 +320,6 @@ class PrintingJobListSerializer(serializers.ModelSerializer):
|
|||||||
records = list(obj.batch_advance_records.all())
|
records = list(obj.batch_advance_records.all())
|
||||||
return PrintingJobBatchAdvanceRecordSerializer(records, many=True).data
|
return PrintingJobBatchAdvanceRecordSerializer(records, many=True).data
|
||||||
|
|
||||||
def get_created_by_name(self, obj):
|
|
||||||
user = getattr(obj, 'created_by', None)
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
emp = getattr(user, 'employee', None)
|
|
||||||
return getattr(emp, 'name', None)
|
|
||||||
|
|
||||||
|
|
||||||
class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
||||||
"""印染款式明细详情序列化器"""
|
"""印染款式明细详情序列化器"""
|
||||||
@@ -374,8 +333,6 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
has_started = serializers.BooleanField(read_only=True)
|
has_started = serializers.BooleanField(read_only=True)
|
||||||
business_object_id = serializers.SerializerMethodField()
|
business_object_id = serializers.SerializerMethodField()
|
||||||
batch_advance_records = serializers.SerializerMethodField()
|
batch_advance_records = serializers.SerializerMethodField()
|
||||||
created_by = serializers.IntegerField(source='created_by_id', read_only=True)
|
|
||||||
created_by_name = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.PrintingJob
|
model = models.PrintingJob
|
||||||
@@ -385,13 +342,11 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
'work_state', 'work_state_display',
|
'work_state', 'work_state_display',
|
||||||
'status', 'status_id', 'is_completed', 'has_started', 'business_object_id',
|
'status', 'status_id', 'is_completed', 'has_started', 'business_object_id',
|
||||||
'batch_advance_records',
|
'batch_advance_records',
|
||||||
'created_by', 'created_by_name',
|
|
||||||
'created_at', 'updated_at'
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'id', 'created_at', 'updated_at',
|
'id', 'created_at', 'updated_at',
|
||||||
'status', 'status_id', 'is_completed', 'has_started', 'business_object_id',
|
'status', 'status_id', 'is_completed', 'has_started', 'business_object_id'
|
||||||
'created_by'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_business_object_id(self, obj):
|
def get_business_object_id(self, obj):
|
||||||
@@ -406,13 +361,6 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer):
|
|||||||
records = list(obj.batch_advance_records.all())
|
records = list(obj.batch_advance_records.all())
|
||||||
return PrintingJobBatchAdvanceRecordSerializer(records, many=True).data
|
return PrintingJobBatchAdvanceRecordSerializer(records, many=True).data
|
||||||
|
|
||||||
def get_created_by_name(self, obj):
|
|
||||||
user = getattr(obj, 'created_by', None)
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
emp = getattr(user, 'employee', None)
|
|
||||||
return getattr(emp, 'name', None)
|
|
||||||
|
|
||||||
|
|
||||||
class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
class PrintingJobCreateUpdateSerializer(serializers.ModelSerializer):
|
||||||
"""印染款式明细创建/更新序列化器"""
|
"""印染款式明细创建/更新序列化器"""
|
||||||
|
|||||||
130
api_v2/tests.py
130
api_v2/tests.py
@@ -1386,3 +1386,133 @@ class PlateOrderBatchUpdateV2APITest(TestCase):
|
|||||||
self.assertEqual(resp2.status_code, 200)
|
self.assertEqual(resp2.status_code, 200)
|
||||||
self.po1.refresh_from_db()
|
self.po1.refresh_from_db()
|
||||||
self.assertTrue(self.po1.is_invalid)
|
self.assertTrue(self.po1.is_invalid)
|
||||||
|
|
||||||
|
|
||||||
|
class MyVisiblePagesAPITest(TestCase):
|
||||||
|
"""测试获取当前用户可见页面 API"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.merchant = basic_models.Merchant.objects.create(
|
||||||
|
name='测试商户',
|
||||||
|
type=basic_models.MerchantTypeEnum.FACTORY,
|
||||||
|
)
|
||||||
|
self.user = get_user_model().objects.create_user(username='testuser', password='pass12345')
|
||||||
|
|
||||||
|
# 创建前端页面
|
||||||
|
self.main_menu = basic_models.FrontendPage.objects.create(
|
||||||
|
key='production',
|
||||||
|
label='生产管理',
|
||||||
|
page_type=basic_models.FrontendPageTypeEnum.MAIN,
|
||||||
|
sort_order=100
|
||||||
|
)
|
||||||
|
self.sub_menu1 = basic_models.FrontendPage.objects.create(
|
||||||
|
key='production-kanban',
|
||||||
|
label='生产看板',
|
||||||
|
page_type=basic_models.FrontendPageTypeEnum.SUB,
|
||||||
|
parent=self.main_menu,
|
||||||
|
sort_order=101
|
||||||
|
)
|
||||||
|
self.sub_menu2 = basic_models.FrontendPage.objects.create(
|
||||||
|
key='printing-order-list',
|
||||||
|
label='生产订单',
|
||||||
|
page_type=basic_models.FrontendPageTypeEnum.SUB,
|
||||||
|
parent=self.main_menu,
|
||||||
|
sort_order=102
|
||||||
|
)
|
||||||
|
self.other_menu = basic_models.FrontendPage.objects.create(
|
||||||
|
key='inventory',
|
||||||
|
label='库存管理',
|
||||||
|
page_type=basic_models.FrontendPageTypeEnum.MAIN,
|
||||||
|
sort_order=200
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建岗位并分配页面
|
||||||
|
self.emp_type = basic_models.EmployeeType.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
title='生产主管'
|
||||||
|
)
|
||||||
|
self.emp_type.visible_pages.add(self.main_menu)
|
||||||
|
|
||||||
|
# 创建员工并关联用户
|
||||||
|
self.employee = basic_models.Employee.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='张三',
|
||||||
|
position=self.emp_type,
|
||||||
|
sys_user=self.user # 关联到用户
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
def test_get_visible_pages_success(self):
|
||||||
|
"""测试成功获取当前用户可见页面"""
|
||||||
|
response = self.client.get('/api/v2/me/visible-pages/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = response.data
|
||||||
|
self.assertEqual(data['employee_id'], self.employee.id)
|
||||||
|
self.assertEqual(data['employee_name'], '张三')
|
||||||
|
self.assertEqual(data['position_id'], self.emp_type.id)
|
||||||
|
self.assertEqual(data['position_title'], '生产主管')
|
||||||
|
|
||||||
|
# 验证可见页面(主菜单 + 子菜单)
|
||||||
|
visible_keys = data['visible_keys']
|
||||||
|
self.assertIn('production', visible_keys)
|
||||||
|
self.assertIn('production-kanban', visible_keys)
|
||||||
|
self.assertIn('printing-order-list', visible_keys)
|
||||||
|
self.assertNotIn('inventory', visible_keys)
|
||||||
|
|
||||||
|
# 验证页面详情
|
||||||
|
self.assertEqual(len(data['visible_pages']), 3)
|
||||||
|
|
||||||
|
def test_get_visible_pages_with_sub_menu_only(self):
|
||||||
|
"""测试仅授予子菜单时只返回该子菜单"""
|
||||||
|
# 重新配置岗位:只授予子菜单
|
||||||
|
self.emp_type.visible_pages.clear()
|
||||||
|
self.emp_type.visible_pages.add(self.sub_menu1)
|
||||||
|
|
||||||
|
response = self.client.get('/api/v2/me/visible-pages/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
visible_keys = response.data['visible_keys']
|
||||||
|
self.assertIn('production-kanban', visible_keys)
|
||||||
|
self.assertNotIn('production', visible_keys)
|
||||||
|
self.assertNotIn('printing-order-list', visible_keys)
|
||||||
|
self.assertEqual(len(visible_keys), 1)
|
||||||
|
|
||||||
|
def test_get_visible_pages_employee_no_position(self):
|
||||||
|
"""测试没有岗位的员工返回空列表"""
|
||||||
|
# 移除员工岗位
|
||||||
|
self.employee.position = None
|
||||||
|
self.employee.save()
|
||||||
|
|
||||||
|
response = self.client.get('/api/v2/me/visible-pages/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = response.data
|
||||||
|
self.assertEqual(data['employee_id'], self.employee.id)
|
||||||
|
self.assertEqual(data['position_id'], None)
|
||||||
|
self.assertEqual(data['visible_pages'], [])
|
||||||
|
self.assertEqual(data['visible_keys'], [])
|
||||||
|
|
||||||
|
def test_get_visible_pages_user_no_employee(self):
|
||||||
|
"""测试用户未关联员工时返回空列表"""
|
||||||
|
# 创建一个没有关联员工的用户
|
||||||
|
user_no_employee = get_user_model().objects.create_user(username='noemployee', password='pass12345')
|
||||||
|
self.client.force_authenticate(user=user_no_employee)
|
||||||
|
|
||||||
|
response = self.client.get('/api/v2/me/visible-pages/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
data = response.data
|
||||||
|
self.assertEqual(data['employee_id'], None)
|
||||||
|
self.assertEqual(data['employee_name'], None)
|
||||||
|
self.assertEqual(data['position_id'], None)
|
||||||
|
self.assertEqual(data['visible_pages'], [])
|
||||||
|
self.assertEqual(data['visible_keys'], [])
|
||||||
|
|
||||||
|
def test_get_visible_pages_unauthenticated(self):
|
||||||
|
"""测试未登录时返回 401"""
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get('/api/v2/me/visible-pages/')
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ from api_v2.views import (
|
|||||||
PlateOrderBatchUpdateView,
|
PlateOrderBatchUpdateView,
|
||||||
BusinessObjectCloneView,
|
BusinessObjectCloneView,
|
||||||
)
|
)
|
||||||
from api_v2.views.basic_info import CustomerEmployeeBindingView
|
from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('health/', HealthCheckView.as_view(), name='api_v2_health_check'),
|
path('health/', HealthCheckView.as_view(), name='api_v2_health_check'),
|
||||||
path('roles/', RoleListView.as_view(), name='api_v2_role_list'),
|
path('roles/', RoleListView.as_view(), name='api_v2_role_list'),
|
||||||
path('users/quick-create/', QuickCreateEmployeeUserView.as_view(), name='api_v2_user_quick_create'),
|
path('users/quick-create/', QuickCreateEmployeeUserView.as_view(), name='api_v2_user_quick_create'),
|
||||||
path('customers/bind-employee/', CustomerEmployeeBindingView.as_view(), name='api_v2_customer_bind_employee'),
|
path('customers/bind-employee/', CustomerEmployeeBindingView.as_view(), name='api_v2_customer_bind_employee'),
|
||||||
|
path('me/visible-pages/', MyVisiblePagesView.as_view(), name='api_v2_my_visible_pages'),
|
||||||
path('printing-jobs/by-customer/', PrintingJobByCustomerView.as_view(), name='api_v2_printing_job_by_customer'),
|
path('printing-jobs/by-customer/', PrintingJobByCustomerView.as_view(), name='api_v2_printing_job_by_customer'),
|
||||||
path('printing-jobs/batch-advance/preview/', PrintingJobBatchAdvancePreviewView.as_view(), name='api_v2_printing_job_batch_advance_preview'),
|
path('printing-jobs/batch-advance/preview/', PrintingJobBatchAdvancePreviewView.as_view(), name='api_v2_printing_job_batch_advance_preview'),
|
||||||
path('printing-jobs/batch-advance/', PrintingJobBatchAdvanceSubmitView.as_view(), name='api_v2_printing_job_batch_advance_submit'),
|
path('printing-jobs/batch-advance/', PrintingJobBatchAdvanceSubmitView.as_view(), name='api_v2_printing_job_batch_advance_submit'),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from basic_info import models as basic_models
|
from basic_info import models as basic_models
|
||||||
|
from basic_info.models import FrontendPage, FrontendPageTypeEnum
|
||||||
|
|
||||||
|
|
||||||
class CustomerEmployeeBindingSerializer(serializers.Serializer):
|
class CustomerEmployeeBindingSerializer(serializers.Serializer):
|
||||||
@@ -84,3 +85,97 @@ class CustomerEmployeeBindingView(APIView):
|
|||||||
},
|
},
|
||||||
status=status.HTTP_201_CREATED
|
status=status.HTTP_201_CREATED
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendPageSerializer(serializers.ModelSerializer):
|
||||||
|
"""前端页面序列化器"""
|
||||||
|
parent_key = serializers.CharField(source='parent.key', read_only=True, allow_null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = FrontendPage
|
||||||
|
fields = ['key', 'label', 'page_type', 'parent_key', 'sort_order']
|
||||||
|
|
||||||
|
|
||||||
|
class MyVisiblePagesView(APIView):
|
||||||
|
"""
|
||||||
|
获取当前登录用户的可见页面 API
|
||||||
|
|
||||||
|
根据当前登录用户关联的员工岗位,返回其可见的前端页面列表。
|
||||||
|
授予主菜单会自动包含其所有子菜单。
|
||||||
|
|
||||||
|
GET /api/v2/me/visible-pages/
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
{
|
||||||
|
"employee_id": 1,
|
||||||
|
"employee_name": "张三",
|
||||||
|
"position_id": 2,
|
||||||
|
"position_title": "生产主管",
|
||||||
|
"visible_pages": [
|
||||||
|
{"key": "production", "label": "生产管理", "page_type": "main", "parent_key": null, "sort_order": 100},
|
||||||
|
{"key": "production-kanban", "label": "生产看板", "page_type": "sub", "parent_key": "production", "sort_order": 101}
|
||||||
|
],
|
||||||
|
"visible_keys": ["production", "production-kanban", "printing-order-list"]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# 从 request.user 获取关联的员工
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# 检查用户是否关联员工
|
||||||
|
if not hasattr(user, 'employee'):
|
||||||
|
return Response({
|
||||||
|
'employee_id': None,
|
||||||
|
'employee_name': None,
|
||||||
|
'position_id': None,
|
||||||
|
'position_title': None,
|
||||||
|
'visible_pages': [],
|
||||||
|
'visible_keys': [],
|
||||||
|
})
|
||||||
|
|
||||||
|
employee = user.employee
|
||||||
|
|
||||||
|
# 检查员工是否有岗位
|
||||||
|
if not employee.position:
|
||||||
|
return Response({
|
||||||
|
'employee_id': employee.id,
|
||||||
|
'employee_name': employee.name,
|
||||||
|
'position_id': None,
|
||||||
|
'position_title': None,
|
||||||
|
'visible_pages': [],
|
||||||
|
'visible_keys': [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# 获取岗位直接授予的页面
|
||||||
|
granted_pages = employee.position.visible_pages.filter(is_active=True).select_related('parent')
|
||||||
|
|
||||||
|
# 计算所有可见页面(含子菜单继承)
|
||||||
|
visible_pages = []
|
||||||
|
visible_keys = set()
|
||||||
|
|
||||||
|
for page in granted_pages:
|
||||||
|
visible_pages.append(page)
|
||||||
|
visible_keys.add(page.key)
|
||||||
|
|
||||||
|
# 如果是主菜单,自动包含所有子菜单
|
||||||
|
if page.page_type == FrontendPageTypeEnum.MAIN:
|
||||||
|
children = page.children.filter(is_active=True).select_related('parent')
|
||||||
|
for child in children:
|
||||||
|
if child.key not in visible_keys:
|
||||||
|
visible_pages.append(child)
|
||||||
|
visible_keys.add(child.key)
|
||||||
|
|
||||||
|
# 按 sort_order 排序
|
||||||
|
visible_pages.sort(key=lambda p: (p.sort_order, p.id))
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'employee_id': employee.id,
|
||||||
|
'employee_name': employee.name,
|
||||||
|
'position_id': employee.position.id,
|
||||||
|
'position_title': employee.position.title,
|
||||||
|
'visible_pages': FrontendPageSerializer(visible_pages, many=True).data,
|
||||||
|
'visible_keys': sorted(visible_keys),
|
||||||
|
})
|
||||||
|
|||||||
@@ -142,12 +142,33 @@ class EmployeeAdmin(AdminBase):
|
|||||||
|
|
||||||
@admin.register(models.EmployeeType)
|
@admin.register(models.EmployeeType)
|
||||||
class EmployeeTypeAdmin(AdminBase):
|
class EmployeeTypeAdmin(AdminBase):
|
||||||
list_display = ('id', 'title', 'description')
|
list_display = ('id', 'title', 'description', 'visible_pages_count')
|
||||||
search_fields = ('title', 'description')
|
search_fields = ('title', 'description')
|
||||||
|
filter_horizontal = ('visible_pages',) # 便于多对多字段选择
|
||||||
|
|
||||||
def get_readonly_fields(self, request, obj=...):
|
def get_readonly_fields(self, request, obj=...):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@admin.display(description='可见页面数')
|
||||||
|
def visible_pages_count(self, obj):
|
||||||
|
return obj.visible_pages.count()
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.FrontendPage)
|
||||||
|
class FrontendPageAdmin(AdminBase):
|
||||||
|
"""前端页面管理"""
|
||||||
|
list_display = ('key', 'label', 'page_type', 'parent', 'sort_order', 'is_active')
|
||||||
|
list_filter = ('page_type', 'is_active', 'parent')
|
||||||
|
search_fields = ('key', 'label')
|
||||||
|
list_editable = ('sort_order', 'is_active')
|
||||||
|
ordering = ('sort_order', 'id')
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=...):
|
||||||
|
# key 字段创建后不可修改
|
||||||
|
if obj:
|
||||||
|
return ['key']
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.DeviceInfo)
|
@admin.register(models.DeviceInfo)
|
||||||
class DeviceInfoAdmin(AdminBase):
|
class DeviceInfoAdmin(AdminBase):
|
||||||
|
|||||||
0
basic_info/management/__init__.py
Normal file
0
basic_info/management/__init__.py
Normal file
0
basic_info/management/commands/__init__.py
Normal file
0
basic_info/management/commands/__init__.py
Normal file
164
basic_info/management/commands/sync_frontend_pages.py
Normal file
164
basic_info/management/commands/sync_frontend_pages.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
同步前端页面数据
|
||||||
|
|
||||||
|
从 docs/menu_keys.json 文件同步前端页面到数据库。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python manage.py sync_frontend_pages
|
||||||
|
python manage.py sync_frontend_pages --dry-run # 仅预览,不实际写入
|
||||||
|
python manage.py sync_frontend_pages --clear # 清空后重新同步
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from basic_info.models import FrontendPage, FrontendPageTypeEnum
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '从 docs/menu_keys.json 同步前端页面到数据库'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run',
|
||||||
|
action='store_true',
|
||||||
|
help='仅预览变更,不实际写入数据库',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--clear',
|
||||||
|
action='store_true',
|
||||||
|
help='清空现有数据后重新同步(谨慎使用)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--file',
|
||||||
|
type=str,
|
||||||
|
default='docs/menu_keys.json',
|
||||||
|
help='JSON 文件路径(相对于项目根目录)',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options['dry_run']
|
||||||
|
clear = options['clear']
|
||||||
|
file_path = options['file']
|
||||||
|
|
||||||
|
# 读取 JSON 文件
|
||||||
|
# __file__ = .../basic_info/management/commands/sync_frontend_pages.py
|
||||||
|
# parents[3] = 项目根目录 (flower/)
|
||||||
|
json_path = Path(__file__).resolve().parents[3] / file_path
|
||||||
|
if not json_path.exists():
|
||||||
|
self.stderr.write(self.style.ERROR(f'文件不存在: {json_path}'))
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(json_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
menus = data.get('menus', [])
|
||||||
|
if not menus:
|
||||||
|
self.stderr.write(self.style.ERROR('JSON 文件中没有 menus 数据'))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stdout.write(f'读取到 {len(menus)} 个主菜单')
|
||||||
|
|
||||||
|
# 统计
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
# 清空现有数据
|
||||||
|
if clear:
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING('[DRY-RUN] 将清空所有前端页面数据'))
|
||||||
|
else:
|
||||||
|
deleted_count, _ = FrontendPage.objects.all().delete()
|
||||||
|
self.stdout.write(self.style.WARNING(f'已清空 {deleted_count} 条记录'))
|
||||||
|
|
||||||
|
# 第一轮:创建/更新主菜单
|
||||||
|
main_menu_map = {} # key -> FrontendPage instance
|
||||||
|
for sort_order, menu in enumerate(menus):
|
||||||
|
key = menu['key']
|
||||||
|
label = menu['label']
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(f'[DRY-RUN] 主菜单: {key} ({label})')
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
page, created = FrontendPage.objects.update_or_create(
|
||||||
|
key=key,
|
||||||
|
defaults={
|
||||||
|
'label': label,
|
||||||
|
'page_type': FrontendPageTypeEnum.MAIN,
|
||||||
|
'parent': None,
|
||||||
|
'sort_order': sort_order * 100, # 主菜单间隔100
|
||||||
|
'is_active': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
main_menu_map[key] = page
|
||||||
|
if created:
|
||||||
|
created_count += 1
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'创建主菜单: {key} ({label})'))
|
||||||
|
else:
|
||||||
|
updated_count += 1
|
||||||
|
self.stdout.write(f'更新主菜单: {key} ({label})')
|
||||||
|
|
||||||
|
# 第二轮:创建/更新子菜单
|
||||||
|
for menu in menus:
|
||||||
|
parent_key = menu['key']
|
||||||
|
children = menu.get('children', [])
|
||||||
|
|
||||||
|
parent_page = main_menu_map.get(parent_key) if not dry_run else None
|
||||||
|
|
||||||
|
for sub_order, child in enumerate(children):
|
||||||
|
child_key = child['key']
|
||||||
|
child_label = child['label']
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(f'[DRY-RUN] 子菜单: {child_key} ({child_label}) <- {parent_key}')
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
# 计算排序:父级排序 + 子级偏移
|
||||||
|
parent_sort = parent_page.sort_order if parent_page else 0
|
||||||
|
child_sort = parent_sort + sub_order + 1
|
||||||
|
|
||||||
|
page, created = FrontendPage.objects.update_or_create(
|
||||||
|
key=child_key,
|
||||||
|
defaults={
|
||||||
|
'label': child_label,
|
||||||
|
'page_type': FrontendPageTypeEnum.SUB,
|
||||||
|
'parent': parent_page,
|
||||||
|
'sort_order': child_sort,
|
||||||
|
'is_active': True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
created_count += 1
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' 创建子菜单: {child_key} ({child_label})'))
|
||||||
|
else:
|
||||||
|
updated_count += 1
|
||||||
|
self.stdout.write(f' 更新子菜单: {child_key} ({child_label})')
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
# 回滚事务
|
||||||
|
raise DryRunException()
|
||||||
|
|
||||||
|
except DryRunException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 输出统计
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write(self.style.SUCCESS('=' * 40))
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING(f'[DRY-RUN] 预计创建: {created_count} 条'))
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'创建: {created_count} 条'))
|
||||||
|
self.stdout.write(f'更新: {updated_count} 条')
|
||||||
|
self.stdout.write(f'总计: {FrontendPage.objects.count()} 条')
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunException(Exception):
|
||||||
|
"""用于 dry-run 模式回滚事务"""
|
||||||
|
pass
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2026-01-07 05:20
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('basic_info', '0023_alter_product_segment_size'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='FrontendPage',
|
||||||
|
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)),
|
||||||
|
('key', models.CharField(db_index=True, help_text='前端路由 key,如 plate-order-kanban', max_length=100, unique=True, verbose_name='页面唯一标识')),
|
||||||
|
('label', models.CharField(max_length=100, verbose_name='页面名称')),
|
||||||
|
('page_type', models.CharField(choices=[('main', '主菜单'), ('sub', '子菜单')], default='sub', max_length=10, verbose_name='页面类型')),
|
||||||
|
('sort_order', models.IntegerField(default=0, verbose_name='排序')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||||
|
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='basic_info.frontendpage', verbose_name='父级菜单')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '前端页面',
|
||||||
|
'verbose_name_plural': '前端页面',
|
||||||
|
'ordering': ['sort_order', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='employeetype',
|
||||||
|
name='visible_pages',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='授予主菜单将自动包含其所有子菜单', related_name='employee_types', to='basic_info.frontendpage', verbose_name='可见页面'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -70,6 +70,54 @@ class DeviceTypeEnum(models.TextChoices):
|
|||||||
# ==================== 模型定义 ====================
|
# ==================== 模型定义 ====================
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendPageTypeEnum(models.TextChoices):
|
||||||
|
"""前端页面类型枚举"""
|
||||||
|
MAIN = 'main', '主菜单'
|
||||||
|
SUB = 'sub', '子菜单'
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendPage(ModelBase):
|
||||||
|
"""
|
||||||
|
前端页面/菜单项
|
||||||
|
|
||||||
|
用于管理前端可见页面,与员工岗位关联实现权限控制。
|
||||||
|
数据来源:docs/menu_keys.json,通过 sync_frontend_pages 命令同步。
|
||||||
|
"""
|
||||||
|
id = models.BigAutoField(primary_key=True)
|
||||||
|
key = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
unique=True,
|
||||||
|
db_index=True,
|
||||||
|
verbose_name='页面唯一标识',
|
||||||
|
help_text='前端路由 key,如 plate-order-kanban'
|
||||||
|
)
|
||||||
|
label = models.CharField(max_length=100, verbose_name='页面名称')
|
||||||
|
page_type = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=FrontendPageTypeEnum.choices,
|
||||||
|
default=FrontendPageTypeEnum.SUB,
|
||||||
|
verbose_name='页面类型'
|
||||||
|
)
|
||||||
|
parent = models.ForeignKey(
|
||||||
|
'self',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='children',
|
||||||
|
verbose_name='父级菜单'
|
||||||
|
)
|
||||||
|
sort_order = models.IntegerField(default=0, verbose_name='排序')
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name='是否启用')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.label} ({self.key})'
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '前端页面'
|
||||||
|
verbose_name_plural = '前端页面'
|
||||||
|
ordering = ['sort_order', 'id']
|
||||||
|
|
||||||
|
|
||||||
class EmployeeType(ModelBase):
|
class EmployeeType(ModelBase):
|
||||||
"""员工职位类型"""
|
"""员工职位类型"""
|
||||||
id = models.BigAutoField(primary_key=True)
|
id = models.BigAutoField(primary_key=True)
|
||||||
@@ -77,6 +125,13 @@ class EmployeeType(ModelBase):
|
|||||||
title = models.CharField(max_length=50, verbose_name='职位名称')
|
title = models.CharField(max_length=50, verbose_name='职位名称')
|
||||||
description = models.TextField(blank=True, null=True, verbose_name='职位描述')
|
description = models.TextField(blank=True, null=True, verbose_name='职位描述')
|
||||||
reverse = models.CharField(max_length=100, blank=True, null=True, verbose_name='预留字段')
|
reverse = models.CharField(max_length=100, blank=True, null=True, verbose_name='预留字段')
|
||||||
|
visible_pages = models.ManyToManyField(
|
||||||
|
'FrontendPage',
|
||||||
|
blank=True,
|
||||||
|
related_name='employee_types',
|
||||||
|
verbose_name='可见页面',
|
||||||
|
help_text='授予主菜单将自动包含其所有子菜单'
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Merchant, MerchantTypeEnum, WareHouse, WarehouseTypeEnum,
|
Merchant, MerchantTypeEnum, WareHouse, WarehouseTypeEnum,
|
||||||
WareHouseModeEnum, EmployeeType, Employee, EmployeeStatusEnum
|
WareHouseModeEnum, EmployeeType, Employee, EmployeeStatusEnum,
|
||||||
|
FrontendPage, FrontendPageTypeEnum
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -164,3 +169,211 @@ class EmployeeTypeTestCase(TestCase):
|
|||||||
self.assertEqual(emp1.job_type, '打纸工')
|
self.assertEqual(emp1.job_type, '打纸工')
|
||||||
self.assertEqual(emp2.job_type, '打纸工')
|
self.assertEqual(emp2.job_type, '打纸工')
|
||||||
self.assertEqual(emp_type.employees.count(), 2)
|
self.assertEqual(emp_type.employees.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendPageTestCase(TestCase):
|
||||||
|
"""测试前端页面模型"""
|
||||||
|
|
||||||
|
def test_create_main_menu(self):
|
||||||
|
"""测试创建主菜单"""
|
||||||
|
page = FrontendPage.objects.create(
|
||||||
|
key='plate-opening',
|
||||||
|
label='开版管理',
|
||||||
|
page_type=FrontendPageTypeEnum.MAIN,
|
||||||
|
sort_order=0
|
||||||
|
)
|
||||||
|
self.assertEqual(page.key, 'plate-opening')
|
||||||
|
self.assertEqual(page.label, '开版管理')
|
||||||
|
self.assertEqual(page.page_type, FrontendPageTypeEnum.MAIN)
|
||||||
|
self.assertIsNone(page.parent)
|
||||||
|
self.assertTrue(page.is_active)
|
||||||
|
self.assertEqual(str(page), '开版管理 (plate-opening)')
|
||||||
|
|
||||||
|
def test_create_sub_menu_with_parent(self):
|
||||||
|
"""测试创建子菜单并关联父级"""
|
||||||
|
parent = FrontendPage.objects.create(
|
||||||
|
key='plate-opening',
|
||||||
|
label='开版管理',
|
||||||
|
page_type=FrontendPageTypeEnum.MAIN
|
||||||
|
)
|
||||||
|
child = FrontendPage.objects.create(
|
||||||
|
key='plate-order',
|
||||||
|
label='开版订单',
|
||||||
|
page_type=FrontendPageTypeEnum.SUB,
|
||||||
|
parent=parent
|
||||||
|
)
|
||||||
|
self.assertEqual(child.parent, parent)
|
||||||
|
self.assertEqual(parent.children.count(), 1)
|
||||||
|
self.assertEqual(parent.children.first(), child)
|
||||||
|
|
||||||
|
def test_key_unique_constraint(self):
|
||||||
|
"""测试 key 字段唯一性约束"""
|
||||||
|
FrontendPage.objects.create(key='test-page', label='测试页面')
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
FrontendPage.objects.create(key='test-page', label='重复页面')
|
||||||
|
|
||||||
|
def test_cascade_delete(self):
|
||||||
|
"""测试删除父级时子级也被删除"""
|
||||||
|
parent = FrontendPage.objects.create(
|
||||||
|
key='parent',
|
||||||
|
label='父级',
|
||||||
|
page_type=FrontendPageTypeEnum.MAIN
|
||||||
|
)
|
||||||
|
FrontendPage.objects.create(
|
||||||
|
key='child1',
|
||||||
|
label='子级1',
|
||||||
|
parent=parent
|
||||||
|
)
|
||||||
|
FrontendPage.objects.create(
|
||||||
|
key='child2',
|
||||||
|
label='子级2',
|
||||||
|
parent=parent
|
||||||
|
)
|
||||||
|
self.assertEqual(FrontendPage.objects.count(), 3)
|
||||||
|
parent.delete()
|
||||||
|
self.assertEqual(FrontendPage.objects.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeTypeVisiblePagesTestCase(TestCase):
|
||||||
|
"""测试员工职位类型的可见页面功能"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""设置测试数据"""
|
||||||
|
self.merchant = Merchant.objects.create(
|
||||||
|
name='测试商户',
|
||||||
|
type=MerchantTypeEnum.STORE
|
||||||
|
)
|
||||||
|
# 创建页面
|
||||||
|
self.main_menu = FrontendPage.objects.create(
|
||||||
|
key='production',
|
||||||
|
label='生产管理',
|
||||||
|
page_type=FrontendPageTypeEnum.MAIN
|
||||||
|
)
|
||||||
|
self.sub_menu1 = FrontendPage.objects.create(
|
||||||
|
key='production-kanban',
|
||||||
|
label='生产看板',
|
||||||
|
page_type=FrontendPageTypeEnum.SUB,
|
||||||
|
parent=self.main_menu
|
||||||
|
)
|
||||||
|
self.sub_menu2 = FrontendPage.objects.create(
|
||||||
|
key='printing-order-list',
|
||||||
|
label='生产订单',
|
||||||
|
page_type=FrontendPageTypeEnum.SUB,
|
||||||
|
parent=self.main_menu
|
||||||
|
)
|
||||||
|
# 创建岗位
|
||||||
|
self.emp_type = EmployeeType.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
title='生产主管'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_assign_visible_pages(self):
|
||||||
|
"""测试为岗位分配可见页面"""
|
||||||
|
self.emp_type.visible_pages.add(self.main_menu)
|
||||||
|
self.assertEqual(self.emp_type.visible_pages.count(), 1)
|
||||||
|
self.assertIn(self.main_menu, self.emp_type.visible_pages.all())
|
||||||
|
|
||||||
|
def test_get_visible_page_keys_with_main_menu(self):
|
||||||
|
"""测试授予主菜单时获取所有可见页面(含子菜单)"""
|
||||||
|
self.emp_type.visible_pages.add(self.main_menu)
|
||||||
|
|
||||||
|
# 模拟查询逻辑
|
||||||
|
visible_keys = set()
|
||||||
|
for page in self.emp_type.visible_pages.filter(is_active=True):
|
||||||
|
visible_keys.add(page.key)
|
||||||
|
if page.page_type == FrontendPageTypeEnum.MAIN:
|
||||||
|
children_keys = page.children.filter(is_active=True).values_list('key', flat=True)
|
||||||
|
visible_keys.update(children_keys)
|
||||||
|
|
||||||
|
self.assertIn('production', visible_keys)
|
||||||
|
self.assertIn('production-kanban', visible_keys)
|
||||||
|
self.assertIn('printing-order-list', visible_keys)
|
||||||
|
self.assertEqual(len(visible_keys), 3)
|
||||||
|
|
||||||
|
def test_get_visible_page_keys_with_sub_menu_only(self):
|
||||||
|
"""测试仅授予子菜单时只能看到该子菜单"""
|
||||||
|
self.emp_type.visible_pages.add(self.sub_menu1)
|
||||||
|
|
||||||
|
visible_keys = set()
|
||||||
|
for page in self.emp_type.visible_pages.filter(is_active=True):
|
||||||
|
visible_keys.add(page.key)
|
||||||
|
if page.page_type == FrontendPageTypeEnum.MAIN:
|
||||||
|
children_keys = page.children.filter(is_active=True).values_list('key', flat=True)
|
||||||
|
visible_keys.update(children_keys)
|
||||||
|
|
||||||
|
self.assertIn('production-kanban', visible_keys)
|
||||||
|
self.assertNotIn('production', visible_keys)
|
||||||
|
self.assertNotIn('printing-order-list', visible_keys)
|
||||||
|
self.assertEqual(len(visible_keys), 1)
|
||||||
|
|
||||||
|
def test_employee_visible_pages_through_position(self):
|
||||||
|
"""测试通过员工岗位查询可见页面"""
|
||||||
|
self.emp_type.visible_pages.add(self.main_menu)
|
||||||
|
|
||||||
|
employee = Employee.objects.create(
|
||||||
|
merchant=self.merchant,
|
||||||
|
name='测试员工',
|
||||||
|
position=self.emp_type
|
||||||
|
)
|
||||||
|
|
||||||
|
# 通过员工 -> 岗位 -> 可见页面
|
||||||
|
visible_pages = employee.position.visible_pages.all()
|
||||||
|
self.assertEqual(visible_pages.count(), 1)
|
||||||
|
self.assertIn(self.main_menu, visible_pages)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncFrontendPagesCommandTestCase(TestCase):
|
||||||
|
"""测试 sync_frontend_pages 管理命令"""
|
||||||
|
|
||||||
|
def test_sync_command_dry_run(self):
|
||||||
|
"""测试 dry-run 模式不写入数据"""
|
||||||
|
out = StringIO()
|
||||||
|
call_command('sync_frontend_pages', '--dry-run', stdout=out)
|
||||||
|
|
||||||
|
# 验证没有实际写入
|
||||||
|
self.assertEqual(FrontendPage.objects.count(), 0)
|
||||||
|
self.assertIn('DRY-RUN', out.getvalue())
|
||||||
|
|
||||||
|
def test_sync_command_creates_pages(self):
|
||||||
|
"""测试命令创建页面数据"""
|
||||||
|
out = StringIO()
|
||||||
|
call_command('sync_frontend_pages', stdout=out)
|
||||||
|
|
||||||
|
# 验证创建了页面
|
||||||
|
self.assertGreater(FrontendPage.objects.count(), 0)
|
||||||
|
|
||||||
|
# 验证主菜单
|
||||||
|
main_menus = FrontendPage.objects.filter(page_type=FrontendPageTypeEnum.MAIN)
|
||||||
|
self.assertEqual(main_menus.count(), 9) # 9 个主菜单
|
||||||
|
|
||||||
|
# 验证子菜单
|
||||||
|
sub_menus = FrontendPage.objects.filter(page_type=FrontendPageTypeEnum.SUB)
|
||||||
|
self.assertEqual(sub_menus.count(), 43) # 43 个子菜单
|
||||||
|
|
||||||
|
# 验证父子关系
|
||||||
|
plate_opening = FrontendPage.objects.get(key='plate-opening')
|
||||||
|
self.assertEqual(plate_opening.children.count(), 5)
|
||||||
|
|
||||||
|
def test_sync_command_idempotent(self):
|
||||||
|
"""测试命令幂等性(多次运行结果一致)"""
|
||||||
|
call_command('sync_frontend_pages', stdout=StringIO())
|
||||||
|
first_count = FrontendPage.objects.count()
|
||||||
|
|
||||||
|
call_command('sync_frontend_pages', stdout=StringIO())
|
||||||
|
second_count = FrontendPage.objects.count()
|
||||||
|
|
||||||
|
self.assertEqual(first_count, second_count)
|
||||||
|
|
||||||
|
def test_sync_command_clear_option(self):
|
||||||
|
"""测试 --clear 选项"""
|
||||||
|
# 先创建数据
|
||||||
|
call_command('sync_frontend_pages', stdout=StringIO())
|
||||||
|
self.assertGreater(FrontendPage.objects.count(), 0)
|
||||||
|
|
||||||
|
# 使用 --clear 重新同步
|
||||||
|
out = StringIO()
|
||||||
|
call_command('sync_frontend_pages', '--clear', stdout=out)
|
||||||
|
|
||||||
|
self.assertIn('已清空', out.getvalue())
|
||||||
|
# 数据应该重新创建
|
||||||
|
self.assertEqual(FrontendPage.objects.count(), 52) # 9 主菜单 + 43 子菜单
|
||||||
|
|||||||
167
docs/api_v2_employee_visible_pages.md
Normal file
167
docs/api_v2_employee_visible_pages.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# 获取当前用户可见页面 API
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
获取当前登录用户关联的员工岗位所拥有的可见前端页面列表。
|
||||||
|
|
||||||
|
- **端点**: `GET /api/v2/me/visible-pages/`
|
||||||
|
- **认证**: 需要登录
|
||||||
|
- **权限**: 任意已认证用户
|
||||||
|
|
||||||
|
## 业务逻辑
|
||||||
|
|
||||||
|
1. 从 `request.user` 获取当前登录用户
|
||||||
|
2. 通过 `user.employee` 获取关联的员工
|
||||||
|
3. 员工通过 `position` 字段关联到岗位(`EmployeeType`)
|
||||||
|
4. 岗位通过 `visible_pages` 多对多字段关联到前端页面(`FrontendPage`)
|
||||||
|
5. **权限继承**: 如果授予主菜单(`page_type=main`),自动包含其所有子菜单
|
||||||
|
|
||||||
|
## 请求
|
||||||
|
|
||||||
|
无需参数,自动从登录用户获取。
|
||||||
|
|
||||||
|
### 请求示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "http://localhost:8000/api/v2/me/visible-pages/" \
|
||||||
|
-H "Authorization: Token <your-token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 响应
|
||||||
|
|
||||||
|
### 成功响应 (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"employee_id": 1,
|
||||||
|
"employee_name": "张三",
|
||||||
|
"position_id": 2,
|
||||||
|
"position_title": "生产主管",
|
||||||
|
"visible_pages": [
|
||||||
|
{
|
||||||
|
"key": "production",
|
||||||
|
"label": "生产管理",
|
||||||
|
"page_type": "main",
|
||||||
|
"parent_key": null,
|
||||||
|
"sort_order": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "production-kanban",
|
||||||
|
"label": "生产看板",
|
||||||
|
"page_type": "sub",
|
||||||
|
"parent_key": "production",
|
||||||
|
"sort_order": 101
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "printing-order-list",
|
||||||
|
"label": "生产订单",
|
||||||
|
"page_type": "sub",
|
||||||
|
"parent_key": "production",
|
||||||
|
"sort_order": 102
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"visible_keys": [
|
||||||
|
"printing-order-list",
|
||||||
|
"production",
|
||||||
|
"production-kanban"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应字段说明
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `employee_id` | int \| null | 员工ID(无关联员工时为 null) |
|
||||||
|
| `employee_name` | string \| null | 员工姓名 |
|
||||||
|
| `position_id` | int \| null | 岗位ID(无岗位时为 null) |
|
||||||
|
| `position_title` | string \| null | 岗位名称 |
|
||||||
|
| `visible_pages` | array | 可见页面详情列表(按 sort_order 排序) |
|
||||||
|
| `visible_keys` | array | 可见页面 key 列表(已排序,便于前端快速查找) |
|
||||||
|
|
||||||
|
### visible_pages 对象结构
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `key` | string | 页面唯一标识(前端路由 key) |
|
||||||
|
| `label` | string | 页面显示名称 |
|
||||||
|
| `page_type` | string | 页面类型:`main`(主菜单)或 `sub`(子菜单) |
|
||||||
|
| `parent_key` | string \| null | 父级菜单 key(主菜单为 null) |
|
||||||
|
| `sort_order` | int | 排序值 |
|
||||||
|
|
||||||
|
### 用户未关联员工
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"employee_id": null,
|
||||||
|
"employee_name": null,
|
||||||
|
"position_id": null,
|
||||||
|
"position_title": null,
|
||||||
|
"visible_pages": [],
|
||||||
|
"visible_keys": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 员工无岗位
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"employee_id": 5,
|
||||||
|
"employee_name": "无岗位员工",
|
||||||
|
"position_id": null,
|
||||||
|
"position_title": null,
|
||||||
|
"visible_pages": [],
|
||||||
|
"visible_keys": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误响应
|
||||||
|
|
||||||
|
#### 未认证 (401 Unauthorized)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Authentication credentials were not provided."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端使用示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 获取当前用户可见页面
|
||||||
|
async function getMyVisiblePages() {
|
||||||
|
const response = await fetch('/api/v2/me/visible-pages/', {
|
||||||
|
headers: { 'Authorization': `Token ${token}` }
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 使用 visible_keys 快速判断菜单是否可见
|
||||||
|
const canSeeProduction = data.visible_keys.includes('production');
|
||||||
|
|
||||||
|
// 或使用 visible_pages 渲染菜单
|
||||||
|
const menuItems = data.visible_pages.filter(p => p.page_type === 'main');
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 相关命令
|
||||||
|
|
||||||
|
### 同步前端页面数据
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从 docs/menu_keys.json 同步页面到数据库
|
||||||
|
python manage.py sync_frontend_pages
|
||||||
|
|
||||||
|
# 预览模式(不实际写入)
|
||||||
|
python manage.py sync_frontend_pages --dry-run
|
||||||
|
|
||||||
|
# 清空后重新同步
|
||||||
|
python manage.py sync_frontend_pages --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## 相关模型
|
||||||
|
|
||||||
|
- `basic_info.FrontendPage` - 前端页面
|
||||||
|
- `basic_info.EmployeeType` - 员工岗位(含 `visible_pages` 字段)
|
||||||
|
- `basic_info.Employee` - 员工(含 `position` 和 `sys_user` 字段)
|
||||||
186
docs/menu_keys.json
Normal file
186
docs/menu_keys.json
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
{
|
||||||
|
"menus": [
|
||||||
|
{
|
||||||
|
"key": "plate-opening",
|
||||||
|
"label": "开版管理",
|
||||||
|
"children": [
|
||||||
|
{ "key": "plate-order-kanban", "label": "开版看板" },
|
||||||
|
{ "key": "plate-order", "label": "开版订单" },
|
||||||
|
{ "key": "plate-order-staging", "label": "开版暂存" },
|
||||||
|
{ "key": "process-node-view", "label": "流程查看" },
|
||||||
|
{ "key": "plate-order-statistics", "label": "开版数据" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "production",
|
||||||
|
"label": "生产管理",
|
||||||
|
"children": [
|
||||||
|
{ "key": "production-kanban", "label": "生产看板" },
|
||||||
|
{ "key": "printing-order-list", "label": "生产订单" },
|
||||||
|
{ "key": "printing-job-list", "label": "生产订单明细" },
|
||||||
|
{ "key": "production-statistics", "label": "生产数据" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "business",
|
||||||
|
"label": "业务管理",
|
||||||
|
"children": [
|
||||||
|
{ "key": "production-order", "label": "生产订单" },
|
||||||
|
{ "key": "process-management", "label": "流程编排" },
|
||||||
|
{ "key": "state-management", "label": "工序状态管理" },
|
||||||
|
{ "key": "parameter-management", "label": "工艺参数管理" },
|
||||||
|
{ "key": "business-object", "label": "业务对象管理" },
|
||||||
|
{ "key": "workflow-testing", "label": "工作流测试" },
|
||||||
|
{ "key": "kanban-demo", "label": "看板组件" },
|
||||||
|
{ "key": "vehicle-record-list", "label": "车次记录" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "procurement",
|
||||||
|
"label": "采购管理",
|
||||||
|
"children": [
|
||||||
|
{ "key": "purchase-order", "label": "采购订单" },
|
||||||
|
{ "key": "purchase-return-order", "label": "采购退货单" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "sales",
|
||||||
|
"label": "销售管理",
|
||||||
|
"children": [
|
||||||
|
{ "key": "sales-order-list", "label": "销售单" },
|
||||||
|
{ "key": "sales-return-order", "label": "销售退货单" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "inventory",
|
||||||
|
"label": "库存管理",
|
||||||
|
"children": [
|
||||||
|
{ "key": "inbound-list", "label": "入库单列表" },
|
||||||
|
{ "key": "outbound-list", "label": "出库单列表" },
|
||||||
|
{ "key": "stock-transfer", "label": "库存调拨" },
|
||||||
|
{ "key": "stock-summary", "label": "库存合计" },
|
||||||
|
{ "key": "stock-realtime", "label": "库存实时清单" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "finance",
|
||||||
|
"label": "财务管理",
|
||||||
|
"children": [
|
||||||
|
{ "key": "payment-order", "label": "付款单" },
|
||||||
|
{ "key": "receipt-order", "label": "收款单" },
|
||||||
|
{ "key": "customer-statement", "label": "客户对账单" },
|
||||||
|
{ "key": "supplier-statement", "label": "供应商对账单" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "report",
|
||||||
|
"label": "报表中心",
|
||||||
|
"children": [
|
||||||
|
{ "key": "report-center", "label": "报表中心" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "basic-data",
|
||||||
|
"label": "基础资料",
|
||||||
|
"children": [
|
||||||
|
{ "key": "customer-management", "label": "客户资料" },
|
||||||
|
{ "key": "supplier-management", "label": "供应商资料" },
|
||||||
|
{ "key": "warehouse-list", "label": "仓库资料" },
|
||||||
|
{ "key": "product-category-list", "label": "产品类别" },
|
||||||
|
{ "key": "product-list", "label": "产品资料" },
|
||||||
|
{ "key": "employee-management", "label": "员工资料" },
|
||||||
|
{ "key": "user-create", "label": "新建用户" },
|
||||||
|
{ "key": "employee-type-management", "label": "职位类型" },
|
||||||
|
{ "key": "device-list", "label": "设备资料" },
|
||||||
|
{ "key": "bank-account-list", "label": "银行账户" },
|
||||||
|
{ "key": "quick-input-list", "label": "字典管理" },
|
||||||
|
{ "key": "vehicle-type-list", "label": "车辆类型" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"all_keys": {
|
||||||
|
"main_menus": [
|
||||||
|
"plate-opening",
|
||||||
|
"production",
|
||||||
|
"business",
|
||||||
|
"procurement",
|
||||||
|
"sales",
|
||||||
|
"inventory",
|
||||||
|
"finance",
|
||||||
|
"report",
|
||||||
|
"basic-data"
|
||||||
|
],
|
||||||
|
"sub_menus": [
|
||||||
|
"plate-order-kanban",
|
||||||
|
"plate-order",
|
||||||
|
"plate-order-staging",
|
||||||
|
"process-node-view",
|
||||||
|
"plate-order-statistics",
|
||||||
|
"production-kanban",
|
||||||
|
"printing-order-list",
|
||||||
|
"printing-job-list",
|
||||||
|
"production-statistics",
|
||||||
|
"production-order",
|
||||||
|
"process-management",
|
||||||
|
"state-management",
|
||||||
|
"parameter-management",
|
||||||
|
"business-object",
|
||||||
|
"workflow-testing",
|
||||||
|
"kanban-demo",
|
||||||
|
"vehicle-record-list",
|
||||||
|
"purchase-order",
|
||||||
|
"purchase-return-order",
|
||||||
|
"sales-order-list",
|
||||||
|
"sales-return-order",
|
||||||
|
"inbound-list",
|
||||||
|
"outbound-list",
|
||||||
|
"stock-transfer",
|
||||||
|
"stock-summary",
|
||||||
|
"stock-realtime",
|
||||||
|
"payment-order",
|
||||||
|
"receipt-order",
|
||||||
|
"customer-statement",
|
||||||
|
"supplier-statement",
|
||||||
|
"report-center",
|
||||||
|
"customer-management",
|
||||||
|
"supplier-management",
|
||||||
|
"warehouse-list",
|
||||||
|
"product-category-list",
|
||||||
|
"product-list",
|
||||||
|
"employee-management",
|
||||||
|
"user-create",
|
||||||
|
"employee-type-management",
|
||||||
|
"device-list",
|
||||||
|
"bank-account-list",
|
||||||
|
"quick-input-list",
|
||||||
|
"vehicle-type-list"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"admin": {
|
||||||
|
"description": "管理员 - 全部权限",
|
||||||
|
"allowed_menus": ["*"]
|
||||||
|
},
|
||||||
|
"production_manager": {
|
||||||
|
"description": "生产主管 - 开版+生产+基础资料",
|
||||||
|
"allowed_menus": ["plate-opening", "production", "basic-data"]
|
||||||
|
},
|
||||||
|
"salesman": {
|
||||||
|
"description": "销售员 - 销售+部分基础资料",
|
||||||
|
"allowed_menus": ["sales", "customer-management", "product-list"]
|
||||||
|
},
|
||||||
|
"warehouse_keeper": {
|
||||||
|
"description": "仓管员 - 库存+部分基础资料",
|
||||||
|
"allowed_menus": ["inventory", "warehouse-list", "product-list"]
|
||||||
|
},
|
||||||
|
"finance_staff": {
|
||||||
|
"description": "财务人员 - 财务+报表+对账相关",
|
||||||
|
"allowed_menus": ["finance", "report", "customer-management", "supplier-management"]
|
||||||
|
},
|
||||||
|
"normal_staff": {
|
||||||
|
"description": "普通员工 - 只看看板",
|
||||||
|
"allowed_menus": ["production-kanban", "plate-order-kanban"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user