diff --git a/backend/apps/brand/__init__.py b/backend/apps/brand/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/brand/migrations/0001_initial.py b/backend/apps/brand/migrations/0001_initial.py new file mode 100644 index 0000000..b771c25 --- /dev/null +++ b/backend/apps/brand/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated migration for brand app + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Brand', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='品牌名称')), + ('description', models.TextField(blank=True, null=True, verbose_name='品牌描述')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '品牌', + 'verbose_name_plural': '品牌', + 'db_table': 'brand', + 'ordering': ['id'], + }, + ), + ] diff --git a/backend/apps/brand/migrations/__init__.py b/backend/apps/brand/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/brand/models.py b/backend/apps/brand/models.py new file mode 100644 index 0000000..78fa6ad --- /dev/null +++ b/backend/apps/brand/models.py @@ -0,0 +1,20 @@ +from django.db import models + + +class Brand(models.Model): + """ + 品牌模型 + """ + name = models.CharField(max_length=100, unique=True, verbose_name='品牌名称') + description = models.TextField(blank=True, null=True, verbose_name='品牌描述') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + verbose_name = '品牌' + verbose_name_plural = '品牌' + db_table = 'brand' + ordering = ['id'] + + def __str__(self): + return self.name diff --git a/backend/apps/brand/serializers.py b/backend/apps/brand/serializers.py new file mode 100644 index 0000000..ccf2bbe --- /dev/null +++ b/backend/apps/brand/serializers.py @@ -0,0 +1,17 @@ +from rest_framework import serializers +from .models import Brand + + +class BrandSerializer(serializers.ModelSerializer): + """ + 品牌序列化器 + """ + material_count = serializers.SerializerMethodField() + + class Meta: + model = Brand + fields = ['id', 'name', 'description', 'created_at', 'updated_at', 'material_count'] + read_only_fields = ['id', 'created_at', 'updated_at', 'material_count'] + + def get_material_count(self, obj): + return obj.materials.count() diff --git a/backend/apps/brand/urls.py b/backend/apps/brand/urls.py new file mode 100644 index 0000000..79bca71 --- /dev/null +++ b/backend/apps/brand/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter + +from .views import BrandViewSet + +router = DefaultRouter() +router.register(r'', BrandViewSet, basename='brand') + +urlpatterns = router.urls diff --git a/backend/apps/brand/views.py b/backend/apps/brand/views.py new file mode 100644 index 0000000..92ff033 --- /dev/null +++ b/backend/apps/brand/views.py @@ -0,0 +1,49 @@ +from django.db.models import ProtectedError +from rest_framework import status +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from .models import Brand +from .serializers import BrandSerializer + + +class BrandViewSet(ModelViewSet): + """ + 品牌视图集:所有已认证用户可读,仅管理员可写 + """ + serializer_class = BrandSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + queryset = Brand.objects.all() + search = self.request.query_params.get('search') + if search: + queryset = queryset.filter(name__icontains=search) + return queryset + + def _check_admin(self, action_verb): + if self.request.user.role != 'admin': + raise PermissionDenied(f"只有管理员可以{action_verb}品牌") + + def perform_create(self, serializer): + self._check_admin("创建") + serializer.save() + + def perform_update(self, serializer): + self._check_admin("修改") + serializer.save() + + def destroy(self, request, *args, **kwargs): + if request.user.role != 'admin': + raise PermissionDenied("只有管理员可以删除品牌") + instance = self.get_object() + try: + instance.delete() + except ProtectedError: + return Response( + {"detail": "该品牌下存在材料,无法删除"}, + status=status.HTTP_400_BAD_REQUEST, + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/apps/factory/management/commands/import_factories_and_users.py b/backend/apps/factory/management/commands/import_factories_and_users.py index 11f30ee..09f1397 100644 --- a/backend/apps/factory/management/commands/import_factories_and_users.py +++ b/backend/apps/factory/management/commands/import_factories_and_users.py @@ -219,7 +219,7 @@ class Command(BaseCommand): website = _normalize_website(r.website_raw) factory, f_created = Factory.objects.get_or_create( - brand=r.brand, + short_name=r.brand, defaults={ "factory_name": r.factory_name, "province": province or "未知", @@ -255,10 +255,10 @@ class Command(BaseCommand): ) if existing_user: ensured_users += 1 - creds.append((factory.brand, existing_user.username, "")) + creds.append((factory.short_name, existing_user.username, "")) continue - base = _make_username_base(factory.brand) + base = _make_username_base(factory.short_name) username = _unique_username(UserModel, base) password = fixed_password or secrets.token_urlsafe(12) @@ -269,7 +269,7 @@ class Command(BaseCommand): factory=factory, ) created_users += 1 - creds.append((factory.brand, user.username, password)) + creds.append((factory.short_name, user.username, password)) self.stdout.write( self.style.SUCCESS( diff --git a/backend/apps/factory/migrations/0004_rename_brand_to_short_name.py b/backend/apps/factory/migrations/0004_rename_brand_to_short_name.py new file mode 100644 index 0000000..c605600 --- /dev/null +++ b/backend/apps/factory/migrations/0004_rename_brand_to_short_name.py @@ -0,0 +1,23 @@ +# Generated migration for factory app + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('factory', '0003_rename_factory_short_name_to_brand'), + ] + + operations = [ + migrations.RenameField( + model_name='factory', + old_name='brand', + new_name='short_name', + ), + migrations.AlterField( + model_name='factory', + name='short_name', + field=models.CharField(max_length=100, unique=True, verbose_name='供应商简称'), + ), + ] diff --git a/backend/apps/factory/models.py b/backend/apps/factory/models.py index 26c1709..8671dff 100644 --- a/backend/apps/factory/models.py +++ b/backend/apps/factory/models.py @@ -7,7 +7,7 @@ class Factory(models.Model): dealer_name = models.CharField(max_length=255, blank=True, null=True, verbose_name='经销商名称') product_category = models.CharField(max_length=255, blank=True, null=True, verbose_name='产品分类') factory_name = models.CharField(max_length=255, verbose_name='生产工厂全称') - brand = models.CharField(max_length=100, unique=True, verbose_name='品牌') + short_name = models.CharField(max_length=100, unique=True, verbose_name='供应商简称') province = models.CharField(max_length=50, verbose_name='省') city = models.CharField(max_length=50, verbose_name='市') district = models.CharField(max_length=50, blank=True, null=True, verbose_name='区') diff --git a/backend/apps/factory/serializers.py b/backend/apps/factory/serializers.py index 2868a95..e869290 100644 --- a/backend/apps/factory/serializers.py +++ b/backend/apps/factory/serializers.py @@ -11,8 +11,8 @@ class FactorySerializer(serializers.ModelSerializer): class Meta: model = Factory - fields = ['id', 'dealer_name', 'product_category', 'factory_name', - 'brand', 'province', 'city', 'district', + fields = ['id', 'dealer_name', 'product_category', 'factory_name', + 'short_name', 'province', 'city', 'district', 'address', 'website', 'created_at', 'updated_at', 'material_count', 'usernames'] read_only_fields = ['id', 'created_at', 'updated_at', 'material_count', 'usernames'] @@ -32,7 +32,7 @@ class FactoryListSerializer(serializers.ModelSerializer): class Meta: model = Factory - fields = ['id', 'factory_name', 'brand', 'province', 'city', 'dealer_name', 'usernames'] + fields = ['id', 'factory_name', 'short_name', 'province', 'city', 'dealer_name', 'usernames'] def get_usernames(self, obj): return list(obj.users.values_list('username', flat=True)) diff --git a/backend/apps/material/importers.py b/backend/apps/material/importers.py index a8b8afb..8c0092f 100644 --- a/backend/apps/material/importers.py +++ b/backend/apps/material/importers.py @@ -229,9 +229,9 @@ def _resolve_factory( break term_lower = term.lower() for factory in all_factories: - brand = (factory.brand or "").lower() + short_name = (factory.short_name or "").lower() full_name = (factory.factory_name or "").lower() - if (brand and brand in term_lower) or (full_name and full_name in term_lower): + if (short_name and short_name in term_lower) or (full_name and full_name in term_lower): matched_factory = factory break if matched_factory: diff --git a/backend/apps/material/management/commands/import_materials_from_excel.py b/backend/apps/material/management/commands/import_materials_from_excel.py index 49b14d0..f085856 100644 --- a/backend/apps/material/management/commands/import_materials_from_excel.py +++ b/backend/apps/material/management/commands/import_materials_from_excel.py @@ -195,15 +195,15 @@ class Command(BaseCommand): if f: factory_cache[brand_name] = f return f - # 2) 模糊:工厂品牌包含 Excel 值(如 Excel「立邦」匹配「立邦中国」) - f = Factory.objects.filter(brand__icontains=brand_name).first() + # 2) 模糊:供应商简称包含 Excel 值(如 Excel「立邦」匹配「立邦中国」) + f = Factory.objects.filter(short_name__icontains=brand_name).first() if f: factory_cache[brand_name] = f return f - # 3) 模糊:Excel 值包含某工厂品牌(如 Excel「立邦中国\n广州立邦」取首行后仍可匹配) + # 3) 模糊:Excel 值包含某供应商简称(如 Excel「立邦中国\n广州立邦」取首行后仍可匹配) brand_lower = brand_name.lower() for factory in Factory.objects.all(): - if factory.brand and factory.brand.lower() in brand_lower: + if factory.short_name and factory.short_name.lower() in brand_lower: factory_cache[brand_name] = factory return factory factory_cache[brand_name] = None @@ -212,7 +212,7 @@ class Command(BaseCommand): # 未识别品牌工厂:品牌列无法匹配到任一工厂时,统一关联到此工厂(不存在则创建) UNRECOGNIZED_BRAND = "未识别的品牌" unrecognized_factory, _ = Factory.objects.get_or_create( - brand=UNRECOGNIZED_BRAND, + short_name=UNRECOGNIZED_BRAND, defaults={ "factory_name": "未识别的品牌工厂", "province": "-", diff --git a/backend/apps/material/migrations/0007_add_brand_fk.py b/backend/apps/material/migrations/0007_add_brand_fk.py new file mode 100644 index 0000000..e2fcd82 --- /dev/null +++ b/backend/apps/material/migrations/0007_add_brand_fk.py @@ -0,0 +1,26 @@ +# Generated migration for material app + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('brand', '0001_initial'), + ('material', '0006_alter_material_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='material', + name='brand', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=models.deletion.PROTECT, + related_name='materials', + to='brand.brand', + verbose_name='品牌', + ), + ), + ] diff --git a/backend/apps/material/migrations/0008_populate_brand.py b/backend/apps/material/migrations/0008_populate_brand.py new file mode 100644 index 0000000..9ee1734 --- /dev/null +++ b/backend/apps/material/migrations/0008_populate_brand.py @@ -0,0 +1,40 @@ +# Data migration: 从 Factory.short_name 的唯一值创建 Brand,并回填 Material.brand + +from django.db import migrations + + +def forwards(apps, schema_editor): + Factory = apps.get_model('factory', 'Factory') + Brand = apps.get_model('brand', 'Brand') + Material = apps.get_model('material', 'Material') + + short_names = ( + Factory.objects + .exclude(short_name__isnull=True) + .exclude(short_name='') + .values_list('short_name', flat=True) + .distinct() + ) + for sn in short_names: + Brand.objects.get_or_create(name=sn) + + for brand in Brand.objects.all(): + Material.objects.filter(factory__short_name=brand.name).update(brand=brand) + + +def backwards(apps, schema_editor): + # schema migration 回滚时会删掉 Material.brand 字段,这里无需操作 + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('material', '0007_add_brand_fk'), + ('factory', '0004_rename_brand_to_short_name'), + ('brand', '0001_initial'), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/backend/apps/material/models.py b/backend/apps/material/models.py index cd85d18..c7d79b8 100644 --- a/backend/apps/material/models.py +++ b/backend/apps/material/models.py @@ -87,6 +87,7 @@ class Material(models.Model): construction_method = models.CharField(max_length=255, blank=True, null=True, verbose_name='施工工艺') limit_condition = models.TextField(blank=True, null=True, verbose_name='限制条件') factory = models.ForeignKey('factory.Factory', on_delete=models.CASCADE, related_name='materials', verbose_name='材料单位名称') + brand = models.ForeignKey('brand.Brand', on_delete=models.PROTECT, null=True, blank=True, related_name='materials', verbose_name='品牌') status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name='状态') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') diff --git a/backend/apps/material/serializers.py b/backend/apps/material/serializers.py index ce28f98..00538a6 100644 --- a/backend/apps/material/serializers.py +++ b/backend/apps/material/serializers.py @@ -1,5 +1,6 @@ import json from rest_framework import serializers +from apps.brand.models import Brand from .models import Material, MaterialCategory, MaterialSubcategory @@ -18,7 +19,11 @@ class MaterialSerializer(serializers.ModelSerializer): 材料序列化器 """ factory_name = serializers.CharField(source='factory.factory_name', read_only=True) - brand = serializers.CharField(source='factory.brand', read_only=True) + factory_short_name = serializers.CharField(source='factory.short_name', read_only=True) + brand = serializers.PrimaryKeyRelatedField( + queryset=Brand.objects.all(), allow_null=True, required=False + ) + brand_name = serializers.CharField(source='brand.name', read_only=True) major_category_display = serializers.CharField(source='get_major_category_display', read_only=True) replace_type_display = serializers.CharField(source='get_replace_type_display', read_only=True) stage_display = serializers.CharField(source='get_stage_display', read_only=True) @@ -51,7 +56,8 @@ class MaterialSerializer(serializers.ModelSerializer): 'advantage_desc', 'cost_compare', 'cost_desc', 'cases', 'brochure', 'brochure_url', 'quality_level', 'durability_level', 'eco_level', 'carbon_level', 'score_level', 'connection_method', 'construction_method', - 'limit_condition', 'factory', 'factory_name', 'brand', + 'limit_condition', 'factory', 'factory_name', 'factory_short_name', + 'brand', 'brand_name', 'status', 'created_at', 'updated_at'] read_only_fields = ['id', 'created_at', 'updated_at'] @@ -82,7 +88,8 @@ class MaterialListSerializer(serializers.ModelSerializer): 材料列表序列化器(简化版) """ factory_name = serializers.CharField(source='factory.factory_name', read_only=True) - brand = serializers.CharField(source='factory.brand', read_only=True) + factory_short_name = serializers.CharField(source='factory.short_name', read_only=True) + brand_name = serializers.CharField(source='brand.name', read_only=True) major_category_display = serializers.CharField(source='get_major_category_display', read_only=True) status_display = serializers.CharField(source='get_status_display', read_only=True) stage_display = serializers.CharField(source='get_stage_display', read_only=True) @@ -94,7 +101,8 @@ class MaterialListSerializer(serializers.ModelSerializer): 'material_category', 'material_subcategory', 'stage', 'stage_display', 'importance_level', 'importance_level_display', 'landing_project', 'contact_person', 'contact_phone', 'handler', 'remark', 'factory', - 'factory_name', 'brand', 'status', 'status_display'] + 'factory_name', 'factory_short_name', 'brand', 'brand_name', + 'status', 'status_display'] class MaterialCategorySerializer(serializers.ModelSerializer): diff --git a/backend/apps/material/views.py b/backend/apps/material/views.py index f332729..3521147 100644 --- a/backend/apps/material/views.py +++ b/backend/apps/material/views.py @@ -170,6 +170,11 @@ class MaterialViewSet(ModelViewSet): if factory_id: queryset = queryset.filter(factory_id=factory_id) + # 支持按品牌过滤 + brand_id = self.request.query_params.get('brand') + if brand_id: + queryset = queryset.filter(brand_id=brand_id) + # 支持按专业类别过滤 major_category = self.request.query_params.get('major_category') if major_category: @@ -373,7 +378,7 @@ class MaterialViewSet(ModelViewSet): material.contact_person or "", material.contact_phone or "", material.handler or "", - material.factory.brand if material.factory else "", + material.factory.short_name if material.factory else "", material.factory.factory_name if material.factory else "", material.spec or "", material.standard or "", diff --git a/backend/apps/statistics/views.py b/backend/apps/statistics/views.py index 5fdbf29..bee28a7 100644 --- a/backend/apps/statistics/views.py +++ b/backend/apps/statistics/views.py @@ -188,7 +188,7 @@ def factory_statistics(request): }) factories_list = list(Factory.objects.values( - 'id', 'factory_name', 'brand', 'province', 'city', 'website' + 'id', 'factory_name', 'short_name', 'province', 'city', 'website' )) return Response({ diff --git a/backend/config/settings.py b/backend/config/settings.py index bb46a51..8f4c7f7 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -53,6 +53,7 @@ INSTALLED_APPS = [ 'corsheaders', 'apps.authentication', 'apps.factory', + 'apps.brand', 'apps.material', 'apps.dictionary', 'apps.statistics', diff --git a/backend/config/urls.py b/backend/config/urls.py index 31d279a..c665bd9 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ path('admin/', admin.site.urls), path('api/auth/', include('apps.authentication.urls')), path('api/factory/', include('apps.factory.urls')), + path('api/brand/', include('apps.brand.urls')), path('api/material/', include('apps.material.urls')), path('api/dictionary/', include('apps.dictionary.urls')), path('api/statistics/', include('apps.statistics.urls')), diff --git a/frontend/src/api/brand.js b/frontend/src/api/brand.js new file mode 100644 index 0000000..3e8d83c --- /dev/null +++ b/frontend/src/api/brand.js @@ -0,0 +1,26 @@ +import api from './client' + +export const fetchBrands = async (params) => { + const { data } = await api.get('/brand/', { params }) + return data +} + +export const fetchBrandDetail = async (id) => { + const { data } = await api.get(`/brand/${id}/`) + return data +} + +export const createBrand = async (payload) => { + const { data } = await api.post('/brand/', payload) + return data +} + +export const updateBrand = async (id, payload) => { + const { data } = await api.put(`/brand/${id}/`, payload) + return data +} + +export const deleteBrand = async (id) => { + const { data } = await api.delete(`/brand/${id}/`) + return data +} diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index e423e66..64d8a94 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -20,6 +20,7 @@ > 用户管理 供应商库 + 品牌库 材料管理 数据大屏 diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 0673fdf..3803094 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -18,6 +18,7 @@ const routes = [ { path: 'users', name: 'users', component: () => import('@/views/UserManage.vue'), meta: { admin: true } }, { path: 'factories', name: 'factories', component: () => import('@/views/FactoryManage.vue') }, { path: 'factories/:id', name: 'factory-detail', component: () => import('@/views/FactoryDetail.vue') }, + { path: 'brands', name: 'brands', component: () => import('@/views/BrandManage.vue'), meta: { admin: true } }, { path: 'dictionary', name: 'dictionary', component: () => import('@/views/DictionaryManage.vue'), meta: { admin: true } }, { path: 'materials', name: 'materials', component: () => import('@/views/MaterialManage.vue') }, { path: 'materials/:id', name: 'material-detail', component: () => import('@/views/MaterialDetail.vue') } diff --git a/frontend/src/views/BrandManage.vue b/frontend/src/views/BrandManage.vue new file mode 100644 index 0000000..5940993 --- /dev/null +++ b/frontend/src/views/BrandManage.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/frontend/src/views/FactoryDetail.vue b/frontend/src/views/FactoryDetail.vue index 9579f70..089c96d 100644 --- a/frontend/src/views/FactoryDetail.vue +++ b/frontend/src/views/FactoryDetail.vue @@ -7,7 +7,7 @@
{{ displayText(factory.factory_name) }} - {{ displayText(factory.brand) }} + {{ displayText(factory.short_name) }} {{ displayText(factory.dealer_name) }} {{ displayText(factory.product_category) }} {{ displayRegion(factory) }} diff --git a/frontend/src/views/FactoryManage.vue b/frontend/src/views/FactoryManage.vue index 3b5d472..0a8f025 100644 --- a/frontend/src/views/FactoryManage.vue +++ b/frontend/src/views/FactoryManage.vue @@ -6,7 +6,7 @@
- +