diff --git a/backend/apps/pisadmin/basicinfo/models.py b/backend/apps/pisadmin/basicinfo/models.py index d2ac2a8..024889f 100644 --- a/backend/apps/pisadmin/basicinfo/models.py +++ b/backend/apps/pisadmin/basicinfo/models.py @@ -76,6 +76,10 @@ class Supplier(CoreModel): class SupplierUser(CoreModel): """供应商用户信息(独立主数据;通过 supplier_id 与供应商主档逻辑关联,非数据库外键)""" + ROLE_CHOICES = ( + (1, "supplier_quote"), # 供应商_报价 + ) + supplier_id = models.CharField( max_length=20, db_index=True, @@ -83,7 +87,7 @@ class SupplierUser(CoreModel): help_text="与供应商信息表 supplier_id 同值,可多条(多联系人)", ) supplier_name = models.CharField(max_length=100, verbose_name="供应商全称", help_text="供应商全称") - supplier_role = models.IntegerField(verbose_name="供应商角色", help_text="供应商角色(1:报价)") + supplier_role = models.IntegerField(choices=ROLE_CHOICES, verbose_name="供应商角色", help_text="供应商角色") user_email = models.CharField(max_length=100, verbose_name="联络人邮箱", help_text="联络人邮箱") user_name = models.CharField(max_length=20, verbose_name="联络人", help_text="联络人") user_phone = models.CharField(max_length=100, verbose_name="联络人电话", help_text="联络人电话") diff --git a/backend/apps/pisadmin/basicinfo/views/supplier.py b/backend/apps/pisadmin/basicinfo/views/supplier.py index a45625d..50c3286 100644 --- a/backend/apps/pisadmin/basicinfo/views/supplier.py +++ b/backend/apps/pisadmin/basicinfo/views/supplier.py @@ -1,10 +1,98 @@ # -*- coding: utf-8 -*- -from rest_framework import serializers +import hashlib +from django.contrib.auth.hashers import make_password +from django.db import IntegrityError, transaction +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from application import dispatch from apps.pisadmin.basicinfo.models import Supplier, SupplierUser +from dvadmin.system.models import Dept, Role, Users from dvadmin.utils.serializers import CustomModelSerializer from dvadmin.utils.viewset import CustomModelViewSet +SUPPLIER_DEPT_KEY = "supplier" + + +def _default_system_password_plain() -> str: + v = dispatch.get_system_config_values("base.default_password") + if v is None or str(v).strip() == "": + return "admin123456" + return str(v).strip() + + +def _system_role_key_from_supplier_role(supplier_role: int) -> str: + role_map = dict(SupplierUser.ROLE_CHOICES) + key = role_map.get(supplier_role) + if not key: + raise ValidationError( + detail={"supplier_role": [f"不支持的供应商角色({supplier_role}),无法映射系统角色"]} + ) + return key + + +def _sync_create_system_user_for_supplier(supplier_user: SupplierUser, request) -> None: + """在系统用户表创建账号:账号=联络人邮箱,部门 key=supplier,角色按 supplier_role 映射,性别=0,密码=系统默认密码。""" + email = (supplier_user.user_email or "").strip() + if not email: + raise ValidationError(detail={"user_email": ["联络人邮箱不能为空(系统登录账号将使用该邮箱)"]}) + + if Users.objects.filter(username=email).exists(): + raise ValidationError( + detail={ + "user_email": ["该邮箱已作为系统用户账号存在,请更换联络人邮箱或联系管理员处理已有账号"] + } + ) + + dept = Dept.objects.filter(key=SUPPLIER_DEPT_KEY, status=True).first() or Dept.objects.filter( + key=SUPPLIER_DEPT_KEY + ).first() + if not dept: + raise ValidationError( + detail={ + "_dept": [f'未找到部门 key="{SUPPLIER_DEPT_KEY}"(供应商),请先在「部门管理」中维护'] + } + ) + + role_key = _system_role_key_from_supplier_role(supplier_user.supplier_role) + role = Role.objects.filter(key=role_key, status=True).first() or Role.objects.filter(key=role_key).first() + if not role: + raise ValidationError( + detail={"supplier_role": [f'未找到系统角色 key="{role_key}",请先在「角色管理」中维护']} + ) + + plain = _default_system_password_plain() + md5_password = hashlib.md5(plain.encode("utf-8")).hexdigest() + hashed = make_password(md5_password) + + is_active = bool(supplier_user.status) + + user = Users( + username=email, + email=email, + name=(supplier_user.user_name or "").strip() or email, + mobile=(supplier_user.user_phone or "").strip() or "", + gender=0, + dept=dept, + dept_belong_id=str(dept.id) if dept.id is not None else None, + is_active=is_active, + ) + user.password = hashed + if request and getattr(request, "user", None) and str(request.user) != "AnonymousUser": + user.creator = request.user + + try: + user.save() + except IntegrityError as e: + raise ValidationError( + detail={"user_email": ["该邮箱已作为系统用户账号存在(并发冲突),请重试或更换邮箱"]} + ) from e + + user.role.add(role) + if dept.id is not None and not user.manage_dept.filter(pk=dept.pk).exists(): + user.manage_dept.add(dept.id) + class SupplierUserSerializer(CustomModelSerializer): """供应商用户信息""" @@ -61,7 +149,7 @@ class SupplierViewSet(CustomModelViewSet): class SupplierUserViewSet(CustomModelViewSet): - """供应商用户信息(独立表;按 supplier_id 筛选可得到某供应商下全部联系人)""" + """供应商用户信息(独立表;按 supplier_id 筛选可得到某供应商下全部联系人)。新建时在系统用户表同步创建账号。""" queryset = SupplierUser.objects.all() serializer_class = SupplierUserSerializer @@ -70,3 +158,8 @@ class SupplierUserViewSet(CustomModelViewSet): filter_fields = ["supplier_id", "supplier_role", "status", "user_email"] search_fields = ["supplier_id", "supplier_name", "user_email", "user_name", "user_phone"] ordering = ["-create_datetime", "id"] + + def perform_create(self, serializer): + with transaction.atomic(): + instance = serializer.save() + _sync_create_system_user_for_supplier(instance, self.request) diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index 9f90360..47264bc 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -373,16 +373,6 @@ class Inquiry(CoreModel): else None ) submitter_name = (getattr(qm_submit, "supplier_name", None) or "").strip() or "—" - RFQOperationLogs.try_append( - inquiry_no=inq.inquiry_no, - purchase_type=int(inq.purchase_type), - operation_type=6, - operation_user=username, - quotation_no=qn, - per_status=old_status, - cur_status=5, - operation_desc=f"供应商({submitter_name})提交报价,受邀供应商均提交报价,询价单报价结束", - ) try: from apps.pisadmin.basicinfo.views.email_utils import send_quote_ended_notice_to_purchaser diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index 0abcc1e..8917c2e 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -85,11 +85,10 @@ def build_invited_supplier_quotation_rows(inquiry) -> list: rows_out.append( { - "operation_time": op_time or "—", "supplier_full_name": full_name, - "operator": op_user, "quotation_status": _quotation_master_status_label(qm.status), - "operation_desc": "", + "operation_time": op_time or "—", + "operator": op_user, "quotation_no": (qm.quotation_no or "").strip(), } ) @@ -292,6 +291,7 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None: from collections import defaultdict spec_weight_candidates: dict[str, list[tuple[Decimal, str]]] = defaultdict(list) spec_unit_candidates: dict[str, list[tuple[Decimal, str]]] = defaultdict(list) + spec_quote_data: dict[str, dict[str, dict]] = {} for m in materials: qn_src = getattr(m, "quotation_no_id", None) or "" @@ -326,25 +326,26 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None: except Exception: pass - # 杂采材料信息:单价适用于所有材质分组 - misc_unit_candidates: list[tuple[Decimal, str]] = [] - for mi in MiscProcurementMaterialInfo.objects.filter(status=1).only("price", "factory"): - if mi.price is not None: - try: - up = Decimal(str(mi.price)) - fac = (getattr(mi, "factory", None) or "").strip() or "MISC" - misc_unit_candidates.append((up, fac)) - except Exception: - pass + # 杂采材料信息:按「材质」与报价 material_spec 对齐;不得把其它材质的最低单价并入本组 + misc_by_spec: dict[str, list[tuple[Decimal, str]]] = defaultdict(list) + for mi in MiscProcurementMaterialInfo.objects.filter(status=1).only("price", "factory", "materialtype"): + if mi.price is None: + continue + mat = (getattr(mi, "materialtype", None) or "").strip() + if not mat: + continue + try: + up = Decimal(str(mi.price)) + fac = (getattr(mi, "factory", None) or "").strip() or "MISC" + misc_by_spec[mat].append((up, fac)) + except Exception: + pass qty_dec = _rfq_qty_decimal(inquiry, pid) total_material_low = Decimal("0") all_src_w_list: list[str] = [] all_src_up_list: list[str] = [] - # 收集每个材质规格的报价数据,用于按材质分组判断是否所有报价单相等 - spec_quote_data: dict[str, dict[str, dict]] = {} - # 获取所有材质规格(有重量或单价数据的) all_specs = set(spec_weight_candidates.keys()) | set(spec_unit_candidates.keys()) @@ -352,8 +353,8 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None: weight_candidates = spec_weight_candidates.get(spec, []) unit_candidates = spec_unit_candidates.get(spec, []) - # 杂采单价也加入该分组的单价候选 - unit_candidates = unit_candidates + misc_unit_candidates + # 仅与同 material_spec 一致的杂采材质行参与该组最低价 + unit_candidates = unit_candidates + list(misc_by_spec.get(spec, [])) # 按材质分组判断所有报价单是否相等 spec_quote_values = spec_quote_data.get(spec, {}) @@ -404,10 +405,10 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None: src_w = earliest_qn src_up = earliest_qn - # 判断是否来自杂采材料信息 + # 判断是否来自杂采材料信息(本材质分组) is_misc_src = False - if min_up is not None and misc_unit_candidates: - for misc_up, misc_fac in misc_unit_candidates: + if min_up is not None and misc_by_spec.get(spec): + for misc_up, misc_fac in misc_by_spec[spec]: if abs(min_up - misc_up) < Decimal("0.0001"): is_misc_src = True break diff --git a/web/src/views/pisadmin/basicinfo/supplier_user/crud.tsx b/web/src/views/pisadmin/basicinfo/supplier_user/crud.tsx index 74a885f..d985bb4 100644 --- a/web/src/views/pisadmin/basicinfo/supplier_user/crud.tsx +++ b/web/src/views/pisadmin/basicinfo/supplier_user/crud.tsx @@ -99,9 +99,13 @@ export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProp addRequest: async ({ form }) => { try { await ensureUserEmailUnique(form.supplier_id, form.user_email) - return await api.AddObj(form) + const res = await api.AddObj(form) + ElMessage.success( + '保存成功;已同步创建系统用户(登录账号为联络人邮箱,初始密码为系统默认密码,部门:供应商)' + ) + return res } catch (err: any) { - ElMessage.error(err?.message || '保存失败') + ElMessage.error(err?.msg || err?.message || '保存失败') throw err } }, @@ -110,7 +114,7 @@ export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProp await ensureUserEmailUnique(form.supplier_id, form.user_email, row.id) return await api.UpdateObj({ ...form, id: row.id }) } catch (err: any) { - ElMessage.error(err?.message || '保存失败') + ElMessage.error(err?.msg || err?.message || '保存失败') throw err } }, diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts index d69ecad..a39f5f4 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/api.ts @@ -20,7 +20,7 @@ export const SubmitPriceAuditObj = (id: string | number) => request({ url: `${ba /** 询价单操作日志(按主键,返回该单 inquiry_no 下全部记录,时间倒序) */ export const GetOperationLogs = (id: string | number) => request({ url: `${baseUrl}${id}/operation_logs/`, method: 'get' }) -/** 受邀供应商报价单一览(与发布生成的报价主表一致,操作日志嵌套表用) */ +/** 受邀供应商一览(与发布生成的报价主表一致,操作日志嵌套表用) */ export const GetInvitedSupplierQuotations = (id: string | number) => request({ url: `${baseUrl}${id}/invited_supplier_quotations/`, method: 'get' }) /** 查询杂采议价记录(比价议价价格存此表,非报价明细「中标价格」) */ diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue index f68351a..782fa06 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue @@ -216,9 +216,7 @@