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/cost_template/SectionBuilder.vue b/web/src/views/pisadmin/miscprocurement/cost_template/SectionBuilder.vue new file mode 100644 index 0000000..052de2e --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/cost_template/SectionBuilder.vue @@ -0,0 +1,550 @@ + + + + + diff --git a/web/src/views/pisadmin/miscprocurement/cost_template/api.ts b/web/src/views/pisadmin/miscprocurement/cost_template/api.ts new file mode 100644 index 0000000..178731f --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/cost_template/api.ts @@ -0,0 +1,54 @@ +import { request } from '/@/utils/service' +import { PageQuery, AddReq, EditReq, InfoReq } from '@fast-crud/fast-crud' + +// 成本结构模板(独立表):t_CostEstimate_Template_Head/Body +export const apiPrefix = '/api/pisadmin/miscprocurement/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 + }) +} + +/** 未确认 → 已确认:POST /cost_template/{id}/confirm/ */ +export function ConfirmObj(id: string | number) { + return request({ + 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/pisadmin/miscprocurement/cost_template/crud.tsx b/web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx new file mode 100644 index 0000000..4ab7abd --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/cost_template/crud.tsx @@ -0,0 +1,962 @@ +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' + +// 后端 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 + if (existing.version !== undefined) copy.version = existing.version + } 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 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) + 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[], headVersion?: number | null) => { + 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 + 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 || '')) + .forEach((sec: any) => { + 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++, + 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 || '', + ...(rowVersion !== undefined ? { version: rowVersion } : {}) + }) + }) + }) + return items +} + +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() + filteredItems.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, + 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, + 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 +} + +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: { + labelWidth: '96px', + col: { span: 12 }, + wrapper: { + is: 'el-dialog', + width: '95vw', + top: '5vh' + }, + group: { + type: 'tab', + base: { + label: '基础信息', + columns: ['template_name', 'procurement_category', 'version', 'template_desc'] + }, + controls: { + label: '启用设置', + columns: ['acti', 'is_bom'] + }, + sections: { + label: '成本结构', + columns: ['sections'] + } + }, + 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 + } + // 查看/编辑时:列表行可能不包含 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 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 || '') + 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 }) + console.log('[CostTemplate] add payload.items computed', computedStats) + return api.AddObj({ + ...payload, + template_no: form.template_no || undefined + }) + }, + editRequest: async ({ form, row }) => { + const base = buildCostTemplateSubmitPayload(form) + const payload: any = { + ...base, + id: row.id, + template_no: form.template_no || row.template_no, + 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 || '') + 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 }) + console.log('[CostTemplate] edit payload.items computed', computedStats) + return api.UpdateObj(payload) + } + }, + table: { + rowKey: 'id' + }, + actionbar: { + buttons: { + add: { show: true } + } + }, + rowHandle: { + fixed: 'right', + 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) + } + } + } + } + }, + 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 } } + } + }, + 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', + 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_time: { + title: '更新时间', + type: 'datetime', + form: { show: false }, + column: { width: 180 } + } + } + } + } +} diff --git a/web/src/views/pisadmin/miscprocurement/cost_template/index.vue b/web/src/views/pisadmin/miscprocurement/cost_template/index.vue new file mode 100644 index 0000000..a944afa --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/cost_template/index.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/web/src/views/pisadmin/miscprocurement/misc_materials/api.ts b/web/src/views/pisadmin/miscprocurement/misc_materials/api.ts new file mode 100644 index 0000000..1d52ac8 --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/misc_materials/api.ts @@ -0,0 +1,8 @@ +import { request } from '/@/utils/service' + +const apiPrefix = '/api/pisadmin/miscprocurement/misc_materials/' + +export const GetList = (params: any) => request({ url: apiPrefix, method: 'get', params }) +export const AddObj = (data: any) => request({ url: apiPrefix, method: 'post', data }) +export const UpdateObj = (data: any) => request({ url: apiPrefix + data.id + '/', method: 'put', data }) +export const DelObj = (id: string | number) => request({ url: apiPrefix + id + '/', method: 'delete' }) diff --git a/web/src/views/pisadmin/miscprocurement/misc_materials/crud.tsx b/web/src/views/pisadmin/miscprocurement/misc_materials/crud.tsx new file mode 100644 index 0000000..a64e83c --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/misc_materials/crud.tsx @@ -0,0 +1,112 @@ +import { dict, CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud' +import * as api from './api' +import { GetCompanies } from '../../basicinfo/currency/api' + +const statusDict = [ + { value: 1, label: '可用' }, + { value: 0, label: '不可用' } +] + +const loadCompanyOptions = async () => { + try { + const res = await GetCompanies({ page: 1, page_size: 1000, pageSize: 1000 }) + const list = + res?.data?.data?.results || + res?.data?.results || + res?.data?.list || + res?.data || + res?.results || + res?.list || + [] + return (Array.isArray(list) ? list : []).map((c: any) => ({ + company_code: c.company_code, + company_short_name: c.company_short_name || c.company_code || c.company_name + })) + } catch (e) { + console.warn('加载公司列表失败', e) + return [] + } +} + +void loadCompanyOptions() + +export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet { + void crudExpose + return { + crudOptions: { + form: { + labelWidth: '110px' + }, + request: { + pageRequest: async (query) => api.GetList(query), + addRequest: async ({ form }) => api.AddObj(form), + editRequest: async ({ form, row }) => api.UpdateObj({ ...form, id: row.id }), + delRequest: async ({ row }) => api.DelObj(row.id) + }, + table: { + rowKey: 'id' + }, + actionbar: { + buttons: { + add: { show: true } + } + }, + rowHandle: { + fixed: 'right' + }, + columns: { + factory: { + title: '交易厂区', + type: 'dict-select', + dict: dict({ + cache: false, + value: 'company_code', + label: 'company_short_name', + getData: async () => { + return loadCompanyOptions() + } + }), + search: { show: true }, + column: { minWidth: 160, showOverflowTooltip: true } + }, + materialtype: { + title: '材质', + type: 'input', + search: { show: true, component: { props: { placeholder: '请输入材质', clearable: true } } }, + column: { minWidth: 160, showOverflowTooltip: true } + }, + density: { + title: '比重', + type: 'input', + form: { rules: [{ required: true, message: '请输入比重' }] }, + column: { width: 120, showOverflowTooltip: true } + }, + price: { + title: '单价', + type: 'number', + form: { rules: [{ required: true, message: '请输入单价' }], component: { props: { precision: 2 } } }, + column: { width: 120 } + }, + status: { + title: '可用状态', + type: 'dict-switch', + dict: dict({ data: statusDict }), + form: { value: 1 }, + column: { width: 120 } + }, + create_datetime: { + title: '创建时间', + type: 'datetime', + form: { show: false }, + column: { width: 180 } + }, + update_datetime: { + title: '更新时间', + type: 'datetime', + form: { show: false }, + column: { width: 180 } + } + } + } + } +} diff --git a/web/src/views/pisadmin/miscprocurement/misc_materials/index.vue b/web/src/views/pisadmin/miscprocurement/misc_materials/index.vue new file mode 100644 index 0000000..113c7e6 --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/misc_materials/index.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/web/src/views/pisadmin/miscprocurement/misc_parts/api.ts b/web/src/views/pisadmin/miscprocurement/misc_parts/api.ts new file mode 100644 index 0000000..dfe1a5c --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/misc_parts/api.ts @@ -0,0 +1,8 @@ +import { request } from '/@/utils/service' + +const apiPrefix = '/api/pisadmin/miscprocurement/misc_parts/' + +export const GetList = (params: any) => request({ url: apiPrefix, method: 'get', params }) +export const AddObj = (data: any) => request({ url: apiPrefix, method: 'post', data }) +export const UpdateObj = (data: any) => request({ url: apiPrefix + data.id + '/', method: 'put', data }) +export const DelObj = (id: string | number) => request({ url: apiPrefix + id + '/', method: 'delete' }) diff --git a/web/src/views/pisadmin/miscprocurement/misc_parts/crud.tsx b/web/src/views/pisadmin/miscprocurement/misc_parts/crud.tsx new file mode 100644 index 0000000..bfef5b0 --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/misc_parts/crud.tsx @@ -0,0 +1,203 @@ +import { dict, CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud' +import * as api from './api' +import { GetCompanies } from '../../basicinfo/currency/api' +import { GetList as GetUnits } from '../../basicinfo/unit/api' +import { ElMessage } from 'element-plus' + +const statusDict = [ + { value: 1, label: '启用' }, + { value: 0, label: '禁用' } +] + +const categoryDict = [ + { value: 1, label: '模治具' }, + { value: 2, label: '石墨' }, + { value: 3, label: '其他' } +] + +const loadCompanyOptions = async () => { + try { + const res = await GetCompanies({ page: 1, page_size: 1000, pageSize: 1000 }) + const list = + res?.data?.data?.results || + res?.data?.results || + res?.data?.list || + res?.data || + res?.results || + res?.list || + [] + return (Array.isArray(list) ? list : []).map((c: any) => ({ + company_code: c.company_code, + company_short_name: c.company_short_name || c.company_code || c.company_name + })) + } catch (e) { + console.warn('加载公司列表失败', e) + return [] + } +} + +const loadUnitOptions = async (companyCode?: string) => { + try { + const params: any = { page: 1, page_size: 500, pageSize: 500 } + if (companyCode) params.factory = companyCode + const res = await GetUnits(params) + const list = + res?.data?.data?.results || + res?.data?.results || + res?.data?.list || + res?.data || + res?.results || + res?.list || + [] + return (Array.isArray(list) ? list : []).map((u: any) => ({ + value: u.unitcode || u.unit_code, + label: u.unitname || u.unit_name || u.unitcode + })) + } catch (e) { + console.warn('加载单位列表失败', e) + return [] + } +} + +void loadCompanyOptions() + +export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet { + void crudExpose + + const ensurePartUnique = async (companyCode: string, partId: string, currentId?: number) => { + if (!companyCode || !partId) return + const res = await api.GetList({ company_code: companyCode, partid: partId, page: 1, page_size: 1, pageSize: 1 }) + const list = + res?.data?.data?.results || + res?.data?.results || + res?.data?.list || + res?.data || + res?.results || + res?.list || + [] + const exists = Array.isArray(list) + ? list.find((item: any) => item.company_code === companyCode && item.partid === partId) + : null + if (exists && (!currentId || exists.id !== currentId)) { + throw new Error('同一交易厂区下料号已存在,不可重复') + } + } + return { + crudOptions: { + form: { + labelWidth: '110px' + }, + request: { + pageRequest: async (query) => api.GetList(query), + addRequest: async ({ form }) => { + try { + await ensurePartUnique(form.company_code, form.partid) + return await api.AddObj(form) + } catch (err: any) { + ElMessage.error(err?.message || '保存失败') + throw err + } + }, + editRequest: async ({ form, row }) => { + try { + await ensurePartUnique(form.company_code, form.partid, row.id) + return await api.UpdateObj({ ...form, id: row.id }) + } catch (err: any) { + ElMessage.error(err?.message || '保存失败') + throw err + } + }, + delRequest: async ({ row }) => api.DelObj(row.id) + }, + table: { + rowKey: 'id' + }, + actionbar: { + buttons: { + add: { show: true } + } + }, + rowHandle: { + fixed: 'right' + }, + columns: { + company_code: { + title: '交易厂区', + type: 'dict-select', + dict: dict({ + cache: false, + value: 'company_code', + label: 'company_short_name', + getData: async () => loadCompanyOptions() + }), + search: { show: true }, + form: { + component: { + on: { + change({ form, value }: any) { + form.unit = undefined + form.company_code = value + } + } + } + }, + column: { minWidth: 160, showOverflowTooltip: true } + }, + partid: { + title: '料号', + type: 'input', + search: { show: true, component: { props: { placeholder: '请输入料号', clearable: true } } }, + form: { rules: [{ required: true, message: '请输入料号' }] }, + column: { minWidth: 160, showOverflowTooltip: true } + }, + partid_name: { + title: '物料说明', + type: 'input', + form: { rules: [{ required: true, message: '请输入物料说明' }] }, + column: { minWidth: 160, showOverflowTooltip: true } + }, + specification: { + title: '品名规格', + type: 'input', + form: { rules: [{ required: true, message: '请输入品名规格' }] }, + column: { minWidth: 160, showOverflowTooltip: true } + }, + unit: { + title: '单位', + type: 'dict-select', + dict: dict({ + cache: false, + getData: async ({ form }: any = {}) => loadUnitOptions(form?.company_code) + }), + form: { rules: [{ required: true, message: '请选择单位' }], component: { props: { placeholder: '先选择厂区' } } }, + column: { width: 140, showOverflowTooltip: true } + }, + partid_category_id: { + title: '物料分类', + type: 'dict-select', + dict: dict({ data: categoryDict }), + column: { width: 140, showOverflowTooltip: true } + }, + status: { + title: '启用否', + type: 'dict-switch', + dict: dict({ data: statusDict }), + form: { value: 1 }, + column: { width: 120 } + }, + create_datetime: { + title: '创建时间', + type: 'datetime', + form: { show: false }, + column: { width: 180 } + }, + update_datetime: { + title: '更新时间', + type: 'datetime', + form: { show: false }, + column: { width: 180 } + } + } + } + } +} diff --git a/web/src/views/pisadmin/miscprocurement/misc_parts/index.vue b/web/src/views/pisadmin/miscprocurement/misc_parts/index.vue new file mode 100644 index 0000000..6981059 --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/misc_parts/index.vue @@ -0,0 +1,22 @@ + + + diff --git a/web/src/views/pisadmin/miscprocurement/misc_stations/api.ts b/web/src/views/pisadmin/miscprocurement/misc_stations/api.ts new file mode 100644 index 0000000..4286ba3 --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/misc_stations/api.ts @@ -0,0 +1,8 @@ +import { request } from '/@/utils/service' + +const apiPrefix = '/api/pisadmin/miscprocurement/misc_stations/' + +export const GetList = (params: any) => request({ url: apiPrefix, method: 'get', params }) +export const AddObj = (data: any) => request({ url: apiPrefix, method: 'post', data }) +export const UpdateObj = (data: any) => request({ url: apiPrefix + data.id + '/', method: 'put', data }) +export const DelObj = (id: string | number) => request({ url: apiPrefix + id + '/', method: 'delete' }) diff --git a/web/src/views/pisadmin/miscprocurement/misc_stations/crud.tsx b/web/src/views/pisadmin/miscprocurement/misc_stations/crud.tsx new file mode 100644 index 0000000..609485c --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/misc_stations/crud.tsx @@ -0,0 +1,192 @@ +import { dict, CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud' +import * as api from './api' +import { GetCompanies } from '../../basicinfo/currency/api' +import { GetList as GetUnits } from '../../basicinfo/unit/api' +import { ElMessage } from 'element-plus' + +const statusDict = [ + { value: 1, label: '可用' }, + { value: 0, label: '不可用' } +] + +const stationTypeDict = [ + { value: 1, label: '模治具' }, + { value: 2, label: '石墨' } +] + +const loadCompanyOptions = async () => { + try { + const res = await GetCompanies({ page: 1, page_size: 1000, pageSize: 1000 }) + const list = + res?.data?.data?.results || + res?.data?.results || + res?.data?.list || + res?.data || + res?.results || + res?.list || + [] + return (Array.isArray(list) ? list : []).map((c: any) => ({ + company_code: c.company_code, + company_short_name: c.company_short_name || c.company_code || c.company_name + })) + } catch (e) { + console.warn('加载公司列表失败', e) + return [] + } +} + +const loadUnitOptions = async () => { + try { + const res = await GetUnits({ page: 1, page_size: 1000, pageSize: 1000 }) + const list = + res?.data?.data?.results || + res?.data?.results || + res?.data?.list || + res?.data || + res?.results || + res?.list || + [] + return (Array.isArray(list) ? list : []).map((u: any) => ({ + value: u.unitcode || u.unit_code || u.unit || u.code, + label: u.unitname || u.unit_name || u.unit || u.code + })) + } catch (e) { + console.warn('加载计量单位失败', e) + return [] + } +} + +void loadCompanyOptions() + +export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet { + void crudExpose + + const ensureStationCodeUnique = async (companyCode: string, stationCode: string, currentId?: number) => { + if (!companyCode || !stationCode) return + const res = await api.GetList({ company_code: companyCode, stationcode: stationCode, page: 1, page_size: 1, pageSize: 1 }) + const list = + res?.data?.data?.results || + res?.data?.results || + res?.data?.list || + res?.data || + res?.results || + res?.list || + [] + const exists = Array.isArray(list) + ? list.find((item: any) => item.company_code === companyCode && item.stationcode === stationCode) + : null + if (exists && (!currentId || exists.id !== currentId)) { + throw new Error('同一交易厂区下工站代码已存在,不可重复') + } + } + return { + crudOptions: { + form: { + labelWidth: '110px' + }, + request: { + pageRequest: async (query) => api.GetList(query), + addRequest: async ({ form }) => { + try { + await ensureStationCodeUnique(form.company_code, form.stationcode) + return await api.AddObj(form) + } catch (err: any) { + ElMessage.error(err?.message || '保存失败') + throw err + } + }, + editRequest: async ({ form, row }) => { + try { + await ensureStationCodeUnique(form.company_code, form.stationcode, row.id) + return await api.UpdateObj({ ...form, id: row.id }) + } catch (err: any) { + ElMessage.error(err?.message || '保存失败') + throw err + } + }, + delRequest: async ({ row }) => api.DelObj(row.id) + }, + table: { + rowKey: 'id' + }, + actionbar: { + buttons: { + add: { show: true } + } + }, + rowHandle: { + fixed: 'right' + }, + columns: { + company_code: { + title: '交易厂区', + type: 'dict-select', + dict: dict({ + cache: false, + value: 'company_code', + label: 'company_short_name', + getData: async () => loadCompanyOptions() + }), + search: { show: true }, + column: { minWidth: 160, showOverflowTooltip: true } + }, + stationcode: { + title: '工站代码', + type: 'input', + search: { show: true, component: { props: { placeholder: '请输入工站代码', clearable: true } } }, + form: { rules: [{ required: true, message: '请输入工站代码' }] }, + column: { minWidth: 140, showOverflowTooltip: true } + }, + stationname: { + title: '工站名称', + type: 'input', + search: { show: true, component: { props: { placeholder: '请输入工站名称', clearable: true } } }, + form: { rules: [{ required: true, message: '请输入工站名称' }] }, + column: { minWidth: 160, showOverflowTooltip: true } + }, + stationtype: { + title: '工站类型', + type: 'dict-select', + dict: dict({ data: stationTypeDict }), + form: { rules: [{ required: true, message: '请选择工站类型' }] }, + column: { width: 140, showOverflowTooltip: true } + }, + unit: { + title: '计量单位', + type: 'dict-select', + dict: dict({ + cache: false, + getData: async () => loadUnitOptions() + }), + form: { rules: [{ required: true, message: '请选择计量单位' }] }, + column: { width: 140, showOverflowTooltip: true } + }, + rate: { + title: '费率', + type: 'number', + form: { rules: [{ required: true, message: '请输入费率' }], component: { props: { precision: 2 } } }, + column: { width: 120 } + }, + status: { + title: '可用状态', + type: 'dict-switch', + dict: dict({ data: statusDict }), + form: { value: 1 }, + column: { width: 120 } + }, + create_datetime: { + title: '创建时间', + type: 'datetime', + form: { show: false }, + column: { width: 180 } + }, + update_datetime: { + title: '更新时间', + type: 'datetime', + form: { show: false }, + column: { width: 180 } + } + } + } + } +} diff --git a/web/src/views/pisadmin/miscprocurement/misc_stations/index.vue b/web/src/views/pisadmin/miscprocurement/misc_stations/index.vue new file mode 100644 index 0000000..3dd337f --- /dev/null +++ b/web/src/views/pisadmin/miscprocurement/misc_stations/index.vue @@ -0,0 +1,24 @@ + + + + + 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 = [