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?.()
})