From 7a9822740c97456d9499a1024ad211ab76dd0dd1 Mon Sep 17 00:00:00 2001 From: nebula_chen Date: Fri, 3 Apr 2026 18:57:09 +0800 Subject: [PATCH] =?UTF-8?q?refactor(pisadmin)=EF=BC=9A=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=88=B6=E7=A8=8B=E6=9C=80=E4=BD=8E=E4=BB=B7=E5=92=8C=E5=B9=B3?= =?UTF-8?q?=E5=9D=87=E4=BB=B7=E7=9A=84=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/pisadmin/miscprocurement/views.py | 134 ++++++++- .../rfqmiscellaneous/comparePrice.vue | 110 ++++++- .../miscprocurement/rfqmiscellaneous/crud.tsx | 280 +++++++++++++++++- 3 files changed, 499 insertions(+), 25 deletions(-) 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) }} + @@ -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()