From b88510ca0a3a660f52e8c276afef109812bf0982 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 13 Mar 2026 11:25:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E5=8E=82=E7=AE=80=E7=A7=B0?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=93=81=E7=89=8C(=E5=94=AF=E4=B8=80)?= =?UTF-8?q?=E3=80=81=E6=9D=90=E6=96=99/=E5=B7=A5=E5=8E=82/=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=AF=BC=E5=85=A5=E5=91=BD=E4=BB=A4=E3=80=81Excel?= =?UTF-8?q?=E6=9D=90=E6=96=99=E5=AF=BC=E5=85=A5(=E6=A8=A1=E7=B3=8A?= =?UTF-8?q?=E5=8C=B9=E9=85=8D+=E6=9C=AA=E8=AF=86=E5=88=AB=E5=93=81?= =?UTF-8?q?=E7=89=8C=E5=B7=A5=E5=8E=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- README.md | 2 +- backend/apps/factory/management/__init__.py | 1 + .../factory/management/commands/__init__.py | 1 + .../commands/import_factories_and_users.py | 284 ++++++++++++++++ ...0003_rename_factory_short_name_to_brand.py | 23 ++ backend/apps/factory/models.py | 2 +- backend/apps/factory/serializers.py | 4 +- backend/apps/material/management/__init__.py | 1 + .../material/management/commands/__init__.py | 1 + .../commands/import_material_categories.py | 233 +++++++++++++ .../commands/import_materials_from_excel.py | 305 ++++++++++++++++++ backend/apps/material/serializers.py | 14 +- backend/apps/statistics/views.py | 2 +- backend/config/urls.py | 3 + backend/config/views.py | 52 +++ backend/requirements.txt | 1 + frontend/src/views/FactoryDetail.vue | 4 +- frontend/src/views/FactoryManage.vue | 10 +- frontend/src/views/MaterialManage.vue | 4 +- 19 files changed, 926 insertions(+), 21 deletions(-) create mode 100644 backend/apps/factory/management/__init__.py create mode 100644 backend/apps/factory/management/commands/__init__.py create mode 100644 backend/apps/factory/management/commands/import_factories_and_users.py create mode 100644 backend/apps/factory/migrations/0003_rename_factory_short_name_to_brand.py create mode 100644 backend/apps/material/management/__init__.py create mode 100644 backend/apps/material/management/commands/__init__.py create mode 100644 backend/apps/material/management/commands/import_material_categories.py create mode 100644 backend/apps/material/management/commands/import_materials_from_excel.py create mode 100644 backend/config/views.py diff --git a/README.md b/README.md index 55ef6cd..e356b63 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ mat/ - dealer_name: 经销商名称 - product_category: 产品分类 - factory_name: 生产工厂全称 -- factory_short_name: 工厂简称 +- brand: 品牌(唯一) - province: 省 - city: 市 - district: 区 diff --git a/backend/apps/factory/management/__init__.py b/backend/apps/factory/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/factory/management/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/factory/management/commands/__init__.py b/backend/apps/factory/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/factory/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/factory/management/commands/import_factories_and_users.py b/backend/apps/factory/management/commands/import_factories_and_users.py new file mode 100644 index 0000000..11f30ee --- /dev/null +++ b/backend/apps/factory/management/commands/import_factories_and_users.py @@ -0,0 +1,284 @@ +import re +import secrets +import sys +from dataclasses import dataclass +from typing import List, Optional, Tuple + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.factory.models import Factory + + +_DEFAULT_INPUT = """生产工厂全称\t品牌\t省市\t详细地址\t官网链接 +立邦涂料(中国)有限公司\t立邦中国\t上海市\t上海市浦东新区金桥出口加工区南区创业路287号\thttps://www.nipponpaint.com.cn/ +广州立邦涂料有限公司\t广州立邦\t广东省广州市\t广州经济技术开发区风华二路1号\thttp://www.nipponpaint.com.cn +黑龙江嘉达建材科技发展有限公司\t黑龙江嘉达\t黑龙江省齐齐哈尔市\t黑龙江省齐齐哈尔市铁锋区北疆中小企业园孵化基地B区\t +吉林市嘉达慧宇建筑节能材料有限责任公司\t吉林嘉达\t吉林省吉林市\t吉林市吉林经济技术开发区政达街139号\t +深圳市荣耀达新材料科技有限公司 (隶属北京荣耀达科技控股有限公司)\t深圳荣耀达\t广东省深圳市\t深圳市福田区深业泰然大厦\t +上海典跃建材科技有限公司\t上海典跃\t上海市\t上海市闵行区万康路328号环普云创4号楼\twww.chinasrs.com.cn +深圳市百欧森环保科技股份有限公司\t百欧森\t广东省深圳市\t广东省深圳市南山区桃源街道塘朗工业区B区44栋厂房第二层B区\twww.baiousen.com +广东马可波罗陶瓷有限公司\t马可波罗\t广东省东莞市\t广东省东莞市沙田镇立沙东路66号502室\thttp://www.marcopolo.com.cn/ +佛山市东鹏陶瓷发展有限公司\t东鹏发展\t广东省佛山市\t佛山市禅城区季华西路127号首层\thttp://www.dongpeng.net +广东新明珠陶瓷集团有限公司\t新明珠集团\t广东省佛山市\t佛山市禅城区南庄镇华夏陶瓷博览城\thttp://slab.newpearl.com/ +蒙娜丽莎集团股份有限公司\t蒙娜丽莎\t广东省佛山市\t佛山市 (总部)\thttp://www.monalisa.com.cn/ +佛山新中源陶瓷建材有限公司\t新中源\t广东省佛山市\t佛山市禅城区南庄镇石南大道190号中源企业大厦\thttps://www.newzhongyuan.com/ +上海斯米克健康环境科技有限公司\t斯米克\t上海市\t未找到公开信息\thttp://www.cimictiles.com/ +普纳尼亚集团陶瓷股份有限公司\t帕纳尼亚\t意大利\t意大利,蒙蒂那,41034芬那艾米丽,帕纳瑞巴萨街22/A\t +阿鲁克邦复合材料(江苏)有限公司\t阿鲁克邦\t江苏省常州市\t中国江苏常州钟楼区合欢南路10号\thttps://www.alucobond.com.cn +张家港飞腾复合新材料股份有限公司 (曾用名:张家港飞腾铝塑板股份有限公司)\t张家港飞腾\t江苏省苏州市\t张家港保税区环保新材料产业园华达路77号\twww.feiteng.cn +江阴天虹板业有限公司 (“虹铠甲”为其产品系列名称)\t江阴天虹\t江苏省无锡市\t未找到公开信息\twww.sky-rainbow.com +佛山市铸辉金属装饰材料有限公司\t佛山铸辉\t广东省佛山市\t未找到公开信息\thttp://zhuhui.cn.makepolo.com +王力安防科技股份有限公司\t王力\t浙江省金华市\t未找到公开信息\thttp://www.wanglianfang.com +温州麦克辛石业有限公司\t温州麦克辛\t浙江省温州市\t(公开信息未找到详细地址)\t +青岛英博建筑装饰有限公司 (曾用名:青岛英博石业有限公司)\t青岛英博\t山东省青岛市\t未找到公开信息\twww.yingbozhuangshi.com +广西碳歌环保新材料股份有限公司\t碳歌\t广西壮族自治区梧州市\t广西壮族自治区藤县中和陶瓷园区B01-02-01地块\twww.toco.cc +大巨龙集团 (品牌归属:典跃集团上海梦想空间文化科技有限公司)\t大巨龙集团\t上海市\t上海市金山工业区金腾璐1106\twww.da-ju-long.com +深圳洛赛声学技术有限公司\t洛赛声学\t广东省深圳市\t未找到公开信息\thttp://www.clocell.com +甘肃世宁新型材料有限公司\t世宁新材\t甘肃省庆阳市\t甘肃省庆阳市宁县和盛镇工业集中区\t +罗姆(上海)有限公司 (Röhm GmbH 中国全资子公司)\t罗姆\t上海市\t上海市闵行区春东路55号1号楼\twww.roehm.com +韩国奥姆勒自动机械有限公司北京代表处\t奥姆勒\t北京市\t(仅为代表处,无生产工厂)\t +北京博联建材机械厂\t博联\t北京市\t北京市昌平区南口镇南大街21号\t +广东新星联合科技有限公司\t广东新星联合\t广东省深圳市\t深圳市宝安区西乡街道共乐社区铁仔路50号凤凰智谷A栋1406\t +""" + + +_WS_RE = re.compile(r"\s+") +_TAB_SPLIT_RE = re.compile(r"\t+") + +# Close to Django's allowed username chars: letters/digits/underscore and @.+-. +_USERNAME_ALLOWED_RE = re.compile(r"[^\w@.+-]+", flags=re.UNICODE) + + +@dataclass(frozen=True) +class Row: + factory_name: str + brand: str + province_raw: str + address: str + website_raw: str + + +def _norm(s: str) -> str: + return _WS_RE.sub(" ", (s or "").strip()) + + +def _parse_province_city(s: str) -> Tuple[str, str]: + s = _norm(s) + if not s: + return "", "" + + # e.g. "广东省广州市" -> ("广东省", "广州市") + if "省" in s and s.endswith("市"): + idx = s.find("省") + if idx != -1 and idx + 1 < len(s): + return s[: idx + 1], s[idx + 1 :] + + # e.g. "广西壮族自治区梧州市" + if "自治区" in s and s.endswith("市"): + idx = s.find("自治区") + if idx != -1 and idx + 3 < len(s): + return s[: idx + 3], s[idx + 3 :] + + # e.g. "北京市" / "上海市" + if s.endswith("市"): + return s, s + + # Other (e.g. "意大利") + return s, s + + +def _normalize_website(s: str) -> Optional[str]: + s = _norm(s) + if not s: + return None + if s.startswith(("http://", "https://")): + return s + return f"http://{s}" + + +def _parse_line(line: str) -> Optional[Row]: + raw = line.strip() + if not raw: + return None + if raw.startswith("生产工厂全称"): + return None + + parts = [p for p in _TAB_SPLIT_RE.split(raw) if p is not None] + # tolerate missing trailing columns + while len(parts) < 5: + parts.append("") + + factory_name = _norm(parts[0]) + short_name = _norm(parts[1]) + province_raw = _norm(parts[2]) + address = _norm(parts[3]) + website_raw = _norm(parts[4]) + + if not factory_name or not short_name: + return None + + return Row( + factory_name=factory_name, + brand=short_name, + province_raw=province_raw, + address=address, + website_raw=website_raw, + ) + + +def parse_text(text: str) -> List[Row]: + rows: List[Row] = [] + for line in text.splitlines(): + row = _parse_line(line) + if row: + rows.append(row) + return rows + + +def _make_username_base(brand: str) -> str: + base = _norm(brand) + base = base.replace(" ", "") + base = _USERNAME_ALLOWED_RE.sub("", base) + base = base.strip("._-@+") + if not base: + base = "user" + return base.lower() + + +def _unique_username(UserModel, base: str) -> str: + if not UserModel.objects.filter(username=base).exists(): + return base + for i in range(2, 10000): + candidate = f"{base}{i}" + if not UserModel.objects.filter(username=candidate).exists(): + return candidate + raise RuntimeError(f"Unable to allocate unique username for base={base!r}") + + +class Command(BaseCommand): + help = "Import factories and create one normal user per factory." + + def add_arguments(self, parser): + parser.add_argument("--file", dest="file", default=None, help="Input TSV text file path.") + parser.add_argument("--stdin", action="store_true", help="Read TSV from stdin.") + parser.add_argument("--dry-run", action="store_true", help="Parse and report only; no DB writes.") + parser.add_argument( + "--password", + dest="password", + default=None, + help="Set the same initial password for all created users. If omitted, random passwords are generated.", + ) + parser.add_argument( + "--print-credentials", + action="store_true", + help="Print created/ensured usernames and passwords as TSV to stdout.", + ) + + @transaction.atomic + def handle(self, *args, **options): + file_path = options.get("file") + use_stdin: bool = bool(options.get("stdin")) + dry_run: bool = bool(options.get("dry_run")) + fixed_password: Optional[str] = options.get("password") or None + print_credentials: bool = bool(options.get("print_credentials")) + + if file_path: + with open(file_path, "r", encoding="utf-8") as f: + text = f.read() + elif use_stdin: + text = sys.stdin.read() + else: + text = _DEFAULT_INPUT + + rows = parse_text(text) + if not rows: + self.stdout.write(self.style.WARNING("No valid rows parsed; nothing to import.")) + return + + unique_factories = {(r.factory_name, r.brand) for r in rows} + self.stdout.write(f"Parsed rows: {len(rows)}") + self.stdout.write(f"Unique factories: {len(unique_factories)}") + + if dry_run: + self.stdout.write(self.style.SUCCESS("Dry run complete (no DB changes).")) + return + + UserModel = get_user_model() + + created_factories = 0 + created_users = 0 + ensured_users = 0 + + # header for credential output + creds: List[Tuple[str, str, str]] = [] # (brand, username, password_or_blank) + + for r in rows: + province, city = _parse_province_city(r.province_raw) + website = _normalize_website(r.website_raw) + + factory, f_created = Factory.objects.get_or_create( + brand=r.brand, + defaults={ + "factory_name": r.factory_name, + "province": province or "未知", + "city": city or "未知", + "address": r.address or None, + "website": website, + "dealer_name": None, + "product_category": None, + }, + ) + if f_created: + created_factories += 1 + else: + changed = False + if factory.province != (province or factory.province): + factory.province = province or factory.province + changed = True + if factory.city != (city or factory.city): + factory.city = city or factory.city + changed = True + if r.address and factory.address != r.address: + factory.address = r.address + changed = True + if website and factory.website != website: + factory.website = website + changed = True + if changed: + factory.save() + + # One normal user per factory. If exists, keep it; otherwise create a new one. + existing_user = ( + UserModel.objects.filter(role="user", factory_id=factory.id).order_by("id").first() + ) + if existing_user: + ensured_users += 1 + creds.append((factory.brand, existing_user.username, "")) + continue + + base = _make_username_base(factory.brand) + username = _unique_username(UserModel, base) + password = fixed_password or secrets.token_urlsafe(12) + + user = UserModel.objects.create_user( + username=username, + password=password, + role="user", + factory=factory, + ) + created_users += 1 + creds.append((factory.brand, user.username, password)) + + self.stdout.write( + self.style.SUCCESS( + f"Import complete. Created factories: {created_factories}, created users: {created_users}, ensured existing users: {ensured_users}." + ) + ) + + if print_credentials: + self.stdout.write("品牌\t用户名\t初始密码(仅新建时输出)") + for short_name, username, password in creds: + self.stdout.write(f"{short_name}\t{username}\t{password}") + diff --git a/backend/apps/factory/migrations/0003_rename_factory_short_name_to_brand.py b/backend/apps/factory/migrations/0003_rename_factory_short_name_to_brand.py new file mode 100644 index 0000000..0542b24 --- /dev/null +++ b/backend/apps/factory/migrations/0003_rename_factory_short_name_to_brand.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('factory', '0002_make_dealer_name_optional'), + ] + + operations = [ + migrations.RenameField( + model_name='factory', + old_name='factory_short_name', + new_name='brand', + ), + migrations.AlterField( + model_name='factory', + name='brand', + 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 180bd41..26c1709 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='生产工厂全称') - factory_short_name = models.CharField(max_length=100, verbose_name='工厂简称') + brand = 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 0052a70..00d70c4 100644 --- a/backend/apps/factory/serializers.py +++ b/backend/apps/factory/serializers.py @@ -11,7 +11,7 @@ class FactorySerializer(serializers.ModelSerializer): class Meta: model = Factory fields = ['id', 'dealer_name', 'product_category', 'factory_name', - 'factory_short_name', 'province', 'city', 'district', + 'brand', 'province', 'city', 'district', 'address', 'website', 'created_at', 'updated_at', 'material_count'] read_only_fields = ['id', 'created_at', 'updated_at', 'material_count'] @@ -28,4 +28,4 @@ class FactoryListSerializer(serializers.ModelSerializer): """ class Meta: model = Factory - fields = ['id', 'factory_name', 'factory_short_name', 'province', 'city', 'dealer_name'] + fields = ['id', 'factory_name', 'brand', 'province', 'city', 'dealer_name'] diff --git a/backend/apps/material/management/__init__.py b/backend/apps/material/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/material/management/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/material/management/commands/__init__.py b/backend/apps/material/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/material/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/material/management/commands/import_material_categories.py b/backend/apps/material/management/commands/import_material_categories.py new file mode 100644 index 0000000..ce2b291 --- /dev/null +++ b/backend/apps/material/management/commands/import_material_categories.py @@ -0,0 +1,233 @@ +import re +import sys +from dataclasses import dataclass +from typing import List, Optional, Tuple + +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.material.models import MaterialCategory, MaterialSubcategory + + +_DEFAULT_INPUT = """ +门窗\t智能门窗、智能升降护栏 +机电产品\t机电产品 +外墙\t涂料 +外墙\t涂料 +外墙\t涂料 +外墙\t涂料 +外墙\t涂料 +外墙\t涂料 +外墙\t涂料 +外墙\t涂料 +内墙\t涂料 +外墙\t涂料 +外墙\t涂料 +地坪\t地坪 +地坪\t地坪 +地坪\t水磨石 +墙材\t墙板 +电工\t电工类 +瓷砖\t岩板 +瓷砖\t大理石 +瓷砖\t玻化砖 +瓷砖\t云石代 +石膏板\t脱硫石膏板 +石膏板\t脱硫石膏板 +石膏板\t脱硫石膏板 +石膏板\t脱硫石膏板 +石膏板\t石膏基纤维板 +石膏板\t脱硫石膏板 +龙骨\t轻钢龙骨 +腻子\t腻子粉 +腻子\t腻子粉 +石膏板\t抹灰石膏 +石膏板\t抹灰石膏 +石膏板\t嵌缝石膏 +石膏板\t石膏基自流平 +石膏板\t石膏基自流平 +胶\t瓷砖胶 +胶\t瓷砖胶 +胶\t瓷砖胶 +固剂\t界面剂 +固剂\t界面剂 +腻子\t腻子 +石膏板\t找平石膏 +石膏板\t嵌缝石膏 +涂料\t节能材料 +涂料\t平面金属漆 +涂料\t质感金属漆 +门窗\t入户门 +墙体\t保温板(无机材料) +墙体\t预制墙板(无机材料) +涂料\t涂料 +板材\t人造板 +装配式外墙 高分子材料保温类 +板材\t人造板 +板材\t人造板 +涂料\t地下工程\t防水涂料 +胶\t瓷砖胶 +地毯\t商用地垫、S垫疏水垫、方块毯 +""" + + +_LINE_SPLIT_RE = re.compile(r"\t+| {2,}") +_SUB_SPLIT_RE = re.compile(r"[、,,;;]+") +_WS_RE = re.compile(r"\s+") + + +@dataclass(frozen=True) +class Row: + category: str + subcategories: Tuple[str, ...] + + +def _normalize_cell(s: str) -> str: + s = s.strip() + s = _WS_RE.sub(" ", s) + return s + + +def _parse_line(line: str) -> Optional[Row]: + raw = line.strip() + if not raw: + return None + if raw.startswith("材料分类"): + return None + + parts = [p for p in _LINE_SPLIT_RE.split(raw) if p.strip()] + if len(parts) >= 2: + category = _normalize_cell(parts[0]) + sub_raw = _normalize_cell(" ".join(parts[1:])) + else: + # Fallback: split by whitespace into 2 chunks + chunks = [c for c in _WS_RE.split(raw) if c.strip()] + if len(chunks) < 2: + return None + category = _normalize_cell(chunks[0]) + sub_raw = _normalize_cell(" ".join(chunks[1:])) + + subs: List[str] = [] + for token in _SUB_SPLIT_RE.split(sub_raw): + token = _normalize_cell(token) + if token: + subs.append(token) + + if not category or not subs: + return None + return Row(category=category, subcategories=tuple(subs)) + + +def parse_text(text: str) -> List[Row]: + rows: List[Row] = [] + for line in text.splitlines(): + row = _parse_line(line) + if row: + rows.append(row) + return rows + + +def _category_value(category_name: str) -> str: + # Keep it human-readable; unique constraint is enforced at DB level. + return category_name + + +def _subcategory_value(category_name: str, subcategory_name: str) -> str: + # MaterialSubcategory.value is globally unique, so include category to avoid collisions. + value = f"{category_name}/{subcategory_name}" + return value[:255] + + +class Command(BaseCommand): + help = "Import MaterialCategory & MaterialSubcategory from text/tsv." + + def add_arguments(self, parser): + parser.add_argument( + "--file", + dest="file", + default=None, + help="Input text file path. If omitted, uses built-in default dataset (unless --stdin is specified).", + ) + parser.add_argument( + "--stdin", + action="store_true", + help="Read import text from stdin (e.g. pipe a file or paste).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Parse and report counts only; do not write to DB.", + ) + + @transaction.atomic + def handle(self, *args, **options): + dry_run: bool = bool(options["dry_run"]) + file_path = options.get("file") + use_stdin: bool = bool(options.get("stdin")) + + if file_path: + with open(file_path, "r", encoding="utf-8") as f: + text = f.read() + elif use_stdin: + text = sys.stdin.read() + else: + text = _DEFAULT_INPUT + + rows = parse_text(text) + if not rows: + self.stdout.write(self.style.WARNING("No valid rows parsed; nothing to import.")) + return + + unique_categories = {r.category for r in rows} + unique_subs = {(r.category, s) for r in rows for s in r.subcategories} + + self.stdout.write(f"Parsed rows: {len(rows)}") + self.stdout.write(f"Unique categories: {len(unique_categories)}") + self.stdout.write(f"Unique subcategories (category-scoped): {len(unique_subs)}") + + if dry_run: + self.stdout.write(self.style.SUCCESS("Dry run complete (no DB changes).")) + return + + created_categories = 0 + created_subcategories = 0 + + category_by_value = {} + for category_name in sorted(unique_categories): + value = _category_value(category_name) + obj, created = MaterialCategory.objects.get_or_create( + value=value, defaults={"name": category_name} + ) + if not created and obj.name != category_name: + obj.name = category_name + obj.save(update_fields=["name"]) + if created: + created_categories += 1 + category_by_value[value] = obj + + for category_name, sub_name in sorted(unique_subs): + cat_value = _category_value(category_name) + category = category_by_value[cat_value] + sub_value = _subcategory_value(category_name, sub_name) + obj, created = MaterialSubcategory.objects.get_or_create( + value=sub_value, + defaults={"category": category, "name": sub_name}, + ) + changed = False + if obj.category_id != category.id: + obj.category = category + changed = True + if obj.name != sub_name: + obj.name = sub_name + changed = True + if changed: + obj.save(update_fields=["category", "name"]) + if created: + created_subcategories += 1 + + self.stdout.write( + self.style.SUCCESS( + f"Import complete. Created categories: {created_categories}, created subcategories: {created_subcategories}." + ) + ) + diff --git a/backend/apps/material/management/commands/import_materials_from_excel.py b/backend/apps/material/management/commands/import_materials_from_excel.py new file mode 100644 index 0000000..872647e --- /dev/null +++ b/backend/apps/material/management/commands/import_materials_from_excel.py @@ -0,0 +1,305 @@ +# -*- coding: utf-8 -*- +""" +从 Excel「材料库」sheet 导入材料数据。 +表头行:材料ID, 材料名称, 专业类别, 材料分类, 材料子类, 品牌, 规格型号, 符合标准, ... +工厂解析:依据「品牌」列与 Factory.brand 模糊匹配(取首行);未匹配时关联到「未识别的品牌」工厂(不存在则自动创建)。 +""" +import re +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, List, Optional, Tuple + +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.factory.models import Factory +from apps.material.models import Material + + +# 专业类别:中文 -> 模型 choice value +MAJOR_CATEGORY_MAP = { + "建筑": "architecture", + "景观": "landscape", + "设备": "equipment", + "装修": "decoration", +} +# 应用场景:中文 -> 模型 choice value +APPLICATION_SCENE_MAP = { + "府系": "fu", + "境系": "jing", + "城系": "cheng", + "住系": "zhu", + "保障房": "affordable", +} +# 替代类型 +REPLACE_TYPE_MAP = {"平替": "alternative", "新研发": "new_development"} +# 竞争优势 +ADVANTAGE_MAP = {"品质": "quality", "成本": "cost"} +# 星级:中文 -> 1/2/3 +STAR_LEVEL_MAP = {"一": 1, "二": 2, "三": 3, "1": 1, "2": 2, "3": 3} + + +def _cell(v: Any) -> str: + if v is None: + return "" + s = str(v).strip() + return s + + +def _norm_header(h: Any) -> str: + return _cell(h).replace("\n", " ").strip() + + +def _parse_application_scene(s: str) -> List[str]: + if not s: + return [] + out = [] + for part in re.split(r"[\s,,、;;]+", s): + key = part.strip() + if key in APPLICATION_SCENE_MAP: + out.append(APPLICATION_SCENE_MAP[key]) + return list(dict.fromkeys(out)) # preserve order, dedup + + +def _parse_advantage(s: str) -> List[str]: + if not s: + return [] + out = [] + for part in re.split(r"[\s,,、;;]+", s): + key = part.strip() + if key in ADVANTAGE_MAP: + out.append(ADVANTAGE_MAP[key]) + return list(dict.fromkeys(out)) + + +def _parse_star(s: str) -> Optional[int]: + if not s: + return None + s = _cell(s) + return STAR_LEVEL_MAP.get(s) + + +def _parse_cost_compare(s: str) -> Optional[Decimal]: + if not s: + return None + s = _cell(s) + try: + return Decimal(s) + except (InvalidOperation, ValueError): + return None + + +def _first_line(s: str) -> str: + if not s: + return "" + return (s.split("\n")[0] or "").strip() + + +def _single_line(s: str, max_len: int = 255) -> str: + if not s: + return "" + s = re.sub(r"\s+", " ", _cell(s))[:max_len] + return s.strip() or "" + + +class Command(BaseCommand): + help = "Import materials from Excel file, sheet name '材料库'." + + def add_arguments(self, parser): + parser.add_argument("excel_path", nargs="?", default=None, help="Path to the .xlsx file.") + parser.add_argument( + "--sheet", + default="材料库", + help="Sheet name to read (default: 材料库).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Only parse and report row count; do not write to DB.", + ) + + @transaction.atomic + def handle(self, *args, **options): + import openpyxl + + excel_path = options.get("excel_path") + if not excel_path: + self.stdout.write(self.style.ERROR("Please provide excel_path (path to .xlsx).")) + return + + sheet_name = options.get("sheet") or "材料库" + dry_run = bool(options.get("dry_run")) + + try: + wb = openpyxl.load_workbook(excel_path, read_only=True, data_only=True) + except Exception as e: + self.stdout.write(self.style.ERROR("Failed to open Excel: %s" % e)) + return + + if sheet_name not in wb.sheetnames: + self.stdout.write(self.style.ERROR("Sheet '%s' not found. Available: %s" % (sheet_name, wb.sheetnames))) + wb.close() + return + + ws = wb[sheet_name] + rows = list(ws.iter_rows(values_only=True)) + wb.close() + + if len(rows) < 2: + self.stdout.write(self.style.WARNING("No header or data rows.")) + return + + # Build column index by header (first row that looks like header) + header_row = None + header_idx = {} + for i, row in enumerate(rows): + if not row: + continue + headers = [_norm_header(c) for c in row] + if "材料名称" in headers: + header_row = row + for j, h in enumerate(headers): + if h: + header_idx[h] = j + break + + if not header_idx or "材料名称" not in header_idx: + self.stdout.write(self.style.ERROR("Header row with '材料名称' not found.")) + return + + data_start = i + 1 # first data row after header + + def get(row: Tuple, key: str, default: str = "") -> str: + j = header_idx.get(key, -1) + if j < 0 or j >= len(row): + return default + return _cell(row[j]) + + # Resolve factory by 品牌:精确匹配 -> 模糊匹配(品牌包含 Excel 值 或 Excel 值包含品牌) + factory_cache: Dict[str, Optional[Factory]] = {} + + def get_factory(brand_name: str) -> Optional[Factory]: + if not brand_name: + return None + if brand_name not in factory_cache: + # 1) 精确匹配 + f = Factory.objects.filter(brand=brand_name).first() + if f: + factory_cache[brand_name] = f + return f + # 2) 模糊:工厂品牌包含 Excel 值(如 Excel「立邦」匹配「立邦中国」) + f = Factory.objects.filter(brand__icontains=brand_name).first() + if f: + factory_cache[brand_name] = f + return f + # 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: + factory_cache[brand_name] = factory + return factory + factory_cache[brand_name] = None + return factory_cache[brand_name] + + # 未识别品牌工厂:品牌列无法匹配到任一工厂时,统一关联到此工厂(不存在则创建) + UNRECOGNIZED_BRAND = "未识别的品牌" + unrecognized_factory, _ = Factory.objects.get_or_create( + brand=UNRECOGNIZED_BRAND, + defaults={ + "factory_name": "未识别的品牌工厂", + "province": "-", + "city": "-", + }, + ) + + created = 0 + updated = 0 + skipped = 0 + errors: List[str] = [] + + for i in range(data_start, len(rows)): + row = rows[i] + if not row: + continue + name = get(row, "材料名称") + if not name: + continue + + # Factory: 依据「品牌」列模糊匹配 Factory.brand(取首行),未匹配则关联到「未识别的品牌」工厂 + brand_val = _first_line(get(row, "品牌")) + factory = get_factory(brand_val) if brand_val else None + if not factory: + factory = unrecognized_factory + + major_cn = get(row, "专业类别") + major_category = MAJOR_CATEGORY_MAP.get(major_cn, "architecture") + material_category = _single_line(get(row, "材料分类")) + material_subcategory = _single_line(get(row, "材料子类")) + if not material_category: + material_category = "-" + if not material_subcategory: + material_subcategory = "-" + + application_scene = _parse_application_scene(get(row, "应用场景")) + replace_type = REPLACE_TYPE_MAP.get(_cell(get(row, "替代材料"))) + advantage = _parse_advantage(get(row, "竞争优势")) + + data = { + "name": name[:255], + "major_category": major_category, + "material_category": material_category, + "material_subcategory": material_subcategory, + "spec": _single_line(get(row, "规格型号")) or None, + "standard": _single_line(get(row, "符合标准")) or None, + "application_scene": application_scene or None, + "application_desc": _cell(get(row, "应用场景说明")) or None, + "replace_type": replace_type, + "advantage_desc": _cell(get(row, "竞争优势说明")) or None, + "advantage": advantage or None, + "cost_compare": _parse_cost_compare(get(row, "成本")), + "cost_desc": _cell(get(row, "成本")) or None, + "cases": _cell(get(row, "应用案例")) or None, + "quality_level": _parse_star(get(row, "质量提升")), + "durability_level": _parse_star(get(row, "耐久可靠")), + "eco_level": _parse_star(get(row, "环保健康")), + "carbon_level": _parse_star(get(row, "循环低碳")), + "score_level": _parse_star(get(row, "评分等级")), + "connection_method": _single_line(get(row, "连接方式")) or None, + "construction_method": _single_line(get(row, "施工工艺")) or None, + "limit_condition": _cell(get(row, "限制条件")) or None, + "factory": factory, + "status": "draft", + } + + if dry_run: + created += 1 + continue + + material_id = None + if header_idx.get("材料ID") is not None and row[header_idx["材料ID"]] is not None: + try: + material_id = int(row[header_idx["材料ID"]]) + except (TypeError, ValueError): + pass + + if material_id and Material.objects.filter(id=material_id).exists(): + m = Material.objects.get(id=material_id) + for k, v in data.items(): + setattr(m, k, v) + m.save() + updated += 1 + else: + Material.objects.create(**data) + created += 1 + + if dry_run: + self.stdout.write(self.style.SUCCESS("Dry run: would process %s rows." % created)) + return + + self.stdout.write( + self.style.SUCCESS("Import complete. Created: %s, Updated: %s, Skipped: %s." % (created, updated, skipped)) + ) + if errors: + for msg in errors[:20]: + self.stdout.write(self.style.WARNING(msg)) + if len(errors) > 20: + self.stdout.write(self.style.WARNING("... and %s more." % (len(errors) - 20))) diff --git a/backend/apps/material/serializers.py b/backend/apps/material/serializers.py index 3625a43..b86e01b 100644 --- a/backend/apps/material/serializers.py +++ b/backend/apps/material/serializers.py @@ -18,7 +18,7 @@ class MaterialSerializer(serializers.ModelSerializer): 材料序列化器 """ factory_name = serializers.CharField(source='factory.factory_name', read_only=True) - factory_short_name = serializers.CharField(source='factory.factory_short_name', read_only=True) + brand = serializers.CharField(source='factory.brand', 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) application_scene = JSONListField( @@ -33,6 +33,9 @@ class MaterialSerializer(serializers.ModelSerializer): ) advantage_display = serializers.SerializerMethodField() application_scene_display = serializers.SerializerMethodField() + brochure = serializers.CharField( + required=False, allow_blank=True, allow_null=True, default='', + ) brochure_url = serializers.SerializerMethodField() class Meta: @@ -44,14 +47,11 @@ 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', 'factory_short_name', + 'limit_condition', 'factory', 'factory_name', 'brand', 'status', 'created_at', 'updated_at'] read_only_fields = ['id', 'created_at', 'updated_at'] def get_brochure_url(self, obj): - """ - 获取宣传页图片URL - """ if obj.brochure: request = self.context.get('request') if request: @@ -78,7 +78,7 @@ class MaterialListSerializer(serializers.ModelSerializer): 材料列表序列化器(简化版) """ factory_name = serializers.CharField(source='factory.factory_name', read_only=True) - factory_short_name = serializers.CharField(source='factory.factory_short_name', read_only=True) + brand = serializers.CharField(source='factory.brand', 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) @@ -86,7 +86,7 @@ class MaterialListSerializer(serializers.ModelSerializer): model = Material fields = ['id', 'name', 'major_category', 'major_category_display', 'material_category', 'material_subcategory', 'factory', - 'factory_name', 'factory_short_name', 'status', 'status_display'] + 'factory_name', 'brand', 'status', 'status_display'] class MaterialCategorySerializer(serializers.ModelSerializer): diff --git a/backend/apps/statistics/views.py b/backend/apps/statistics/views.py index 104ca89..5dbbd9b 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', 'factory_short_name', 'province', 'city', 'website' + 'id', 'factory_name', 'brand', 'province', 'city', 'website' )) return Response({ diff --git a/backend/config/urls.py b/backend/config/urls.py index 9324de8..31d279a 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -7,6 +7,8 @@ from django.conf import settings from django.conf.urls.static import static from django.views.generic import TemplateView +from .views import upload_image + urlpatterns = [ path('admin/', admin.site.urls), path('api/auth/', include('apps.authentication.urls')), @@ -14,6 +16,7 @@ urlpatterns = [ path('api/material/', include('apps.material.urls')), path('api/dictionary/', include('apps.dictionary.urls')), path('api/statistics/', include('apps.statistics.urls')), + path('api/upload/', upload_image, name='upload-image'), ] # 开发环境下提供媒体文件服务 diff --git a/backend/config/views.py b/backend/config/views.py new file mode 100644 index 0000000..bfdb61f --- /dev/null +++ b/backend/config/views.py @@ -0,0 +1,52 @@ +import os +import uuid + +from django.conf import settings +from rest_framework.decorators import api_view, permission_classes, parser_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework import status + + +ALLOWED_IMAGE_TYPES = { + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp', +} + +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@parser_classes([MultiPartParser]) +def upload_image(request): + file = request.FILES.get('file') + if not file: + return Response({'detail': '未提供文件'}, status=status.HTTP_400_BAD_REQUEST) + + if file.content_type not in ALLOWED_IMAGE_TYPES: + return Response( + {'detail': '仅支持 JPG/PNG/GIF/WEBP/BMP 格式的图片'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if file.size > MAX_FILE_SIZE: + return Response( + {'detail': '文件大小不能超过 10MB'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ext = os.path.splitext(file.name)[1].lower() + filename = f"{uuid.uuid4().hex}{ext}" + relative_path = f"uploads/{filename}" + + full_path = os.path.join(settings.MEDIA_ROOT, relative_path) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + + with open(full_path, 'wb+') as dest: + for chunk in file.chunks(): + dest.write(chunk) + + url = request.build_absolute_uri(f"/{settings.MEDIA_URL}{relative_path}") + + return Response({'url': url, 'path': relative_path}) diff --git a/backend/requirements.txt b/backend/requirements.txt index 7453056..3f9a628 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ psycopg2-binary==2.9.9 django-cors-headers==4.3.0 Pillow==10.1.0 python-decouple==3.8 +openpyxl==3.1.2 diff --git a/frontend/src/views/FactoryDetail.vue b/frontend/src/views/FactoryDetail.vue index 7b44db1..25a2a6b 100644 --- a/frontend/src/views/FactoryDetail.vue +++ b/frontend/src/views/FactoryDetail.vue @@ -1,4 +1,4 @@ -