diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py
index 8576234..0abcc1e 100644
--- a/backend/apps/pisadmin/miscprocurement/views.py
+++ b/backend/apps/pisadmin/miscprocurement/views.py
@@ -168,6 +168,30 @@ def _safe_decimal(v):
return None
+def _get_earliest_quotation(quotations: list) -> tuple:
+ """从报价单列表中获取最早提交的报价单(按 quotetime 或 creattime 排序)"""
+ if not quotations:
+ return None, None
+
+ earliest = None
+ earliest_time = None
+ earliest_qn = None
+
+ for q in quotations:
+ q_time = getattr(q, 'quotetime', None) or getattr(q, 'creattime', None)
+ qn = getattr(q, 'quotation_no', None) or getattr(q, 'quotationNo', None)
+
+ if q_time is None:
+ continue
+
+ if earliest is None or q_time < earliest_time:
+ earliest = q
+ earliest_time = q_time
+ earliest_qn = qn
+
+ return earliest, earliest_qn
+
+
def _resolve_inquiry_primary_part_id(inquiry: Inquiry) -> str:
"""与采购端比价弹窗一致:上阶物料首行料号。"""
first = InquiryRfqItem.objects.filter(inquiry_no=inquiry).order_by("id").first()
@@ -220,24 +244,26 @@ def _rfq_qty_decimal(inquiry: Inquiry, pid: str) -> Optional[Decimal]:
def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None:
"""
制程最低价落库(与比价展开明细一致):
- - 前端比价展开不依赖成本结构模板:材料按材质分组仅展示重量/单价/材料费用,加工按工站分组仅展示加工费;落库口径仍按下列规则。
- - 询价单下**每个上阶物料料号**单独一套主/次表数据(多料号互不合并)。
- - **材料**:次表按材质分组记录,每组两行(最低重量、最低单价);souce_no 为取得该最小值对应的报价单单号,单价若来自「杂采材料信息」
- 则存交易厂区(factory);主表材料行 souce_no 聚合为 W:…;U:…(重量来源与单价来源可能不同)。
- 主表材料行的 min_price 是所有材质分组的(最低重量×最低单价×数量)之和。
- - **加工**:仅主表一行(无次表、不按工站);每报价单合计加工费后取最小,全空/全 0 仍写入 MinPrice=0。
- - 注意:材料行必须用 quotation_no__in=报价单号列表 过滤,勿用 QuerySet(QuotationMaster) 作 __in,否则 ORM 按主键匹配会查不到材料行。
- - **其它**:包装费、运输费分列取 min(优先 sup_quotation_other;无则用上阶物料 total_other_expense 回退)。采购端比价主表「其它成本」行制程最低价列与「加工成本」相同,可链向合计最低的报价单详情。
- - **利润率**:cost_type=6,各报价单该料号利润率取 min。杂采不落库管销研(cost_type=5)。
+ - 先比较所有报价单的最低价。
+ - 如果**所有报价单最低价相等**,则取**最早提交报价单**的最低价。
+ - 如果**最低价不一致**,则取**所有报价单中的最小值**。
+ - 材料:按材质分组,每组单独计算最低重量/单价,再汇总所有材质的材料费用总和。
+ - 加工:每份报价单加工费合计后取最小值。
+ - 材料/加工最低价仅更新 `MiscLowPriceHeader`/`MiscLowPriceDetail` 记录表,
+ `MiscProcMaterialMinPrices`/`MiscProcProcessingMinPrices` 信息表仅在核价完成后更新(暂不实现)。
"""
inquiry_no = (inquiry.inquiry_no or "").strip()
if not inquiry_no:
return
+ # 获取所有报价单(排除已过期 status=4)
qm_qs = QuotationMaster.objects.filter(inquiry_no=inquiry_no).exclude(status=4)
qn_list = list(qm_qs.values_list("quotation_no", flat=True))
souce_ref = inquiry_no[:20]
+ # 获取所有报价单详情,用于判断最早提交报价单
+ all_quotations = list(qm_qs.order_by("creattime", "quotetime"))
+
raw_parts = [
str(x).strip()
for x in InquiryRfqItem.objects.filter(inquiry_no=inquiry)
@@ -254,7 +280,11 @@ 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()
+ # 获取该料号的所有报价单(用于判断最早提交报价单)
+ pid_quotations = [q for q in all_quotations if q.quotation_no in qn_list]
+
# —— 材料:按材质规格分组,每组次表两行(重量、单价);主表 = 所有分组的(最低重量×最低单价×数量)之和。
+ # 如果所有报价单材料费用相等,则取最早提交报价单;否则取最小值。
# FK 须用报价单单号列表:quotation_no__in=QuerySet(QuotationMaster) 会按主键匹配,导致材料行查不到。
materials = QuotationMaterial.objects.filter(quotation_no__in=qn_list, part_id=pid)
@@ -266,17 +296,33 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None:
for m in materials:
qn_src = getattr(m, "quotation_no_id", None) or ""
qn_src = str(qn_src).strip()
+ qn = getattr(m, "quotation_no_id", None) or qn_src
spec = (getattr(m, "material_spec", None) or "").strip() or "-"
+
+ # 记录每个材质规格下每个报价单的数据,用于判断是否所有报价单相等
+ if spec not in spec_quote_data:
+ spec_quote_data[spec] = {}
+ if qn not in spec_quote_data[spec]:
+ spec_quote_data[spec][qn] = {}
+
if m.weight is not None:
try:
w = Decimal(str(m.weight))
spec_weight_candidates[spec].append((w, qn_src))
+ spec_quote_data[spec][qn]['weight'] = w
except Exception:
pass
if m.unit_price is not None:
try:
up = Decimal(str(m.unit_price))
spec_unit_candidates[spec].append((up, qn_src))
+ spec_quote_data[spec][qn]['unit_price'] = up
+ except Exception:
+ pass
+ if m.material_cost is not None:
+ try:
+ mc = Decimal(str(m.material_cost))
+ spec_quote_data[spec][qn]['material_cost'] = mc
except Exception:
pass
@@ -296,6 +342,9 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None:
all_src_w_list: list[str] = []
all_src_up_list: list[str] = []
+ # 收集每个材质规格的报价数据,用于按材质分组判断是否所有报价单相等
+ spec_quote_data: dict[str, dict[str, dict]] = {}
+
# 获取所有材质规格(有重量或单价数据的)
all_specs = set(spec_weight_candidates.keys()) | set(spec_unit_candidates.keys())
@@ -306,6 +355,34 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None:
# 杂采单价也加入该分组的单价候选
unit_candidates = unit_candidates + misc_unit_candidates
+ # 按材质分组判断所有报价单是否相等
+ spec_quote_values = spec_quote_data.get(spec, {})
+ all_quotes_equal = True
+ first_value = None
+
+ for qn, data in spec_quote_values.items():
+ # 检查重量、单价、材料费用是否都相等
+ current_values = []
+ if 'weight' in data:
+ current_values.append(data['weight'])
+ if 'unit_price' in data:
+ current_values.append(data['unit_price'])
+ if 'material_cost' in data:
+ current_values.append(data['material_cost'])
+
+ if not current_values:
+ continue
+
+ if first_value is None:
+ first_value = current_values[0]
+ else:
+ for val in current_values:
+ if abs(val - first_value) > Decimal('0.0001'):
+ all_quotes_equal = False
+ break
+ if not all_quotes_equal:
+ break
+
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
@@ -313,6 +390,20 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> 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
+ # 如果该材质分组的所有报价单相等,则取最早提交报价单的数据
+ if all_quotes_equal and len(pid_quotations) > 0 and min_w is not None and min_up is not None:
+ earliest, earliest_qn = _get_earliest_quotation(pid_quotations)
+ if earliest_qn:
+ # 找到最早报价单对应的具体数据
+ earliest_data = spec_quote_data.get(spec, {}).get(earliest_qn)
+ if earliest_data:
+ if 'weight' in earliest_data:
+ min_w = earliest_data['weight']
+ if 'unit_price' in earliest_data:
+ min_up = earliest_data['unit_price']
+ src_w = earliest_qn
+ src_up = earliest_qn
+
# 判断是否来自杂采材料信息
is_misc_src = False
if min_up is not None and misc_unit_candidates:
@@ -333,7 +424,7 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None:
souce_no=_norm_low_price_src((src_w or souce_ref)),
)
if src_w:
- all_src_w_list.append(src_w)
+ all_src_w_list.append(str(src_w))
# 写入次表:最低单价
if min_up is not None:
@@ -344,10 +435,10 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None:
material_spec=spec[:50],
item_no="2",
value=_clip_price_str(min_up),
- souce_no=_norm_low_price_src((src_up or souce_ref)) if not is_misc_src else _norm_low_price_src(src_up or souce_ref),
+ souce_no=_norm_low_price_src((src_up or souce_ref)) if not is_misc_src else _norm_low_price_src(str(src_up or souce_ref)),
)
if src_up:
- all_src_up_list.append(src_up)
+ all_src_up_list.append(str(src_up))
# 累加到总材料费用
if min_w is not None and min_up is not None and qty_dec is not None and qty_dec > 0:
@@ -369,7 +460,7 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None:
)
# —— 加工:仅主表一行,无次表、不按工站。每份报价单对该料号加工费合计(空/缺省按 0),再取最小;
- # 若全无报价单或合计均为空,仍写入 MinPrice=0,来源询价单号。 ——
+ # 如果所有报价单加工费合计相等,则取最早提交报价单;否则取最小值。
proc_totals: list[tuple[Decimal, str]] = []
for qn in qn_list:
total = Decimal("0")
@@ -380,11 +471,26 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None:
except Exception:
pass
proc_totals.append((total, str(qn).strip()))
+
if proc_totals:
- best_total, best_qn = min(proc_totals, key=lambda t: (t[0], t[1]))
+ # 检查所有加工费是否相等
+ all_equal = all(abs(t[0] - proc_totals[0][0]) < Decimal('0.0001') for t in proc_totals)
+
+ if all_equal and len(pid_quotations) > 0:
+ # 所有报价加工费相等,取最早提交报价单
+ earliest, earliest_qn = _get_earliest_quotation(pid_quotations)
+ if earliest_qn:
+ best_total = proc_totals[0][0] # 任意一个都相等
+ best_qn = earliest_qn
+ else:
+ best_total, best_qn = min(proc_totals, key=lambda t: (t[0], t[1]))
+ else:
+ # 加工费不一致或无报价单,取最小值
+ 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,
diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue
index 1d55274..f68351a 100644
--- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue
+++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue
@@ -224,6 +224,10 @@
{{ formatCompareMin(row.min, row.key) }}
+
+ {{ formatCompareMin(row.min, row.key)
+ }}(利润率:{{ row.profitMinMarginDisplay }})
+
{{ formatCompareMin(row.min, row.key) }}
@@ -278,6 +282,14 @@ import {
buildMaterialCostMinLink,
allCompareSupplierValuesEqual,
formatQuotationProfitMarginForCompare,
+ computeProfitProcessMinPrice,
+ formatMinProfitMarginForCompare,
+ computeTaxProcessMinPrice,
+ sumComparisonProcessMinTotals,
+ sumComparisonAverageTotals,
+ computeProfitRowAveragePrice,
+ computeTaxRowAveragePrice,
+ coalesceCompareNumeric,
type CompareMinLink,
type ComparisonDetailMetric,
type LowPriceMinContext
@@ -313,6 +325,8 @@ type ComparisonRow = ComparisonDetailRow & {
detailGroups?: ComparisonDetailGroup[]
/** 利润行:各供应商列括号内利润率文案(与 QuotationProfit.profit_rate 一致) */
profitMarginPctBySupplier?: Record
+ /** 制程最低价列:利润行末尾「利润率:x%」(取各报价最小利润率) */
+ profitMinMarginDisplay?: string
}
const compareTableRef = ref()
@@ -857,6 +871,8 @@ const buildComparisonRowsFromPisQuotes = (
lowPriceCtx?: LowPriceMinContext | null
/** 加工工站代码 → 名称,用于展开表分组标题 */
stationNameByCode?: Record
+ /** 与询价单头部「税率」一致(如 `firstNonEmptyString` 结果),用于税金行制程最低价 */
+ taxRateForProcessMin?: string
}
) => {
const supplierKeys = quotes.map((q, idx) => quotationSupplierKey(q, idx))
@@ -982,11 +998,35 @@ const buildComparisonRowsFromPisQuotes = (
row.minLink = null
}
}
- setRowMinLinkToLowestQuotation(procRow, sumProcessCost)
+ // 加工成本:与 computeLowPriceMinContext 一致,制程最低价列可链向对应报价单(含各列数值全相等时取最早单)
+ const procQid = lp?.minProcessQuotationId
+ if (
+ procRow &&
+ procQid != null &&
+ procQid !== '' &&
+ typeof lp?.minProcessTotal === 'number' &&
+ Number.isFinite(lp.minProcessTotal)
+ ) {
+ procRow.min = lp.minProcessTotal
+ procRow.minLink = { kind: 'quotation', id: procQid }
+ } else {
+ setRowMinLinkToLowestQuotation(procRow, sumProcessCost)
+ }
setRowMinLinkToLowestQuotation(otherRow, sumOtherCost)
+ const profitRateRaws = quotes.map((q) => profitCostRowForQuote(q)?.profit_rate)
+ const profitMinComputed = computeProfitProcessMinPrice(
+ matRow?.min,
+ procRow?.min,
+ otherRow?.min,
+ profitRateRaws
+ )
+
const profitRow = rows.find((r) => r.key === 'profit')
if (profitRow) {
+ profitRow.min = profitMinComputed
+ const pm = formatMinProfitMarginForCompare(profitRateRaws)
+ if (pm) profitRow.profitMinMarginDisplay = pm
const marginMap: Record = {}
supplierKeys.forEach((name, idx) => {
const pc = profitCostRowForQuote(quotes[idx])
@@ -996,6 +1036,55 @@ const buildComparisonRowsFromPisQuotes = (
profitRow.profitMarginPctBySupplier = marginMap
}
+ const matAvg = matRow?.avg
+ const procAvg = procRow?.avg
+ const otherAvg = otherRow?.avg
+ const profitAvgComputed = computeProfitRowAveragePrice(matAvg, procAvg, otherAvg, profitRateRaws)
+ if (profitRow) {
+ profitRow.avg = profitAvgComputed
+ }
+
+ const taxRow = rows.find((r) => r.key === 'tax')
+ if (taxRow) {
+ const tr = String(detailOpts?.taxRateForProcessMin ?? '').trim()
+ taxRow.min = computeTaxProcessMinPrice(
+ matRow?.min,
+ procRow?.min,
+ otherRow?.min,
+ profitMinComputed,
+ tr
+ )
+ taxRow.avg = computeTaxRowAveragePrice(matAvg, procAvg, otherAvg, profitAvgComputed, tr)
+ }
+
+ for (const key of ['material', 'process', 'other', 'profit', 'tax'] as const) {
+ const r = rows.find((x) => x.key === key)
+ if (r) {
+ r.avg = coalesceCompareNumeric(r.avg)
+ r.min = coalesceCompareNumeric(r.min)
+ }
+ }
+
+ const totalRow = rows.find((r) => r.key === 'total')
+ if (totalRow) {
+ totalRow.min = sumComparisonProcessMinTotals(
+ matRow?.min,
+ procRow?.min,
+ otherRow?.min,
+ profitRow?.min,
+ taxRow?.min
+ )
+ totalRow.avg = sumComparisonAverageTotals(
+ matRow?.avg,
+ procRow?.avg,
+ otherRow?.avg,
+ profitRow?.avg,
+ taxRow?.avg
+ )
+ totalRow.min = coalesceCompareNumeric(totalRow.min)
+ totalRow.avg = coalesceCompareNumeric(totalRow.avg)
+ }
+
const suppliers = supplierKeys.map((name, idx) => ({
name: name || `供应商${idx + 1}`,
code: quotes[idx]?.supplier_code || quotes[idx]?.supplierCode || '',
@@ -1068,15 +1157,6 @@ const loadComparisonPage = async (row: any) => {
}
const stationNameByCode = await fetchStationCodeToNameMap()
const lowPriceCtx = computeLowPriceMinContext(quotes, miscMinUnitPrice, miscMinFactory)
- const { suppliers, rows } = buildComparisonRowsFromPisQuotes(quotes, {
- materialMetrics: COMPARE_PRICE_MATERIAL_DETAIL_METRICS,
- processMetrics: COMPARE_PRICE_PROCESS_DETAIL_METRICS,
- lowPriceCtx,
- stationNameByCode
- })
- comparisonDialog.quotes = quotes
- comparisonDialog.suppliers = suppliers
- comparisonDialog.rows = rows
const rq0 = quotes[0]?.rfq_items?.[0]
if (rq0) {
if (!comparisonDialog.baseInfo.partNo) comparisonDialog.baseInfo.partNo = String(rq0.part_id || '').trim()
@@ -1095,6 +1175,16 @@ const loadComparisonPage = async (row: any) => {
const c = quotes[0].currency ?? quotes[0].transaction_currency
if (c != null && c !== '') comparisonDialog.baseInfo.currency = String(c).trim()
}
+ const { suppliers, rows } = buildComparisonRowsFromPisQuotes(quotes, {
+ materialMetrics: COMPARE_PRICE_MATERIAL_DETAIL_METRICS,
+ processMetrics: COMPARE_PRICE_PROCESS_DETAIL_METRICS,
+ lowPriceCtx,
+ stationNameByCode,
+ taxRateForProcessMin: comparisonDialog.baseInfo.taxRate
+ })
+ comparisonDialog.quotes = quotes
+ comparisonDialog.suppliers = suppliers
+ comparisonDialog.rows = rows
try {
const negRes = await api.GetNegotiationRecordsObj(row.id, {})
const raw = negRes?.data?.data ?? negRes?.data
diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx
index 4df494f..79ae618 100644
--- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx
+++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx
@@ -178,6 +178,162 @@ export const formatQuotationProfitMarginForCompare = (raw: unknown): string => {
return `${pct.toFixed(2)}%`
}
+/**
+ * 将 `QuotationProfit.profit_rate` 转为乘数(如 1% → 0.01),与 `formatQuotationProfitMarginForCompare` 口径一致:
+ * 介于 0 与 1 之间视为小数形式;否则视为百分数(3 → 3%)。
+ */
+export const quotationProfitRateToMultiplier = (raw: unknown): number | null => {
+ if (raw === null || raw === undefined || raw === '') return null
+ const n = Number(raw)
+ if (!Number.isFinite(n) || n < 0) return null
+ if (n > 0 && n < 1) return n
+ return n / 100
+}
+
+/** 比价数值托底:缺省或非有限数字时按 0 */
+export const coalesceCompareNumeric = (v: unknown): number => {
+ if (v === null || v === undefined || v === '') return 0
+ const n = Number(v)
+ return Number.isFinite(n) ? n : 0
+}
+
+/**
+ * 比价「利润」行制程最低价:各报价中最小利润率 ×(材料+加工+其它三项制程最低价之和);缺项按 0。
+ */
+export const computeProfitProcessMinPrice = (
+ materialMin: number | undefined,
+ processMin: number | undefined,
+ otherMin: number | undefined,
+ profitRateRaws: unknown[]
+): number => {
+ const m0 = coalesceCompareNumeric(materialMin)
+ const m1 = coalesceCompareNumeric(processMin)
+ const m2 = coalesceCompareNumeric(otherMin)
+ const mults: number[] = []
+ for (const raw of profitRateRaws) {
+ const m = quotationProfitRateToMultiplier(raw)
+ if (m != null) mults.push(m)
+ }
+ const minM = mults.length ? Math.min(...mults) : 0
+ return (m0 + m1 + m2) * minM
+}
+
+/** 各报价中最小利润率对应的展示文案(与 `formatQuotationProfitMarginForCompare` 一致) */
+export const formatMinProfitMarginForCompare = (profitRateRaws: unknown[]): string => {
+ const entries = profitRateRaws
+ .map((raw) => ({ raw, m: quotationProfitRateToMultiplier(raw) }))
+ .filter((x): x is { raw: unknown; m: number } => x.m != null && Number.isFinite(x.m))
+ if (!entries.length) return ''
+ let minM = entries[0].m
+ let minRaw = entries[0].raw
+ for (const e of entries) {
+ if (e.m < minM) {
+ minM = e.m
+ minRaw = e.raw
+ }
+ }
+ return formatQuotationProfitMarginForCompare(minRaw)
+}
+
+/**
+ * 与「税率」信息区一致:将 `firstNonEmptyString` 得到的税率字符串(如 `1.0000%`)转为乘数(如 0.01)。
+ */
+export const comparisonTaxRateToMultiplier = (raw: unknown): number | null => {
+ if (raw === null || raw === undefined || raw === '') return null
+ const n = Number(String(raw).replace(/%/g, '').trim())
+ if (!Number.isFinite(n)) return null
+ return n / 100
+}
+
+/**
+ * 比价「税金」行制程最低价:(材料+加工+其它制程最低价 + 利润制程最低价)× 税率。
+ */
+export const computeTaxProcessMinPrice = (
+ materialMin: number | undefined,
+ processMin: number | undefined,
+ otherMin: number | undefined,
+ profitMin: number | undefined,
+ taxRateDisplay: unknown
+): number => {
+ const m = comparisonTaxRateToMultiplier(taxRateDisplay) ?? 0
+ return (
+ coalesceCompareNumeric(materialMin) +
+ coalesceCompareNumeric(processMin) +
+ coalesceCompareNumeric(otherMin) +
+ coalesceCompareNumeric(profitMin)
+ ) * m
+}
+
+/**
+ * 比价「总价」行制程最低价:材料+加工+其它+利润+税金 五行制程最低价之和(与各行展示一致后再加总)。
+ */
+export const sumComparisonProcessMinTotals = (
+ materialMin: number | undefined,
+ processMin: number | undefined,
+ otherMin: number | undefined,
+ profitMin: number | undefined,
+ taxMin: number | undefined
+): number =>
+ coalesceCompareNumeric(materialMin) +
+ coalesceCompareNumeric(processMin) +
+ coalesceCompareNumeric(otherMin) +
+ coalesceCompareNumeric(profitMin) +
+ coalesceCompareNumeric(taxMin)
+
+/**
+ * 比价「总价」行平均价:材料+加工+其它+利润+税金 五行平均价之和(与各行展示一致后再加总)。
+ */
+export const sumComparisonAverageTotals = (
+ materialAvg: number | undefined,
+ processAvg: number | undefined,
+ otherAvg: number | undefined,
+ profitAvg: number | undefined,
+ taxAvg: number | undefined
+): number =>
+ coalesceCompareNumeric(materialAvg) +
+ coalesceCompareNumeric(processAvg) +
+ coalesceCompareNumeric(otherAvg) +
+ coalesceCompareNumeric(profitAvg) +
+ coalesceCompareNumeric(taxAvg)
+
+/**
+ * 比价「利润」行平均价:(材料+加工+其它平均价之和)× 各报价利润率乘数之算术平均。
+ */
+export const computeProfitRowAveragePrice = (
+ materialAvg: number | undefined,
+ processAvg: number | undefined,
+ otherAvg: number | undefined,
+ profitRateRaws: unknown[]
+): number => {
+ const sum = coalesceCompareNumeric(materialAvg) + coalesceCompareNumeric(processAvg) + coalesceCompareNumeric(otherAvg)
+ const mults: number[] = []
+ for (const raw of profitRateRaws) {
+ const m = quotationProfitRateToMultiplier(raw)
+ if (m != null) mults.push(m)
+ }
+ const avgM = mults.length ? mults.reduce((a, b) => a + b, 0) / mults.length : 0
+ return sum * avgM
+}
+
+/**
+ * 比价「税金」行平均价:(材料+加工+其它+利润平均价之和)× 头部税率(与 `comparisonTaxRateToMultiplier` 一致)。
+ */
+export const computeTaxRowAveragePrice = (
+ materialAvg: number | undefined,
+ processAvg: number | undefined,
+ otherAvg: number | undefined,
+ profitAvg: number | undefined,
+ taxRateDisplay: unknown
+): number => {
+ const m = comparisonTaxRateToMultiplier(taxRateDisplay) ?? 0
+ return (
+ coalesceCompareNumeric(materialAvg) +
+ coalesceCompareNumeric(processAvg) +
+ coalesceCompareNumeric(otherAvg) +
+ coalesceCompareNumeric(profitAvg)
+ ) * m
+}
+
/** 比价展开明细:与模板 fields 顺序一致 */
export type ComparisonDetailMetric = {
label: string
@@ -341,6 +497,8 @@ export type LowPriceMinContext = {
/** 所有材质分组的材料费用总和(用于材料成本行的制程最低价) */
totalMaterialProduct?: number
minProcessTotal?: number
+ /** 加工成本制程最低价对应的报价单 id(可点击跳转) */
+ minProcessQuotationId?: string | number
pid: string
/** 杂采材料最低价对应交易厂区(全局,供浮窗) */
miscMinFactory?: string
@@ -395,6 +553,8 @@ export function computeLowPriceMinContext(
let specMinQuoteUp: number | undefined
let specMinWeightQuotationId: string | number | undefined
let specMinUnitPriceQuotationId: string | number | undefined
+ let allQuotesEqualForSpec = true
+ let firstValue: number | undefined
// 遍历所有报价单,找出该材质规格的最低重量和最低单价
for (const q of quotes) {
@@ -409,6 +569,12 @@ export function computeLowPriceMinContext(
specMinW = w
specMinWeightQuotationId = qid(q)
}
+ // 检查该材质下所有报价单是否相等
+ if (firstValue === undefined) {
+ firstValue = w
+ } else if (Math.abs(w - firstValue) > 0.0001) {
+ allQuotesEqualForSpec = false
+ }
}
const up = Number(r.unit_price)
if (Number.isFinite(up)) {
@@ -416,6 +582,12 @@ export function computeLowPriceMinContext(
specMinQuoteUp = up
specMinUnitPriceQuotationId = qid(q)
}
+ // 检查该材质下所有报价单是否相等
+ if (firstValue === undefined) {
+ firstValue = up
+ } else if (Math.abs(up - firstValue) > 0.0001) {
+ allQuotesEqualForSpec = false
+ }
}
}
}
@@ -447,6 +619,38 @@ export function computeLowPriceMinContext(
}
}
+ // 如果该材质的所有报价单相等,则取最早提交的报价单
+ if (allQuotesEqualForSpec && specMinW !== undefined && specMinUp !== undefined) {
+ // 找到最早提交的报价单
+ let earliestQuote = quotes[0]
+ let earliestTime = Infinity
+
+ for (const q of quotes) {
+ const qTime = q.quotetime || q.creattime || q.createTime
+ if (qTime) {
+ const timeVal = new Date(qTime).getTime()
+ if (timeVal < earliestTime) {
+ earliestTime = timeVal
+ earliestQuote = q
+ }
+ }
+ }
+
+ // 使用最早提交报价单的数据
+ for (const r of earliestQuote.material_costs || []) {
+ const rSpec = String(r.material_spec || '').trim() || '材料'
+ if (rSpec === spec) {
+ const w = Number(r.weight)
+ const up = Number(r.unit_price)
+ if (Number.isFinite(w)) specMinW = w
+ if (Number.isFinite(up)) specMinUp = up
+ specMinWeightQuotationId = qid(earliestQuote)
+ specMinUnitPriceQuotationId = qid(earliestQuote)
+ break
+ }
+ }
+ }
+
// 计算该材质的材料费用(最低重量 × 最低单价 × 数量)
let specMaterialProduct: number | undefined
if (specMinW !== undefined && specMinUp !== undefined && Number.isFinite(qty) && qty > 0) {
@@ -467,7 +671,12 @@ export function computeLowPriceMinContext(
}
// 加工成本:各报价单加工费合计之最小值(不按工站分组)
+ // 按照后端逻辑:如果所有报价单加工费相等,取最早提交报价单;否则取最小值
let minProcessTotal: number | undefined
+ let allProcessEqual = true
+ let firstProcessValue: number | undefined
+ let earliestProcessQuote: any = quotes[0]
+
for (const q of quotes) {
let s = 0
let ok = false
@@ -479,13 +688,82 @@ export function computeLowPriceMinContext(
ok = true
}
}
- if (ok) minProcessTotal = minProcessTotal === undefined ? s : Math.min(minProcessTotal, s)
+ if (ok) {
+ if (firstProcessValue === undefined) {
+ firstProcessValue = s
+ } else if (Math.abs(s - firstProcessValue) > 0.0001) {
+ allProcessEqual = false
+ }
+ if (minProcessTotal === undefined || s < minProcessTotal) {
+ minProcessTotal = s
+ }
+ }
+ }
+
+ // 如果所有加工费相等,取最早提交的报价单
+ if (allProcessEqual && quotes.length > 1) {
+ let earliestTime = Infinity
+ for (const q of quotes) {
+ const qTime = q.quotetime || q.creattime || q.createTime
+ if (qTime) {
+ const timeVal = new Date(qTime).getTime()
+ if (timeVal < earliestTime) {
+ earliestTime = timeVal
+ earliestProcessQuote = q
+ }
+ }
+ }
+ // 重新计算最早报价单的加工费
+ let earliestProcessSum = 0
+ for (const r of earliestProcessQuote.process_costs || []) {
+ if (pid && String(r.part_id || '').trim() !== pid) continue
+ const p = Number(r.process_price)
+ if (Number.isFinite(p)) {
+ earliestProcessSum += p
+ }
+ }
+ minProcessTotal = earliestProcessSum
+ }
+
+ // 加工成本「最低价」对应报价单:与后端一致——全相等取最早提交;否则取最小合计,并列按报价单号
+ let minProcessQuotationId: string | number | undefined
+ if (minProcessTotal !== undefined) {
+ if (allProcessEqual && quotes.length > 1) {
+ minProcessQuotationId = qid(earliestProcessQuote)
+ } else if (allProcessEqual) {
+ minProcessQuotationId = qid(quotes[0])
+ } else {
+ const entries: { q: any; s: number; qn: string }[] = []
+ 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) continue
+ const qn = String(q.quotation_no || q.quotationNo || '').trim()
+ entries.push({ q, s, qn })
+ }
+ if (entries.length) {
+ entries.sort((a, b) => {
+ if (Math.abs(a.s - b.s) > 1e-6) return a.s - b.s
+ return a.qn.localeCompare(b.qn, undefined, { numeric: true })
+ })
+ minProcessQuotationId = qid(entries[0].q)
+ }
+ }
}
return {
materialSpecs,
totalMaterialProduct: totalMaterialProduct || undefined,
minProcessTotal,
+ minProcessQuotationId,
pid,
miscMinFactory: miscMinFactory != null && String(miscMinFactory).trim() !== ''
? String(miscMinFactory).trim()