diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index 796fbc2..08b0dcc 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -672,7 +672,7 @@ class InquiryRfqItem(models.Model): class MiscLowPriceHeader(CoreModel): - """比价-制程最低价记录主表。按询价单上阶物料料号分套;材料按规格、加工按工站各占一行(无整单合并行)。""" + """比价-制程最低价记录主表。按询价单上阶物料料号分套;材料一行(最低重量×最低单价×数量)、加工一行(各报价单加工费合计之最小值);包装费/运输费/利润率各一行。""" # 成本类别: 1-材料; 2-加工; 3-包装费; 4-运输费; 5-管销研费用(杂采制程最低价不落库); 6-利润率 COST_TYPE_CHOICES = ( @@ -692,9 +692,9 @@ class MiscLowPriceHeader(CoreModel): help_text="业务主键列之一;逻辑主键与 id 并存", ) part_id = models.CharField(max_length=50, db_column="PartId", verbose_name="产品料号") - # 来源单号(报价单&询价单);设计库字段名为 SouceNo + # 来源单号(报价单号/厂区等);材料行可聚合多来源(如 W:单号;U:单号);设计库字段名为 SouceNo souce_no = models.CharField( - max_length=20, + max_length=200, null=True, blank=True, db_column="SouceNo", @@ -733,12 +733,12 @@ class MiscLowPriceHeader(CoreModel): class MiscLowPriceDetail(CoreModel): - """比价-制程最低价记录次表""" + """比价-制程最低价记录次表(材料:全供应商最低重量、最低单价各一行,material_spec 占位「-」)""" ITEM_NO_CHOICES = ( - (1, "重量"), - (2, "单价"), - ) + ("1", "重量"), + ("2", "单价"), + ) inquiry_no = models.CharField(max_length=20, db_column="inquiry_no", db_index=True, verbose_name="询价单号") part_id = models.CharField(max_length=50, db_column="PartId", verbose_name="产品料号") cost_type = models.CharField( @@ -757,9 +757,10 @@ class MiscLowPriceDetail(CoreModel): ) value = models.CharField(max_length=10, db_column="Value", verbose_name="最小值") souce_no = models.CharField( - max_length=20, + max_length=200, db_column="SouceNo", - verbose_name="来源单号(报价单&询价单)", + verbose_name="来源单号(报价单号或交易厂区)", + help_text="重量/单价取最小值时对应报价单单号;单价来自杂采材料信息时存交易厂区代码", ) class Meta: diff --git a/backend/apps/pisadmin/miscprocurement/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index 5128677..35de75c 100644 --- a/backend/apps/pisadmin/miscprocurement/serializers.py +++ b/backend/apps/pisadmin/miscprocurement/serializers.py @@ -267,7 +267,8 @@ class MiscPartCreateUpdateSerializer(CustomModelSerializer): class MiscLowPriceHeaderSerializer(CustomModelSerializer): - """比价-制程最低价主表:开启/确认比价时写入;含材料规格、工站、包装费(3)、运输费(4)、利润率(6);杂采不写管销研(5)。""" + """比价-制程最低价主表:开启/确认比价时写入;材料一行(重量×单价×数量)、加工一行(无次表、不按工站;合计最小,全 0/空也落库)、包装费(3)、运输费(4)、利润率(6);杂采不写管销研(5)。 + souce_no:材料行为次表来源聚合(如 W:报价单号;U:报价单号或厂区);加工行为合计最小的报价单单号,无报价时回退询价单号。""" class Meta: model = MiscLowPriceHeader @@ -283,7 +284,7 @@ class MiscLowPriceHeaderSerializer(CustomModelSerializer): class MiscLowPriceDetailSerializer(CustomModelSerializer): - """比价-制程最低价记录次表(材料规格下重量/单价最小值;与 confirm_negotiation 同步写入)。""" + """比价-制程最低价记录次表(材料:最低重量、最低单价各一行;souce_no 为对应报价单单号或杂采交易厂区代码)。""" class Meta: model = MiscLowPriceDetail diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index 4d96f16..030c9ce 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -1,6 +1,6 @@ import logging -from collections import defaultdict from decimal import Decimal +from typing import Optional from django.db import transaction from django.db.models import Q @@ -115,15 +115,48 @@ def _clip_field(value, max_len: int) -> str: return str(value or "").strip()[:max_len] +def _norm_low_price_src(s: str, max_len: int = 200) -> str: + return (s or "").strip()[:max_len] + + +def _material_cost_header_souce_no(weight_src: Optional[str], unit_src: Optional[str]) -> str: + """主表材料行:聚合次表来源;W=最低重量对应报价单号,U=最低单价对应报价单号或杂采厂区代码。""" + parts = [] + if weight_src: + parts.append(f"W:{_norm_low_price_src(weight_src)}") + if unit_src: + parts.append(f"U:{_norm_low_price_src(unit_src)}") + return _norm_low_price_src(";".join(parts)) + + +def _rfq_qty_decimal(inquiry: Inquiry, pid: str) -> Optional[Decimal]: + """上阶物料需求数量;无则返回 None。""" + pid = (pid or "").strip() + if not pid: + return None + it = ( + InquiryRfqItem.objects.filter(inquiry_no=inquiry, part_id=pid) + .order_by("id") + .first() + ) + if it is None or it.qty is None: + return None + try: + return Decimal(str(it.qty)) + except Exception: + return None + + def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None: """ 制程最低价落库(与比价展开明细一致): - 询价单下**每个上阶物料料号**单独一套主/次表数据(多料号互不合并)。 - - **材料**:按「材料规格」各一行,取各供应商该规格 material_cost 的 min;次表为同规格下重量、单价 min。 - - **加工**:按「工站」各一行,取各供应商该工站 process_price 的 min(多工站互不合并)。 - - **其它**:包装费、运输费分列取 min(优先 sup_quotation_other;无则用上阶物料 total_other_expense 回退,两列同 min)。 + - **材料**:次表两行分别记录最低重量、最低单价;souce_no 为取得该最小值对应的报价单单号,单价若来自「杂采材料信息」 + 则存交易厂区(factory);主表材料行 souce_no 聚合为 W:…;U:…(重量来源与单价来源可能不同)。 + - **加工**:仅主表一行(无次表、不按工站);每报价单合计加工费后取最小,全空/全 0 仍写入 MinPrice=0。 + - 注意:材料行必须用 quotation_no__in=报价单号列表 过滤,勿用 QuerySet(QuotationMaster) 作 __in,否则 ORM 按主键匹配会查不到材料行。 + - **其它**:包装费、运输费分列取 min(优先 sup_quotation_other;无则用上阶物料 total_other_expense 回退)。 - **利润率**:cost_type=6,各报价单该料号利润率取 min。杂采不落库管销研(cost_type=5)。 - 不写「整单材料成本/加工成本」汇总行,避免多规格/多工站被合并成一条。 """ inquiry_no = (inquiry.inquiry_no or "").strip() if not inquiry_no: @@ -131,9 +164,6 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None: qm_qs = QuotationMaster.objects.filter(inquiry_no=inquiry_no).exclude(status=4) qn_list = list(qm_qs.values_list("quotation_no", flat=True)) - if not qn_list: - return - souce_ref = inquiry_no[:20] raw_parts = [ @@ -152,93 +182,104 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None: MiscLowPriceHeader.objects.filter(inquiry_no=inquiry_no, part_id=pid).delete() MiscLowPriceDetail.objects.filter(inquiry_no=inquiry_no, part_id=pid).delete() - # —— 材料:按材料规格一行;次表重量/单价 —— - materials = QuotationMaterial.objects.filter(quotation_no__in=qm_qs, part_id=pid) - by_spec: dict[str, list] = defaultdict(list) + # —— 材料:次表两行(重量、单价);主表 = 最低重量×最低单价×数量。 + # FK 须用报价单单号列表:quotation_no__in=QuerySet(QuotationMaster) 会按主键匹配,导致材料行查不到。 + materials = QuotationMaterial.objects.filter(quotation_no__in=qn_list, part_id=pid) + weight_candidates: list[tuple[Decimal, str]] = [] + unit_candidates: list[tuple[Decimal, str]] = [] for m in materials: - spec = (m.material_spec or "").strip() or "材料" - by_spec[spec].append(m) + qn_src = getattr(m, "quotation_no_id", None) or "" + qn_src = str(qn_src).strip() + if m.weight is not None: + try: + w = Decimal(str(m.weight)) + weight_candidates.append((w, qn_src)) + except Exception: + pass + if m.unit_price is not None: + try: + up = Decimal(str(m.unit_price)) + unit_candidates.append((up, qn_src)) + except Exception: + pass + for mi in MiscProcurementMaterialInfo.objects.filter(status=1).only("price", "factory"): + if mi.price is not None: + try: + up = Decimal(str(mi.price)) + fac = (getattr(mi, "factory", None) or "").strip() or "MISC" + unit_candidates.append((up, fac)) + except Exception: + pass - for spec, rows in by_spec.items(): - costs = [] - for r in rows: - if r.material_cost is not None: - try: - costs.append(Decimal(str(r.material_cost))) - except Exception: - continue - if costs: - MiscLowPriceHeader.objects.create( - inquiry_no=inquiry_no, - part_id=pid, - souce_no=souce_ref or None, - cost_type="1", - item_no=_clip_field(spec, 100), - min_price=_clip_price_str(min(costs)), - ) + detail_spec = "-" + min_w_row = min(weight_candidates, key=lambda t: (t[0], t[1])) if weight_candidates else None + min_up_row = min(unit_candidates, key=lambda t: (t[0], t[1])) if unit_candidates else None + min_w = min_w_row[0] if min_w_row else None + min_up = min_up_row[0] if min_up_row else None + src_w = min_w_row[1] if min_w_row else None + src_up = min_up_row[1] if min_up_row else None + qty_dec = _rfq_qty_decimal(inquiry, pid) - weights = [] - unit_prices = [] - for r in rows: - if r.weight is not None: + if min_w is not None: + MiscLowPriceDetail.objects.create( + inquiry_no=inquiry_no, + part_id=pid, + cost_type="1", + material_spec=detail_spec[:50], + item_no="1", + value=_clip_price_str(min_w), + souce_no=_norm_low_price_src((src_w or souce_ref)), + ) + if min_up is not None: + MiscLowPriceDetail.objects.create( + inquiry_no=inquiry_no, + part_id=pid, + cost_type="1", + material_spec=detail_spec[:50], + item_no="2", + value=_clip_price_str(min_up), + souce_no=_norm_low_price_src((src_up or souce_ref)), + ) + + if min_w is not None and min_up is not None and qty_dec is not None and qty_dec > 0: + material_low = min_w * min_up * qty_dec + MiscLowPriceHeader.objects.create( + inquiry_no=inquiry_no, + part_id=pid, + souce_no=_material_cost_header_souce_no(src_w, src_up) or None, + cost_type="1", + item_no="材料", + min_price=_clip_price_str(material_low), + ) + + # —— 加工:仅主表一行,无次表、不按工站。每份报价单对该料号加工费合计(空/缺省按 0),再取最小; + # 若全无报价单或合计均为空,仍写入 MinPrice=0,来源询价单号。 —— + proc_totals: list[tuple[Decimal, str]] = [] + for qn in qn_list: + total = Decimal("0") + for p in QuotationProcess.objects.filter(quotation_no=qn, part_id=pid): + if p.process_price is not None: try: - weights.append(Decimal(str(r.weight))) + total += Decimal(str(p.process_price)) except Exception: pass - if r.unit_price is not None: - try: - unit_prices.append(Decimal(str(r.unit_price))) - except Exception: - pass - spec_key = _clip_field(spec, 50) - if weights: - MiscLowPriceDetail.objects.create( - inquiry_no=inquiry_no, - part_id=pid, - cost_type="1", - material_spec=spec_key, - item_no="1", - value=_clip_price_str(min(weights)), - souce_no=souce_ref, - ) - if unit_prices: - MiscLowPriceDetail.objects.create( - inquiry_no=inquiry_no, - part_id=pid, - cost_type="1", - material_spec=spec_key, - item_no="2", - value=_clip_price_str(min(unit_prices)), - souce_no=souce_ref, - ) + proc_totals.append((total, str(qn).strip())) + if proc_totals: + best_total, best_qn = min(proc_totals, key=lambda t: (t[0], t[1])) + else: + best_total = Decimal("0") + best_qn = "" + MiscLowPriceHeader.objects.create( + inquiry_no=inquiry_no, + part_id=pid, + souce_no=_norm_low_price_src(best_qn) if best_qn else (souce_ref or None), + cost_type="2", + item_no="加工", + min_price=_clip_price_str(best_total), + ) - # —— 加工:按工站一行(多工站互不合并)—— - processes = QuotationProcess.objects.filter(quotation_no__in=qm_qs, part_id=pid) - by_station: dict[str, list] = defaultdict(list) - for p in processes: - st = (p.process_station or "").strip() or "工站" - by_station[st].append(p) - - for station, rows in by_station.items(): - prices = [] - for r in rows: - if r.process_price is not None: - try: - prices.append(Decimal(str(r.process_price))) - except Exception: - continue - if prices: - MiscLowPriceHeader.objects.create( - inquiry_no=inquiry_no, - part_id=pid, - souce_no=souce_ref or None, - cost_type="2", - item_no=_clip_field(station, 100), - min_price=_clip_price_str(min(prices)), - ) - - # —— 包装费 / 运输费(FK 用报价主表 QuerySet;兼容 part_id 大小写;无子表时回退上阶物料 total_other_expense)—— - others = QuotationOther.objects.filter(quotation_no__in=qm_qs).filter( + # —— 包装费 / 运输费(报价单单号列表;兼容 part_id 大小写;无子表时回退上阶物料 total_other_expense)—— + others = QuotationOther.objects.filter(quotation_no__in=qn_list).filter( Q(part_id=pid) | Q(part_id__iexact=(pid or "").strip()) ) pkg_vals = [] diff --git a/backend/apps/pissupplier/views.py b/backend/apps/pissupplier/views.py index a0feaff..ecfda42 100644 --- a/backend/apps/pissupplier/views.py +++ b/backend/apps/pissupplier/views.py @@ -29,6 +29,23 @@ from apps.pissupplier.serializers import ( ) +def _supplier_bidding_window_error(instance: QuotationMaster): + """招标:报价/提交仅允许在投标开始时间~投标截止时间(含端点)内。""" + bm = getattr(instance, "buying_method", None) + if bm != 2: + return None + now = timezone.now() + bs = getattr(instance, "bid_start_time", None) + be = getattr(instance, "bid_end_time", None) + if bs is None or be is None: + return ErrorResponse(msg="招标项目未设置投标开始或截止时间,无法报价或提交") + if now < bs: + return ErrorResponse(msg="投标尚未开始") + if now > be: + return ErrorResponse(msg="已超过投标截止时间") + return None + + class QuotationMasterViewSet(CustomModelViewSet): """杂采报价单主表管理接口 @@ -164,6 +181,9 @@ class QuotationMasterViewSet(CustomModelViewSet): dl = getattr(instance, "quote_deadline", None) if dl is not None and dl < timezone.now(): return ErrorResponse(msg="已超过报价截止时间") + bid_err = _supplier_bidding_window_error(instance) + if bid_err is not None: + return bid_err instance.status = 2 instance.quotetime = timezone.now() username = getattr(getattr(request, "user", None), "username", None) @@ -188,6 +208,9 @@ class QuotationMasterViewSet(CustomModelViewSet): dl = getattr(instance, "quote_deadline", None) if dl is not None and dl < timezone.now(): return ErrorResponse(msg="已超过报价截止时间") + bid_err = _supplier_bidding_window_error(instance) + if bid_err is not None: + return bid_err instance.status = 3 instance.quotetime = timezone.now() username = getattr(getattr(request, "user", None), "username", None) diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx index 8c171eb..db173d3 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx @@ -36,9 +36,9 @@ const formatQuoteDeadlineDisplay = (value: unknown) => { if (!value) return '' const text = String(value).trim() if (!text) return '' - const matched = text.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2})/) - if (matched) { - return `${matched[1]} ${matched[2]}:00` + const withMin = text.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})/) + if (withMin) { + return `${withMin[1]} ${withMin[2]}:${withMin[3]}` } const dateOnly = text.match(/^(\d{4}-\d{2}-\d{2})$/) if (dateOnly) { @@ -171,9 +171,12 @@ export type ComparisonDetailMetric = { label: string get: (r: any) => unknown isText: boolean + /** 成本模板字段 key;用于跳过与分组标题重复的「材质 / 工站」行 */ + fieldKey?: string } -const normCmpTplKey = (k: string) => +/** 比价展开:模板字段 key 归一化(与 index.vue 聚合逻辑共用) */ +export const normCmpTplKey = (k: string) => String(k || '') .trim() .toLowerCase() @@ -239,7 +242,7 @@ export function buildMaterialComparisonMetricsFromTemplateFields(fields: Array<{ String(f.label || f.nameCn || f.name_cn || '').trim() || rawKey const getter = materialGetterByNormKey[nk] ?? fallbackRowGetter(rawKey) const isText = materialTextNormKeys.has(nk) - out.push({ label, get: getter, isText }) + out.push({ label, get: getter, isText, fieldKey: rawKey }) } return out } @@ -256,11 +259,110 @@ export function buildProcessComparisonMetricsFromTemplateFields(fields: Array<{ String(f.label || f.nameCn || f.name_cn || '').trim() || rawKey const getter = processGetterByNormKey[nk] ?? fallbackRowGetter(rawKey) const isText = processTextNormKeys.has(nk) - out.push({ label, get: getter, isText }) + out.push({ label, get: getter, isText, fieldKey: rawKey }) } return out } +/** 按材质规格分组后,不再展示「材质」行(与分组标题重复) */ +export function shouldSkipMaterialDetailMetric(fieldKey: string | undefined): boolean { + if (!fieldKey?.trim()) return false + return normCmpTplKey(fieldKey) === 'material' +} + +/** 按工站分组后,不再展示「加工工站」行(与分组标题重复) */ +export function shouldSkipProcessDetailMetric(fieldKey: string | undefined): boolean { + if (!fieldKey?.trim()) return false + return normCmpTplKey(fieldKey) === 'processstation' +} + +/** 与后端制程最低价落库一致:全报价最低重量/最低单价(单价含杂采材料信息)、材料费=三者乘积;加工费=各报价单加工费合计之最小值 */ +export type LowPriceMinContext = { + minW?: number + minUp?: number + materialProduct?: number + minProcessTotal?: number + pid: string +} + +export function computeLowPriceMinContext( + quotes: any[], + miscMinUnitPrice?: number | null +): LowPriceMinContext { + const pid = String(quotes[0]?.rfq_items?.[0]?.part_id || '').trim() + const qty = Number(quotes[0]?.rfq_items?.[0]?.qty) + let minW: number | undefined + let minUp: number | undefined + for (const q of quotes) { + for (const r of q.material_costs || []) { + if (pid && String(r.part_id || '').trim() !== pid) continue + const w = Number(r.weight) + if (Number.isFinite(w)) minW = minW === undefined ? w : Math.min(minW, w) + const up = Number(r.unit_price) + if (Number.isFinite(up)) minUp = minUp === undefined ? up : Math.min(minUp, up) + } + } + if (miscMinUnitPrice != null && Number.isFinite(miscMinUnitPrice)) { + minUp = minUp === undefined ? miscMinUnitPrice : Math.min(minUp, miscMinUnitPrice) + } + let materialProduct: number | undefined + if (minW !== undefined && minUp !== undefined && Number.isFinite(qty) && qty > 0) { + materialProduct = minW * minUp * qty + } + let minProcessTotal: number | undefined + for (const q of quotes) { + let s = 0 + let ok = false + for (const r of q.process_costs || []) { + if (pid && String(r.part_id || '').trim() !== pid) continue + const p = Number(r.process_price) + if (Number.isFinite(p)) { + s += p + ok = true + } + } + if (ok) minProcessTotal = minProcessTotal === undefined ? s : Math.min(minProcessTotal, s) + } + return { minW, minUp, materialProduct, minProcessTotal, pid } +} + +/** 展开仍按规格分行时,「制程最低价」列用全局口径覆盖该行 min */ +export function applyMaterialDetailLowPriceMin( + line: { min?: number }, + m: ComparisonDetailMetric, + ctx: LowPriceMinContext +): void { + const fk = m.fieldKey ? normCmpTplKey(m.fieldKey) : '' + const label = String(m.label || '').trim() + if (ctx.minW !== undefined && (fk === 'weight' || /用量|重量/.test(label))) { + line.min = ctx.minW + return + } + if (ctx.minUp !== undefined && (fk === 'unitprice' || /材料单价/.test(label))) { + line.min = ctx.minUp + return + } + if (ctx.materialProduct !== undefined && (fk === 'materialcost' || /材料费用|材料费/.test(label))) { + line.min = ctx.materialProduct + } +} + +/** 展开仍按工站分行时,「加工费用」行制程最低价为各报价单加工费合计的最小值 */ +export function applyProcessDetailLowPriceMin( + line: { min?: number }, + m: ComparisonDetailMetric, + ctx: LowPriceMinContext +): void { + const fk = m.fieldKey ? normCmpTplKey(m.fieldKey) : '' + const label = String(m.label || '').trim() + if ( + ctx.minProcessTotal !== undefined && + (fk === 'processprice' || fk === 'processcost' || /加工费|加工费用/.test(label)) + ) { + line.min = ctx.minProcessTotal + } +} + export const createCrudOptions = function ({ context, crudExpose, @@ -591,7 +693,7 @@ export const createCrudOptions = function ({ } }, quote_deadline: { - title: '报价截止时', + title: '报价截止时间', type: 'datetime', column: { width: 150, diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue index b0bac3a..0055fe2 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/index.vue @@ -71,7 +71,7 @@ - +
- - + +
@@ -96,23 +96,8 @@ :disabled-date="isQuoteDeadlineDateDisabled" style="max-width: 100%" /> - - - - -
- -
- - - + +
@@ -129,6 +114,21 @@ + +
+ + + + +
+
@@ -732,7 +732,13 @@ import { displayPercentRate, buildMaterialComparisonMetricsFromTemplateFields, buildProcessComparisonMetricsFromTemplateFields, - type ComparisonDetailMetric + shouldSkipMaterialDetailMetric, + shouldSkipProcessDetailMetric, + computeLowPriceMinContext, + applyMaterialDetailLowPriceMin, + applyProcessDetailLowPriceMin, + type ComparisonDetailMetric, + type LowPriceMinContext } from './crud' import * as api from './api' import * as costTemplateApi from '../cost_template/api' @@ -1336,7 +1342,7 @@ const toNumberOrZero = (val: any) => { const padTwoDigits = (value: number) => String(value).padStart(2, '0') const formatQuoteDeadlineValue = (value: Date) => - `${value.getFullYear()}-${padTwoDigits(value.getMonth() + 1)}-${padTwoDigits(value.getDate())} ${padTwoDigits(value.getHours())}:00:00` + `${value.getFullYear()}-${padTwoDigits(value.getMonth() + 1)}-${padTwoDigits(value.getDate())} ${padTwoDigits(value.getHours())}:${padTwoDigits(value.getMinutes())}:00` const normalizeQuoteDeadline = (value: unknown) => { if (!value) return '' @@ -1350,9 +1356,11 @@ const normalizeQuoteDeadline = (value: unknown) => { if (!Number.isNaN(parsed.getTime())) { return formatQuoteDeadlineValue(parsed) } - const matched = text.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}))?/) + const matched = text.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{1,2})(?::(\d{1,2}))?)?/) if (matched) { - return `${matched[1]} ${matched[2] || '00'}:00:00` + const hh = Math.min(23, Math.max(0, Number(matched[2] ?? 0))) + const mm = Math.min(59, Math.max(0, Number(matched[3] ?? 0))) + return `${matched[1]} ${padTwoDigits(hh)}:${padTwoDigits(mm)}:00` } } return '' @@ -1381,13 +1389,52 @@ const normalizeDateTime = (value: unknown) => { return '' } -const quoteDeadlineHourOptions = Array.from({ length: 24 }, (_, index) => padTwoDigits(index)) +/** 表单时间下拉:每 30 分钟一档 */ +const THIRTY_MIN_TIME_SLOTS = Array.from({ length: 48 }, (_, i) => { + const h = Math.floor(i / 2) + const m = (i % 2) * 30 + return `${padTwoDigits(h)}:${padTwoDigits(m)}` +}) + +/** 旧数据非整半点时,对齐到最近 30 分钟(不超过 23:30) */ +const snapToHalfHourSlot = (hhmm: string): string => { + const parts = hhmm.split(':') + if (parts.length < 2) return '23:00' + const h = Number(parts[0]) + const m = Number(parts[1]) + if (!Number.isFinite(h) || !Number.isFinite(m)) return '23:00' + const total = h * 60 + m + const snapped = Math.round(total / 30) * 30 + const maxM = 23 * 60 + 30 + const clamped = Math.min(maxM, Math.max(0, snapped)) + const nh = Math.floor(clamped / 60) + const nm = clamped % 60 + return `${padTwoDigits(nh)}:${padTwoDigits(nm)}` +} + const openBaseDate = ref(null) const getDayStart = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()) +const extractTimeSlotHHmm = (raw: unknown): string => { + const d = (normalizeDateTime(raw) || normalizeQuoteDeadline(raw) || '').trim() + if (d.length >= 16) return d.slice(11, 16) + return '' +} + +/** 将日期时间字符串加若干分钟(用于投标截止默认 = 开始 +30 分钟) */ +const addMinutesToDateTimeString = (src: string, addMin: number): string => { + let normalized = normalizeDateTime(src) + if (!normalized) normalized = normalizeQuoteDeadline(src) + if (!normalized) return '' + const d = new Date(normalized.replace(' ', 'T')) + if (Number.isNaN(d.getTime())) return '' + d.setMinutes(d.getMinutes() + addMin) + return formatDateTimeFull(d) +} + const parseDate = (value: unknown) => { - const normalized = normalizeQuoteDeadline(value) + const normalized = normalizeDateTime(value) || normalizeQuoteDeadline(value) if (!normalized) return null const parsed = new Date(normalized.replace(' ', 'T')) return Number.isNaN(parsed.getTime()) ? null : parsed @@ -1399,7 +1446,7 @@ const captureQuoteDeadlineOpenBaseDate = () => { const isQuoteDeadlineDateDisabled = (date: Date) => { if (!openBaseDate.value) return false - return getDayStart(date).getTime() <= openBaseDate.value.getTime() + return getDayStart(date).getTime() < openBaseDate.value.getTime() } const validateQuoteDeadlineAfterOpenDay = () => { @@ -1410,7 +1457,7 @@ const validateQuoteDeadlineAfterOpenDay = () => { activeTab.value = 'base' return false } - if (openBaseDate.value && getDayStart(selectedDate).getTime() <= openBaseDate.value.getTime()) { + if (openBaseDate.value && getDayStart(selectedDate).getTime() < openBaseDate.value.getTime()) { ElMessage.error('报价截止时只能选择大于打开创建/编辑当天的日期') activeTab.value = 'base' return false @@ -1428,7 +1475,7 @@ const validateQuoteDeadlineAfterOpenDay = () => { activeTab.value = 'base' return false } - if (openBaseDate.value && getDayStart(selectedBSDate).getTime() <= openBaseDate.value.getTime()) { + if (openBaseDate.value && getDayStart(selectedBSDate).getTime() < openBaseDate.value.getTime()) { ElMessage.error('投标开始时间只能选择大于打开创建/编辑当天的日期') activeTab.value = 'base' return false @@ -1447,15 +1494,16 @@ const quoteDeadlineDate = computed({ form.quote_deadline = '' return } - const currentHour = quoteDeadlineHour.value || '23' - form.quote_deadline = `${value} ${currentHour}:00:00` + const slot = extractTimeSlotHHmm(form.quote_deadline) || '23:00' + form.quote_deadline = `${value} ${slot}:00` } }) -const quoteDeadlineHour = computed({ +const quoteDeadlineTimeSlot = computed({ get: () => { - const normalized = normalizeQuoteDeadline(form.quote_deadline) - return normalized ? normalized.slice(11, 13) : '23' + const slot = extractTimeSlotHHmm(form.quote_deadline) + if (!slot) return '23:00' + return THIRTY_MIN_TIME_SLOTS.includes(slot) ? slot : snapToHalfHourSlot(slot) }, set: (value: string) => { const date = quoteDeadlineDate.value @@ -1463,7 +1511,8 @@ const quoteDeadlineHour = computed({ form.quote_deadline = '' return } - form.quote_deadline = `${date} ${value || '00'}:00:00` + const slot = value || '23:00' + form.quote_deadline = `${date} ${slot}:00` } }) @@ -1477,15 +1526,16 @@ const bidStartTimeDate = computed({ form.bid_start_time = '' return } - const currentHour = bidStartTimeHour.value || '23' - form.bid_start_time = `${value} ${currentHour}:00:00` + const slot = extractTimeSlotHHmm(form.bid_start_time) || '23:00' + form.bid_start_time = `${value} ${slot}:00` } }) -const bidStartTimeHour = computed({ +const bidStartTimeSlot = computed({ get: () => { - const normalized = normalizeDateTime(form.bid_start_time) - return normalized ? normalized.slice(11, 13) : '23' + const slot = extractTimeSlotHHmm(form.bid_start_time) + if (!slot) return '23:00' + return THIRTY_MIN_TIME_SLOTS.includes(slot) ? slot : snapToHalfHourSlot(slot) }, set: (value: string) => { const date = bidStartTimeDate.value @@ -1493,7 +1543,8 @@ const bidStartTimeHour = computed({ form.bid_start_time = '' return } - form.bid_start_time = `${date} ${value || '00'}:00:00` + const slot = value || '23:00' + form.bid_start_time = `${date} ${slot}:00` } }) @@ -1507,15 +1558,16 @@ const bidEndTimeDate = computed({ form.bid_end_time = '' return } - const currentHour = bidEndTimeHour.value || '23' - form.bid_end_time = `${value} ${currentHour}:00:00` + const slot = extractTimeSlotHHmm(form.bid_end_time) || '23:00' + form.bid_end_time = `${value} ${slot}:00` } }) -const bidEndTimeHour = computed({ +const bidEndTimeSlot = computed({ get: () => { - const normalized = normalizeDateTime(form.bid_end_time) - return normalized ? normalized.slice(11, 13) : '23' + const slot = extractTimeSlotHHmm(form.bid_end_time) + if (!slot) return '23:00' + return THIRTY_MIN_TIME_SLOTS.includes(slot) ? slot : snapToHalfHourSlot(slot) }, set: (value: string) => { const date = bidEndTimeDate.value @@ -1523,10 +1575,28 @@ const bidEndTimeHour = computed({ form.bid_end_time = '' return } - form.bid_end_time = `${date} ${value || '00'}:00:00` + const slot = value || '23:00' + form.bid_end_time = `${date} ${slot}:00` } }) +/** 回填详情时勿触发「投标截止 = 开始」自动覆盖 */ +const skipBidEndAutoFill = ref(false) + +watch( + () => form.bid_start_time, + (newVal) => { + if (skipBidEndAutoFill.value) return + if (Number(form.buying_method) !== 2) return + if (!newVal) { + form.bid_end_time = '' + return + } + const next = addMinutesToDateTimeString(String(newVal), 30) + if (next) form.bid_end_time = next + } +) + const currentTemplate = computed(() => templates.value.find((t: any) => t.template_no === form.template)) const templateSectionMap = computed(() => { const map = new Map() @@ -1847,6 +1917,7 @@ const openDetail = async (row: any, mode: 'edit' | 'view') => { dialog.mode = nextMode dialog.currentId = row.id skipTemplateWatch.value = true + skipBidEndAutoFill.value = true // 以详情接口为准,避免列表字段缺失导致子表/字段不同步 let detail = row try { @@ -1876,6 +1947,8 @@ const openDetail = async (row: any, mode: 'edit' | 'view') => { target_price: detail?.target_price ?? detail?.inquiry_price ?? rfqItem?.unit_price ?? 0 } Object.assign(form, emptyForm(), mappedDetail) + await nextTick() + skipBidEndAutoFill.value = false ;['purchase_qty', 'target_price', 'lead_time_days'].forEach((k) => { ;(form as any)[k] = toNumberOrZero((form as any)[k]) }) @@ -2461,11 +2534,12 @@ const DEFAULT_PROCESS_COMPARISON_METRICS: ComparisonDetailMetric[] = [ { label: '备注', get: (r) => r?.remark, isText: true } ] -/** 材料成本展开:按材料规格分组;组内行顺序按成本结构模板 fields */ +/** 材料成本展开:按材料规格分组;「制程最低价」列对重量/单价/材料费用行使用全局口径(与后端落库一致) */ const buildMaterialDetailGroups = ( quotes: any[], supplierKeys: string[], - metrics: ComparisonDetailMetric[] = DEFAULT_MATERIAL_COMPARISON_METRICS + metrics: ComparisonDetailMetric[] = DEFAULT_MATERIAL_COMPARISON_METRICS, + lowPriceCtx: LowPriceMinContext | null = null ): ComparisonDetailGroup[] => { const specs = new Set() quotes.forEach((q) => { @@ -2478,6 +2552,7 @@ const buildMaterialDetailGroups = ( for (const spec of sortedSpecs) { const lines: ComparisonDetailRow[] = [] for (const m of metrics) { + if (shouldSkipMaterialDetailMetric(m.fieldKey)) continue const values: Record = {} supplierKeys.forEach((sup, idx) => { const raw = findMaterialRowBySpec(quotes[idx], spec) @@ -2486,18 +2561,21 @@ const buildMaterialDetailGroups = ( }) const allDash = supplierKeys.every((sup) => values[sup] === '-') if (allDash) continue - lines.push({ label: m.label, values, isText: m.isText, ...calcCompareStats(values) }) + const line: ComparisonDetailRow = { label: m.label, values, isText: m.isText, ...calcCompareStats(values) } + if (lowPriceCtx) applyMaterialDetailLowPriceMin(line, m, lowPriceCtx) + lines.push(line) } if (lines.length) groups.push({ title: spec || '材料', lines }) } return groups } -/** 加工成本展开:按工站分组;组内行顺序按成本结构模板 fields */ +/** 加工成本展开:按工站分组;「加工费用」行制程最低价为各报价单加工费合计之最小值 */ const buildProcessDetailGroups = ( quotes: any[], supplierKeys: string[], - metrics: ComparisonDetailMetric[] = DEFAULT_PROCESS_COMPARISON_METRICS + metrics: ComparisonDetailMetric[] = DEFAULT_PROCESS_COMPARISON_METRICS, + lowPriceCtx: LowPriceMinContext | null = null ): ComparisonDetailGroup[] => { const stations = new Set() quotes.forEach((q) => { @@ -2510,6 +2588,7 @@ const buildProcessDetailGroups = ( for (const station of sorted) { const lines: ComparisonDetailRow[] = [] for (const m of metrics) { + if (shouldSkipProcessDetailMetric(m.fieldKey)) continue const values: Record = {} supplierKeys.forEach((sup, idx) => { const raw = findProcessRowByStation(quotes[idx], station) @@ -2518,7 +2597,9 @@ const buildProcessDetailGroups = ( }) const allDash = supplierKeys.every((s) => values[s] === '-') if (allDash) continue - lines.push({ label: m.label, values, isText: m.isText, ...calcCompareStats(values) }) + const line: ComparisonDetailRow = { label: m.label, values, isText: m.isText, ...calcCompareStats(values) } + if (lowPriceCtx) applyProcessDetailLowPriceMin(line, m, lowPriceCtx) + lines.push(line) } if (lines.length) groups.push({ title: station || '工站', lines }) } @@ -2558,6 +2639,7 @@ const buildComparisonRowsFromPisQuotes = ( detailOpts?: { materialMetrics?: ComparisonDetailMetric[] processMetrics?: ComparisonDetailMetric[] + lowPriceCtx?: LowPriceMinContext | null } ) => { const supplierKeys = quotes.map((q, idx) => quotationSupplierKey(q, idx)) @@ -2629,14 +2711,17 @@ const buildComparisonRowsFromPisQuotes = ( }) rows.push({ key: 'award', label: '中标否', values: winValues }) - const materialGroups = buildMaterialDetailGroups(quotes, supplierKeys, detailOpts?.materialMetrics) - const processGroups = buildProcessDetailGroups(quotes, supplierKeys, detailOpts?.processMetrics) + const lp = detailOpts?.lowPriceCtx ?? null + const materialGroups = buildMaterialDetailGroups(quotes, supplierKeys, detailOpts?.materialMetrics, lp) + const processGroups = buildProcessDetailGroups(quotes, supplierKeys, detailOpts?.processMetrics, lp) const otherDetails = buildOtherCostDetails(quotes, supplierKeys) const matRow = rows.find((r) => r.key === 'material') if (matRow && materialGroups.length) matRow.detailGroups = materialGroups + if (matRow && lp?.materialProduct != null) matRow.min = lp.materialProduct const procRow = rows.find((r) => r.key === 'process') if (procRow && processGroups.length) procRow.detailGroups = processGroups + if (procRow && lp?.minProcessTotal != null) procRow.min = lp.minProcessTotal const otherRow = rows.find((r) => r.key === 'other') if (otherRow && otherDetails.length) otherRow.details = otherDetails @@ -2721,7 +2806,24 @@ const openComparison = async (row: any) => { if (built.length) processMetrics = built } } - const { suppliers, rows } = buildComparisonRowsFromPisQuotes(quotes, { materialMetrics, processMetrics }) + let miscMinUnitPrice: number | undefined + try { + const mres = await GetMaterials({ page: 1, page_size: 5000 }) + const mlist = extractQuotationList(mres) + for (const it of mlist) { + if (it.status != null && Number(it.status) !== 1) continue + const p = Number(it.price) + if (Number.isFinite(p)) miscMinUnitPrice = miscMinUnitPrice === undefined ? p : Math.min(miscMinUnitPrice, p) + } + } catch { + /* 杂采材料信息不可用则制程最低价单价仅来自报价 */ + } + const lowPriceCtx = computeLowPriceMinContext(quotes, miscMinUnitPrice) + const { suppliers, rows } = buildComparisonRowsFromPisQuotes(quotes, { + materialMetrics, + processMetrics, + lowPriceCtx + }) comparisonDialog.quotes = quotes comparisonDialog.suppliers = suppliers comparisonDialog.rows = rows diff --git a/web/src/views/pissupplier/quotation/crud.tsx b/web/src/views/pissupplier/quotation/crud.tsx index c977367..6b87d13 100644 --- a/web/src/views/pissupplier/quotation/crud.tsx +++ b/web/src/views/pissupplier/quotation/crud.tsx @@ -127,6 +127,33 @@ export function parseQuoteDeadlineToMs(value: unknown): number { return Number.isNaN(t) ? NaN : t } +/** 报价主表 `buying_method === 2`(招标) */ +export function isBuyingMethodBidding(row: any) { + return Number(row?.buyingMethod ?? row?.buying_method) === 2 +} + +/** 非招标不限制;招标须在投标开始~截止(含端点)内;缺时间则不可用 */ +export function isWithinSupplierBidWindow(row: any, nowMs: number = Date.now()) { + if (!isBuyingMethodBidding(row)) return true + const start = parseQuoteDeadlineToMs(row?.bidStartTime ?? row?.bid_start_time) + const end = parseQuoteDeadlineToMs(row?.bidEndTime ?? row?.bid_end_time) + if (!Number.isFinite(start) || !Number.isFinite(end)) return false + return nowMs >= start && nowMs <= end +} + +/** 非招标或未超限返回 null;否则返回提示文案(列表按钮禁用、openQuote/submit 前置校验) */ +export function getSupplierBidWindowRejectReason(row: any, nowMs: number = Date.now()): string | null { + if (!isBuyingMethodBidding(row)) return null + const start = parseQuoteDeadlineToMs(row?.bidStartTime ?? row?.bid_start_time) + const end = parseQuoteDeadlineToMs(row?.bidEndTime ?? row?.bid_end_time) + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return '招标项目缺少投标开始或截止时间,无法报价或提交' + } + if (nowMs < start) return '投标尚未开始,请在投标开始后再报价或提交' + if (nowMs > end) return '已超过投标截止时间,无法报价或提交' + return null +} + /** * 保存主表时提交 `quote_deadline`:后端为 DateTimeField,提交 `YYYY-MM-DD HH:mm:ss` 或省略 */ @@ -400,12 +427,12 @@ export type QuotationCostColumn = { export const FIXED_QUOTATION_SECTION_COLUMNS: Record = { 材料成本: [ { key: 'material', label: '材质' }, - { key: 'len', label: '长' }, - { key: 'width', label: '宽' }, - { key: 'height', label: '高' }, - { key: 'specificgravity', label: '比重' }, + { key: 'len', label: '长(mm)' }, + { key: 'width', label: '宽(mm)' }, + { key: 'height', label: '高(mm)' }, + { key: 'specificgravity', label: '比重(kg/cm³)' }, { key: 'qty', label: '数量' }, - { key: 'weight', label: '重量' }, + { key: 'weight', label: '重量(kg)' }, { key: 'unitPrice', label: '单价' }, { key: 'material_fee', label: '材料费用' }, { key: 'remark', label: '备注' } @@ -1834,6 +1861,11 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { } const openQuote = async (row: Quote) => { + const bidReason = getSupplierBidWindowRejectReason(row) + if (bidReason) { + ElMessage.warning(bidReason) + return + } dialog.mode = 'edit' dialog.quoteId = row.id loading.value = true @@ -2187,6 +2219,11 @@ export function useQuoteCrud(options?: { onChange?: () => void }) { ElMessage.warning('仅报价中状态可提交报价') return } + const bidReason = getSupplierBidWindowRejectReason(row) + if (bidReason) { + ElMessage.warning(bidReason) + return + } const c = (row.base?.contact || '').trim() const p = (row.base?.phone || '').trim() const e = (row.base?.email || '').trim() diff --git a/web/src/views/pissupplier/quotation/index.vue b/web/src/views/pissupplier/quotation/index.vue index 9365059..b1b004e 100644 --- a/web/src/views/pissupplier/quotation/index.vue +++ b/web/src/views/pissupplier/quotation/index.vue @@ -321,7 +321,8 @@ import { type InquiryAttachmentRow, buyingMethodDict, formatBidTimeColumn, - formatQuoteDeadlineDisplay + formatQuoteDeadlineDisplay, + isWithinSupplierBidWindow } from './crud' @@ -408,6 +409,10 @@ const { submitQuotationFromRow } = useQuoteCrud({ onChange: () => crudExpose?.doRefresh?.() }) +/** 使「招标」行操作按钮随当前时间进出投标窗口自动刷新(约 30s) */ +const bidWindowClock = ref(0) +let bidWindowTimer: ReturnType | undefined + const onQuotationAttachmentListChange = (_file: unknown, fileList: any[]) => { current.attachments = fileList } @@ -495,6 +500,7 @@ watch(activeTab, () => { onUnmounted(() => { quoteStickyStatusRo?.disconnect() quoteStickyStatusRo = null + if (bidWindowTimer != null) clearInterval(bidWindowTimer) }) const syncFilters = (form: any = {}) => { @@ -556,17 +562,31 @@ const crudOptions = { }, quoteNow: { text: '报价', - type: compute(({ row }) => (isPendingQuotation(row) ? 'primary' : 'info')), + type: compute(({ row }) => { + bidWindowClock.value + const ok = isPendingQuotation(row) && isWithinSupplierBidWindow(row) + return ok ? 'primary' : 'info' + }), show: true, click: ({ row }: any) => openQuote(row), - disabled: compute(({ row }) => !isPendingQuotation(row)) + disabled: compute(({ row }) => { + bidWindowClock.value + return !isPendingQuotation(row) || !isWithinSupplierBidWindow(row) + }) }, editQuote: { text: '提交', - type: compute(({ row }) => (isQuotedQuotation(row) ? 'warning' : 'info')), + type: compute(({ row }) => { + bidWindowClock.value + const ok = isQuotedQuotation(row) && isWithinSupplierBidWindow(row) + return ok ? 'warning' : 'info' + }), show: true, click: ({ row }: any) => submitQuotationFromRow(row), - disabled: compute(({ row }) => !isQuotedQuotation(row)) + disabled: compute(({ row }) => { + bidWindowClock.value + return !isQuotedQuotation(row) || !isWithinSupplierBidWindow(row) + }) }, } }, @@ -706,6 +726,9 @@ const crudOptions = { useCrud({ crudRef, crudBinding, crudExpose, crudOptions }) onMounted(() => { + bidWindowTimer = setInterval(() => { + bidWindowClock.value += 1 + }, 30000) crudExpose?.doRefresh?.() })