From 4566c2a2c83c4a730787b6dbab9d442dec0394ed Mon Sep 17 00:00:00 2001 From: colaftc Date: Wed, 7 Jan 2026 14:28:17 +0800 Subject: [PATCH] feat: added frontend_pages models(basic_info) and api(v2), support employees query pages that they can visit by themself token --- api_v1/views/printing/serializers.py | 62 +---- api_v1/views/printing/test_plate_order_api.py | 2 +- api_v2/tests.py | 130 +++++++++++ api_v2/urls.py | 3 +- api_v2/views/basic_info.py | 95 ++++++++ basic_info/admin.py | 23 +- basic_info/management/__init__.py | 0 basic_info/management/commands/__init__.py | 0 .../commands/sync_frontend_pages.py | 164 +++++++++++++ .../0024_frontend_page_and_visible_pages.py | 38 ++++ basic_info/models.py | 55 +++++ basic_info/tests.py | 215 +++++++++++++++++- docs/api_v2_employee_visible_pages.md | 167 ++++++++++++++ docs/menu_keys.json | 186 +++++++++++++++ 14 files changed, 1079 insertions(+), 61 deletions(-) create mode 100644 basic_info/management/__init__.py create mode 100644 basic_info/management/commands/__init__.py create mode 100644 basic_info/management/commands/sync_frontend_pages.py create mode 100644 basic_info/migrations/0024_frontend_page_and_visible_pages.py create mode 100644 docs/api_v2_employee_visible_pages.md create mode 100644 docs/menu_keys.json diff --git a/api_v1/views/printing/serializers.py b/api_v1/views/printing/serializers.py index 48c7b77..bf6f80a 100644 --- a/api_v1/views/printing/serializers.py +++ b/api_v1/views/printing/serializers.py @@ -138,8 +138,6 @@ class PrintingOrderListSerializer(serializers.ModelSerializer): process_name = serializers.CharField(source='process.name', read_only=True) progress = serializers.IntegerField(read_only=True) jobs_status_summary = serializers.SerializerMethodField() - created_by = serializers.IntegerField(source='created_by_id', read_only=True) - created_by_name = serializers.SerializerMethodField() class Meta: model = models.PrintingOrder @@ -149,17 +147,9 @@ class PrintingOrderListSerializer(serializers.ModelSerializer): 'is_fabric_received', 'outgoing_date', 'is_invalid', 'new_curve', 'process', 'process_name', 'progress', 'position', 'print_count', 'jobs_status_summary', - 'created_by', 'created_by_name', 'created_at', 'updated_at', ] - read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count', 'created_by'] - - 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) + read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count'] 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) process_name = serializers.CharField(source='process.name', 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: model = models.PrintingOrder @@ -208,17 +196,9 @@ class PrintingOrderDetailSerializer(serializers.ModelSerializer): 'curve', 'new_curve', 'position', 'printing_warn', 'rolling_warn', 'production_warn', 'is_invalid', 'process', 'process_name', 'progress', 'print_count', - 'created_by', 'created_by_name', 'created_at', 'updated_at' ] - read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count', 'created_by'] - - 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) + read_only_fields = ['id', 'human_id', 'created_at', 'updated_at', 'progress', 'print_count'] class PrintingOrderCreateUpdateSerializer(serializers.ModelSerializer): @@ -278,16 +258,10 @@ class PrintingJobListSerializer(serializers.ModelSerializer): product_name = serializers.CharField(source='product.name', read_only=True) product_image_url = serializers.SerializerMethodField() status = serializers.CharField(read_only=True) - status_id = serializers.IntegerField(read_only=True) work_state_display = serializers.SerializerMethodField() 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() batch_advance_records = serializers.SerializerMethodField() - created_by = serializers.IntegerField(source='created_by_id', read_only=True) - created_by_name = serializers.SerializerMethodField() class Meta: model = models.PrintingJob @@ -296,19 +270,11 @@ class PrintingJobListSerializer(serializers.ModelSerializer): 'product_image_url', 'quantity', 'unit', 'size', 'pieces', 'description', 'work_state', 'work_state_display', - 'status', 'status_id', 'is_completed', 'has_started', - 'progress_percentage', 'last_completed_state', - 'business_object_id', + 'status', 'is_completed', 'business_object_id', 'batch_advance_records', - 'created_by', 'created_by_name', 'created_at', 'updated_at' ] - read_only_fields = [ - 'id', 'created_at', 'updated_at', - 'status', 'status_id', 'is_completed', 'has_started', - 'progress_percentage', 'last_completed_state', 'business_object_id', - 'created_by' - ] + read_only_fields = ['id', 'created_at', 'updated_at', 'status', 'is_completed', 'business_object_id'] def get_business_object_id(self, obj): """安全地获取 business_object_id""" @@ -354,13 +320,6 @@ class PrintingJobListSerializer(serializers.ModelSerializer): records = list(obj.batch_advance_records.all()) 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): """印染款式明细详情序列化器""" @@ -374,8 +333,6 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): has_started = serializers.BooleanField(read_only=True) business_object_id = 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: model = models.PrintingJob @@ -385,13 +342,11 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): 'work_state', 'work_state_display', 'status', 'status_id', 'is_completed', 'has_started', 'business_object_id', 'batch_advance_records', - 'created_by', 'created_by_name', 'created_at', 'updated_at' ] read_only_fields = [ 'id', 'created_at', 'updated_at', - 'status', 'status_id', 'is_completed', 'has_started', 'business_object_id', - 'created_by' + 'status', 'status_id', 'is_completed', 'has_started', 'business_object_id' ] def get_business_object_id(self, obj): @@ -406,13 +361,6 @@ class PrintingJobDetailSerializer(serializers.ModelSerializer): records = list(obj.batch_advance_records.all()) 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): """印染款式明细创建/更新序列化器""" diff --git a/api_v1/views/printing/test_plate_order_api.py b/api_v1/views/printing/test_plate_order_api.py index f3e5924..ced5f64 100644 --- a/api_v1/views/printing/test_plate_order_api.py +++ b/api_v1/views/printing/test_plate_order_api.py @@ -531,7 +531,7 @@ class PlateOrderAPITestCase(TestCase): else: self.assertEqual(len(response.data), 1) self.assertEqual(response.data[0]['is_invalid'], False) - + def test_filter_by_plate_at_from(self): """测试按开版时间起始筛选(带时分秒)""" from django.utils import timezone diff --git a/api_v2/tests.py b/api_v2/tests.py index e8c4b9a..f3e7f30 100644 --- a/api_v2/tests.py +++ b/api_v2/tests.py @@ -1386,3 +1386,133 @@ class PlateOrderBatchUpdateV2APITest(TestCase): self.assertEqual(resp2.status_code, 200) self.po1.refresh_from_db() 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) diff --git a/api_v2/urls.py b/api_v2/urls.py index d55fa5d..b4b83bb 100644 --- a/api_v2/urls.py +++ b/api_v2/urls.py @@ -13,13 +13,14 @@ from api_v2.views import ( PlateOrderBatchUpdateView, BusinessObjectCloneView, ) -from api_v2.views.basic_info import CustomerEmployeeBindingView +from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView urlpatterns = [ path('health/', HealthCheckView.as_view(), name='api_v2_health_check'), path('roles/', RoleListView.as_view(), name='api_v2_role_list'), 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('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/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'), diff --git a/api_v2/views/basic_info.py b/api_v2/views/basic_info.py index 25b2648..3da1abb 100644 --- a/api_v2/views/basic_info.py +++ b/api_v2/views/basic_info.py @@ -4,6 +4,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from basic_info import models as basic_models +from basic_info.models import FrontendPage, FrontendPageTypeEnum class CustomerEmployeeBindingSerializer(serializers.Serializer): @@ -84,3 +85,97 @@ class CustomerEmployeeBindingView(APIView): }, 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), + }) diff --git a/basic_info/admin.py b/basic_info/admin.py index a7b1661..62ca332 100644 --- a/basic_info/admin.py +++ b/basic_info/admin.py @@ -142,12 +142,33 @@ class EmployeeAdmin(AdminBase): @admin.register(models.EmployeeType) class EmployeeTypeAdmin(AdminBase): - list_display = ('id', 'title', 'description') + list_display = ('id', 'title', 'description', 'visible_pages_count') search_fields = ('title', 'description') + filter_horizontal = ('visible_pages',) # 便于多对多字段选择 def get_readonly_fields(self, request, obj=...): 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) class DeviceInfoAdmin(AdminBase): diff --git a/basic_info/management/__init__.py b/basic_info/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/basic_info/management/commands/__init__.py b/basic_info/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/basic_info/management/commands/sync_frontend_pages.py b/basic_info/management/commands/sync_frontend_pages.py new file mode 100644 index 0000000..9b84705 --- /dev/null +++ b/basic_info/management/commands/sync_frontend_pages.py @@ -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 + diff --git a/basic_info/migrations/0024_frontend_page_and_visible_pages.py b/basic_info/migrations/0024_frontend_page_and_visible_pages.py new file mode 100644 index 0000000..a2d1482 --- /dev/null +++ b/basic_info/migrations/0024_frontend_page_and_visible_pages.py @@ -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='可见页面'), + ), + ] diff --git a/basic_info/models.py b/basic_info/models.py index 1fc640c..503bfd4 100644 --- a/basic_info/models.py +++ b/basic_info/models.py @@ -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): """员工职位类型""" id = models.BigAutoField(primary_key=True) @@ -77,6 +125,13 @@ class EmployeeType(ModelBase): 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='预留字段') + visible_pages = models.ManyToManyField( + 'FrontendPage', + blank=True, + related_name='employee_types', + verbose_name='可见页面', + help_text='授予主菜单将自动包含其所有子菜单' + ) def __str__(self): return self.title diff --git a/basic_info/tests.py b/basic_info/tests.py index 4c88cd4..a1af04b 100644 --- a/basic_info/tests.py +++ b/basic_info/tests.py @@ -1,8 +1,13 @@ +from io import StringIO + +from django.core.management import call_command from django.test import TestCase from django.core.exceptions import ValidationError + from .models import ( 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(emp2.job_type, '打纸工') 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 子菜单 diff --git a/docs/api_v2_employee_visible_pages.md b/docs/api_v2_employee_visible_pages.md new file mode 100644 index 0000000..82b0b26 --- /dev/null +++ b/docs/api_v2_employee_visible_pages.md @@ -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 " +``` + +## 响应 + +### 成功响应 (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` 字段) diff --git a/docs/menu_keys.json b/docs/menu_keys.json new file mode 100644 index 0000000..82a1195 --- /dev/null +++ b/docs/menu_keys.json @@ -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"] + } + } +} +