diff --git a/backend/apps/pisadmin/basicinfo/models.py b/backend/apps/pisadmin/basicinfo/models.py index a86e941..94dedf1 100644 --- a/backend/apps/pisadmin/basicinfo/models.py +++ b/backend/apps/pisadmin/basicinfo/models.py @@ -87,3 +87,59 @@ class Company(CoreModel): def __str__(self): return self.company_name or self.company_code + + +class SystemNoRule(CoreModel): + """单据编号规则;业务取号请用 `system_no_allocate.allocate_system_number`。""" + + RESET_CYCLE_CHOICES = ( + ("yy", "YY"), + ("yyyy", "YYYY"), + ("yymm", "YYMM"), + ("yyyymm", "YYYYMM"), + ("yymmdd", "YYMMDD"), + ("yyyymmdd", "YYYYMMDD"), + ) + + RULE_CODE_CHOICES = ( + ("miscQTS", "杂采报价单"), + ("miscRFS", "杂采询价单"), + ) + + company_code = models.CharField(max_length=20, verbose_name="交易厂区", help_text="交易厂区(公司)") + rule_code = models.CharField(max_length=50, choices=RULE_CODE_CHOICES, verbose_name="生成单据标识号", help_text="标识一组单号规则") + reset_cycle = models.CharField(max_length=16, choices=RESET_CYCLE_CHOICES, verbose_name="流水码重置类别", help_text="重置粒度") + prefix = models.CharField(max_length=20, verbose_name="单据头", help_text="单据开头固定字符", null=True, blank=True) + factory_code = models.CharField(max_length=20, verbose_name="厂区区分码", help_text="厂区区分码", null=True, blank=True) + seq_length = models.IntegerField(verbose_name="流水码长度", help_text="流水码长度", default=4) + createuser = models.CharField(max_length=50, verbose_name="单据创建人", help_text="单据创建人", null=True, blank=True) + updateuser = models.CharField(max_length=50, verbose_name="单据修改人", help_text="单据修改人", null=True, blank=True) + sequence_date = models.CharField( + max_length=12, + verbose_name="流水日期", + help_text="与 reset_cycle 对应的日期段(如 yymmdd→260324);新建/改重置类别时由服务端写入", + null=True, + blank=True, + ) + prev_sequence = models.IntegerField( + verbose_name="上一流水码", + help_text="上一笔已成功发放的流水号;未发过为 0,与「下一流水码」相差 1", + default=0, + ) + current_sequence = models.IntegerField( + verbose_name="下一流水码", + help_text="下一笔待发流水号;新建/重置周期后为 1,每次取号成功后 +1", + default=1, + ) + last_generate_user = models.CharField(max_length=50, verbose_name="单据最后产生人", help_text="最后产生人", null=True, blank=True) + last_generate_time = models.DateTimeField(verbose_name="单据最后产生时间", help_text="最后产生时间", null=True, blank=True) + + class Meta: + db_table = table_prefix + "system_no_rules" + verbose_name = "系统单号规则" + verbose_name_plural = verbose_name + ordering = ("-create_datetime",) + unique_together = ("company_code", "rule_code") + + def __str__(self): + return f"{self.rule_code}@{self.company_code}" diff --git a/backend/apps/pisadmin/basicinfo/system_no_allocate.py b/backend/apps/pisadmin/basicinfo/system_no_allocate.py new file mode 100644 index 0000000..7d2c030 --- /dev/null +++ b/backend/apps/pisadmin/basicinfo/system_no_allocate.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +""" +系统单号:基于 `SystemNoRule` 取号(与 `system_no_rules` 表一致)。 + +组装规则:prefix + factory_code + sequence_date + str(本次流水).zfill(seq_length) +示例:杂采询价单 miscRFS,prefix=RFS、厂区空、按日 yymmdd → RFS2603240001;厂区 SZ → RFSSZ2603240001。 +""" +from __future__ import annotations + +from django.db import transaction +from django.utils import timezone +from rest_framework import serializers + +from apps.pisadmin.basicinfo.models import SystemNoRule + +# 与前端「通用」厂区、SystemNoRule 维护一致 +DEFAULT_SYSTEM_NO_COMPANY_CODE = "GENERAL" + +RESET_CYCLE_STRFTIME = { + "yy": "%y", + "yyyy": "%Y", + "yymm": "%y%m", + "yyyymm": "%Y%m", + "yymmdd": "%y%m%d", + "yyyymmdd": "%Y%m%d", +} + + +def format_sequence_date(reset_cycle: str, when=None) -> str: + fmt = RESET_CYCLE_STRFTIME.get(reset_cycle or "") + if not fmt: + return "" + dt = when if when is not None else timezone.now() + return dt.strftime(fmt) + + +def sequence_period_matches(rule: SystemNoRule, seq_date: str) -> bool: + stored = (rule.sequence_date or "").strip() + return bool(stored) and stored == seq_date + + +def _allocate_next_code_for_locked_rule(rule: SystemNoRule, now) -> str: + """ + 假定 rule 已由 select_for_update 锁定;计算下一单号并更新 rule 内存字段(未 save)。 + """ + seq_date = format_sequence_date(rule.reset_cycle, now) + if not seq_date: + raise serializers.ValidationError("未支持的流水码重置类别") + + if not sequence_period_matches(rule, seq_date): + rule.prev_sequence = 0 + new_seq = 1 + else: + nxt = int(rule.current_sequence or 0) + new_seq = nxt if nxt > 0 else 1 + if ( + rule.last_generate_time is not None + and int(rule.prev_sequence or 0) == 0 + and new_seq == 1 + ): + new_seq = 2 + + slen = max(int(rule.seq_length or 4), 1) + max_seq = 10**slen - 1 + if new_seq > max_seq: + raise serializers.ValidationError( + f"流水码已达上限(长度 {slen} 位,最大 {max_seq}),请调整规则或联系管理员" + ) + + seq_str = str(new_seq).zfill(slen) + code = "".join( + [ + rule.prefix or "", + rule.factory_code or "", + seq_date, + seq_str, + ] + ) + + rule.sequence_date = seq_date + rule.prev_sequence = new_seq + rule.current_sequence = new_seq + 1 + return code + + +def allocate_system_number( + company_code: str, + rule_code: str, + *, + username: str | None = None, + now=None, +) -> str: + """ + 在事务内对匹配 (company_code, rule_code) 的规则行加锁,生成单号并更新流水字段。 + + :param company_code: 交易厂区(与规则表一致,常用 DEFAULT_SYSTEM_NO_COMPANY_CODE) + :param rule_code: 如 miscRFS / miscQTS + :param username: 写入 last_generate_user + :return: 完整单号字符串 + """ + codes = allocate_system_numbers(company_code, rule_code, 1, username=username, now=now) + return codes[0] + + +def allocate_system_numbers( + company_code: str, + rule_code: str, + count: int, + *, + username: str | None = None, + now=None, +) -> list[str]: + """ + 同一事务内连续取 `count` 个号(一次锁表),用于批量生成报价单等场景。 + """ + if count <= 0: + return [] + + if not (company_code or "").strip() or not (rule_code or "").strip(): + raise serializers.ValidationError("取号参数缺失:company_code 与 rule_code 必填") + + company_code = str(company_code).strip() + rule_code = str(rule_code).strip() + now = now or timezone.now() + + with transaction.atomic(): + rule = ( + SystemNoRule.objects.select_for_update() + .filter(company_code=company_code, rule_code=rule_code) + .first() + ) + if not rule: + raise serializers.ValidationError( + f"未配置取号规则:rule_code={rule_code},company_code={company_code},请在「单据编号规则」中维护。" + ) + + codes = [] + for _ in range(count): + codes.append(_allocate_next_code_for_locked_rule(rule, now)) + + rule.last_generate_user = username + rule.last_generate_time = now + rule.save( + update_fields=[ + "sequence_date", + "prev_sequence", + "current_sequence", + "last_generate_user", + "last_generate_time", + "update_datetime", + ] + ) + + return codes diff --git a/backend/apps/pisadmin/basicinfo/urls.py b/backend/apps/pisadmin/basicinfo/urls.py index 06378c0..a5037b9 100644 --- a/backend/apps/pisadmin/basicinfo/urls.py +++ b/backend/apps/pisadmin/basicinfo/urls.py @@ -5,11 +5,13 @@ from apps.pisadmin.basicinfo.views.unit import UnitViewSet from apps.pisadmin.basicinfo.views.currency import CurrencyViewSet from apps.pisadmin.basicinfo.views.supplier import SupplierViewSet from apps.pisadmin.basicinfo.views.company import CompanyViewSet +from apps.pisadmin.basicinfo.views.system_no_rule import SystemNoRuleViewSet basicinfo_url = routers.SimpleRouter() basicinfo_url.register(r'units', UnitViewSet) basicinfo_url.register(r'currencies', CurrencyViewSet) basicinfo_url.register(r'suppliers', SupplierViewSet) basicinfo_url.register(r'companies', CompanyViewSet) +basicinfo_url.register(r'system_no_rules', SystemNoRuleViewSet) urlpatterns = [] + basicinfo_url.urls diff --git a/backend/apps/pisadmin/basicinfo/views/system_no_rule.py b/backend/apps/pisadmin/basicinfo/views/system_no_rule.py new file mode 100644 index 0000000..4e52709 --- /dev/null +++ b/backend/apps/pisadmin/basicinfo/views/system_no_rule.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +from rest_framework import serializers +from rest_framework.decorators import action +from rest_framework.response import Response +from django.utils import timezone + +from apps.pisadmin.basicinfo.models import SystemNoRule +from apps.pisadmin.basicinfo.system_no_allocate import allocate_system_number, format_sequence_date +from dvadmin.utils.serializers import CustomModelSerializer +from dvadmin.utils.viewset import CustomModelViewSet + +# 由服务端维护,禁止前端随表单覆盖(避免取号后被编辑保存冲掉) +_SEQUENCE_FIELDS_FROM_CLIENT_FORBIDDEN = ( + "sequence_date", + "prev_sequence", + "current_sequence", + "last_generate_user", + "last_generate_time", +) + + +class SystemNoRuleSerializer(CustomModelSerializer): + """系统单号规则-序列化器""" + + class Meta: + model = SystemNoRule + fields = "__all__" + read_only_fields = ["id"] + + +class SystemNoRuleCreateUpdateSerializer(CustomModelSerializer): + """系统单号规则 创建/更新""" + + company_code = serializers.CharField(max_length=20) + rule_code = serializers.CharField(max_length=50) + reset_cycle = serializers.ChoiceField(choices=SystemNoRule.RESET_CYCLE_CHOICES) + seq_length = serializers.IntegerField(min_value=1, max_value=20) + + class Meta: + model = SystemNoRule + fields = "__all__" + + def validate(self, attrs): + company_code = attrs.get("company_code") + if company_code is None and getattr(self.instance, "company_code", None) is not None: + company_code = self.instance.company_code + rule_code = attrs.get("rule_code") + if rule_code is None and getattr(self.instance, "rule_code", None) is not None: + rule_code = self.instance.rule_code + instance_id = getattr(self.instance, "id", None) + if company_code is not None and rule_code is not None: + exists = SystemNoRule.objects.filter(company_code=company_code, rule_code=rule_code) + if instance_id: + exists = exists.exclude(id=instance_id) + if exists.exists(): + raise serializers.ValidationError("同一厂区下的生成单据标识号不可重复") + return attrs + + def create(self, validated_data): + for k in _SEQUENCE_FIELDS_FROM_CLIENT_FORBIDDEN: + validated_data.pop(k, None) + rc = validated_data["reset_cycle"] + validated_data["sequence_date"] = format_sequence_date(rc) + validated_data["prev_sequence"] = 0 + validated_data["current_sequence"] = 1 + return super().create(validated_data) + + def update(self, instance, validated_data): + for k in _SEQUENCE_FIELDS_FROM_CLIENT_FORBIDDEN: + validated_data.pop(k, None) + new_cycle = validated_data.get("reset_cycle") + if new_cycle is not None and new_cycle != instance.reset_cycle: + validated_data["sequence_date"] = format_sequence_date(new_cycle) + validated_data["prev_sequence"] = 0 + validated_data["current_sequence"] = 1 + return super().update(instance, validated_data) + + +class SystemNoRuleViewSet(CustomModelViewSet): + """系统单号规则管理接口""" + + queryset = SystemNoRule.objects.all() + serializer_class = SystemNoRuleSerializer + create_serializer_class = SystemNoRuleCreateUpdateSerializer + update_serializer_class = SystemNoRuleCreateUpdateSerializer + search_fields = ["company_code", "rule_code", "factory_code", "prefix"] + ordering = ["-create_datetime"] + + @action(methods=["post"], detail=False, url_path="generate_code") + def generate_code(self, request): + """生成单据号,含并发锁保证流水唯一(与 `allocate_system_number` 同源逻辑)""" + + company_code = request.data.get("company_code") + rule_code = request.data.get("rule_code") + if not company_code or not rule_code: + return Response({"success": False, "message": "参数缺失:company_code 与 rule_code 必填", "code": None}, status=400) + + username = getattr(request.user, "username", None) or getattr(request.user, "name", None) + try: + code = allocate_system_number( + company_code, + rule_code, + username=username, + now=timezone.now(), + ) + except serializers.ValidationError as exc: + detail = getattr(exc, "detail", None) + if isinstance(detail, list) and detail: + msg = "; ".join(str(x) for x in detail) + elif isinstance(detail, dict): + msg = "; ".join(f"{k}: {v}" for k, v in detail.items()) + else: + msg = str(detail) if detail is not None else str(exc) + return Response({"success": False, "message": msg, "code": None}, status=400) + except Exception as exc: # pragma: no cover - defensive + return Response({"success": False, "message": str(exc), "code": None}, status=500) + + return Response({"success": True, "message": "", "code": code}) diff --git a/backend/apps/pisadmin/miscprocurement/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index 6fa5c64..960df89 100644 --- a/backend/apps/pisadmin/miscprocurement/serializers.py +++ b/backend/apps/pisadmin/miscprocurement/serializers.py @@ -21,6 +21,7 @@ from .models import ( CostEstimateTemplateBody, ) from apps.pisadmin.basicinfo.models import Unit +from apps.pisadmin.basicinfo.system_no_allocate import DEFAULT_SYSTEM_NO_COMPANY_CODE, allocate_system_number from dvadmin.utils.serializers import CustomModelSerializer @@ -640,6 +641,8 @@ class NestedInquirySupplierSerializer(InquirySupplierSerializer): class InquiryAttachmentSerializer(serializers.ModelSerializer): + """采购端询价单附件读写;供应商端只读见 `apps.pissupplier.serializers.QuotationMasterSerializer.inquiry_attachments`。""" + file_type = serializers.CharField() def validate_file_type(self, value): @@ -883,30 +886,14 @@ class InquirySerializer(CustomModelSerializer): ] extra_kwargs = {"inquiry_no": {"required": False, "allow_blank": True, "allow_null": True}} - def _generate_code(self) -> str: + def _generate_code(self, validated_data: dict) -> str: """ - 自动生成唯一的询价单号 - 格式: RFS + YYMMDD + 5位序号 (e.g., RFS26031700001) + 按 `SystemNoRule` 取号:rule_code=miscRFS,company_code 取表单或默认「通用」厂区。 + 组装见 `allocate_system_number`(prefix + factory_code + sequence_date + 流水)。 """ - date_part = timezone.now().strftime("%y%m%d") - base = f"RFS{date_part}" - with transaction.atomic(): - last = ( - Inquiry.objects.select_for_update() - .filter(inquiry_no__startswith=base) - .order_by("-inquiry_no") - .first() - ) - if last and last.inquiry_no and len(last.inquiry_no) >= len(base) + 5: - try: - seq = int(last.inquiry_no[-5:]) + 1 - except ValueError: - seq = 1 - else: - seq = 1 - if seq > 99999: - raise serializers.ValidationError("当日编号已达上限,请联系管理员") - return f"{base}{seq:05d}" + company_code = (validated_data.get("company_code") or "").strip() or DEFAULT_SYSTEM_NO_COMPANY_CODE + username = get_request_username(self) or None + return allocate_system_number(company_code, "miscRFS", username=username) def _upsert_suppliers(self, inquiry: Inquiry, suppliers): """更新或创建供应商信息""" @@ -1132,7 +1119,7 @@ class InquirySerializer(CustomModelSerializer): current_time = timezone.now() if not validated_data.get("inquiry_no"): - validated_data["inquiry_no"] = self._generate_code() + validated_data["inquiry_no"] = self._generate_code(validated_data) # 询价单创建后默认进入“开立”,状态流转仅允许通过专用动作接口处理 validated_data["status"] = 1 validated_data.pop("confirm_user", None) diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index fef5c1e..cee9af3 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -6,6 +6,7 @@ from rest_framework import serializers from dvadmin.utils.json_response import DetailResponse, ErrorResponse, SuccessResponse from dvadmin.utils.viewset import CustomModelViewSet +from apps.pisadmin.basicinfo.system_no_allocate import DEFAULT_SYSTEM_NO_COMPANY_CODE, allocate_system_numbers from apps.pissupplier.models import ( QuotationMaster, QuotationAttachment, @@ -321,31 +322,22 @@ class InquiryViewSet(CustomModelViewSet): serializer = self.get_serializer(instance) return DetailResponse(data=serializer.data, msg="状态更新成功") - def _generate_quotation_numbers(self, count: int): + def _generate_quotation_numbers(self, count: int, inquiry: Inquiry): + """ + 杂采报价单号:复用 `SystemNoRule`(rule_code=miscQTS),与询价单 miscRFS 同源取号逻辑。 + company_code 优先询价单字段,否则「通用」厂区。 + """ if count <= 0: return [] - - date_part = timezone.now().strftime("%y%m%d") - base = f"QFS{date_part}" - last = ( - QuotationMaster.objects.select_for_update() - .filter(quotation_no__startswith=base) - .order_by("-quotation_no") - .first() + company_code = (getattr(inquiry, "company_code", None) or "").strip() or DEFAULT_SYSTEM_NO_COMPANY_CODE + username = self._get_request_username() or None + return allocate_system_numbers( + company_code, + "miscQTS", + count, + username=username, + now=timezone.now(), ) - if last and last.quotation_no and len(last.quotation_no) >= len(base) + 5: - try: - start_seq = int(last.quotation_no[-5:]) + 1 - except ValueError: - start_seq = 1 - else: - start_seq = 1 - - end_seq = start_seq + count - 1 - if end_seq > 99999: - raise serializers.ValidationError("当日报价单编号已达上限,请联系管理员") - - return [f"{base}{seq:05d}" for seq in range(start_seq, end_seq + 1)] def _get_template_prefill_fields(self, template_no): """ @@ -458,7 +450,7 @@ class InquiryViewSet(CustomModelViewSet): QuotationMaster.objects.filter(inquiry_no=inquiry.inquiry_no).delete() - quotation_numbers = self._generate_quotation_numbers(len(supplier_groups)) + quotation_numbers = self._generate_quotation_numbers(len(supplier_groups), inquiry) prefill_fields = self._get_template_prefill_fields(getattr(inquiry, "template", None)) current_user = self._clip( self._get_request_username() or getattr(inquiry, "release_user", None), diff --git a/backend/apps/pissupplier/serializers.py b/backend/apps/pissupplier/serializers.py index 5d579f7..12a0183 100644 --- a/backend/apps/pissupplier/serializers.py +++ b/backend/apps/pissupplier/serializers.py @@ -9,6 +9,7 @@ from apps.pisadmin.miscprocurement.models import ( CostEstimateTemplateBody, CostEstimateTemplateHead, Inquiry, + InquiryAttachment, ) from apps.pissupplier.models import ( QuotationMaster, @@ -322,6 +323,45 @@ class NestedQuotationItemSerializer(QuotationItemSerializer): validators = [] +_INQUIRY_ATTACHMENT_TYPE_LABELS = {1: "产品图纸", 2: "招标文件", 3: "其它文件"} + + +class SupplierInquiryAttachmentSerializer(serializers.ModelSerializer): + """供应商报价详情:询价单附件(`pis_proc_inquiry_attachment`),只读。""" + + file_type_label = serializers.SerializerMethodField() + + def get_file_type_label(self, obj): + ft = getattr(obj, "file_type", None) + try: + ft = int(ft) + except (TypeError, ValueError): + ft = 3 + return _INQUIRY_ATTACHMENT_TYPE_LABELS.get(ft, "其它文件") + + class Meta: + model = InquiryAttachment + fields = [ + "id", + "part_id", + "file_type", + "file_type_label", + "file_name", + "file_path", + "upload_time", + "upload_user", + ] + read_only_fields = [ + "id", + "part_id", + "file_type", + "file_name", + "file_path", + "upload_time", + "upload_user", + ] + + class QuotationMasterSerializer(BusinessAuditSerializer): """杂采报价单主表序列化器""" @@ -331,6 +371,7 @@ class QuotationMasterSerializer(BusinessAuditSerializer): allow_null=True, ) template_sections = serializers.SerializerMethodField(read_only=True) + inquiry_attachments = serializers.SerializerMethodField(read_only=True) attachments = QuotationAttachmentSerializer(many=True, required=False) material_costs = QuotationMaterialSerializer(many=True, required=False) process_costs = NestedQuotationProcessSerializer(many=True, required=False) @@ -357,6 +398,19 @@ class QuotationMasterSerializer(BusinessAuditSerializer): return [] return build_cost_template_sections_for_quotation(inq.template) + def get_inquiry_attachments(self, obj): + """详情接口返回询价单附件;列表不查,避免 N+1。""" + if self.context.get("view_action") != "retrieve": + return [] + inquiry_no = getattr(obj, "inquiry_no", None) + if not inquiry_no: + return [] + qs = ( + InquiryAttachment.objects.filter(inquiry_no=inquiry_no) + .order_by("file_type", "-upload_time", "id") + ) + return SupplierInquiryAttachmentSerializer(qs, many=True).data + class Meta: model = QuotationMaster fields = "__all__" diff --git a/web/src/views/pisadmin/basicinfo/systemno/api.ts b/web/src/views/pisadmin/basicinfo/systemno/api.ts new file mode 100644 index 0000000..d8bff13 --- /dev/null +++ b/web/src/views/pisadmin/basicinfo/systemno/api.ts @@ -0,0 +1,8 @@ +import { request } from '/@/utils/service' + +const apiPrefix = '/api/pisadmin/basicinfo/system_no_rules/' + +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/basicinfo/systemno/crud.tsx b/web/src/views/pisadmin/basicinfo/systemno/crud.tsx new file mode 100644 index 0000000..81f6b82 --- /dev/null +++ b/web/src/views/pisadmin/basicinfo/systemno/crud.tsx @@ -0,0 +1,246 @@ +import { dict, CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud' +import * as api from './api' +import { GetCompanies } from '../currency/api' +import { useUserInfo } from '/@/stores/userInfo' +import { ElMessage } from 'element-plus' + +const resetCycleDict = [ + { value: 'yy', label: '按年2(YY)' }, + { value: 'yyyy', label: '按年4(YYYY)' }, + { value: 'yymm', label: '按月4(YYMM)' }, + { value: 'yyyymm', label: '按月6(YYYYMM)' }, + { value: 'yymmdd', label: '按日6(YYMMDD)' }, + { value: 'yyyymmdd', label: '按日8(YYYYMMDD)' } +] + +/** 与 `SystemNoRule.RULE_CODE_CHOICES` 一致;表单/搜索下拉展示「代码 - 名称」,列表列仅显示代码(见 column.formatter) */ +const ruleCodeDict = [ + { value: 'miscQTS', label: 'miscQTS (杂采报价单)' }, + { value: 'miscRFS', label: 'miscRFS (杂采询价单)' } +] + +/** 交易厂区「通用」:非公司主数据,仅存于编号规则下拉;与 unique(company_code, rule_code) 兼容 */ +export const SYSTEMNO_GENERAL_COMPANY_CODE = 'GENERAL' + +const generalCompanyOption = () => ({ + company_code: SYSTEMNO_GENERAL_COMPANY_CODE, + company_short_name: '通用' +}) + +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 || + [] + const fromApi = (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 + })) + .filter((c: { company_code: string }) => c.company_code !== SYSTEMNO_GENERAL_COMPANY_CODE) + return [generalCompanyOption(), ...fromApi] + } catch (e) { + console.warn('加载公司列表失败', e) + return [generalCompanyOption()] + } +} + +export const createCrudOptions = function ({ crudExpose }: Partial): CreateCrudOptionsRet { + void crudExpose + const userStore = useUserInfo() + const currentUser = + userStore.userInfos?.name || + userStore.userInfos?.username || + userStore.userInfos?.email || + '' + + const ensureRuleUnique = async (companyCode: string, ruleCode: string, currentId?: number) => { + if (!companyCode || !ruleCode) return + const res = await api.GetList({ company_code: companyCode, rule_code: ruleCode, 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.rule_code === ruleCode) + : null + if (exists && (!currentId || exists.id !== currentId)) { + throw new Error('同一交易厂区下的生成单据标识号不可重复') + } + } + + return { + crudOptions: { + form: { + labelWidth: '120px' + }, + request: { + pageRequest: async (query) => api.GetList(query), + addRequest: async ({ form }) => { + try { + await ensureRuleUnique(form.company_code, form.rule_code) + return await api.AddObj({ + ...form, + createuser: currentUser, + updateuser: currentUser + }) + } catch (err: any) { + ElMessage.error(err?.message || '保存失败') + throw err + } + }, + editRequest: async ({ form, row }) => { + try { + await ensureRuleUnique(form.company_code, form.rule_code, row.id) + return await api.UpdateObj({ + ...form, + id: row.id, + updateuser: currentUser + }) + } 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: { + value: SYSTEMNO_GENERAL_COMPANY_CODE, + rules: [{ required: true, message: '请选择交易厂区' }] + }, + column: { minWidth: 160, showOverflowTooltip: true } + }, + rule_code: { + title: '生成单据标识号', + type: 'dict-select', + dict: dict({ data: ruleCodeDict }), + search: { + show: true, + component: { props: { placeholder: '请选择', clearable: true } } + }, + form: { + rules: [{ required: true, message: '请选择生成单据标识号' }], + component: { props: { placeholder: '请选择', filterable: true } } + }, + column: { + minWidth: 140, + showOverflowTooltip: true, + formatter: (ctx: { value?: unknown; row?: { rule_code?: string } }) => { + const v = ctx.value ?? ctx.row?.rule_code + return v === undefined || v === null || v === '' ? '' : String(v) + } + } + }, + reset_cycle: { + title: '流水码重置类别', + type: 'dict-select', + dict: dict({ data: resetCycleDict }), + form: { rules: [{ required: true, message: '请选择流水码重置类别' }] }, + column: { minWidth: 180, showOverflowTooltip: true } + }, + prefix: { + title: '单据头', + type: 'input', + column: { minWidth: 140, showOverflowTooltip: true } + }, + factory_code: { + title: '厂区区分码', + type: 'input', + column: { minWidth: 140, showOverflowTooltip: true } + }, + seq_length: { + title: '流水码长度', + type: 'input-number', + form: { value: 4, component: { props: { min: 1, max: 20 } } }, + column: { width: 120 } + }, + createuser: { + title: '单据创建人', + type: 'input', + form: { show: false }, + column: { width: 140, showOverflowTooltip: true } + }, + create_datetime: { + title: '创建时间', + type: 'datetime', + form: { show: false }, + column: { width: 180 } + }, + updateuser: { + title: '单据修改人', + type: 'input', + form: { show: false }, + column: { width: 140, showOverflowTooltip: true } + }, + update_datetime: { + title: '修改时间', + type: 'datetime', + form: { show: false }, + column: { width: 180 } + }, + sequence_date: { + title: '流水日期', + type: 'input', + form: { show: false }, + column: { minWidth: 140, showOverflowTooltip: true } + }, + prev_sequence: { + title: '上一流水码', + type: 'number', + form: { show: false }, + column: { width: 120 } + }, + current_sequence: { + title: '下一流水码', + type: 'number', + form: { show: false }, + column: { width: 140 } + }, + last_generate_user: { + title: '单据最后产生人', + type: 'input', + form: { show: false }, + column: { minWidth: 150, showOverflowTooltip: true } + }, + last_generate_time: { + title: '单据最后产生时间', + type: 'datetime', + form: { show: false }, + column: { width: 190 } + } + } + } + } +} diff --git a/web/src/views/pisadmin/basicinfo/systemno/index.vue b/web/src/views/pisadmin/basicinfo/systemno/index.vue new file mode 100644 index 0000000..47d56ca --- /dev/null +++ b/web/src/views/pisadmin/basicinfo/systemno/index.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/web/src/views/pissupplier/quotation/crud.tsx b/web/src/views/pissupplier/quotation/crud.tsx index 596a5c7..adbc6f9 100644 --- a/web/src/views/pissupplier/quotation/crud.tsx +++ b/web/src/views/pissupplier/quotation/crud.tsx @@ -57,7 +57,10 @@ type Quote = { paymentTerm: string } costItems: CostItem[] + /** 报价方上传的附件(`pis_sup_quotation_attachment`) */ attachments: any[] + /** 询价单关联附件(`pis_proc_inquiry_attachment`),详情接口 `inquiry_attachments` */ + inquiryAttachments: InquiryAttachmentRow[] remark: string createdAt: string templateSections?: any @@ -68,6 +71,17 @@ type Quote = { type SummaryRow = { section: string; amount: number; isSubtotal?: boolean } +export type InquiryAttachmentRow = { + id?: number | string + part_id?: string + file_type?: number + file_type_label?: string + file_name?: string + file_path?: string + upload_time?: string + upload_user?: string +} + const statusOptions = [ { label: '未报价', value: 'pending' }, { label: '已报价', value: 'quoted' }, @@ -922,6 +936,7 @@ function blankQuote(): Quote { base: { contact: '', phone: '', email: '', validityDays: '', leadTimeDays: '', paymentTerm: '' }, costItems: [], attachments: [], + inquiryAttachments: [], remark: '', createdAt: '', templateSections: [], @@ -1207,6 +1222,11 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { ), rfqItems: Array.isArray(item.rfq_items) ? item.rfq_items : [], attachments: Array.isArray(item.attachments) ? item.attachments : [], + inquiryAttachments: Array.isArray(item.inquiry_attachments) + ? item.inquiry_attachments + : Array.isArray(item.inquiryAttachments) + ? item.inquiryAttachments + : [], remark: item.remark || '', createdAt: item.creattime || item.creat_time || item.create_time || item.createTime || item.createdAt || '', templateSections: diff --git a/web/src/views/pissupplier/quotation/index.vue b/web/src/views/pissupplier/quotation/index.vue index d4e39ca..f249092 100644 --- a/web/src/views/pissupplier/quotation/index.vue +++ b/web/src/views/pissupplier/quotation/index.vue @@ -19,190 +19,133 @@ -
- 报价状态: - {{ statusLabel(current.status) }} -
+
+
+ 报价状态: + {{ statusLabel(current.status) }} +
- - - 关联询价信息 - - {{ current.inquiryCode }} - {{ current.inquiryTitle }} - {{ templateLabel(current.template) }} - {{ current.currency }} - {{ current.quoteDeadline }} - {{ formatMiscInquiryStatus(current) }} - - - 报价基础信息 - - - - - - - - - - - - - - - - - - - - - - - - - - - 报价合计 -
- - - - - - -
-
最终报价
-
{{ formatMoney(quoteTotal) }}
-
-
- - 报价附件与说明 -
- - -
拖拽或点击上传 (PDF/DOC/JPG/PNG)
-
- -
-
- - -
- - 清空已填内容 -
-
-
-
-
{{ section }}
- - 新增一行 + + + + {{ current.inquiryCode }} + {{ current.inquiryTitle }} + {{ templateLabel(current.template) }} + {{ current.currency }} + {{ current.quoteDeadline }} + {{ formatMiscInquiryStatus(current) }} + + 询价附件 +
+
+
{{ group.label }}
+
+
暂无询价附件
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 报价合计 +
- + + - - - +
+
最终报价
+
{{ formatMoney(quoteTotal) }}
+
-
-
+ 报价附件与说明 +
+ + +
拖拽或点击上传 (PDF/DOC/JPG/PNG)
+
+ +
+ + + +
+ + 清空已填内容 +
+
+
{{ section }}
+ + 新增一行
- + + + +
-
-
- - +
+
+
+
{{ section }}
+
+ + + + + +
+
+
+
+ +
+