diff --git a/backend/apps/pisadmin/miscprocurement/cost_template_builder.py b/backend/apps/pisadmin/miscprocurement/cost_template_builder.py index 95d3aa1..d37ef44 100644 --- a/backend/apps/pisadmin/miscprocurement/cost_template_builder.py +++ b/backend/apps/pisadmin/miscprocurement/cost_template_builder.py @@ -30,6 +30,10 @@ purchaser_required: 采购人是否必填(1=是, 0=否) supplier_required: 供应商操作(0-6) remark: 字段备注 + +说明:独立成本模板在 PIS 中「新版本」派生使用 serializers.create_cost_template_new_version(同 template_no、 +version 递增;若提交明细与源版本规范化后一致则拒绝创建);确认新版本时由 CostEstimateTemplateViewSet.confirm +将同编号更低版本主表行置为作废(2)。本模块将前端 sections 展开后写入 `CostEstimateTemplateBody`。 """ from typing import Dict, List, Optional, Tuple @@ -281,9 +285,13 @@ class CostTemplateBuilder: create_time = timezone.now() # 批量创建 body 记录 + head_ver = getattr(template_head, "version", None) + if head_ver is None: + head_ver = 1 bulk_objects = [ CostEstimateTemplateBody( - template_no=template_head, + template_no=template_head.template_no, + version=head_ver, create_user=create_user, create_time=create_time, update_user=create_user, diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index 1fe62e3..689ff32 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -64,38 +64,15 @@ class MiscProcMaterial(CoreModel): return self.partid or self.partid_name -class PriceTemplate(CoreModel): - PROCUREMENT_CHOICES = ( - ("strategic", "strategic"), - ("misc", "misc"), - ) - CATEGORY_CHOICES = ( - ("tooling", "tooling"), - ("stamping", "stamping"), - ("injection", "injection"), - ) - - code = models.CharField(max_length=32, unique=True, db_index=True, verbose_name="模板编号") - name = models.CharField(max_length=128, verbose_name="模板名称") - procurement_category = models.CharField(max_length=32, choices=PROCUREMENT_CHOICES, db_index=True, verbose_name="采购类别") - category = models.CharField(max_length=32, choices=CATEGORY_CHOICES, db_index=True, verbose_name="产品类别") - remark = models.TextField(null=True, blank=True, verbose_name="备注") - active = models.BooleanField(default=True, verbose_name="是否有效") - enable_cost_structure = models.BooleanField(default=True, verbose_name="启用成本结构") - # `sections` 字段已弃用:成本结构改为写入独立的成本模板主/明细表 - - class Meta: - db_table = table_prefix + "proc_price_template" - verbose_name = "价格模板" - verbose_name_plural = verbose_name - ordering = ("-update_datetime", "-id") - - def __str__(self) -> str: # pragma: no cover - simple repr - return self.name - - class CostEstimateTemplateHead(models.Model): - template_no = models.CharField(max_length=20, unique=True, db_column="TemplateNo", verbose_name="模板编号") + """成本估算模板主表;确认某一版本时,同 template_no 下更低版本的主表行由接口批量改为作废(2)。""" + STATUS_CHOICES = ( + (0, "未确认"), + (1, "已确认"), + (2, "作废"), + ) + + template_no = models.CharField(max_length=20, db_index=True, db_column="TemplateNo", verbose_name="模板编号") template_name = models.CharField(max_length=50, db_column="TemplateName", verbose_name="模板名称") procurement_category = models.CharField(max_length=4, db_column="ProcurementCategory", verbose_name="采购类别:1-策采;2-杂采") is_bom = models.CharField(max_length=4, db_column="IsBom", verbose_name="启用BOM否") @@ -108,16 +85,35 @@ class CostEstimateTemplateHead(models.Model): create_time = models.DateTimeField(db_column="CreateTime", null=True, blank=True, verbose_name="创建时间") update_user = models.CharField(max_length=20, db_column="UpdateUser", null=True, blank=True, verbose_name="最后更新人") update_time = models.DateTimeField(db_column="UpdateTime", null=True, blank=True, verbose_name="最后更新时间") + version = models.IntegerField(db_column="Version", default=1, verbose_name="版本号") + status = models.IntegerField(db_column="Status", default=0, verbose_name="状态", choices=STATUS_CHOICES) class Meta: db_table = "t_CostEstimate_Template_Head" verbose_name = "成本估算模板主表" verbose_name_plural = verbose_name ordering = ("-update_time", "-id") + indexes = [ + models.Index(fields=["template_no", "version"], name="idx_cost_template_head_no_ver"), + ] + constraints = [ + models.UniqueConstraint( + fields=("template_no", "version"), + name="uq_cost_template_head_no_ver", + ), + ] def __str__(self) -> str: # pragma: no cover - simple repr return self.template_name + @property + def items(self): + """明细表按 (template_no, version) 与当前主表行对齐(非 ORM 反向关联)。""" + return CostEstimateTemplateBody.objects.filter( + template_no=self.template_no, + version=self.version, + ).order_by("item_order", "id") + class CostEstimateTemplateBody(models.Model): COST_CATEGORY_CHOICES = ( @@ -140,14 +136,8 @@ class CostEstimateTemplateBody(models.Model): (6, "可空"), ) - template_no = models.ForeignKey( - CostEstimateTemplateHead, - to_field="template_no", - db_column="TemplateNo", - on_delete=models.CASCADE, - related_name="items", - verbose_name="模板编号", - ) + # 与主表通过 (template_no, version) 逻辑关联;主表上该组合唯一(见 CostEstimateTemplateHead.Meta.constraints) + template_no = models.CharField(max_length=20, db_column="TemplateNo", verbose_name="模板编号") cost_category = models.CharField(max_length=4, choices=COST_CATEGORY_CHOICES, db_column="CostCategory", verbose_name="成本类别") item_order = models.IntegerField(db_column="ItemOrder", verbose_name="排序序号") item_no = models.CharField(max_length=100, db_column="ItemNo", verbose_name="项次编号") @@ -163,12 +153,16 @@ class CostEstimateTemplateBody(models.Model): create_time = models.DateTimeField(db_column="CreateTime", null=True, blank=True, verbose_name="创建时间") update_user = models.CharField(max_length=20, db_column="UpdateUser", null=True, blank=True, verbose_name="最后更新人") update_time = models.DateTimeField(db_column="UpdateTime", null=True, blank=True, verbose_name="最后更新时间") + version = models.IntegerField(db_column="Version", default=1, verbose_name="版本号") class Meta: db_table = "t_CostEstimate_Template_Body" verbose_name = "成本估算模板明细" verbose_name_plural = verbose_name ordering = ("item_order", "id") + indexes = [ + models.Index(fields=["template_no", "version"], name="idx_cost_template_body_no_ver"), + ] def __str__(self) -> str: # pragma: no cover - simple repr return self.item_no diff --git a/backend/apps/pisadmin/miscprocurement/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index a17818c..6fa5c64 100644 --- a/backend/apps/pisadmin/miscprocurement/serializers.py +++ b/backend/apps/pisadmin/miscprocurement/serializers.py @@ -1,6 +1,7 @@ import json from django.db import transaction +from django.db.models import Max from django.utils import timezone from rest_framework import serializers @@ -8,7 +9,6 @@ from .models import ( MiscProcurementMaterialInfo, MiscProcurementStationInfo, MiscProcMaterial, - PriceTemplate, Inquiry, InquirySupplier, InquiryAttachment, @@ -22,7 +22,6 @@ from .models import ( ) from apps.pisadmin.basicinfo.models import Unit from dvadmin.utils.serializers import CustomModelSerializer -from .cost_template_builder import CostTemplateBuilder def get_request_username(serializer) -> str: @@ -39,6 +38,21 @@ def get_request_username(serializer) -> str: ) +def get_request_username_from_request(request) -> str: + if not request: + return "" + user = getattr(request, "user", None) + if not user: + return "" + return ( + getattr(user, "username", "") + or getattr(user, "name", "") + or getattr(user, "nick_name", "") + or getattr(user, "nickname", "") + or "" + ) + + def normalize_unit_code(value): text = str(value or "").strip() if not text: @@ -221,160 +235,6 @@ class MiscPartCreateUpdateSerializer(CustomModelSerializer): fields = "__all__" -class PriceTemplateSerializer(CustomModelSerializer): - """ - 将前端传入的成本结构写入独立的成本模板主/明细表(`CostEstimateTemplateHead` / `CostEstimateTemplateBody`)。 - `sections` 字段已弃用,调用方仍可传 `sections` 或 `templateLines` 兼容旧前端。 - """ - - def _generate_code(self) -> str: - date_part = timezone.now().strftime("%Y%m%d") - base = f"CT{date_part}" - with transaction.atomic(): - last = ( - PriceTemplate.objects.select_for_update() - .filter(code__startswith=base) - .order_by("-code") - .first() - ) - if last and last.code and len(last.code) >= len(base) + 3: - try: - seq = int(last.code[-3:]) + 1 - except ValueError: - seq = 1 - else: - seq = 1 - if seq > 999: - raise serializers.ValidationError("当日编号已达上限,请联系管理员") - return f"{base}{seq:03d}" - - class Meta: - model = PriceTemplate - fields = "__all__" - read_only_fields = ["id", "create_datetime", "update_datetime", "creator", "modifier"] - extra_kwargs = {"code": {"required": False, "allow_blank": True, "allow_null": True}} - - def _map_procurement_category(self, val: str) -> str: - # 将本项目的采购类别映射到成本模板的取值(1/2)。根据需要调整映射规则。 - if val == "strategic": - return "1" - if val == "misc": - return "2" - return "1" - - def _create_cost_template(self, price_obj: PriceTemplate, sections): - """ - 根据前端发送的 sections 数据创建成本模板 - - 使用 CostTemplateBuilder 工具类将分层结构的 sections 数据展开, - 逐条创建 CostEstimateTemplateBody 明细记录。 - - 数据流: - 前端 sections (分层结构) - ↓ (展开) - CostEstimateTemplateHead (主表,一条) - ↓ - CostEstimateTemplateBody (明细表,多条,逐行插入) - """ - if not sections: - return None - - # 获取当前请求用户信息 - request = self.context.get("request") - current_user = getattr(request.user, "username", "") if request and request.user else "" - current_time = timezone.now() - - # 生成唯一的 template_no - date_part = timezone.now().strftime("%Y%m%d") - base = f"CT{date_part}" - with transaction.atomic(): - last = ( - CostEstimateTemplateHead.objects.select_for_update() - .filter(template_no__startswith=base) - .order_by("-template_no") - .first() - ) - if last and last.template_no and len(last.template_no) >= len(base) + 3: - try: - seq = int(last.template_no[-3:]) + 1 - except ValueError: - seq = 1 - else: - seq = 1 - if seq > 999: - seq = 1 - template_no = f"{base}{seq:03d}" - - # 创建成本模板主表记录,并设置审计字段 - head = CostEstimateTemplateHead.objects.create( - template_no=template_no, - template_name=price_obj.name, - procurement_category=self._map_procurement_category(price_obj.procurement_category), - is_bom="N", - acti="Y", - template_desc=(getattr(price_obj, "remark", None) or ""), - is_can_add_materials=0, - is_can_add_process=0, - create_user=current_user, - create_time=current_time, - update_user=current_user, - update_time=current_time, - ) - - # 使用 CostTemplateBuilder 将 sections 数据展开,逐条创建明细 - # 传递 create_user 和 create_time 以填充审计字段 - created_count = CostTemplateBuilder.create_cost_template_body_records( - head, - sections, - create_user=current_user, - create_time=current_time - ) - - # 可选:调试输出(取消注释以启用) - # CostTemplateBuilder.debug_print_sections_expansion(sections) - - return head - - def create(self, validated_data): - # 兼容旧前端:从 request 中读取 sections/templateLines - request = self.context.get("request") - sections = None - if request is not None: - sections = request.data.get("sections") or request.data.get("templateLines") - - if not validated_data.get("code"): - validated_data["code"] = self._generate_code() - - price = super().create(validated_data) - - # 将成本结构写入独立表(以 PriceTemplate.code 为唯一关联键) - if sections: - try: - self._create_cost_template(price, sections) - except Exception as e: - # 不吞异常,避免“前端显示成功但数据库未写入” - raise serializers.ValidationError(f"成本结构模板写入失败:{e}") - - return price - - def update(self, instance, validated_data): - request = self.context.get("request") - sections = None - if request is not None: - sections = request.data.get("sections") or request.data.get("templateLines") - - price = super().update(instance, validated_data) - - # 更新时:对同一 template_no(=PriceTemplate.code) 做 upsert,并重建明细 - if sections: - try: - self._create_cost_template(price, sections) - except Exception as e: - raise serializers.ValidationError(f"成本结构模板更新失败:{e}") - - return price - - class CostEstimateTemplateBodySerializer(serializers.ModelSerializer): supplier_behavior = serializers.SerializerMethodField() item_name_en = serializers.CharField(required=False, allow_blank=True, allow_null=True) @@ -417,11 +277,14 @@ class CostEstimateTemplateBodySerializer(serializers.ModelSerializer): "supplier_required", "supplier_behavior", "remark", + "version", ] class CostEstimateTemplateHeadSerializer(CustomModelSerializer): - template_no = serializers.CharField(required=False, allow_blank=True, allow_null=True) + # 模型上 template_no 无 blank=True,ModelSerializer 自动字段会 required=True; + # 创建时由 create()/_generate_template_no() 写入,故对入参只读,避免「模板编号必填」校验错误。 + template_no = serializers.CharField(read_only=True) template_desc = serializers.CharField(required=False, allow_blank=True, allow_null=True) items = CostEstimateTemplateBodySerializer(many=True, required=False) @@ -437,6 +300,8 @@ class CostEstimateTemplateHeadSerializer(CustomModelSerializer): "template_desc", "is_can_add_materials", "is_can_add_process", + "version", + "status", "create_user", "create_time", "update_user", @@ -445,6 +310,19 @@ class CostEstimateTemplateHeadSerializer(CustomModelSerializer): ] read_only_fields = ["id", "create_time", "update_time"] + def validate_status(self, value): + if value is None: + return value + allowed = {c[0] for c in CostEstimateTemplateHead.STATUS_CHOICES} + try: + v = int(value) + except (TypeError, ValueError): + raise serializers.ValidationError("状态必须为整数") + if v not in allowed: + choices_txt = ";".join(f"{code}({label})" for code, label in CostEstimateTemplateHead.STATUS_CHOICES) + raise serializers.ValidationError(f"状态无效,允许:{choices_txt}") + return v + def _generate_template_no(self) -> str: date_part = timezone.now().strftime("%Y%m%d") base = f"CT{date_part}" @@ -467,7 +345,11 @@ class CostEstimateTemplateHeadSerializer(CustomModelSerializer): return f"{base}{seq:03d}" def _upsert_items(self, head: CostEstimateTemplateHead, items, current_user: str = "", current_time=None): - CostEstimateTemplateBody.objects.filter(template_no=head).delete() + # 同一模板编号下可多版本;只删除当前主表行对应版本号的明细,避免误删其它版本 + head_ver = getattr(head, "version", None) + if head_ver is None: + head_ver = 1 + CostEstimateTemplateBody.objects.filter(template_no=head.template_no, version=head_ver).delete() bulk = [] if current_time is None: current_time = timezone.now() @@ -503,9 +385,14 @@ class CostEstimateTemplateHeadSerializer(CustomModelSerializer): item_name_cn = (item.get("item_name_cn") or item.get("itemNameCn") or "").strip() or "" item_name_en = (item.get("item_name_en") or item.get("itemNameEn") or "").strip() or None item_name_vn = (item.get("item_name_vn") or item.get("itemNameVn") or "").strip() or None + body_version = item.get("version") + if body_version is None or body_version == "": + body_version = getattr(head, "version", 1) or 1 + else: + body_version = to_int(body_version, getattr(head, "version", 1) or 1) bulk.append( CostEstimateTemplateBody( - template_no=head, + template_no=head.template_no, cost_category=item.get("cost_category", ""), item_order=item.get("item_order", idx), item_no=item.get("item_no", str(idx)), @@ -522,6 +409,7 @@ class CostEstimateTemplateHeadSerializer(CustomModelSerializer): is_computed=is_computed, ), remark=item.get("remark"), + version=body_version, create_user=current_user or None, create_time=current_time, update_user=current_user or None, @@ -537,6 +425,8 @@ class CostEstimateTemplateHeadSerializer(CustomModelSerializer): current_time = timezone.now() if not validated_data.get("template_no"): validated_data["template_no"] = self._generate_template_no() + if "version" not in validated_data or validated_data.get("version") is None: + validated_data["version"] = 1 validated_data["create_user"] = current_user or validated_data.get("create_user") validated_data["create_time"] = current_time validated_data["update_user"] = current_user or validated_data.get("update_user") @@ -560,6 +450,171 @@ class CostEstimateTemplateHeadSerializer(CustomModelSerializer): return instance +class CostEstimateTemplateNewVersionSerializer(serializers.Serializer): + """ + 已确认模板派生新版本:沿用同一 template_no,版本号由服务端在同编号下取 max(version)+1; + 与「添加」初始模板(自动生成新编号)的业务入口分离。 + """ + + template_name = serializers.CharField(max_length=50) + procurement_category = serializers.CharField(max_length=4) + is_bom = serializers.CharField(max_length=4, required=False, default="Y") + acti = serializers.CharField(max_length=4, required=False, allow_blank=True, default="Y") + template_desc = serializers.CharField(required=False, allow_blank=True, allow_null=True, default="") + is_can_add_materials = serializers.IntegerField(required=False, default=0) + is_can_add_process = serializers.IntegerField(required=False, default=0) + items = CostEstimateTemplateBodySerializer(many=True, required=False) + + +def _cost_item_to_int(v, default=0): + """与 CostEstimateTemplateHeadSerializer._upsert_items 内 to_int 一致,用于明细比对。""" + if v is None or v == "": + return default + if isinstance(v, bool): + return 1 if v else 0 + try: + return int(v) + except Exception: + if isinstance(v, str): + lv = v.strip().lower() + if lv in ("true", "yes", "y", "on"): + return 1 + if lv in ("false", "no", "n", "off"): + return 0 + return default + + +def _cost_template_item_row_tuple_from_dict(item: dict, idx: int) -> tuple: + """将请求体中的单条明细规范为与落库语义一致的比对元组(不含 template_no/version/id)。""" + is_computed_raw = item.get("is_computed", None) + if is_computed_raw is None: + is_computed_raw = item.get("isComputed", item.get("autoFill", 0)) + is_computed = _cost_item_to_int(is_computed_raw, 0) + purchaser_required = normalize_purchaser_required(item.get("purchaser_required", 0), is_computed=is_computed) + supplier_required = normalize_supplier_required( + item.get("supplier_required", item.get("supplier_behavior")), + purchaser_required=purchaser_required, + supplier_editable=item.get("supplier_editable"), + is_computed=is_computed, + ) + item_order = _cost_item_to_int(item.get("item_order", idx), idx) + return ( + str(item.get("cost_category", "") or ""), + item_order, + str(item.get("item_no", str(idx)) or ""), + (item.get("item_name_cn") or item.get("itemNameCn") or "").strip(), + (item.get("item_name_en") or item.get("itemNameEn") or "").strip(), + (item.get("item_name_vn") or item.get("itemNameVn") or "").strip(), + _cost_item_to_int(item.get("is_fixed", item.get("item_category", 0)), 0), + is_computed, + purchaser_required, + supplier_required, + (item.get("remark") or "").strip(), + ) + + +def _cost_template_item_row_tuple_from_body(obj: CostEstimateTemplateBody) -> tuple: + is_computed = _cost_item_to_int(getattr(obj, "is_computed", 0), 0) + purchaser_required = normalize_purchaser_required(getattr(obj, "purchaser_required", 0), is_computed=is_computed) + supplier_required = normalize_supplier_required( + getattr(obj, "supplier_required", 0), + purchaser_required=purchaser_required, + supplier_editable=None, + is_computed=is_computed, + ) + return ( + str(getattr(obj, "cost_category", "") or ""), + _cost_item_to_int(getattr(obj, "item_order", 0), 0), + str(getattr(obj, "item_no", "") or ""), + (getattr(obj, "item_name_cn", None) or "").strip(), + (getattr(obj, "item_name_en", None) or "").strip(), + (getattr(obj, "item_name_vn", None) or "").strip(), + _cost_item_to_int(getattr(obj, "is_fixed", 0), 0), + is_computed, + purchaser_required, + supplier_required, + (getattr(obj, "remark", None) or "").strip(), + ) + + +def _cost_template_items_compare_signature_from_payload(items_clean: list) -> tuple: + rows = [] + for idx, row in enumerate(items_clean or [], start=1): + if isinstance(row, dict): + rows.append(_cost_template_item_row_tuple_from_dict(row, idx)) + rows.sort(key=lambda t: (t[1], t[2])) + return tuple(rows) + + +def _cost_template_items_compare_signature_from_db(template_no: str, version: int) -> tuple: + qs = CostEstimateTemplateBody.objects.filter(template_no=template_no, version=version).order_by("item_order", "id") + rows = [_cost_template_item_row_tuple_from_body(r) for r in qs] + rows.sort(key=lambda t: (t[1], t[2])) + return tuple(rows) + + +@transaction.atomic +def create_cost_template_new_version( + source_head: CostEstimateTemplateHead, + validated: dict, + request, +) -> CostEstimateTemplateHead: + """ + 从已确认主表行创建新版本主表行并写入明细;不调用 HeadSerializer.create,不复用「初始模板」编号生成逻辑。 + """ + template_no = source_head.template_no + # 锁定同编号行,避免并发下 version 冲突 + list(CostEstimateTemplateHead.objects.select_for_update().filter(template_no=template_no)) + + items_in = validated.get("items") or [] + items_clean = [] + for row in items_in: + if isinstance(row, dict): + # 忽略客户端带的明细 version,一律与新建主表 version 对齐(避免与 max+1 不一致) + row = {k: v for k, v in row.items() if k not in ("id", "version")} + items_clean.append(row) + + prev_ver = int(getattr(source_head, "version", 1) or 1) + sig_new = _cost_template_items_compare_signature_from_payload(items_clean) + sig_old = _cost_template_items_compare_signature_from_db(template_no, prev_ver) + if sig_new == sig_old: + raise serializers.ValidationError( + {"items": ["新版本明细与上一版本相同,无需创建新版本"]} + ) + + agg = CostEstimateTemplateHead.objects.filter(template_no=template_no).aggregate(mx=Max("version")) + max_v = agg["mx"] + next_v = int(max_v if max_v is not None else 0) + 1 + + current_user = get_request_username_from_request(request) + current_time = timezone.now() + acti = validated.get("acti") + if acti in (None, ""): + acti = "Y" + is_bom = validated.get("is_bom") or "Y" + + head = CostEstimateTemplateHead.objects.create( + template_no=template_no, + template_name=validated["template_name"], + procurement_category=validated["procurement_category"], + is_bom=is_bom, + acti=acti, + template_desc=validated.get("template_desc") or "", + is_can_add_materials=int(validated.get("is_can_add_materials") or 0), + is_can_add_process=int(validated.get("is_can_add_process") or 0), + version=next_v, + status=0, + create_user=current_user or None, + create_time=current_time, + update_user=current_user or None, + update_time=current_time, + ) + + helper = CostEstimateTemplateHeadSerializer() + helper._upsert_items(head, items_clean, current_user=current_user, current_time=current_time) + return head + + class InquirySupplierSerializer(serializers.ModelSerializer): class Meta: model = InquirySupplier @@ -814,7 +869,18 @@ class InquirySerializer(CustomModelSerializer): class Meta: model = Inquiry fields = "__all__" - read_only_fields = ["id", "create_datetime", "update_datetime", "creator", "modifier"] + # CoreModel 审计 + 主表业务审计:禁止客户端写入篡改;create/update 中仍可向 validated_data 注入业务字段 + read_only_fields = [ + "id", + "create_datetime", + "update_datetime", + "creator", + "modifier", + "create_time", + "create_user", + "update_time", + "update_user", + ] extra_kwargs = {"inquiry_no": {"required": False, "allow_blank": True, "allow_null": True}} def _generate_code(self) -> str: diff --git a/backend/apps/pisadmin/miscprocurement/urls.py b/backend/apps/pisadmin/miscprocurement/urls.py index 5284b05..fae8b45 100644 --- a/backend/apps/pisadmin/miscprocurement/urls.py +++ b/backend/apps/pisadmin/miscprocurement/urls.py @@ -4,7 +4,6 @@ from .views import ( MiscMaterialViewSet, MiscStationViewSet, MiscPartViewSet, - PriceTemplateViewSet, InquiryViewSet, InquirySupplierViewSet, InquiryAttachmentViewSet, @@ -20,7 +19,6 @@ router = routers.SimpleRouter() router.register(r'misc_materials', MiscMaterialViewSet) router.register(r'misc_stations', MiscStationViewSet) router.register(r'misc_parts', MiscPartViewSet) -router.register(r'price_template', PriceTemplateViewSet) router.register(r'inquiry', InquiryViewSet) router.register(r'inquiry_supplier', InquirySupplierViewSet) router.register(r'inquiry_attachment', InquiryAttachmentViewSet) diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index 4cef5cc..fef5c1e 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -20,7 +20,6 @@ from .models import ( MiscProcurementMaterialInfo, MiscProcurementStationInfo, MiscProcMaterial, - PriceTemplate, Inquiry, InquirySupplier, InquiryAttachment, @@ -39,7 +38,6 @@ from .serializers import ( MiscStationCreateUpdateSerializer, MiscPartSerializer, MiscPartCreateUpdateSerializer, - PriceTemplateSerializer, InquirySerializer, InquirySupplierSerializer, InquiryAttachmentSerializer, @@ -49,6 +47,8 @@ from .serializers import ( InquiryProfitCostSerializer, InquiryRfqItemSerializer, CostEstimateTemplateHeadSerializer, + CostEstimateTemplateNewVersionSerializer, + create_cost_template_new_version, ) @@ -79,43 +79,127 @@ class MiscPartViewSet(CustomModelViewSet): ordering = ["-create_datetime"] -class PriceTemplateViewSet(CustomModelViewSet): - queryset = PriceTemplate.objects.all() - serializer_class = PriceTemplateSerializer - filter_fields = ("code", "name", "procurement_category", "category", "active", "enable_cost_structure") - search_fields = ("code", "name", "remark") - ordering = ("-update_datetime",) - - def perform_create(self, serializer): - serializer.save() - - def perform_update(self, serializer): - serializer.save() - - def perform_destroy(self, instance): - """ - 删除价格模板时,同步删除对应的成本结构模板主/明细表数据。 - 关联键:CostEstimateTemplateHead.template_no == PriceTemplate.code - """ - template_no = getattr(instance, "code", None) - if template_no: - CostEstimateTemplateHead.objects.filter(template_no=template_no).delete() - instance.delete() - - class CostEstimateTemplateViewSet(CustomModelViewSet): + """成本估算模板主表;status:0 未确认 / 1 已确认 / 2 作废(列表默认不含作废)。""" + queryset = CostEstimateTemplateHead.objects.all() serializer_class = CostEstimateTemplateHeadSerializer - filter_fields = ("template_no", "template_name", "procurement_category", "is_bom", "acti") + filter_fields = ("template_no", "template_name", "procurement_category", "is_bom", "acti", "version", "status") search_fields = ("template_no", "template_name", "template_desc") ordering = ("-update_time", "-id") + # 与 models.CostEstimateTemplateHead.STATUS_CHOICES 中「作废」取值一致 + TEMPLATE_STATUS_VOID = 2 + TEMPLATE_STATUS_DRAFT = 0 + TEMPLATE_STATUS_CONFIRMED = 1 + + @staticmethod + def _head_status_value(instance) -> int: + # status 为 0(未确认)时不能使用 (value or -1),否则 0 会被当成 falsy 变成 -1 + v = getattr(instance, "status", None) + if v is None: + return -1 + try: + return int(v) + except (TypeError, ValueError): + return -1 + + def get_queryset(self): + qs = super().get_queryset() + return qs.exclude(status=self.TEMPLATE_STATUS_VOID) + def perform_create(self, serializer): serializer.save() def perform_update(self, serializer): serializer.save() + def update(self, request, *args, **kwargs): + instance = self.get_object() + st = self._head_status_value(instance) + if st == self.TEMPLATE_STATUS_VOID: + return ErrorResponse(msg="模板已作废,不可修改") + if st == self.TEMPLATE_STATUS_CONFIRMED: + return ErrorResponse(msg="已确认模板不可修改") + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + instance = self.get_object() + st = self._head_status_value(instance) + if st == self.TEMPLATE_STATUS_VOID: + return ErrorResponse(msg="模板已作废,不可修改") + if st == self.TEMPLATE_STATUS_CONFIRMED: + return ErrorResponse(msg="已确认模板不可修改") + return super().partial_update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + return ErrorResponse(msg="成本模板不允许删除") + + @action(methods=['delete'], detail=False) + def multiple_delete(self, request, *args, **kwargs): + return ErrorResponse(msg="成本模板不允许删除") + + @action(methods=['post'], detail=True) + def confirm(self, request, *args, **kwargs): + """未确认(0) → 已确认(1)。同模板编号下 version 低于当前行的非作废主表行一律改为作废(2)。""" + instance = self.get_object() + cur = self._head_status_value(instance) + if cur == self.TEMPLATE_STATUS_VOID: + return ErrorResponse(msg="作废模板不可确认") + if cur != self.TEMPLATE_STATUS_DRAFT: + return ErrorResponse(msg="仅未确认状态的模板可确认") + now = timezone.now() + username = getattr(getattr(request, "user", None), "username", None) or "" + uname = str(username)[:20] if username else "" + + with transaction.atomic(): + qs_old = CostEstimateTemplateHead.objects.filter( + template_no=instance.template_no, + version__lt=instance.version, + ).exclude(status=self.TEMPLATE_STATUS_VOID) + voided_count = qs_old.count() + upd = {"status": self.TEMPLATE_STATUS_VOID, "update_time": now} + if uname: + upd["update_user"] = uname + qs_old.update(**upd) + + instance.status = self.TEMPLATE_STATUS_CONFIRMED + instance.update_time = now + if uname: + instance.update_user = uname + instance.save(update_fields=["status", "update_time", "update_user"]) + + serializer = self.get_serializer(instance) + msg = "确认成功,已作废旧版本" if voided_count else "确认成功" + return DetailResponse(data=serializer.data, msg=msg) + + @action(methods=["post"], detail=True, url_path="new_version") + def new_version(self, request, *args, **kwargs): + """已确认模板派生新版本:同 template_no,version=max+1,status=0;请求体校验见 CostEstimateTemplateNewVersionSerializer。""" + source = self.get_object() + if self._head_status_value(source) != self.TEMPLATE_STATUS_CONFIRMED: + return ErrorResponse(msg="仅已确认模板可创建新版本") + sz = CostEstimateTemplateNewVersionSerializer(data=request.data, context={"request": request}) + if not sz.is_valid(): + return ErrorResponse(msg="参数校验失败", data=sz.errors) + try: + head = create_cost_template_new_version(source, sz.validated_data, request) + except serializers.ValidationError as e: + detail = getattr(e, "detail", None) + if isinstance(detail, dict): + first_msg = None + for v in detail.values(): + if isinstance(v, list) and v: + first_msg = str(v[0]) + break + if isinstance(v, str) and v.strip(): + first_msg = v.strip() + break + return ErrorResponse(msg=first_msg or "创建新版本失败", data=detail) + return ErrorResponse(msg=str(detail or e)) + out = CostEstimateTemplateHeadSerializer(head, context={"request": request}) + return DetailResponse(data=out.data, msg="新版本创建成功") + class InquiryViewSet(CustomModelViewSet): queryset = Inquiry.objects.all().prefetch_related( @@ -275,9 +359,19 @@ class InquiryViewSet(CustomModelViewSet): if not template_no: return {} + # 与 pissupplier.build_cost_template_sections_for_quotation 一致:仅已确认主表,避免草稿/作废版本与供应商端展示脱节 + head = ( + CostEstimateTemplateHead.objects.filter(template_no=str(template_no).strip(), status=1) + .order_by("-version", "-id") + .first() + ) + if not head: + return {} + prefill_fields = {} queryset = CostEstimateTemplateBody.objects.filter( - template_no_id=template_no, + template_no=head.template_no, + version=head.version, ).filter(Q(is_computed=1) | Q(supplier_required__in=(1, 2))).values( "cost_category", "item_no", "item_name_cn" ) @@ -344,14 +438,23 @@ class InquiryViewSet(CustomModelViewSet): g["supplier_name"] = self._clip(g.get("supplier_code") or "", 20) return groups + def _ensure_inquiry_has_supplier_groups(self, inquiry: Inquiry, *, empty_message: str): + """ + 仅在「确认」时调用:供应商子表经 `_group_inquiry_suppliers` 汇总后须非空。 + 「发布」仅允许在已确认后进行,不再重复校验名单(与业务约定一致)。 + """ + groups = self._group_inquiry_suppliers(inquiry) + if not groups: + raise serializers.ValidationError(empty_message) + return groups + def _create_supplier_quotations(self, inquiry: Inquiry): """ 询价单发布:按供应商子表汇总结果,为每个供应商创建一张 `QuotationMaster`(及子表明细), 主表 `supplier_code`、`supplier_name` 与联系人信息与 `InquirySupplier` 对应数据一致(见 `_group_inquiry_suppliers`)。 + 名单是否在空已在确认环节校验,此处不再校验。 """ supplier_groups = self._group_inquiry_suppliers(inquiry) - if not supplier_groups: - raise serializers.ValidationError("请先维护询价单供应商名单,再执行发布") QuotationMaster.objects.filter(inquiry_no=inquiry.inquiry_no).delete() @@ -479,11 +582,11 @@ class InquiryViewSet(CustomModelViewSet): for row in inquiry_items: if row.part_id in part_ids: - product_name_src = self._pick_prefill_value( - prefill_fields, "7", "product_name", row.product_name, empty_default="" - ) - unit_src = self._pick_prefill_value(prefill_fields, "7", "unit", row.unit, empty_default="") - qty_src = self._pick_prefill_value(prefill_fields, "7", "qty", row.qty, empty_default=0) + # 上阶物料核心字段与询价 `InquiryRfqItem` 一致,发布时始终带入报价单(不依赖模板 prefill 是否勾选「产品明细」列) + product_name_src = row.product_name + unit_src = row.unit + qty_src = row.qty + unit_price_src = row.unit_price item_bulk.append( QuotationItem( quotation_no=quotation, @@ -492,7 +595,7 @@ class InquiryViewSet(CustomModelViewSet): unit=self._clip(unit_src or "", 10), qty=int(qty_src) if qty_src is not None else 0, is_bom=row.is_bom or 0, - unit_price=self._pick_prefill_value(prefill_fields, "7", "unit_price", row.unit_price), + unit_price=unit_price_src, product_cost=self._pick_prefill_value(prefill_fields, "7", "product_cost", None), total_material_cost=self._pick_prefill_value(prefill_fields, "7", "total_material_cost", row.total_material_cost), total_processing_cost=self._pick_prefill_value(prefill_fields, "7", "total_processing_cost", row.total_processing_cost), @@ -563,6 +666,17 @@ class InquiryViewSet(CustomModelViewSet): instance = self.get_object() if int(instance.status or self.STATUS_OPEN) != self.STATUS_OPEN: return ErrorResponse(msg="只有“开立”状态的询价单才能确认") + try: + self._ensure_inquiry_has_supplier_groups( + instance, empty_message="请先维护询价单供应商名单后再确认" + ) + except serializers.ValidationError as exc: + detail = getattr(exc, "detail", None) + if isinstance(detail, (list, tuple)) and detail: + msg = str(detail[0]) + else: + msg = str(detail or exc) + return ErrorResponse(msg=msg) current_user = self._get_request_username() or None current_time = timezone.now() return self._save_status( diff --git a/backend/apps/pissupplier/models.py b/backend/apps/pissupplier/models.py index 99d3119..2bf297a 100644 --- a/backend/apps/pissupplier/models.py +++ b/backend/apps/pissupplier/models.py @@ -33,6 +33,7 @@ class QuotationMaster(models.Model): contact_phone = models.CharField( max_length=20, null=True, blank=True, verbose_name="联系人电话" ) contact_email = models.CharField( max_length=20, null=True, blank=True, verbose_name="联系人邮件" ) quote_deadline = models.CharField( max_length=20, null=True, blank=True, verbose_name="报价截止时间" ) + validity_days = models.IntegerField( null=True, blank=True, verbose_name="有效天数" ) delivery_days = models.IntegerField( null=True, blank=True, verbose_name="交货周期" ) payment_method = models.IntegerField( choices=PAYMENT_METHOD_CHOICES, null=True, blank=True, verbose_name="付款方式" ) status = models.IntegerField( @@ -169,9 +170,9 @@ class QuotationProcess(models.Model): blank=True, verbose_name="加工工站", ) - unit = models.DecimalField( - max_digits=12, - decimal_places=4, + # 单位为文本(如 PCS/小时/cm),与 miscprocurement.InquiryProcessCost 一致 + unit = models.CharField( + max_length=20, db_column="Unit", null=True, blank=True, diff --git a/backend/apps/pissupplier/serializers.py b/backend/apps/pissupplier/serializers.py index fc1196e..5d579f7 100644 --- a/backend/apps/pissupplier/serializers.py +++ b/backend/apps/pissupplier/serializers.py @@ -5,7 +5,11 @@ from django.utils import timezone from rest_framework import serializers from dvadmin.utils.serializers import CustomModelSerializer -from apps.pisadmin.miscprocurement.models import CostEstimateTemplateHead, Inquiry +from apps.pisadmin.miscprocurement.models import ( + CostEstimateTemplateBody, + CostEstimateTemplateHead, + Inquiry, +) from apps.pissupplier.models import ( QuotationMaster, QuotationAttachment, @@ -96,13 +100,17 @@ def build_cost_template_sections_for_quotation(template_no: str): if not template_no: return [] head = ( - CostEstimateTemplateHead.objects.filter(template_no=str(template_no).strip()) - .prefetch_related("items") + CostEstimateTemplateHead.objects.filter(template_no=str(template_no).strip(), status=1) + .order_by("-version", "-id") .first() ) if not head: return [] - items = list(head.items.all().order_by("item_order", "id")) + items = list( + CostEstimateTemplateBody.objects.filter(template_no=head.template_no, version=head.version).order_by( + "item_order", "id" + ) + ) group = {} for it in items: cat = str(it.cost_category or "") @@ -372,6 +380,7 @@ class QuotationMasterCreateUpdateSerializer(BusinessAuditSerializer): contact_phone = serializers.CharField(max_length=20, required=False, allow_blank=True, allow_null=True) contact_email = serializers.CharField(max_length=20, required=False, allow_blank=True, allow_null=True) quote_deadline = serializers.CharField(max_length=20, required=False, allow_blank=True, allow_null=True) + validity_days = serializers.IntegerField(required=False, allow_null=True) delivery_days = serializers.IntegerField(required=False, allow_null=True) payment_method = serializers.IntegerField(required=False, allow_null=True) status = serializers.IntegerField(required=False, allow_null=True) @@ -390,7 +399,8 @@ class QuotationMasterCreateUpdateSerializer(BusinessAuditSerializer): class Meta: model = QuotationMaster fields = "__all__" - read_only_fields = ["autoid"] + # 创建人/创建时间仅发布生成或首次创建时由服务端写入;禁止 PUT 用详情回传覆盖 + read_only_fields = ["autoid", "createuser", "creattime"] def _apply_business_audit(self, validated_data, *, is_create): validated_data = super()._apply_business_audit(validated_data, is_create=is_create) diff --git a/web/src/views/pisadmin/miscprocurement/pricetemplate/SectionBuilder.vue b/web/src/views/pisadmin/miscprocurement/cost_template/SectionBuilder.vue similarity index 90% rename from web/src/views/pisadmin/miscprocurement/pricetemplate/SectionBuilder.vue rename to web/src/views/pisadmin/miscprocurement/cost_template/SectionBuilder.vue index 4da1254..052de2e 100644 --- a/web/src/views/pisadmin/miscprocurement/pricetemplate/SectionBuilder.vue +++ b/web/src/views/pisadmin/miscprocurement/cost_template/SectionBuilder.vue @@ -27,12 +27,12 @@ - + - + @@ -79,15 +79,18 @@ - + @@ -98,7 +101,7 @@ @@ -513,6 +530,10 @@ watch( .w-220 { width: 220px; } .w-full { width: 100%; } .mb8 { margin-bottom: 8px; } +.remove-col-placeholder { + color: var(--el-text-color-placeholder); + font-size: 12px; +} .tab-label { color: #6b7280; } .tab-label--on { color: #16a34a; font-weight: 600; } :global(.fc-section-item > .el-form-item__label) { display: none !important; } diff --git a/web/src/views/pisadmin/miscprocurement/pricetemplate/api.ts b/web/src/views/pisadmin/miscprocurement/cost_template/api.ts similarity index 54% rename from web/src/views/pisadmin/miscprocurement/pricetemplate/api.ts rename to web/src/views/pisadmin/miscprocurement/cost_template/api.ts index e28147e..178731f 100644 --- a/web/src/views/pisadmin/miscprocurement/pricetemplate/api.ts +++ b/web/src/views/pisadmin/miscprocurement/cost_template/api.ts @@ -1,5 +1,5 @@ import { request } from '/@/utils/service' -import { PageQuery, AddReq, DelReq, EditReq, InfoReq } from '@fast-crud/fast-crud' +import { PageQuery, AddReq, EditReq, InfoReq } from '@fast-crud/fast-crud' // 成本结构模板(独立表):t_CostEstimate_Template_Head/Body export const apiPrefix = '/api/pisadmin/miscprocurement/cost_template/' @@ -35,9 +35,20 @@ export function UpdateObj(obj: EditReq) { }) } -export function DelObj(id: DelReq) { +/** 未确认 → 已确认:POST /cost_template/{id}/confirm/ */ +export function ConfirmObj(id: string | number) { return request({ - url: apiPrefix + id + '/', - method: 'delete' + url: `${apiPrefix}${id}/confirm/`, + method: 'post', + data: {} + }) +} + +/** 已确认模板派生新版本:POST /cost_template/{sourceId}/new_version/(同模板编号,服务端 version=max+1) */ +export function NewVersionFromSource(sourceId: string | number, data: Record) { + return request({ + url: `${apiPrefix}${sourceId}/new_version/`, + method: 'post', + data }) } diff --git a/web/src/views/procurement/pricetemplate/crud.tsx b/web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx similarity index 76% rename from web/src/views/procurement/pricetemplate/crud.tsx rename to web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx index 4bb7d93..4ab7abd 100644 --- a/web/src/views/procurement/pricetemplate/crud.tsx +++ b/web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx @@ -1,4 +1,5 @@ -import { dict, CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud' +import { compute, dict, CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud' +import { ElMessage, ElMessageBox } from 'element-plus' import * as api from './api' import SectionBuilder from './SectionBuilder.vue' @@ -266,6 +267,7 @@ const replaceBuiltInFields = (section: any, defaultFields: any[]) => { if (existing.supplierRequired !== undefined) copy.supplierRequired = existing.supplierRequired if (existing.supplierEditable !== undefined) copy.supplierEditable = existing.supplierEditable if (existing.remark !== undefined) copy.remark = existing.remark + if (existing.version !== undefined) copy.version = existing.version } else { copy.nameCn = copy.nameCn ?? copy.label ?? '' copy.nameEn = copy.nameEn ?? '' @@ -396,6 +398,12 @@ let latestSectionDraft: any[] = [] const countSectionFields = (sections: any[]) => normalizeSections(sections).reduce((total, section: any) => total + (Array.isArray(section?.fields) ? section.fields.length : 0), 0) +const parseHeadVersion = (form: any) => { + if (form?.version == null || form.version === '') return 1 + const n = Number(form.version) + return Number.isFinite(n) ? n : 1 +} + const resolveSectionsForSubmit = (form: any) => { const draftSections = normalizeSections(latestSectionDraft) const currentSections = normalizeSections(form.sections) @@ -407,7 +415,7 @@ const resolveSectionsForSubmit = (form: any) => { return draftCount >= currentCount ? draftSections : currentSections } -const sectionsToItems = (sectionsRaw: any[], allowTitles?: string[]) => { +const sectionsToItems = (sectionsRaw: any[], allowTitles?: string[], headVersion?: number | null) => { const sections = normalizeSections(sectionsRaw) const allowList = Array.isArray(allowTitles) && allowTitles.length @@ -417,6 +425,14 @@ const sectionsToItems = (sectionsRaw: any[], allowTitles?: string[]) => { : [] const items: any[] = [] let order = 1 + const resolveBodyVersion = (f: any) => { + const raw = f?.version + if (raw !== undefined && raw !== null && raw !== '') { + const n = Number(raw) + return Number.isFinite(n) ? n : headVersion ?? undefined + } + return headVersion ?? undefined + } sections .filter((s: any) => s && s.enabled !== false) .filter((s: any) => !allowList.length || allowList.includes(s.title || s.name || '')) @@ -424,6 +440,7 @@ const sectionsToItems = (sectionsRaw: any[], allowTitles?: string[]) => { const title = sec.title || sec.name || '' const cost_category = titleToCostCategory[title] || '1' ;(sec.fields || []).forEach((f: any) => { + const rowVersion = resolveBodyVersion(f) items.push({ cost_category, item_order: order++, @@ -435,7 +452,8 @@ const sectionsToItems = (sectionsRaw: any[], allowTitles?: string[]) => { is_computed: f.autoFill ? 1 : 0, purchaser_required: f.autoFill ? 0 : f.purchaserRequired ? 1 : 0, supplier_required: getSupplierRequiredCode(f), - remark: f.remark || '' + remark: f.remark || '', + ...(rowVersion !== undefined ? { version: rowVersion } : {}) }) }) }) @@ -443,8 +461,24 @@ const sectionsToItems = (sectionsRaw: any[], allowTitles?: string[]) => { } const itemsToSections = (items: any[], head?: any) => { + // 新版本弹窗里 form.version 为「下一版」预览值,但 items 仍来自上一版;过滤必须用明细实际所属版本 + const headVerRaw = head?.__itemsSourceVersion ?? head?.version + const headVer = + headVerRaw != null && headVerRaw !== '' + ? Number(headVerRaw) + : null + const filteredItems = + headVer != null && Number.isFinite(headVer) + ? (items || []).filter((it: any) => { + const rowVer = + it?.version != null && it?.version !== '' + ? Number(it.version) + : headVer + return rowVer === headVer + }) + : items || [] const grouped = new Map() - ;(items || []).forEach((it: any) => { + filteredItems.forEach((it: any) => { const title = costCategoryToTitle[String(it.cost_category ?? '')] || '其它成本' if (!grouped.has(title)) grouped.set(title, []) grouped.get(title)!.push(it) @@ -465,6 +499,7 @@ const itemsToSections = (items: any[], head?: any) => { nameCn: r.item_name_cn, nameEn: r.item_name_en, nameVn: r.item_name_vn, + version: r.version != null && r.version !== '' ? Number(r.version) : undefined, fixed: Number(r.is_fixed ?? r.item_category ?? 0) === 1, autoFill: Number(r.is_computed || 0) === 1, purchaserRequired: Number(r.is_computed || 0) === 1 ? false : Number(r.purchaser_required || 0) === 1, @@ -486,7 +521,28 @@ const itemsToSections = (items: any[], head?: any) => { return defaults } -export const createCrudOptions = function ({ context }: CreateCrudOptionsProps): CreateCrudOptionsRet { +const templateStatusUnconfirmed = (row: any) => Number(row?.status) === 0 +const templateStatusConfirmed = (row: any) => Number(row?.status) === 1 + +/** 提交用载荷(不含业务分流字段);初始添加与「新版本」共用结构,后者走独立 API。 */ +const buildCostTemplateSubmitPayload = (form: any) => { + const sections = resolveSectionsForSubmit(form) + const allowTitles = visibleTitles(form.procurement_category, (form.is_bom || 'Y') === 'Y') + const headVersion = parseHeadVersion(form) + return { + template_name: form.template_name, + procurement_category: form.procurement_category, + is_bom: form.is_bom || 'Y', + acti: form.acti || 'Y', + template_desc: form.template_desc || '', + version: headVersion, + is_can_add_materials: Number(sections.find((s: any) => s?.title === '材料成本')?.supplierCanAddRow) ? 1 : 0, + is_can_add_process: Number(sections.find((s: any) => s?.title === '加工成本')?.supplierCanAddRow) ? 1 : 0, + items: sectionsToItems(sections, allowTitles, headVersion) + } +} + +export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet { return { crudOptions: { form: { @@ -501,7 +557,7 @@ export const createCrudOptions = function ({ context }: CreateCrudOptionsProps): type: 'tab', base: { label: '基础信息', - columns: ['template_name', 'procurement_category', 'template_desc'] + columns: ['template_name', 'procurement_category', 'version', 'template_desc'] }, controls: { label: '启用设置', @@ -514,9 +570,22 @@ export const createCrudOptions = function ({ context }: CreateCrudOptionsProps): }, async onOpened(ctx: any) { if (ctx.mode === 'add') { + // 新版本:由 openAdd 预填完整 sections/items;勿 refreshSectionsIfNeeded(会再走 replaceBuiltInFields,且易与预填冲突) + if (ctx.form?.__newVersionSourceId != null && ctx.form.__newVersionSourceId !== '') { + const vis = visibleTitles(ctx.form.procurement_category, (ctx.form.is_bom || 'Y') === 'Y') + ctx.form.__visibleTitles = vis + if (Array.isArray(ctx.form.sections)) { + ;(ctx.form.sections as any).__visibleTitles = vis + } + latestSectionDraft = normalizeSections(ctx.form.sections) + return + } ctx.form.procurement_category = '2' ctx.form.is_bom = 'Y' ctx.form.acti = 'Y' + if (ctx.form.version == null || ctx.form.version === '') { + ctx.form.version = 1 + } refreshSectionsIfNeeded(ctx.form) latestSectionDraft = normalizeSections(ctx.form.sections) return @@ -546,18 +615,10 @@ export const createCrudOptions = function ({ context }: CreateCrudOptionsProps): request: { pageRequest: async (query) => api.GetList(query), addRequest: async ({ form }) => { - const sections = resolveSectionsForSubmit(form) - const allowTitles = visibleTitles(form.procurement_category, (form.is_bom || 'Y') === 'Y') - const payload: any = { - template_no: form.template_no || undefined, - template_name: form.template_name, - procurement_category: form.procurement_category, - is_bom: form.is_bom || 'Y', - acti: form.acti || 'Y', - template_desc: form.template_desc || '', - is_can_add_materials: Number(sections.find((s: any) => s?.title === '材料成本')?.supplierCanAddRow) ? 1 : 0, - is_can_add_process: Number(sections.find((s: any) => s?.title === '加工成本')?.supplierCanAddRow) ? 1 : 0, - items: sectionsToItems(sections, allowTitles) + const payload = buildCostTemplateSubmitPayload(form) + const src = form.__newVersionSourceId + if (src != null && src !== '') { + return api.NewVersionFromSource(src, payload) } const categoryStats = payload.items.reduce((acc: Record, item: any) => { const key = String(item.cost_category || '') @@ -569,24 +630,20 @@ export const createCrudOptions = function ({ context }: CreateCrudOptionsProps): item_no: item.item_no, is_computed: item.is_computed })) - console.log('[CostTemplate] add payload.items stats', { total: payload.items?.length || 0, categories: categoryStats, allowTitles }) + console.log('[CostTemplate] add payload.items stats', { total: payload.items?.length || 0, categories: categoryStats }) console.log('[CostTemplate] add payload.items computed', computedStats) - return api.AddObj(payload) + return api.AddObj({ + ...payload, + template_no: form.template_no || undefined + }) }, editRequest: async ({ form, row }) => { - const sections = resolveSectionsForSubmit(form) - const allowTitles = visibleTitles(form.procurement_category, (form.is_bom || 'Y') === 'Y') + const base = buildCostTemplateSubmitPayload(form) const payload: any = { + ...base, id: row.id, template_no: form.template_no || row.template_no, - template_name: form.template_name, - procurement_category: form.procurement_category, - is_bom: form.is_bom || 'Y', - acti: form.acti || 'Y', - template_desc: form.template_desc || '', - is_can_add_materials: Number(sections.find((s: any) => s?.title === '材料成本')?.supplierCanAddRow) ? 1 : 0, - is_can_add_process: Number(sections.find((s: any) => s?.title === '加工成本')?.supplierCanAddRow) ? 1 : 0, - items: sectionsToItems(sections, allowTitles) + status: form.status != null && form.status !== '' ? Number(form.status) : undefined } const categoryStats = payload.items.reduce((acc: Record, item: any) => { const key = String(item.cost_category || '') @@ -598,11 +655,10 @@ export const createCrudOptions = function ({ context }: CreateCrudOptionsProps): item_no: item.item_no, is_computed: item.is_computed })) - console.log('[CostTemplate] edit payload.items stats', { total: payload.items?.length || 0, categories: categoryStats, allowTitles }) + console.log('[CostTemplate] edit payload.items stats', { total: payload.items?.length || 0, categories: categoryStats }) console.log('[CostTemplate] edit payload.items computed', computedStats) return api.UpdateObj(payload) - }, - delRequest: async ({ row }) => api.DelObj(row.id) + } }, table: { rowKey: 'id' @@ -614,10 +670,95 @@ export const createCrudOptions = function ({ context }: CreateCrudOptionsProps): }, rowHandle: { fixed: 'right', - minWidth: 220, + minWidth: 320, buttons: { view: { show: true + }, + edit: { + show: compute(({ row }) => templateStatusUnconfirmed(row)) + }, + remove: { + show: false + }, + confirm: { + text: '确认', + title: '确认', + type: 'success', + order: 4, + show: compute(({ row }) => templateStatusUnconfirmed(row)), + async click({ row }: { row: any }) { + try { + await ElMessageBox.confirm('确认将模板设为「已确认」状态?', '确认', { + type: 'warning', + confirmButtonText: '确定', + cancelButtonText: '取消' + }) + const cres: any = await api.ConfirmObj(row.id) + const okMsg = cres?.data?.msg || cres?.msg || '确认成功' + ElMessage.success(okMsg) + crudExpose?.doRefresh?.() + } catch (e: any) { + if (e === 'cancel' || e === 'close') return + const msg = e?.response?.data?.msg || e?.message || '确认失败' + ElMessage.error(msg) + } + } + }, + newVersion: { + text: '新版本', + title: '新版本', + type: 'primary', + order: 5, + show: compute(({ row }) => templateStatusConfirmed(row)), + async click({ row }: { row: any }) { + try { + const res: any = await api.GetObj(row.id) + const detail = res?.data?.data || res?.data || res + if (!detail || typeof detail !== 'object') { + ElMessage.error('加载模板详情失败') + return + } + const nextV = Number(detail.version) + 1 + const sourceVer = Number(detail.version) || 1 + const rawItems = Array.isArray(detail.items) ? detail.items : [] + const itemsStripped = rawItems.map((it: any) => { + if (!it || typeof it !== 'object') return it + const { id: _id, ...rest } = it + return rest + }) + // 必须用源模板版本过滤明细;勿把 version 设为 nextV,否则 itemsToSections 会筛掉全部旧版行 + const headForSections = { ...detail, __itemsSourceVersion: sourceVer } + const secs = itemsToSections(itemsStripped, headForSections) + const vis = visibleTitles(detail.procurement_category, (detail.is_bom || 'Y') === 'Y') + ;(secs as any).__visibleTitles = vis + await crudExpose?.openAdd?.( + { + row: { + __newVersionSourceId: row.id, + __itemsSourceVersion: sourceVer, + template_no: detail.template_no, + template_name: detail.template_name, + procurement_category: detail.procurement_category, + is_bom: detail.is_bom || 'Y', + acti: detail.acti || 'Y', + template_desc: detail.template_desc || '', + version: nextV, + status: 0, + is_can_add_materials: detail.is_can_add_materials, + is_can_add_process: detail.is_can_add_process, + sections: secs, + __visibleTitles: vis, + items: itemsStripped + } + }, + { title: '新版本' } + ) + } catch (e: any) { + const msg = e?.response?.data?.msg || e?.message || '打开新版本失败' + ElMessage.error(msg) + } + } } } }, @@ -705,6 +846,34 @@ export const createCrudOptions = function ({ context }: CreateCrudOptionsProps): component: { props: { rows: 2 } } } }, + version: { + title: '版本号', + type: 'number', + column: { width: 100 }, + form: { + show: true, + col: { span: 12 }, + value: 1, + component: { props: { min: 1, step: 1, controlsPosition: 'right' } }, + viewForm: { component: { disabled: true } } + } + }, + status: { + title: '状态', + type: 'dict-select', + dict: dict({ + data: [ + { value: '0', label: '未确认' }, + { value: '1', label: '已确认' } + ] + }), + search: { + show: true, + component: { props: { clearable: true, placeholder: '状态' } } + }, + form: { show: false }, + column: { width: 100 } + }, sections: { title: '', type: 'text', @@ -781,7 +950,7 @@ export const createCrudOptions = function ({ context }: CreateCrudOptionsProps): logPT('valueResolve', { source: 'ensured', sections: form.sections?.length }) } }, - update_datetime: { + update_time: { title: '更新时间', type: 'datetime', form: { show: false }, diff --git a/web/src/views/pisadmin/miscprocurement/pricetemplate/index.vue b/web/src/views/pisadmin/miscprocurement/cost_template/index.vue similarity index 100% rename from web/src/views/pisadmin/miscprocurement/pricetemplate/index.vue rename to web/src/views/pisadmin/miscprocurement/cost_template/index.vue diff --git a/web/src/views/pisadmin/miscprocurement/misc-material/api.ts b/web/src/views/pisadmin/miscprocurement/misc_materials/api.ts similarity index 100% rename from web/src/views/pisadmin/miscprocurement/misc-material/api.ts rename to web/src/views/pisadmin/miscprocurement/misc_materials/api.ts diff --git a/web/src/views/pisadmin/miscprocurement/misc-material/crud.tsx b/web/src/views/pisadmin/miscprocurement/misc_materials/crud.tsx similarity index 100% rename from web/src/views/pisadmin/miscprocurement/misc-material/crud.tsx rename to web/src/views/pisadmin/miscprocurement/misc_materials/crud.tsx diff --git a/web/src/views/pisadmin/miscprocurement/misc-material/index.vue b/web/src/views/pisadmin/miscprocurement/misc_materials/index.vue similarity index 100% rename from web/src/views/pisadmin/miscprocurement/misc-material/index.vue rename to web/src/views/pisadmin/miscprocurement/misc_materials/index.vue diff --git a/web/src/views/pisadmin/miscprocurement/misc-part/api.ts b/web/src/views/pisadmin/miscprocurement/misc_parts/api.ts similarity index 100% rename from web/src/views/pisadmin/miscprocurement/misc-part/api.ts rename to web/src/views/pisadmin/miscprocurement/misc_parts/api.ts diff --git a/web/src/views/pisadmin/miscprocurement/misc-part/crud.tsx b/web/src/views/pisadmin/miscprocurement/misc_parts/crud.tsx similarity index 100% rename from web/src/views/pisadmin/miscprocurement/misc-part/crud.tsx rename to web/src/views/pisadmin/miscprocurement/misc_parts/crud.tsx diff --git a/web/src/views/pisadmin/miscprocurement/misc-part/index.vue b/web/src/views/pisadmin/miscprocurement/misc_parts/index.vue similarity index 100% rename from web/src/views/pisadmin/miscprocurement/misc-part/index.vue rename to web/src/views/pisadmin/miscprocurement/misc_parts/index.vue diff --git a/web/src/views/pisadmin/miscprocurement/misc-station/api.ts b/web/src/views/pisadmin/miscprocurement/misc_stations/api.ts similarity index 100% rename from web/src/views/pisadmin/miscprocurement/misc-station/api.ts rename to web/src/views/pisadmin/miscprocurement/misc_stations/api.ts diff --git a/web/src/views/pisadmin/miscprocurement/misc-station/crud.tsx b/web/src/views/pisadmin/miscprocurement/misc_stations/crud.tsx similarity index 100% rename from web/src/views/pisadmin/miscprocurement/misc-station/crud.tsx rename to web/src/views/pisadmin/miscprocurement/misc_stations/crud.tsx diff --git a/web/src/views/pisadmin/miscprocurement/misc-station/index.vue b/web/src/views/pisadmin/miscprocurement/misc_stations/index.vue similarity index 100% rename from web/src/views/pisadmin/miscprocurement/misc-station/index.vue rename to web/src/views/pisadmin/miscprocurement/misc_stations/index.vue diff --git a/web/src/views/pisadmin/miscprocurement/pricetemplate/crud.tsx b/web/src/views/pisadmin/miscprocurement/pricetemplate/crud.tsx deleted file mode 100644 index 4bb7d93..0000000 --- a/web/src/views/pisadmin/miscprocurement/pricetemplate/crud.tsx +++ /dev/null @@ -1,793 +0,0 @@ -import { dict, CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud' -import * as api from './api' -import SectionBuilder from './SectionBuilder.vue' - -// 后端 cost_template.procurement_category:1=策采;2=杂采 -const procurementDict = [ - { value: '1', label: '策采' }, - { value: '2', label: '杂采' } -] - -const logPT = (...args: any[]) => console.log('[CostTemplate]', ...args) - -type SupplierBehavior = - | 'prefill_locked' - | 'prefill_editable' - | 'hidden_required' - | 'hidden_optional' - | 'required' - | 'optional' - -type FieldOpts = { supplierEditable?: boolean; supplierRequired?: boolean; supplierBehavior?: SupplierBehavior } - -const supplierBehaviorCodeMap: Record = { - prefill_locked: 1, - prefill_editable: 2, - hidden_required: 3, - hidden_optional: 4, - required: 5, - optional: 6 -} - -const legacySupplierBehavior = (purchaserRequired: boolean, supplierRequired: boolean, supplierEditable: boolean): SupplierBehavior => { - if (purchaserRequired) { - if (supplierRequired && !supplierEditable) return 'prefill_locked' - if (supplierRequired && supplierEditable) return 'prefill_editable' - if (!supplierRequired && !supplierEditable) return 'hidden_required' - return 'hidden_optional' - } - return supplierRequired ? 'required' : 'optional' -} - -const normalizeSupplierBehavior = ( - raw: unknown, - purchaserRequired = false, - supplierRequired = false, - supplierEditable = true -): SupplierBehavior => { - if (typeof raw === 'string' && raw in supplierBehaviorCodeMap) { - return raw as SupplierBehavior - } - const code = Number(raw) - if (Number.isInteger(code) && code >= 1 && code <= 6) { - return (Object.keys(supplierBehaviorCodeMap) as SupplierBehavior[]).find((key) => supplierBehaviorCodeMap[key] === code) || 'optional' - } - return legacySupplierBehavior(purchaserRequired, supplierRequired, supplierEditable) -} - -const getSupplierRequiredCode = (field: any) => - field?.autoFill - ? 0 - : - supplierBehaviorCodeMap[ - normalizeSupplierBehavior( - field?.supplier_required ?? field?.supplierRequiredCode ?? field?.supplierBehavior, - !!field?.purchaserRequired, - !!field?.supplierRequired, - field?.supplierEditable !== false - ) - ] || 0 - -const createField = ( - label: string, - key: string, - type: 'text' | 'select' | 'number', - formula = '', - opts: FieldOpts = {} -) => { - const supplierRequired = opts.supplierRequired === true - const supplierEditable = opts.supplierEditable !== false - const supplierBehavior = - opts.supplierBehavior || legacySupplierBehavior(false, supplierRequired, supplierEditable) - return { - label, - key, - type, - formula, - fixed: true, - purchaserRequired: false, - supplierBehavior, - builtIn: true - } -} - -const materialsFieldsMisc = [ - createField('材质', 'material', 'text'), - createField('长', 'length', 'number'), - createField('宽', 'width', 'number'), - createField('高', 'height', 'number'), - createField('比重', 'specificgravity', 'number', '根据材质自动带出', { supplierEditable: false }), - createField('数量', 'qty', 'number', '', { supplierRequired: true }), - createField('重量', 'weight', 'number', '长*宽*高*比重*数量', { supplierEditable: false }), - createField('单价', 'unitPrice', 'number'), - createField('材料费用', 'material_cost', 'number', '重量*单价', { supplierEditable: false }) -] - -const materialsFieldsStrategic = [ - createField('材质', 'material', 'text'), - createField('用量/重量', 'weight', 'number'), - createField('单价', 'unitPrice', 'number'), - createField('单位', 'unit', 'text'), - createField('损耗率', 'loss_rate', 'number'), - createField('材料费用', 'material_cost', 'number', '重量*单价', { supplierEditable: false }), - createField('备注', 'remark', 'text') -] - -const getMaterialsFields = (procurementCategory?: string) => - procurementCategory === '1' ? materialsFieldsStrategic : materialsFieldsMisc - -const processFieldsMisc = [ - createField('加工工站', 'process_station', 'select'), - createField('单位', 'unit', 'text', '', { supplierEditable: false }), - createField('费率', 'unitrate', 'number', '根据工站自动带出', { supplierEditable: false }), - createField('加工计量', 'processqty', 'number'), - createField('加工费', 'processprice', 'number', '费率*加工计量', { supplierEditable: false }), - createField('备注', 'remark', 'text', '', { supplierEditable: false }) -] - -const processFieldsStrategic = [ - createField('加工工站', 'process_station', 'select'), - createField('加工时间', 'process_time', 'number'), - createField('加工单价', 'unit_price', 'number'), - createField('单位', 'unit', 'text', '', { supplierEditable: false }), - createField('损耗率', 'loss_rate', 'number', '', { supplierEditable: false }), - createField('加工费', 'process_cost', 'number', '', { supplierEditable: false }), - createField('备注', 'remark', 'text', '', { supplierEditable: false }) -] - -const getProcessFields = (procurementCategory?: string) => - procurementCategory === '1' ? processFieldsStrategic : processFieldsMisc - -const normalizeSections = (value: unknown) => { - if (value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) { - return [] - } - if (typeof value === 'string') { - try { - const parsed = JSON.parse(value) - return Array.isArray(parsed) ? parsed : [] - } catch (e) { - return [] - } - } - if (Array.isArray(value)) { - return value - } - return [] -} - -const baseSections: Record = { - 产品明细: { - id: 'productDetail', - title: '产品明细', - enabled: true, - supplierCanAddRow: true, - allowAddField: true, - fields: [ - createField('料号', 'partNo', 'text'), - createField('规格描述', 'desc', 'text'), - createField('数量', 'qty', 'number'), - createField('单价', 'price', 'number'), - createField('合计价格', 'amount', 'number', '数量*单价', { supplierEditable: false }) - ] - }, - 材料成本: { - id: 'materials', - title: '材料成本', - enabled: true, - supplierCanAddRow: true, - allowAddField: true, - fields: [] - }, - 加工成本: { - id: 'process', - title: '加工成本', - enabled: true, - supplierCanAddRow: true, - allowAddField: true, - fields: [] - }, - 管销研费用: { - id: 'sgna', - title: '管销研费用', - enabled: true, - supplierCanAddRow: false, - allowAddField: false, - fields: [createField('费用', 'fee', 'number')] - }, - 其它成本: { - id: 'others', - title: '其它成本', - enabled: true, - supplierCanAddRow: true, - allowAddField: false, - fields: [ - createField('包装费', 'packaging_cost', 'number'), - createField('运输费', 'transportation_cost', 'number') - ] - }, - 利润: { - id: 'profit', - title: '利润', - enabled: true, - supplierCanAddRow: false, - allowAddField: false, - fields: [ - createField('利润率', 'profitRate', 'number') - ] - }, - 税金: { - id: 'tax', - title: '税金', - enabled: true, - supplierCanAddRow: false, - allowAddField: false, - fields: [ - createField('税率', 'taxRate', 'number', '根据交易厂区自动带出', { supplierEditable: false }) - ] - } -} - -const deepCopy = (obj: T): T => JSON.parse(JSON.stringify(obj)) - -const buildDefaultSections = (procurementCategory?: string) => { - const sections = Object.values(baseSections).map((s) => deepCopy(s)) - const materials = sections.find((s) => s.title === '材料成本') - if (materials) { - materials.fields = deepCopy(getMaterialsFields(procurementCategory)) - } - const process = sections.find((s) => s.title === '加工成本') - if (process) { - process.fields = deepCopy(getProcessFields(procurementCategory)) - } - return sections -} - -const replaceBuiltInFields = (section: any, defaultFields: any[]) => { - const customFields = Array.isArray(section?.fields) ? section.fields.filter((field: any) => !field?.builtIn) : [] - const existingByKey = new Map() - ;(section?.fields || []).forEach((f: any) => { - if (f?.key) existingByKey.set(String(f.key), f) - }) - const mergedDefaults = defaultFields.map((df: any) => { - const copy = deepCopy(df) - const existing = df?.key ? existingByKey.get(String(df.key)) : null - if (existing) { - copy.label = existing.label ?? copy.label - copy.nameCn = existing.nameCn ?? existing.label ?? copy.label - copy.nameEn = existing.nameEn ?? copy.nameEn ?? '' - copy.nameVn = existing.nameVn ?? copy.nameVn ?? '' - // 内置字段也允许编辑这些业务属性,合并默认字段时必须保留用户修改值 - if (existing.fixed !== undefined) copy.fixed = existing.fixed - if (existing.autoFill !== undefined) copy.autoFill = existing.autoFill - if (existing.purchaserRequired !== undefined) copy.purchaserRequired = existing.purchaserRequired - if (existing.supplierBehavior !== undefined) copy.supplierBehavior = existing.supplierBehavior - if (existing.supplierRequiredCode !== undefined) copy.supplierRequiredCode = existing.supplierRequiredCode - if (existing.supplierRequired !== undefined) copy.supplierRequired = existing.supplierRequired - if (existing.supplierEditable !== undefined) copy.supplierEditable = existing.supplierEditable - if (existing.remark !== undefined) copy.remark = existing.remark - } else { - copy.nameCn = copy.nameCn ?? copy.label ?? '' - copy.nameEn = copy.nameEn ?? '' - copy.nameVn = copy.nameVn ?? '' - } - return copy - }) - section.fields = [...mergedDefaults, ...customFields] -} - -const isBuiltInField = (sectionTitle: string, fieldKey: string, procurementCategory?: string) => { - if (!sectionTitle || !fieldKey) return false - const defaults = buildDefaultSections(procurementCategory) - const section = defaults.find((item) => item.title === sectionTitle) - return !!section?.fields?.some((field: any) => field?.key === fieldKey && field?.builtIn) -} - -const titleAliases: Record = { - 其它: '其它成本', - 其他: '其它成本', - 其他费用: '其它成本', - 税率: '税金', - 管销研: '管销研费用', - 管销研成本: '管销研费用' -} - -const normalizeTitleKey = (title?: string) => { - if (!title) return '' - return titleAliases[title] || title -} - -const applyEnabledFlags = (sections: any[], titles: string[]) => { - const set = new Set(titles.map((t) => normalizeTitleKey(t))) - sections.forEach((s) => { - s.enabled = set.has(normalizeTitleKey(s?.title || s?.name)) - }) -} - -const visibleTitles = (procurementCategory?: string, enableCostStructure?: boolean) => { - // 杂采(2) 不显示“管销研费用” - if (procurementCategory === '2') { - return enableCostStructure - ? ['材料成本', '加工成本', '其它成本', '利润', '税金'] - : ['产品明细', '利润', '税金'] - } - return enableCostStructure - ? ['材料成本', '加工成本', '其它成本', '管销研费用', '利润', '税金'] - : ['产品明细', '利润', '税金'] -} - -const ensureAllSections = (form: any, titlesForEnabled?: string[], procurementCategory?: string) => { - const existing = normalizeSections(form?.sections) - const map = new Map() - existing.forEach((s: any) => { - const key = normalizeTitleKey(s?.title || s?.name) - if (key) map.set(key, s) - }) - const defaults = buildDefaultSections(procurementCategory || form?.procurement_category) - defaults.forEach((base) => { - if (!map.has(base.title)) { - map.set(base.title, deepCopy(base)) - } - }) - const arr = Array.from(map.values()) - const currentCategory = procurementCategory || form?.procurement_category - // Always replace内置材料成本字段按采购类别,保留用户新增字段 - const materials = arr.find((s: any) => s?.title === '材料成本') - if (materials) { - replaceBuiltInFields(materials, getMaterialsFields(currentCategory)) - } - // Always replace内置加工成本字段按采购类别,保留用户新增字段 - const process = arr.find((s: any) => s?.title === '加工成本') - if (process) { - replaceBuiltInFields(process, getProcessFields(currentCategory)) - } - if (titlesForEnabled && titlesForEnabled.length) { - applyEnabledFlags(arr, titlesForEnabled) - } - return arr -} - -const refreshSectionsIfNeeded = (form: any, overrides: any = {}) => { - const titles = visibleTitles( - overrides.procurement_category ?? form?.procurement_category, - overrides.enable_cost_structure ?? (form?.is_bom || 'Y') === 'Y' - ) - const procurementCategory = overrides.procurement_category ?? form?.procurement_category - form.sections = ensureAllSections({ ...form, ...overrides }, titles, procurementCategory) - form.__visibleTitles = titles - if (Array.isArray(form.sections)) { - ;(form.sections as any).__visibleTitles = titles - } - latestSectionDraft = normalizeSections(form.sections) - logPT('refreshSections', { - procurement_category: overrides.procurement_category ?? form?.procurement_category, - enable_cost_structure: overrides.enable_cost_structure ?? (form?.is_bom || 'Y') === 'Y', - titles, - sectionsCount: Array.isArray(form.sections) ? form.sections.length : 0, - draftSectionsCount: latestSectionDraft.length - }) -} - -const titleToCostCategory: Record = { - 材料成本: '1', - 加工成本: '2', - 其它成本: '3', - 其他成本: '3', - 管销研费用: '4', - 利润: '5', - 税金: '6', - 产品明细: '7' -} - -const costCategoryToTitle: Record = { - '1': '材料成本', - '2': '加工成本', - '3': '其它成本', - '4': '管销研费用', - '5': '利润', - '6': '税金', - '7': '产品明细' -} - -// 绕过 fast-crud 对自定义组件深层回写偶发失效的问题: -// SectionBuilder 会把当前界面的最新分段草稿主动回传到这里,保存时优先使用这份草稿。 -let latestSectionDraft: any[] = [] - -const countSectionFields = (sections: any[]) => - normalizeSections(sections).reduce((total, section: any) => total + (Array.isArray(section?.fields) ? section.fields.length : 0), 0) - -const resolveSectionsForSubmit = (form: any) => { - const draftSections = normalizeSections(latestSectionDraft) - const currentSections = normalizeSections(form.sections) - if (!draftSections.length) return currentSections - if (!currentSections.length) return draftSections - // 优先使用 latestSectionDraft(SectionBuilder 实时回传的用户编辑),避免 valueResolve 用旧 items 覆盖后提交 - const draftCount = countSectionFields(draftSections) - const currentCount = countSectionFields(currentSections) - return draftCount >= currentCount ? draftSections : currentSections -} - -const sectionsToItems = (sectionsRaw: any[], allowTitles?: string[]) => { - const sections = normalizeSections(sectionsRaw) - const allowList = - Array.isArray(allowTitles) && allowTitles.length - ? allowTitles - : Array.isArray((sectionsRaw as any)?.__visibleTitles) && (sectionsRaw as any).__visibleTitles.length - ? (sectionsRaw as any).__visibleTitles - : [] - const items: any[] = [] - let order = 1 - sections - .filter((s: any) => s && s.enabled !== false) - .filter((s: any) => !allowList.length || allowList.includes(s.title || s.name || '')) - .forEach((sec: any) => { - const title = sec.title || sec.name || '' - const cost_category = titleToCostCategory[title] || '1' - ;(sec.fields || []).forEach((f: any) => { - items.push({ - cost_category, - item_order: order++, - item_no: f.key || '', - item_name_cn: f.nameCn || f.label || '', - item_name_en: f.nameEn || '', - item_name_vn: f.nameVn || '', - is_fixed: f.fixed ? 1 : 0, - is_computed: f.autoFill ? 1 : 0, - purchaser_required: f.autoFill ? 0 : f.purchaserRequired ? 1 : 0, - supplier_required: getSupplierRequiredCode(f), - remark: f.remark || '' - }) - }) - }) - return items -} - -const itemsToSections = (items: any[], head?: any) => { - const grouped = new Map() - ;(items || []).forEach((it: any) => { - const title = costCategoryToTitle[String(it.cost_category ?? '')] || '其它成本' - if (!grouped.has(title)) grouped.set(title, []) - grouped.get(title)!.push(it) - }) - const defaults = buildDefaultSections(head?.procurement_category) - defaults.forEach((sec: any) => { - const title = sec.title - const rows = grouped.get(title) || [] - if (rows.length) { - sec.enabled = true - sec.fields = rows - .slice() - .sort((a: any, b: any) => (a.item_order || 0) - (b.item_order || 0)) - .map((r: any) => ({ - id: r.id, - key: r.item_no, - label: r.item_name_cn, - nameCn: r.item_name_cn, - nameEn: r.item_name_en, - nameVn: r.item_name_vn, - fixed: Number(r.is_fixed ?? r.item_category ?? 0) === 1, - autoFill: Number(r.is_computed || 0) === 1, - purchaserRequired: Number(r.is_computed || 0) === 1 ? false : Number(r.purchaser_required || 0) === 1, - supplierRequiredCode: Number(r.supplier_required || 0) || 0, - supplierBehavior: normalizeSupplierBehavior( - Number(r.is_computed || 0) === 1 ? '' : r.supplier_behavior ?? r.supplier_required, - Number(r.is_computed || 0) === 1 ? false : Number(r.purchaser_required || 0) === 1 - ), - supplierRequired: Number(r.is_computed || 0) === 1 ? false : [1, 2, 5].includes(Number(r.supplier_required || 0)), - remark: r.remark || '', - builtIn: isBuiltInField(title, r.item_no, head?.procurement_category) - })) - } - if (title === '材料成本') sec.supplierCanAddRow = Number(head?.is_can_add_materials || 0) === 1 - if (title === '加工成本') sec.supplierCanAddRow = Number(head?.is_can_add_process || 0) === 1 - }) - const allowedTitles = visibleTitles(head?.procurement_category, (head?.is_bom || 'Y') === 'Y') - applyEnabledFlags(defaults, allowedTitles) - return defaults -} - -export const createCrudOptions = function ({ context }: CreateCrudOptionsProps): CreateCrudOptionsRet { - return { - crudOptions: { - form: { - labelWidth: '96px', - col: { span: 12 }, - wrapper: { - is: 'el-dialog', - width: '95vw', - top: '5vh' - }, - group: { - type: 'tab', - base: { - label: '基础信息', - columns: ['template_name', 'procurement_category', 'template_desc'] - }, - controls: { - label: '启用设置', - columns: ['acti', 'is_bom'] - }, - sections: { - label: '成本结构', - columns: ['sections'] - } - }, - async onOpened(ctx: any) { - if (ctx.mode === 'add') { - ctx.form.procurement_category = '2' - ctx.form.is_bom = 'Y' - ctx.form.acti = 'Y' - refreshSectionsIfNeeded(ctx.form) - latestSectionDraft = normalizeSections(ctx.form.sections) - return - } - // 查看/编辑时:列表行可能不包含 items,强制拉详情回填,避免 sections/items 不同步导致页面展示不完整 - if ((ctx.mode === 'edit' || ctx.mode === 'view') && ctx.form?.id) { - try { - const res: any = await api.GetObj(ctx.form.id) - const detail = res?.data?.data || res?.data || res - if (detail && typeof detail === 'object') { - Object.assign(ctx.form, detail) - if (Array.isArray(detail.items)) { - const secs = itemsToSections(detail.items, detail) - const vis = visibleTitles(detail.procurement_category, (detail.is_bom || 'Y') === 'Y') - ;(secs as any).__visibleTitles = vis - ctx.form.sections = secs - ctx.form.__visibleTitles = vis - latestSectionDraft = normalizeSections(secs) - } - } - } catch (e) { - console.warn('加载成本模板详情失败', e) - } - } - }, - }, - request: { - pageRequest: async (query) => api.GetList(query), - addRequest: async ({ form }) => { - const sections = resolveSectionsForSubmit(form) - const allowTitles = visibleTitles(form.procurement_category, (form.is_bom || 'Y') === 'Y') - const payload: any = { - template_no: form.template_no || undefined, - template_name: form.template_name, - procurement_category: form.procurement_category, - is_bom: form.is_bom || 'Y', - acti: form.acti || 'Y', - template_desc: form.template_desc || '', - is_can_add_materials: Number(sections.find((s: any) => s?.title === '材料成本')?.supplierCanAddRow) ? 1 : 0, - is_can_add_process: Number(sections.find((s: any) => s?.title === '加工成本')?.supplierCanAddRow) ? 1 : 0, - items: sectionsToItems(sections, allowTitles) - } - const categoryStats = payload.items.reduce((acc: Record, item: any) => { - const key = String(item.cost_category || '') - acc[key] = (acc[key] || 0) + 1 - return acc - }, {}) - const computedStats = payload.items.map((item: any) => ({ - cost_category: item.cost_category, - item_no: item.item_no, - is_computed: item.is_computed - })) - console.log('[CostTemplate] add payload.items stats', { total: payload.items?.length || 0, categories: categoryStats, allowTitles }) - console.log('[CostTemplate] add payload.items computed', computedStats) - return api.AddObj(payload) - }, - editRequest: async ({ form, row }) => { - const sections = resolveSectionsForSubmit(form) - const allowTitles = visibleTitles(form.procurement_category, (form.is_bom || 'Y') === 'Y') - const payload: any = { - id: row.id, - template_no: form.template_no || row.template_no, - template_name: form.template_name, - procurement_category: form.procurement_category, - is_bom: form.is_bom || 'Y', - acti: form.acti || 'Y', - template_desc: form.template_desc || '', - is_can_add_materials: Number(sections.find((s: any) => s?.title === '材料成本')?.supplierCanAddRow) ? 1 : 0, - is_can_add_process: Number(sections.find((s: any) => s?.title === '加工成本')?.supplierCanAddRow) ? 1 : 0, - items: sectionsToItems(sections, allowTitles) - } - const categoryStats = payload.items.reduce((acc: Record, item: any) => { - const key = String(item.cost_category || '') - acc[key] = (acc[key] || 0) + 1 - return acc - }, {}) - const computedStats = payload.items.map((item: any) => ({ - cost_category: item.cost_category, - item_no: item.item_no, - is_computed: item.is_computed - })) - console.log('[CostTemplate] edit payload.items stats', { total: payload.items?.length || 0, categories: categoryStats, allowTitles }) - console.log('[CostTemplate] edit payload.items computed', computedStats) - return api.UpdateObj(payload) - }, - delRequest: async ({ row }) => api.DelObj(row.id) - }, - table: { - rowKey: 'id' - }, - actionbar: { - buttons: { - add: { show: true } - } - }, - rowHandle: { - fixed: 'right', - minWidth: 220, - buttons: { - view: { - show: true - } - } - }, - columns: { - template_name: { - title: '模板名称', - type: 'input', - search: { - show: true, - component: { - props: { placeholder: '请输入模板名称', clearable: true } - } - }, - form: { - col: { span: 12 }, - rules: [{ required: true, message: '请输入模板名称' }] - }, - column: { minWidth: 160 } - }, - template_no: { - title: '模板编号', - type: 'input', - search: { - show: true, - component: { props: { placeholder: '模板编号', clearable: true } } - }, - form: { show: false }, - column: { width: 160, showOverflowTooltip: true } - }, - procurement_category: { - title: '采购类别', - type: 'dict-select', - dict: dict({ data: procurementDict }), - column: { width: 120 }, - search: { show: true }, - form: { - col: { span: 12 }, - value: '2', - rules: [{ required: true, message: '请选择采购类别' }], - valueChange({ form, value }) { - refreshSectionsIfNeeded(form, { procurement_category: value }) - logPT('onChange procurement_category', value, { visibleTitles: form.__visibleTitles }) - logPT('sections titles now', Array.isArray(form.sections) ? form.sections.map((s: any) => s.title) : []) - } - }, - editForm: { - component: { props: { disabled: true } } - } - }, - acti: { - title: '有效', - type: 'dict-select', - dict: dict({ data: [ - { value: 'Y', label: '是' }, - { value: 'N', label: '否' } - ] }), - column: { width: 90 }, - search: { show: true }, - form: { value: 'Y' } - }, - is_bom: { - title: '启用BOM', - type: 'dict-select', - dict: dict({ data: [ - { value: 'Y', label: '是' }, - { value: 'N', label: '否' } - ] }), - column: { width: 100 }, - search: { show: true }, - form: { - value: 'Y', - valueChange({ form, value }) { - refreshSectionsIfNeeded(form, { enable_cost_structure: value === 'Y' }) - logPT('onChange is_bom', value, { visibleTitles: form.__visibleTitles }) - logPT('sections titles now', Array.isArray(form.sections) ? form.sections.map((s: any) => s.title) : []) - } - } - }, - template_desc: { - title: '备注', - type: 'textarea', - column: { minWidth: 180, showOverflowTooltip: true }, - form: { - col: { span: 12 }, - component: { props: { rows: 2 } } - } - }, - sections: { - title: '', - type: 'text', - column: { show: false }, - form: { - label: '', - labelWidth: '0px', - component: { - name: SectionBuilder, - props: { - hideTitle: true, - showTitleInput: false, - defaultSections: buildDefaultSections('2'), - visibleTitles: [], - onDraftChange: (sections: any[]) => { - latestSectionDraft = normalizeSections(sections) - } - } - }, - value: () => { - const vis = visibleTitles('2', true) - const defaults = ensureAllSections({ sections: buildDefaultSections('2') }, vis, '2') - ;(defaults as any).__visibleTitles = vis - return defaults - }, - col: { span: 24 }, - itemProps: { labelWidth: 0, class: 'fc-section-item' }, - wrapper: { class: 'price-template-fs-dialog' } - }, - valueBuilder({ form }) { - // 编辑态优先以后端返回的 Body 明细(items) 还原 UI,不能再回退到默认 sections, - // 否则会把数据库里的 is_computed 等真实值覆盖成前端预设默认值。 - if (Array.isArray(form.items) && form.items.length) { - const secs = itemsToSections(form.items, form) - const vis = visibleTitles(form.procurement_category, (form.is_bom || 'Y') === 'Y') - ;(secs as any).__visibleTitles = vis - form.sections = secs - form.__visibleTitles = vis - latestSectionDraft = normalizeSections(secs) - logPT('valueBuilder(items)', { visibleTitles: form.__visibleTitles, sections: form.sections?.length, items: form.items.length }) - return - } - const vis = visibleTitles(form.procurement_category, (form.is_bom || 'Y') === 'Y') - const ensured = ensureAllSections(form, vis, form.procurement_category) - ;(ensured as any).__visibleTitles = vis - form.sections = ensured - form.__visibleTitles = vis - latestSectionDraft = normalizeSections(ensured) - logPT('valueBuilder', { visibleTitles: form.__visibleTitles, sections: form.sections?.length }) - }, - valueResolve({ form }) { - const currentSections = normalizeSections(form.sections) - const currentSectionFieldCount = countSectionFields(currentSections) - // 若当前已存在可编辑 sections(用户可能已修改),不要再被 form.items 覆盖 - if (currentSections.length && currentSectionFieldCount > 0) { - latestSectionDraft = currentSections - logPT('valueResolve', { source: 'sections', sections: currentSections.length, fields: currentSectionFieldCount }) - return - } - // 初次回填时(尚未形成 sections),再从 items 转换 - if (Array.isArray(form.items) && form.items.length) { - const secs = itemsToSections(form.items, form) - const vis = visibleTitles(form.procurement_category, (form.is_bom || 'Y') === 'Y') - ;(secs as any).__visibleTitles = vis - form.sections = secs - form.__visibleTitles = vis - latestSectionDraft = normalizeSections(secs) - logPT('valueResolve', { source: 'items', sections: secs.length, items: form.items.length }) - return - } - const ensured = ensureAllSections(form, form.__visibleTitles || [], form.procurement_category) - form.sections = ensured - latestSectionDraft = normalizeSections(ensured) - logPT('valueResolve', { source: 'ensured', sections: form.sections?.length }) - } - }, - update_datetime: { - title: '更新时间', - type: 'datetime', - form: { show: false }, - column: { width: 180 } - } - } - } - } -} diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx index 908b476..c6850ac 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx @@ -82,6 +82,21 @@ const isConfirmedStatus = (row: any) => getRowStatus(row) === STATUS_CONFIRMED const canPublishStatus = (row: any) => isConfirmedStatus(row) const getErrorMessage = (err: any, fallback: string) => err?.msg || err?.message || err?.response?.data?.msg || fallback +/** 列表行含 `suppliers` 时可先做提示;未返回嵌套时交由接口校验 */ +const rowHasSuppliersList = (row: any): boolean | null => { + const list = row?.suppliers + if (!Array.isArray(list)) return null + return list.length > 0 +} + +/** 成本模板版本号两位展示(与 pricetemplate 一致) */ +export const formatCostTemplateVersionTwoDigits = (v: unknown) => { + if (v == null || v === '') return '00' + const n = Number(v) + if (!Number.isFinite(n)) return String(v) + return String(Math.trunc(n)).padStart(2, '0') +} + export const createCrudOptions = function ({ context, crudExpose, onAdd, onEdit, onView }: Partial & ExtraHooks): CreateCrudOptionsRet { void context return { @@ -163,6 +178,11 @@ export const createCrudOptions = function ({ context, crudExpose, onAdd, onEdit, show: compute(({ row }) => isOpenStatus(row)), async click({ row }) { try { + const supplierOk = rowHasSuppliersList(row) + if (supplierOk === false) { + ElMessage.warning('请先维护询价单供应商名单后再确认') + return + } await ElMessageBox.confirm('【确认】状态仅支持查看,不允许编辑或删除;如需修改请点击【还原】', '提示', { type: 'warning', confirmButtonText: '确定', @@ -250,7 +270,17 @@ export const createCrudOptions = function ({ context, crudExpose, onAdd, onEdit, template: { title: '询价模版', type: 'input', - column: { minWidth: 140, showOverflowTooltip: true } + column: { + minWidth: 180, + showOverflowTooltip: true, + formatter: ({ row, value }: { row: any; value: unknown }) => { + const code = String(value ?? row?.template ?? row?.template_code ?? '').trim() + const verRaw = row?.template_version ?? row?.templateVersion ?? row?.cost_template_version + if (!code) return '' + if (verRaw != null && verRaw !== '') return `${code}(V${formatCostTemplateVersionTwoDigits(verRaw)})` + return code + } + } }, quote_deadline: { title: '报价截止时', diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue index 3706794..c0fd79b 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue @@ -397,14 +397,14 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { useCrud, useExpose } from '@fast-crud/fast-crud' import { ElMessage } from 'element-plus' -import { createCrudOptions, normalizeDict } from './crud' +import { createCrudOptions, normalizeDict, formatCostTemplateVersionTwoDigits } from './crud' import * as api from './api' -import * as costTemplateApi from '../pricetemplate/api' +import * as costTemplateApi from '../cost_template/api' import { GetCompanies, GetList as GetCurrencies } from '../../basicinfo/currency/api' -import { GetList as GetParts } from '../misc-part/api' -import { GetList as GetMaterials } from '../misc-material/api' +import { GetList as GetParts } from '../misc_parts/api' +import { GetList as GetMaterials } from '../misc_materials/api' import { GetList as GetSuppliers } from '../../basicinfo/supplier/api' -import { GetList as GetStations } from '../misc-station/api' +import { GetList as GetStations } from '../misc_stations/api' import { GetList as GetUnits } from '../../basicinfo/unit/api' import { useUserInfo } from '/@/stores/userInfo' @@ -514,10 +514,22 @@ const templateLoading = ref(false) const templatesLoaded = ref(false) const templateOptions = computed(() => - templates.value.map((t: any) => ({ - value: t.template_no, - label: `${t.template_name || '模板'}${t.template_no ? `(${t.template_no})` : ''}` - })) + templates.value.map((t: any) => { + const name = t.template_name || '模板' + const no = t.template_no || '' + const v = + t.version != null && t.version !== '' + ? formatCostTemplateVersionTwoDigits(t.version) + : '' + const label = no + ? v + ? `${name}(${no} · V${v})` + : `${name}(${no})` + : v + ? `${name}(V${v})` + : name + return { value: t.template_no, label } + }) ) const normalizeUnitCode = (value?: string | null) => { @@ -1648,7 +1660,14 @@ const groupedCostRows = computed>(() => { const fetchTemplates = async () => { templateLoading.value = true try { - const res = await costTemplateApi.GetList({ procurement_category: '2', acti: 'Y', page: 1, pageSize: 200, page_size: 200 }) + const res = await costTemplateApi.GetList({ + procurement_category: '2', + acti: 'Y', + status: 1, + page: 1, + pageSize: 200, + page_size: 200 + }) const list = res?.data?.results || res?.data?.data || diff --git a/web/src/views/pissupplier/quotation/crud.tsx b/web/src/views/pissupplier/quotation/crud.tsx index c38691e..596a5c7 100644 --- a/web/src/views/pissupplier/quotation/crud.tsx +++ b/web/src/views/pissupplier/quotation/crud.tsx @@ -2,11 +2,10 @@ import { computed, onMounted, reactive, ref } from 'vue' import { ElMessage } from 'element-plus' import * as api from './api' import * as inquiryApi from '../../pisadmin/miscprocurement/rfqmiscellaneous/api' -import { GetList as GetCostTemplateList } from '../../pisadmin/miscprocurement/pricetemplate/api' -import { fetchPriceTemplates } from '/@/api/miscprocurement/priceTemplate' -import { GetList as GetMaterials } from '../../pisadmin/miscprocurement/misc-material/api' -import { GetList as GetMiscParts } from '../../pisadmin/miscprocurement/misc-part/api' -import { GetList as GetStations } from '../../pisadmin/miscprocurement/misc-station/api' +import { GetList as GetCostTemplateList } from '../../pisadmin/miscprocurement/cost_template/api' +import { GetList as GetMaterials } from '../../pisadmin/miscprocurement/misc_materials/api' +import { GetList as GetMiscParts } from '../../pisadmin/miscprocurement/misc_parts/api' +import { GetList as GetStations } from '../../pisadmin/miscprocurement/misc_stations/api' import { GetList as GetUnits } from '../../pisadmin/basicinfo/unit/api' /** 与 loadQuotes / openQuote 等处解析列表响应一致 */ @@ -123,6 +122,43 @@ export function formatAwardBidStatus(row: { return { mode: 'text', text: '—' } } +/** 与 `miscprocurement.Inquiry.STATUS_CHOICES` 一致 */ +const MISC_INQUIRY_STATUS_LABELS: Record = { + 1: '开立', + 2: '确认', + 3: '发布', + 4: '报价中', + 5: '报价结束', + 6: '比议价中', + 7: '价格审核', + 8: '核价通过(结束)', + 9: '落标(结束)', + 0: '作废' +} + +/** 弹窗/列表:询价状态由数字转为文案;已是文案则原样返回 */ +export function formatMiscInquiryStatus(row: { + inquiryStatus?: string + inquiryStatusCode?: number | string | null +}): string { + const codeRaw = row.inquiryStatusCode + if (codeRaw !== undefined && codeRaw !== null && codeRaw !== '') { + const n = Number(codeRaw) + if (Number.isFinite(n) && MISC_INQUIRY_STATUS_LABELS[n] != null) { + return MISC_INQUIRY_STATUS_LABELS[n] + } + } + const s = row.inquiryStatus == null ? '' : String(row.inquiryStatus).trim() + if (s === '') return '' + if (/^\d+$/.test(s)) { + const n = Number(s) + if (Number.isFinite(n) && MISC_INQUIRY_STATUS_LABELS[n] != null) { + return MISC_INQUIRY_STATUS_LABELS[n] + } + } + return s +} + const paymentMapFrontToBackend: Record = { tt_30_70: 4, net30: 1, @@ -258,6 +294,52 @@ const TEMPLATE_KEY_TO_UI_KEYS: Record> = { const normTplKey = (k: string) => String(k || '').toLowerCase().replace(/[^a-z0-9]/g, '') +/** 固定列 key 集合(用于判断模板字段是否已映射到内置列,避免重复) */ +const fixedQuotationSectionKeys = (section: string) => + new Set((FIXED_QUOTATION_SECTION_COLUMNS[section] || []).map((c) => c.key)) + +/** + * 在固定报价列基础上,仅按成本模板 `template_sections` 追加「扩展」列(如自定义 item_no),不展开整段 JSON。 + * 仅处理材料/加工(与 option_json 扩展落库一致);内置字段若已映射到固定列则跳过。 + */ +const mergeQuotationSectionColumns = (section: string, templateSections: any): QuotationCostColumn[] => { + const base = [...(FIXED_QUOTATION_SECTION_COLUMNS[section] || [])] + if (section !== '材料成本' && section !== '加工成本') return base + + const tpl = normalizeSections(templateSections).find( + (s: any) => (s.title || s.name || s.section || '') === section + ) + const fixedKeys = fixedQuotationSectionKeys(section) + const keyMap = TEMPLATE_KEY_TO_UI_KEYS[section] || {} + const seen = new Set(base.map((c) => c.key)) + const extras: QuotationCostColumn[] = [] + + for (const f of tpl?.fields || []) { + const rawKey = String(f?.key || '').trim() + if (!rawKey) continue + const nk = normTplKey(rawKey) + if (nk === 'partid' || rawKey === 'part_id') continue + + const uiKeys: string[] = keyMap[nk] || keyMap[String(f.key).toLowerCase()] || [rawKey] + const mapsToFixed = uiKeys.some((k) => fixedKeys.has(k)) + if (mapsToFixed) continue + if (fixedKeys.has(rawKey)) continue + if (seen.has(rawKey)) continue + seen.add(rawKey) + const label = String(f.label || f.nameCn || f.name || rawKey).trim() || rawKey + extras.push({ key: rawKey, label }) + } + + if (!extras.length) return base + const remarkIdx = base.findIndex((c) => c.key === 'remark') + if (remarkIdx >= 0) { + const remarkCol = base[remarkIdx] + const head = base.filter((c) => c.key !== 'remark') + return [...head, ...extras, remarkCol] + } + return [...base, ...extras] +} + const labelFallbacks: Record = { material: '材质', material_cost: '材料费用', @@ -424,7 +506,7 @@ const buildRowsFromTemplate = (sections: any, enableCostStructure = true): CostR const allowed = allowedSectionsForTemplate(sections, enableCostStructure) const rows: CostRow[] = [] allowed.forEach((title: string, idx: number) => { - const cols = FIXED_QUOTATION_SECTION_COLUMNS[title] + const cols = mergeQuotationSectionColumns(title, sections) const values: Record = {} const labels: Record = {} ;(cols || []).forEach((c) => { @@ -454,6 +536,14 @@ const costItemsToRows = (items: CostItem[] = [], sections: any, enableCostStruct values[a.key || ''] = a.value ?? '' if (a.key) labels[a.key] = a.label || a.key }) + if (item.section === '材料成本' || item.section === '加工成本') { + mergeQuotationSectionColumns(item.section, sections).forEach((c) => { + if (!(c.key in values)) { + values[c.key] = '' + if (!labels[c.key]) labels[c.key] = c.label + } + }) + } const partId = (values.part_id || values.partId || '') as string rows.push({ id: `${baseId}-0`, @@ -496,7 +586,7 @@ const buildCostItemsFromTemplateSections = (sections: any, enableCostStructure = const allowed = allowedSectionsForTemplate(sections, enableCostStructure) return allowed .map((title: string) => { - const cols = FIXED_QUOTATION_SECTION_COLUMNS[title] + const cols = mergeQuotationSectionColumns(title, sections) if (!cols?.length) return null return { id: crypto.randomUUID(), @@ -663,6 +753,13 @@ const numOrUndef = (v: any) => { return Number.isFinite(n) ? n : undefined } +/** 加工成本等单位字段:文本(与 QuotationProcess.unit CharField 一致) */ +const strOrNullSlice = (v: any, maxLen: number) => { + if (v === '' || v === undefined || v === null) return null + const s = String(v).trim() + return s ? s.slice(0, maxLen) : null +} + /** 将当前表格行写回后端 QuotationMasterCreateUpdateSerializer 期望的嵌套字段 */ const costRowsToNestedPayload = (rows: CostRow[]) => { const material_costs: any[] = [] @@ -737,7 +834,7 @@ const costRowsToNestedPayload = (rows: CostRow[]) => { process_costs.push({ part_id: partId, process_station: v.processStation != null ? String(v.processStation).slice(0, 20) : v.process_station != null ? String(v.process_station).slice(0, 20) : null, - unit: numOrUndef(v.processUnit ?? v.unit), + unit: strOrNullSlice(v.processUnit ?? v.unit, 20), unit_rate: numOrUndef(v.processRate ?? v.unit_rate ?? v.rate), process_qty: v.processMeasure != null ? String(v.processMeasure) : v.process_qty != null ? String(v.process_qty) : null, process_price: numOrUndef(v.processFee ?? v.process_price), @@ -900,13 +997,14 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { return out }) - /** 列固定为报价单子表结构,不合并模板/行键,避免重复列 */ + /** 固定列 + 模板定义的扩展列(仅材料/加工,与 option_json 一致);锁格规则仍来自 template_sections */ const sectionColumns = computed>(() => { const res: Record = {} + const tpl = current.templateSections enabledSections.value.forEach((section) => { - const base = FIXED_QUOTATION_SECTION_COLUMNS[section] || [] + const merged = mergeQuotationSectionColumns(section, tpl) const locks = templateUiLockBySection.value.get(section) || new Map() - res[section] = base.map((col) => { + res[section] = merged.map((col) => { const meta = locks.get(col.key) return { ...col, @@ -994,7 +1092,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { /** 杂采料号 MiscProcMaterial:partid → 物料说明、单位,回填 rfq_items */ const miscPartByPartId = ref>({}) - /** `CostEstimateTemplateHead.template_no` / `PriceTemplate.code` -> 模板名称 */ + /** `CostEstimateTemplateHead.template_no` -> 模板名称 */ const templateNameByCode = ref>({}) let templateNameLookupDone = false @@ -1002,22 +1100,12 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { if (templateNameLookupDone) return templateNameLookupDone = true try { - const [costRes, priceRes] = await Promise.all([ - GetCostTemplateList({ page: 1, page_size: 500, pageSize: 500 } as any), - fetchPriceTemplates({ page: 1, page_size: 500, pageSize: 500 }) - ]) + const costRes = await GetCostTemplateList({ page: 1, page_size: 500, pageSize: 500 } as any) const merge: Record = {} for (const row of extractPagedList(costRes)) { const no = row.template_no if (no) merge[String(no).trim()] = trim(row.template_name) || String(no) } - for (const row of extractPagedList(priceRes)) { - const c = row.code - if (c) { - const k = String(c).trim() - if (!merge[k]) merge[k] = trim(row.name) || k - } - } templateNameByCode.value = merge } catch (e) { console.warn('加载询价模板名称失败', e) @@ -1065,11 +1153,12 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { if (invSt !== undefined && invSt !== null && invSt !== '') { q.inquiryStatusCode = Number(invSt) } + q.inquiryStatus = formatMiscInquiryStatus(q) } } const mapBackendQuote = (item: any): Quote => { - return { + const quote: Quote = { id: String(item.id ?? item.autoid ?? item.quotation_no ?? crypto.randomUUID()), quoteNo: item.quotation_no || item.quoteNo || '', inquiryCode: item.inquiry_no || item.inquiryCode || '', @@ -1103,8 +1192,8 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { contact: item.contact_person || item.contact || '', phone: item.contact_phone || item.phone || '', email: item.contact_email || item.email || '', - validityDays: item.validity_days || item.validityDays || '', - leadTimeDays: item.delivery_days || item.leadTimeDays || '', + validityDays: item.validity_days ?? item.validityDays ?? '', + leadTimeDays: item.delivery_days ?? item.leadTimeDays ?? '', paymentTerm: normalizePayment(item.payment_method) }, costItems: hasQuotationNestedCosts(item) @@ -1129,6 +1218,8 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { enableCostStructure: item.enable_cost_structure ?? item.enableCostStructure ?? item.is_bom ?? item.isBom ?? item.template?.enable_cost_structure ?? true } + quote.inquiryStatus = formatMiscInquiryStatus(quote) + return quote } const loadMaterialOptions = async () => { @@ -1323,14 +1414,30 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { return map }) - const quoteSummaryRows = computed(() => { + /** + * 与报价合计表一致,用于写入 QuotationItem(上阶物料明细)汇总字段。 + * 字段语义与 InquiryRfqItem / 后端模型一致:profit_rate、tax_rate 存金额非百分数。 + */ + const quoteRollupForRfq = computed(() => { const sections = enabledSections.value - const amt = (s: string) => sectionAmountMap.value[s] ?? 0 - const costSum = costSubtotalSectionNames.filter((s) => sections.includes(s)).reduce((sum, s) => sum + amt(s), 0) + const map = sectionAmountMap.value + const amt = (s: string) => map[s] ?? 0 + const material = amt('材料成本') + const processing = amt('加工成本') + const other = amt('其它成本') + amt('其他成本') + const opex = amt('管销研费用') + const costSum = costSubtotalSectionNames.filter((s) => sections.includes(s)).reduce((sum, s) => sum + amt(String(s)), 0) const profitAmt = sections.includes('利润') ? amt('利润') : 0 const taxAmt = sections.includes('税金') ? amt('税金') : 0 const preTax = costSum + profitAmt const postTax = preTax + taxAmt + return { material, processing, other, opex, costSum, profitAmt, taxAmt, preTax, postTax } + }) + + const quoteSummaryRows = computed(() => { + const sections = enabledSections.value + const amt = (s: string) => sectionAmountMap.value[s] ?? 0 + const { costSum, profitAmt, taxAmt, preTax, postTax } = quoteRollupForRfq.value const rows: SummaryRow[] = [] let addedCostTotal = false @@ -1513,6 +1620,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { quoteData.templateSections = rawDetail.template_sections } + quoteData.inquiryStatus = formatMiscInquiryStatus(quoteData) fillCurrent(quoteData, effectiveRows) dialog.visible = true } finally { @@ -1644,7 +1752,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { } const addCostRow = (section: string) => { - const cols = FIXED_QUOTATION_SECTION_COLUMNS[section] || [] + const cols = mergeQuotationSectionColumns(section, current.templateSections) const values: Record = {} const labels: Record = {} cols.forEach((c) => { @@ -1761,6 +1869,48 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { }) } + const round4 = (n: number) => Number(n.toFixed(4)) + + /** 将报价合计写入 BOM 行(is_bom=1);无 BOM 时写入第一行,与单产品上阶明细场景一致 */ + const applyRfqItemsWithQuoteRollup = (items: any[]) => { + const base = applyRfqItemsForPayload(items) + if (!base.length) return base + const r = quoteRollupForRfq.value + const isBomLine = (it: any) => { + const v = it.is_bom ?? it.IsBom + return v === 1 || v === '1' || v === true + } + const targetIdxs = base.map((it, i) => i).filter((i) => isBomLine(base[i])) + const idxs = targetIdxs.length ? targetIdxs : [0] + const totals = { + total_material_cost: round4(r.material), + total_processing_cost: round4(r.processing), + total_other_expense: round4(r.other), + total_opex_amt: round4(r.opex), + profit_rate: round4(r.profitAmt), + tax_rate: round4(r.taxAmt), + total_price_excl_tax: round4(r.preTax), + total_price_incl_tax: round4(r.postTax) + } + for (const i of idxs) { + const it = base[i] + Object.assign(it, totals) + const qty = Number(it.qty ?? 0) + if (qty > 0 && r.postTax > 0) { + it.unit_price = round4(r.postTax / qty) + } + it.product_cost = round4(r.postTax) + } + return base + } + + /** 可选整数:空不提交;0 合法(勿用 `|| undefined` 误丢 0) */ + const optionalIntForPayload = (v: string | number | null | undefined) => { + if (v === '' || v == null) return undefined + const n = typeof v === 'number' ? v : Number(String(v).trim()) + return Number.isFinite(n) ? n : undefined + } + /** 弹窗保存:与后端约定不提交 status / quotetime(由列表「提交报价」接口写入) */ const buildSavePayload = (q: Quote) => { const nested = costRowsToNestedPayload(costRows.value) @@ -1776,21 +1926,18 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { nested.other_costs = fillPart(nested.other_costs) nested.profit_costs = fillPart(nested.profit_costs) } + // 仅提交 QuotationMaster / 嵌套子表存在的字段(询价标题、模板、币别等由询价主表维护,不在报价主表模型上) const payload: any = { quotation_no: q.quoteNo || undefined, inquiry_no: q.inquiryCode || undefined, - inquiry_name: q.inquiryTitle || undefined, - template: q.template || undefined, - currency: q.currency || undefined, quote_deadline: q.quoteDeadline || undefined, - quote_amount: q.quoteAmount || undefined, supplier_code: q.supplierCode || undefined, supplier_name: q.supplierName || undefined, contact_person: q.base.contact || undefined, contact_phone: q.base.phone || undefined, contact_email: q.base.email || undefined, - validity_days: q.base.validityDays || undefined, - delivery_days: q.base.leadTimeDays || undefined, + validity_days: optionalIntForPayload(q.base.validityDays), + delivery_days: optionalIntForPayload(q.base.leadTimeDays), payment_method: paymentMapFrontToBackend[q.base.paymentTerm] || undefined, /** 与 QuotationMasterCreateUpdateSerializer 一致,写入 material_costs 等子表 */ material_costs: nested.material_costs, @@ -1801,7 +1948,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { remark: q.remark || '' } if (Array.isArray(q.rfqItems) && q.rfqItems.length) { - payload.rfq_items = applyRfqItemsForPayload(q.rfqItems) + payload.rfq_items = applyRfqItemsWithQuoteRollup(q.rfqItems) } return payload } diff --git a/web/src/views/pissupplier/quotation/index.vue b/web/src/views/pissupplier/quotation/index.vue index 4a60035..d4e39ca 100644 --- a/web/src/views/pissupplier/quotation/index.vue +++ b/web/src/views/pissupplier/quotation/index.vue @@ -33,7 +33,7 @@ {{ templateLabel(current.template) }} {{ current.currency }} {{ current.quoteDeadline }} - {{ current.inquiryStatus }} + {{ formatMiscInquiryStatus(current) }} 报价基础信息 @@ -239,7 +239,7 @@ import { onMounted, ref } from 'vue' import { Flag } from '@element-plus/icons-vue' import { compute, dict, useCrud, useExpose } from '@fast-crud/fast-crud' -import { useQuoteCrud, formatAwardBidStatus } from './crud' +import { useQuoteCrud, formatAwardBidStatus, formatMiscInquiryStatus } from './crud' /** 查询区:按主表 is_awarded 筛选(与后端字段一致) */ const isAwardedOptions = [ diff --git a/web/src/views/procurement/pricetemplate/SectionBuilder.vue b/web/src/views/procurement/pricetemplate/SectionBuilder.vue deleted file mode 100644 index 4da1254..0000000 --- a/web/src/views/procurement/pricetemplate/SectionBuilder.vue +++ /dev/null @@ -1,529 +0,0 @@ - - - - - diff --git a/web/src/views/procurement/pricetemplate/api.ts b/web/src/views/procurement/pricetemplate/api.ts deleted file mode 100644 index 99f0bce..0000000 --- a/web/src/views/procurement/pricetemplate/api.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { request } from '/@/utils/service' -import { PageQuery, AddReq, DelReq, EditReq, InfoReq } from '@fast-crud/fast-crud' - -// 成本结构模板(独立表):t_CostEstimate_Template_Head/Body -export const apiPrefix = '/api/procurement/cost_template/' - -export function GetList(query: PageQuery) { - return request({ - url: apiPrefix, - method: 'get', - params: query - }) -} - -export function GetObj(id: InfoReq) { - return request({ - url: apiPrefix + id + '/', - method: 'get' - }) -} - -export function AddObj(obj: AddReq) { - return request({ - url: apiPrefix, - method: 'post', - data: obj - }) -} - -export function UpdateObj(obj: EditReq) { - return request({ - url: apiPrefix + obj.id + '/', - method: 'put', - data: obj - }) -} - -export function DelObj(id: DelReq) { - return request({ - url: apiPrefix + id + '/', - method: 'delete' - }) -} diff --git a/web/src/views/procurement/pricetemplate/index.vue b/web/src/views/procurement/pricetemplate/index.vue deleted file mode 100644 index a944afa..0000000 --- a/web/src/views/procurement/pricetemplate/index.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - - - diff --git a/web/src/views/procurement/rfqmiscellaneous/api.ts b/web/src/views/procurement/rfqmiscellaneous/api.ts deleted file mode 100644 index e24515d..0000000 --- a/web/src/views/procurement/rfqmiscellaneous/api.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { request } from '/@/utils/service' - -const baseUrl = '/api/procurement/inquiry/' - -export const GetList = (params: any) => request({ url: baseUrl, method: 'get', params }) -export const GetObj = (id: string | number) => request({ url: baseUrl + id + '/', method: 'get' }) -export const AddObj = (data: any) => request({ url: baseUrl, method: 'post', data }) -export const UpdateObj = (data: any) => request({ url: baseUrl + data.id + '/', method: 'put', data }) -export const DelObj = (id: string | number) => request({ url: baseUrl + id + '/', method: 'delete' }) -export const ConfirmObj = (id: string | number) => request({ url: `${baseUrl}${id}/confirm/`, method: 'put' }) -export const RestoreObj = (id: string | number) => request({ url: `${baseUrl}${id}/restore/`, method: 'put' }) -export const PublishObj = (id: string | number) => request({ url: `${baseUrl}${id}/publish/`, method: 'put' }) -export const UploadFile = (data: FormData) => - request({ - url: '/api/system/file/', - method: 'post', - data, - timeout: 60000, - headers: { - 'Content-Type': 'multipart/form-data' - } - }) diff --git a/web/src/views/procurement/rfqmiscellaneous/crud.tsx b/web/src/views/procurement/rfqmiscellaneous/crud.tsx deleted file mode 100644 index 3514a16..0000000 --- a/web/src/views/procurement/rfqmiscellaneous/crud.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import { compute, dict, CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud' -import { ElMessage, ElMessageBox } from 'element-plus' -import * as api from './api' - -const statusDict = [ - { value: 1, label: '开立' }, - { value: 2, label: '确认' }, - { value: 3, label: '发布' }, - { value: 4, label: '报价中' }, - { value: 5, label: '报价结束' }, - { value: 6, label: '比议价中' }, - { value: 7, label: '价格审核' }, - { value: 8, label: '核价通过(结束)' }, - { value: 9, label: '落标(结束)' }, - { value: 0, label: '作废' } -] - -const paymentMethods = [ - { value: 1, label: '月结30天' }, - { value: 2, label: '月结60天' }, - { value: 3, label: '不到付款' }, - { value: 4, label: '预付30%' }, - { value: 5, label: '预付50%' }, - { value: 6, label: '余款至生产' }, - { value: 7, label: '价格审核' }, - { value: 8, label: '价格结算(运费)' }, - { value: 9, label: '新建(结束)' } -] - -const formatQuoteDeadlineDisplay = (value: unknown) => { - if (!value) return '' - const text = String(value).trim() - if (!text) return '' - const matched = text.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2})/) - if (matched) { - return `${matched[1]} ${matched[2]}:00` - } - const dateOnly = text.match(/^(\d{4}-\d{2}-\d{2})$/) - if (dateOnly) { - return `${dateOnly[1]} 00:00` - } - return text -} - -const normalizeList = (value: unknown, label: string) => { - if (value === null || value === undefined || value === '') return [] - if (typeof value === 'string') { - const parsed = JSON.parse(value) - if (!Array.isArray(parsed)) throw new Error(`${label} 需为 JSON 数组`) - return parsed - } - if (Array.isArray(value)) return value - throw new Error(`${label} 需为 JSON 数组`) -} - -export const normalizeDict = (value: unknown, label: string) => { - if (value === null || value === undefined || value === '') return {} - if (typeof value === 'string') { - const parsed = JSON.parse(value) - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error(`${label} 需为 JSON 对象`) - } - return parsed - } - if (typeof value === 'object') return value as Record - throw new Error(`${label} 需为 JSON 对象`) -} - -type ExtraHooks = { - onAdd?: () => void - onEdit?: (row: any) => void - onView?: (row: any) => void -} - -const STATUS_OPEN = 1 -const STATUS_CONFIRMED = 2 -const STATUS_PUBLISHED = 3 - -const getRowStatus = (row: any) => Number(row?.status) -const isOpenStatus = (row: any) => getRowStatus(row) === STATUS_OPEN -const isConfirmedStatus = (row: any) => getRowStatus(row) === STATUS_CONFIRMED -const canPublishStatus = (row: any) => isConfirmedStatus(row) -const getErrorMessage = (err: any, fallback: string) => err?.msg || err?.message || err?.response?.data?.msg || fallback - -export const createCrudOptions = function ({ context, crudExpose, onAdd, onEdit, onView }: Partial & ExtraHooks): CreateCrudOptionsRet { - void context - return { - crudOptions: { - form: { - labelWidth: '120px', - col: { span: 12 }, - wrapper: { is: 'el-dialog', width: '960px', top: '6vh', title: '询价单' }, - group: { - type: 'tab', - base: { label: '基础信息', columns: ['code', 'title', 'product_category', 'template', 'part_no', 'part_name', 'quote_deadline', 'purchase_qty', 'buyer', 'currency', 'plant', 'target_price', 'lead_time_days', 'payment_term', 'status', 'remark'] }, - cost: { label: '成本结构', columns: ['cost_items'] }, - vendors: { label: '供应商名单', columns: ['vendors'] }, - attachments: { label: '附件', columns: ['attachments'] } - } - }, - request: { - pageRequest: async (query) => api.GetList(query), - // 新版:新增/编辑由 index.vue 自定义弹窗负责(嵌套子表一次提交) - addRequest: async ({ form }) => api.AddObj(form), - editRequest: async ({ form, row }) => api.UpdateObj({ ...form, id: row.id }), - delRequest: async ({ row }) => { - try { - return await api.DelObj(row.id) - } catch (err: any) { - ElMessage.error(getErrorMessage(err, '删除失败')) - throw err - } - } - }, - table: { - rowKey: 'id' - }, - actionbar: { - buttons: { - add: { - show: true, - text: '新建询价单', - click() { - onAdd && onAdd() - } - } - } - }, - rowHandle: { - fixed: 'right', - width: 420, - buttons: { - view: { show: false }, - edit: { show: false }, - remove: { - text: '删除', - type: 'danger', - order: 1, - show: compute(({ row }) => isOpenStatus(row)) - }, - customView: { - text: '查看', - type: 'info', - order: 0, - show: true, - click({ row }) { - onView && onView(row) - } - }, - customEdit: { - text: '编辑', - type: 'primary', - order: 1.5, - show: compute(({ row }) => isOpenStatus(row)), - click({ row }) { - onEdit && onEdit(row) - } - }, - confirm: { - text: '确认', - type: 'success', - order: 2, - show: compute(({ row }) => isOpenStatus(row)), - async click({ row }) { - try { - await ElMessageBox.confirm('【确认】状态仅支持查看,不允许编辑或删除;如需修改请点击【还原】', '提示', { - type: 'warning', - confirmButtonText: '确定', - cancelButtonText: '取消' - }) - const res = await api.ConfirmObj(row.id) - crudExpose?.doRefresh?.() - return res - } catch (err: any) { - if (err === 'cancel' || err === 'close') return - ElMessage.error(getErrorMessage(err, '确认失败')) - throw err - } - } - }, - restore: { - text: '还原', - type: 'primary', - order: 2.5, - show: compute(({ row }) => isConfirmedStatus(row)), - async click({ row }) { - try { - await ElMessageBox.confirm('确认将状态还原为【开立】?', '提示', { - type: 'warning', - confirmButtonText: '确定', - cancelButtonText: '取消' - }) - const res = await api.RestoreObj(row.id) - crudExpose?.doRefresh?.() - return res - } catch (err: any) { - if (err === 'cancel' || err === 'close') return - ElMessage.error(getErrorMessage(err, '还原失败')) - throw err - } - } - }, - publish: { - text: '发布', - type: 'warning', - order: 3, - show: compute(({ row }) => canPublishStatus(row)), - async click({ row }) { - try { - await ElMessageBox.confirm('确认将状态改为【发布】?', '提示', { - type: 'warning', - confirmButtonText: '确定', - cancelButtonText: '取消' - }) - const res = await api.PublishObj(row.id) - crudExpose?.doRefresh?.() - return res - } catch (err: any) { - if (err === 'cancel' || err === 'close') return - ElMessage.error(getErrorMessage(err, '发布失败')) - throw err - } - } - } - } - }, - columns: { - inquiry_no: { - title: '询价单号', - type: 'input', - search: { - show: true, - component: { props: { placeholder: '请输入询价单号', clearable: true } } - }, - form: { - show: false - }, - column: { minWidth: 140 } - }, - title: { - title: '询价单名称', - type: 'input', - search: { - show: true, - component: { props: { placeholder: '请输入询价单名称', clearable: true } } - }, - form: { rules: [{ required: true, message: '请输入询价单名称' }] }, - column: { minWidth: 160, showOverflowTooltip: true } - }, - template: { - title: '询价模版', - type: 'input', - column: { minWidth: 140, showOverflowTooltip: true } - }, - quote_deadline: { - title: '报价截止时', - type: 'datetime', - column: { - width: 160, - formatter: ({ value }: { value: unknown }) => formatQuoteDeadlineDisplay(value) - } - }, - buyer: { - title: '采购负责人', - type: 'input', - search: { - show: true, - component: { props: { placeholder: '请输入采购负责人', clearable: true } } - }, - form: { rules: [{ required: true, message: '请输入采购负责人' }] }, - column: { minWidth: 120 } - }, - currency: { - title: '币别', - type: 'dict-select', - dict: dict({ data: [ - { value: 'CNY', label: 'CNY' }, - { value: 'USD', label: 'USD' } - ] }), - column: { width: 100 }, - form: { value: 'CNY' } - }, - lead_time_days: { - title: '交货周期(天)', - type: 'number', - column: { width: 120 }, - form: { component: { props: { precision: 0 } } } - }, - payment_method: { - title: '付款方式', - type: 'dict-select', - dict: dict({ data: paymentMethods }), - column: { width: 140, showOverflowTooltip: true }, - form: { show: false } - }, - status: { - title: '状态', - type: 'dict-select', - dict: dict({ data: statusDict }), - column: { width: 140 } - }, - remark: { - title: '备注', - type: 'textarea', - column: { minWidth: 180, showOverflowTooltip: true }, - form: { component: { props: { rows: 3 } } } - }, - update_datetime: { - title: '更新时间', - type: 'datetime', - form: { show: false }, - column: { width: 180 } - } - } - } - } -} diff --git a/web/src/views/procurement/rfqmiscellaneous/index.vue b/web/src/views/procurement/rfqmiscellaneous/index.vue deleted file mode 100644 index 9d3cb49..0000000 --- a/web/src/views/procurement/rfqmiscellaneous/index.vue +++ /dev/null @@ -1,2034 +0,0 @@ - - - - -