From 59fb4044603d3230e78b49397ba22bec15f26b69 Mon Sep 17 00:00:00 2001 From: nebula_chen Date: Fri, 3 Apr 2026 15:17:43 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(pisadmin)=EF=BC=9A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E7=94=A8=E6=88=B7=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pisadmin/basicinfo/supplier_user/api.ts | 3 + .../pisadmin/basicinfo/supplier_user/crud.tsx | 248 ++++++++++++++++++ .../basicinfo/supplier_user/index.vue | 24 ++ 3 files changed, 275 insertions(+) create mode 100644 web/src/views/pisadmin/basicinfo/supplier_user/crud.tsx create mode 100644 web/src/views/pisadmin/basicinfo/supplier_user/index.vue diff --git a/web/src/views/pisadmin/basicinfo/supplier_user/api.ts b/web/src/views/pisadmin/basicinfo/supplier_user/api.ts index 35f1c77..a0b5073 100644 --- a/web/src/views/pisadmin/basicinfo/supplier_user/api.ts +++ b/web/src/views/pisadmin/basicinfo/supplier_user/api.ts @@ -3,3 +3,6 @@ import { request } from '/@/utils/service' const apiPrefix = '/api/pisadmin/basicinfo/supplier_users/' export const GetList = (params: any) => request({ url: apiPrefix, method: 'get', params }) +export const AddObj = (data: any) => request({ url: apiPrefix, method: 'post', data }) +export const UpdateObj = (data: any) => request({ url: apiPrefix + data.id + '/', method: 'put', data }) +export const DelObj = (id: string | number) => request({ url: apiPrefix + id + '/', method: 'delete' }) diff --git a/web/src/views/pisadmin/basicinfo/supplier_user/crud.tsx b/web/src/views/pisadmin/basicinfo/supplier_user/crud.tsx new file mode 100644 index 0000000..74a885f --- /dev/null +++ b/web/src/views/pisadmin/basicinfo/supplier_user/crud.tsx @@ -0,0 +1,248 @@ +import { dict, CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud' +import * as api from './api' +import { GetList as GetSupplierList } from '../supplier/api' +import { ElMessage } from 'element-plus' + +const statusDict = [ + { value: 1, label: '有效' }, + { value: 0, label: '无效' } +] + +const supplierRoleDict = [{ value: 1, label: '报价' }] + +let supplierById: Record = {} + +/** 兼容 dvadmin 分页:{ code, data: [...] };部分封装可能是 data.results */ +const extractSupplierList = (res: any) => { + const inner = res?.data + if (Array.isArray(inner)) return inner + if (Array.isArray(inner?.results)) return inner.results + if (Array.isArray(inner?.data)) return inner.data + if (Array.isArray(inner?.list)) return inner.list + const flat = + res?.data?.data?.results || + res?.data?.results || + res?.data?.list || + res?.results || + res?.list || + [] + return Array.isArray(flat) ? flat : [] +} + +const loadSupplierOptions = async () => { + try { + const res = await GetSupplierList({ page: 1, page_size: 1000, pageSize: 1000 }) + const arr = extractSupplierList(res) + supplierById = Object.fromEntries(arr.map((s: any) => [String(s.supplier_id), s])) + return arr + } catch (e) { + console.warn('加载供应商列表失败', e) + supplierById = {} + return [] + } +} + +/** 按供应商唯一ID 从「供应商信息」接口取一条(与询价单选料号带出名称类似:先选项缓存,必要时再查接口) */ +const fetchSupplierBySupplierId = async (supplierId: string) => { + if (!supplierId) return null + try { + const res = await GetSupplierList({ + supplier_id: supplierId, + page: 1, + page_size: 5, + pageSize: 5 + }) + const arr = extractSupplierList(res) + const row = arr.find((s: any) => String(s.supplier_id) === String(supplierId)) || arr[0] || null + if (row) supplierById[String(supplierId)] = row + return row + } catch (e) { + console.warn('按供应商ID查询供应商信息失败', e) + return null + } +} + +void loadSupplierOptions() + +export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet { + void crudExpose + + const ensureUserEmailUnique = async (supplierId: string, userEmail: string, currentId?: number) => { + if (!supplierId || !userEmail) return + const res = await api.GetList({ + supplier_id: supplierId, + user_email: userEmail, + page: 1, + page_size: 50, + pageSize: 50 + }) + const list = extractSupplierList(res) + const exists = Array.isArray(list) + ? list.find( + (item: any) => + String(item.supplier_id) === String(supplierId) && + String(item.user_email || '').trim().toLowerCase() === String(userEmail).trim().toLowerCase() + ) + : null + if (exists && (!currentId || exists.id !== currentId)) { + throw new Error('同一供应商下该联络邮箱已存在') + } + } + + return { + crudOptions: { + form: { + labelWidth: '110px' + }, + request: { + pageRequest: async (query) => api.GetList(query), + addRequest: async ({ form }) => { + try { + await ensureUserEmailUnique(form.supplier_id, form.user_email) + return await api.AddObj(form) + } catch (err: any) { + ElMessage.error(err?.message || '保存失败') + throw err + } + }, + editRequest: async ({ form, row }) => { + try { + await ensureUserEmailUnique(form.supplier_id, form.user_email, row.id) + return await api.UpdateObj({ ...form, id: row.id }) + } catch (err: any) { + ElMessage.error(err?.message || '保存失败') + throw err + } + }, + delRequest: async ({ row }) => api.DelObj(row.id) + }, + table: { + rowKey: 'id' + }, + actionbar: { + buttons: { + add: { show: true } + } + }, + rowHandle: { + fixed: 'right' + }, + columns: { + supplier_id: { + title: '供应商唯一ID', + type: 'dict-select', + dict: dict({ + cache: false, + value: 'supplier_id', + label: 'supplier_short_name', + getData: async () => { + const rows = await loadSupplierOptions() + return rows.map((s: any) => ({ + ...s, + supplier_short_name: `${s.supplier_short_name || s.supplier_name || ''} (${s.supplier_id})` + })) + } + }), + search: { show: true, component: { props: { placeholder: '请选择或搜索', filterable: true, clearable: true } } }, + form: { + rules: [{ required: true, message: '请选择供应商' }], + /** dict-select 需用 fast-crud 的 valueChange;component.on.change 往往不会触发 */ + valueChange: async ({ value, form }: any) => { + const v = value + if (v == null || v === '') { + form.supplier_name = '' + return + } + let s = supplierById[String(v)] + if (!s?.supplier_name) { + await loadSupplierOptions() + s = supplierById[String(v)] + } + if (!s?.supplier_name) { + s = await fetchSupplierBySupplierId(String(v)) + } + form.supplier_name = s?.supplier_name != null ? String(s.supplier_name) : '' + }, + component: { + props: { filterable: true } + } + }, + editForm: { + component: { + props: { + disabled: true + } + } + }, + column: { minWidth: 200, showOverflowTooltip: true } + }, + supplier_name: { + title: '供应商全称', + type: 'input', + search: { show: true, component: { props: { placeholder: '请输入供应商全称', clearable: true } } }, + form: { + rules: [{ required: true, message: '请先选择供应商唯一ID,将自动带出供应商全称' }], + component: { + props: { + disabled: true, + placeholder: '选择供应商唯一ID后自动带出' + } + } + }, + column: { minWidth: 180, showOverflowTooltip: true } + }, + supplier_role: { + title: '供应商角色', + type: 'dict-select', + dict: dict({ data: supplierRoleDict }), + search: { show: true }, + form: { + rules: [{ required: true, message: '请选择供应商角色' }], + value: 1 + }, + column: { minWidth: 120, showOverflowTooltip: true } + }, + user_email: { + title: '联络人邮箱', + type: 'input', + search: { show: true, component: { props: { placeholder: '请输入邮箱', clearable: true } } }, + form: { rules: [{ required: true, message: '请输入联络人邮箱' }] }, + column: { minWidth: 200, showOverflowTooltip: true } + }, + user_name: { + title: '联络人', + type: 'input', + search: { show: true, component: { props: { placeholder: '请输入联络人', clearable: true } } }, + form: { rules: [{ required: true, message: '请输入联络人' }] }, + column: { minWidth: 120, showOverflowTooltip: true } + }, + user_phone: { + title: '联络人电话', + type: 'input', + search: { show: true, component: { props: { placeholder: '请输入电话', clearable: true } } }, + form: { rules: [{ required: true, message: '请输入联络人电话' }] }, + column: { minWidth: 140, showOverflowTooltip: true } + }, + status: { + title: '有效否', + type: 'dict-switch', + dict: dict({ data: statusDict }), + form: { value: 1 }, + column: { width: 120 } + }, + create_datetime: { + title: '创建时间', + type: 'datetime', + form: { show: false }, + column: { width: 180 } + }, + update_datetime: { + title: '更新时间', + type: 'datetime', + form: { show: false }, + column: { width: 180 } + } + } + } + } +} diff --git a/web/src/views/pisadmin/basicinfo/supplier_user/index.vue b/web/src/views/pisadmin/basicinfo/supplier_user/index.vue new file mode 100644 index 0000000..80a9a38 --- /dev/null +++ b/web/src/views/pisadmin/basicinfo/supplier_user/index.vue @@ -0,0 +1,24 @@ + + + + + From cb91c7c4f55e241e321a15e7c42f6e7a63b0047c Mon Sep 17 00:00:00 2001 From: nebula_chen Date: Fri, 3 Apr 2026 16:32:13 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(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=E8=AE=A1=E7=AE=97?= =?UTF-8?q?=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 | 122 ++++--- .../rfqmiscellaneous/comparePrice.vue | 10 +- .../miscprocurement/rfqmiscellaneous/crud.tsx | 319 +++++++++++------- 3 files changed, 295 insertions(+), 156 deletions(-) diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index 7fa320f..5ff8af7 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -1,4 +1,5 @@ import logging +from collections import defaultdict from decimal import Decimal from datetime import datetime from typing import Optional @@ -217,8 +218,9 @@ def _sync_misc_low_price_records(inquiry: Inquiry, part_id: str) -> None: 制程最低价落库(与比价展开明细一致): - 前端比价展开不依赖成本结构模板:材料按材质分组仅展示重量/单价/材料费用,加工按工站分组仅展示加工费;落库口径仍按下列规则。 - 询价单下**每个上阶物料料号**单独一套主/次表数据(多料号互不合并)。 - - **材料**:次表两行分别记录最低重量、最低单价;souce_no 为取得该最小值对应的报价单单号,单价若来自「杂采材料信息」 + - **材料**:次表按材质分组记录,每组两行(最低重量、最低单价);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 回退)。采购端比价主表「其它成本」行制程最低价列与「加工成本」相同,可链向合计最低的报价单详情。 @@ -248,74 +250,118 @@ 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() - # —— 材料:次表两行(重量、单价);主表 = 最低重量×最低单价×数量。 + # —— 材料:按材质规格分组,每组次表两行(重量、单价);主表 = 所有分组的(最低重量×最低单价×数量)之和。 # 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]] = [] + + # 按材质规格分组收集候选值 + from collections import defaultdict + spec_weight_candidates: dict[str, list[tuple[Decimal, str]]] = defaultdict(list) + spec_unit_candidates: dict[str, list[tuple[Decimal, str]]] = defaultdict(list) + for m in materials: qn_src = getattr(m, "quotation_no_id", None) or "" qn_src = str(qn_src).strip() + spec = (getattr(m, "material_spec", None) or "").strip() or "-" if m.weight is not None: try: w = Decimal(str(m.weight)) - weight_candidates.append((w, qn_src)) + spec_weight_candidates[spec].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)) + spec_unit_candidates[spec].append((up, qn_src)) except Exception: pass + + # 杂采材料信息:单价适用于所有材质分组 + misc_unit_candidates: list[tuple[Decimal, str]] = [] 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)) + misc_unit_candidates.append((up, fac)) except Exception: pass - 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) + total_material_low = Decimal("0") + all_src_w_list: list[str] = [] + all_src_up_list: list[str] = [] - 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)), - ) + # 获取所有材质规格(有重量或单价数据的) + all_specs = set(spec_weight_candidates.keys()) | set(spec_unit_candidates.keys()) - 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 + for spec in all_specs: + weight_candidates = spec_weight_candidates.get(spec, []) + unit_candidates = spec_unit_candidates.get(spec, []) + + # 杂采单价也加入该分组的单价候选 + unit_candidates = unit_candidates + misc_unit_candidates + + 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 + + # 判断是否来自杂采材料信息 + is_misc_src = False + if min_up is not None and misc_unit_candidates: + for misc_up, misc_fac in misc_unit_candidates: + if abs(min_up - misc_up) < Decimal("0.0001"): + is_misc_src = True + break + + # 写入次表:最低重量 + if min_w is not None: + MiscLowPriceDetail.objects.create( + inquiry_no=inquiry_no, + part_id=pid, + cost_type="1", + material_spec=spec[:50], + item_no="1", + value=_clip_price_str(min_w), + souce_no=_norm_low_price_src((src_w or souce_ref)), + ) + if src_w: + all_src_w_list.append(src_w) + + # 写入次表:最低单价 + if min_up is not None: + MiscLowPriceDetail.objects.create( + inquiry_no=inquiry_no, + part_id=pid, + cost_type="1", + 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), + ) + if src_up: + all_src_up_list.append(src_up) + + # 累加到总材料费用 + if min_w is not None and min_up is not None and qty_dec is not None and qty_dec > 0: + spec_material_low = min_w * min_up * qty_dec + total_material_low += spec_material_low + + # 写入主表材料行:所有分组的材料费用总和 + if total_material_low > 0: + # 聚合来源:W:所有重量来源; U:所有单价来源 + aggregated_src_w = ",".join(sorted(set(all_src_w_list))) if all_src_w_list else None + aggregated_src_up = ",".join(sorted(set(all_src_up_list))) if all_src_up_list else None MiscLowPriceHeader.objects.create( inquiry_no=inquiry_no, part_id=pid, - souce_no=_material_cost_header_souce_no(src_w, src_up) or None, + souce_no=_material_cost_header_souce_no(aggregated_src_w, aggregated_src_up) or None, cost_type="1", item_no="材料", - min_price=_clip_price_str(material_low), + min_price=_clip_price_str(total_material_low), ) # —— 加工:仅主表一行,无次表、不按工站。每份报价单对该料号加工费合计(空/缺省按 0),再取最小; diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue index eff2daa..1d55274 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/comparePrice.vue @@ -742,7 +742,7 @@ const findProcessRowByStation = (q: any, station: string) => { return (q.process_costs || []).find((r: any) => String(r.process_station || '').trim() === t) } -/** 材料成本展开:按材料规格分组;「制程最低价」列对重量/单价/材料费用行使用全局口径(与后端落库一致) */ +/** 材料成本展开:按材料规格分组;「制程最低价」列对每个材质分组单独计算最低价 */ const buildMaterialDetailGroups = ( quotes: any[], supplierKeys: string[], @@ -770,7 +770,8 @@ const buildMaterialDetailGroups = ( const allDash = supplierKeys.every((sup) => values[sup] === '-') if (allDash) continue const line: ComparisonDetailRow = { label: m.label, values, isText: m.isText, ...calcCompareStats(values) } - if (lowPriceCtx) applyMaterialDetailLowPriceMin(line, m, lowPriceCtx, supplierKeys) + // 传入当前材质规格,使每个分组使用各自的最低价数据 + if (lowPriceCtx) applyMaterialDetailLowPriceMin(line, m, lowPriceCtx, supplierKeys, spec) lines.push(line) } if (lines.length) groups.push({ title: spec || '材料', lines }) @@ -939,8 +940,9 @@ const buildComparisonRowsFromPisQuotes = ( const matRow = rows.find((r) => r.key === 'material') if (matRow && materialGroups.length) matRow.detailGroups = materialGroups - if (matRow && lp?.materialProduct != null) { - matRow.min = lp.materialProduct + // 材料成本行的制程最低价是所有材质分组材料费用的总和 + if (matRow && lp?.totalMaterialProduct != null) { + matRow.min = lp.totalMaterialProduct matRow.minLink = buildMaterialCostMinLink(lp, quotes, { materialRowValues: matRow.values, supplierKeys diff --git a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx index 1630d95..4df494f 100644 --- a/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx +++ b/web/src/views/pisadmin/miscprocurement/rfqmiscellaneous/crud.tsx @@ -314,20 +314,35 @@ export type CompareMinLink = unitPriceSource: string } +/** 单个材质分组的最低价数据 */ +export type MaterialSpecLowPrice = { + /** 材质规格名称 */ + spec: string + /** 最低重量 */ + minW?: number + /** 最低单价 */ + minUp?: number + /** 材料费用 = 最低重量 × 最低单价 × 数量 */ + materialProduct?: number + /** 最低重量所在报价单主键 */ + minWeightQuotationId?: string | number + /** 最低单价所在报价单主键(若最低单价由杂采材料信息决定则为空) */ + minUnitPriceQuotationId?: string | number + /** 仅当杂采单价严格低于所有报价单价时,单价/材料成本链向杂采材料管理 */ + minUnitPriceFromMisc?: boolean + /** 杂采材料最低价对应交易厂区 */ + miscMinFactory?: string +} + /** 与后端制程最低价落库一致:全报价最低重量/最低单价(单价含杂采材料信息)、材料费=三者乘积;加工费=各报价单加工费合计之最小值 */ export type LowPriceMinContext = { - minW?: number - minUp?: number - materialProduct?: number + /** 按材质规格分组的最低价数据(每个材质单独计算最低价) */ + materialSpecs: MaterialSpecLowPrice[] + /** 所有材质分组的材料费用总和(用于材料成本行的制程最低价) */ + totalMaterialProduct?: number minProcessTotal?: number pid: string - /** 全报价中材料明细最低重量所在报价单主键 */ - minWeightQuotationId?: string | number - /** 全报价中材料明细最低单价所在报价单主键(若最低单价由杂采材料信息决定则为空) */ - minUnitPriceQuotationId?: string | number - /** 仅当杂采单价严格低于所有报价单价时,单价/材料成本链向杂采材料管理;与报价持平或更高则链向报价单 */ - minUnitPriceFromMisc?: boolean - /** 杂采材料最低价对应交易厂区(与杂采材料表 factory 等字段一致,供浮窗) */ + /** 杂采材料最低价对应交易厂区(全局,供浮窗) */ miscMinFactory?: string } @@ -359,64 +374,99 @@ export function computeLowPriceMinContext( ): 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 minQuoteUp: number | undefined - let minWeightQuotationId: string | number | undefined - let minUnitPriceQuotationId: string | number | undefined const qid = (q: any) => q?.autoid ?? q?.id - 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)) { - if (minW === undefined || w < minW) { - minW = w - minWeightQuotationId = qid(q) + // 收集所有材质规格 + const specs = new Set() + quotes.forEach((q) => { + ;(q.material_costs || []).forEach((r: any) => { + if (pid && String(r.part_id || '').trim() !== pid) return + const spec = String(r.material_spec || '').trim() || '材料' + specs.add(spec) + }) + }) + + // 为每个材质规格计算最低价 + const materialSpecs: MaterialSpecLowPrice[] = [] + let totalMaterialProduct = 0 + + for (const spec of specs) { + let specMinW: number | undefined + let specMinQuoteUp: number | undefined + let specMinWeightQuotationId: string | number | undefined + let specMinUnitPriceQuotationId: string | number | undefined + + // 遍历所有报价单,找出该材质规格的最低重量和最低单价 + for (const q of quotes) { + for (const r of q.material_costs || []) { + if (pid && String(r.part_id || '').trim() !== pid) continue + const rSpec = String(r.material_spec || '').trim() || '材料' + if (rSpec !== spec) continue + + const w = Number(r.weight) + if (Number.isFinite(w)) { + if (specMinW === undefined || w < specMinW) { + specMinW = w + specMinWeightQuotationId = qid(q) + } } - } - const up = Number(r.unit_price) - if (Number.isFinite(up)) { - if (minQuoteUp === undefined || up < minQuoteUp) { - minQuoteUp = up - minUnitPriceQuotationId = qid(q) + const up = Number(r.unit_price) + if (Number.isFinite(up)) { + if (specMinQuoteUp === undefined || up < specMinQuoteUp) { + specMinQuoteUp = up + specMinUnitPriceQuotationId = qid(q) + } } } } - } - let minUp: number | undefined = minQuoteUp - let minUnitPriceFromMisc = false - let miscFactoryOut: string | undefined - const pickMiscFactory = () => { - const f = miscMinFactory != null && String(miscMinFactory).trim() !== '' ? String(miscMinFactory).trim() : '' - if (f) miscFactoryOut = f - } + // 考虑杂采材料信息的单价 + let specMinUp = specMinQuoteUp + let specMinUnitPriceFromMisc = false + let specMiscMinFactory: string | undefined - if (miscMinUnitPrice != null && Number.isFinite(miscMinUnitPrice)) { - const misc = miscMinUnitPrice - if (minQuoteUp === undefined) { - minUp = misc - minUnitPriceFromMisc = true - minUnitPriceQuotationId = undefined - pickMiscFactory() - } else if (misc < minQuoteUp && !_nearlyEqualMin(misc, minQuoteUp)) { - /** 仅当杂采单价严格低于所有报价单价时,制程最低价单价才记为来自杂采 */ - minUp = misc - minUnitPriceFromMisc = true - minUnitPriceQuotationId = undefined - pickMiscFactory() - } else { - /** 杂采高于或与最低报价持平:取 min(报价最低, 杂采),来源优先报价单(含持平) */ - minUp = Math.min(minQuoteUp, misc) - minUnitPriceFromMisc = false + if (miscMinUnitPrice != null && Number.isFinite(miscMinUnitPrice)) { + const misc = miscMinUnitPrice + if (specMinQuoteUp === undefined) { + specMinUp = misc + specMinUnitPriceFromMisc = true + specMinUnitPriceQuotationId = undefined + specMiscMinFactory = miscMinFactory != null && String(miscMinFactory).trim() !== '' + ? String(miscMinFactory).trim() + : undefined + } else if (misc < specMinQuoteUp && !_nearlyEqualMin(misc, specMinQuoteUp)) { + specMinUp = misc + specMinUnitPriceFromMisc = true + specMinUnitPriceQuotationId = undefined + specMiscMinFactory = miscMinFactory != null && String(miscMinFactory).trim() !== '' + ? String(miscMinFactory).trim() + : undefined + } else { + specMinUp = Math.min(specMinQuoteUp, misc) + specMinUnitPriceFromMisc = false + } } + + // 计算该材质的材料费用(最低重量 × 最低单价 × 数量) + let specMaterialProduct: number | undefined + if (specMinW !== undefined && specMinUp !== undefined && Number.isFinite(qty) && qty > 0) { + specMaterialProduct = specMinW * specMinUp * qty + totalMaterialProduct += specMaterialProduct + } + + materialSpecs.push({ + spec, + minW: specMinW, + minUp: specMinUp, + materialProduct: specMaterialProduct, + minWeightQuotationId: specMinWeightQuotationId, + minUnitPriceQuotationId: specMinUnitPriceQuotationId, + minUnitPriceFromMisc: specMinUnitPriceFromMisc, + miscMinFactory: specMiscMinFactory + }) } - 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 @@ -431,20 +481,19 @@ export function computeLowPriceMinContext( } if (ok) minProcessTotal = minProcessTotal === undefined ? s : Math.min(minProcessTotal, s) } + return { - minW, - minUp, - materialProduct, + materialSpecs, + totalMaterialProduct: totalMaterialProduct || undefined, minProcessTotal, pid, - minWeightQuotationId, - minUnitPriceQuotationId, - minUnitPriceFromMisc, - miscMinFactory: miscFactoryOut + miscMinFactory: miscMinFactory != null && String(miscMinFactory).trim() !== '' + ? String(miscMinFactory).trim() + : undefined } } -/** 主表「材料成本」制程最低价:同源则链向报价单详情,异源则链样式 + 浮窗说明分项来源 */ +/** 主表「材料成本」制程最低价:材料成本行的最低价是所有材质分组材料费用的总和 */ export function buildMaterialCostMinLink( ctx: LowPriceMinContext, quotes: any[], @@ -455,65 +504,103 @@ export function buildMaterialCostMinLink( !!opts.supplierKeys?.length && allCompareSupplierValuesEqual(opts.materialRowValues, opts.supplierKeys) - /** 各报价一致且杂采单价严格低于所有报价:制程最低价指向杂采材料管理 */ - if (materialAllEqual && ctx.minUnitPriceFromMisc) { - return { - kind: 'misc_materials', - sourceFactory: ctx.miscMinFactory - } - } if (materialAllEqual) { return null } - if (ctx.materialProduct === undefined) return null - const w = ctx.minW - const up = ctx.minUp - if (w === undefined || up === undefined || !Number.isFinite(w) || !Number.isFinite(up)) return null - const wid = ctx.minWeightQuotationId - const uid = ctx.minUnitPriceQuotationId - const misc = ctx.minUnitPriceFromMisc + // 材料成本行使用 totalMaterialProduct(所有材质分组的材料费用总和) + if (ctx.totalMaterialProduct === undefined) return null - const quoteLabel = (qid: string | number | undefined) => { - if (qid == null || qid === '') return '' - const q = quotes.find((x) => String(x.autoid ?? x.id) === String(qid)) - const name = String(q?.supplier_name || q?.supplierName || '').trim() - const code = String(q?.supplier_code || q?.supplierCode || '').trim() - const label = name || code - return label || `报价单 #${qid}` + // 如果只有一个材质分组,可以链向该分组的来源 + if (ctx.materialSpecs.length === 1) { + const spec = ctx.materialSpecs[0] + if (spec.minUnitPriceFromMisc) { + return { + kind: 'misc_materials', + sourceFactory: spec.miscMinFactory + } + } + // 如果重量和单价来自同一个报价单,链向该报价单 + const wid = spec.minWeightQuotationId + const uid = spec.minUnitPriceQuotationId + if (wid != null && wid !== '' && uid != null && uid !== '' && String(wid) === String(uid)) { + return { kind: 'quotation', id: wid } + } + // 否则显示拆分来源 + const quoteLabel = (qid: string | number | undefined) => { + if (qid == null || qid === '') return '' + const q = quotes.find((x) => String(x.autoid ?? x.id) === String(qid)) + const name = String(q?.supplier_name || q?.supplierName || '').trim() + const code = String(q?.supplier_code || q?.supplierCode || '').trim() + return name || code || `报价单 #${qid}` + } + return { + kind: 'material_cost_split', + weight: spec.minW ?? 0, + unitPrice: spec.minUp ?? 0, + weightSource: quoteLabel(wid) || '—', + unitPriceSource: spec.minUnitPriceFromMisc ? '杂采材料信息' : quoteLabel(uid) || '—' + } } - const weightSrc = quoteLabel(wid) - const unitSrc = misc ? '杂采材料信息' : quoteLabel(uid) - - const sameQuotation = - !misc && wid != null && wid !== '' && uid != null && uid !== '' && String(wid) === String(uid) - - if (sameQuotation) { - return { kind: 'quotation', id: wid } + // 多个材质分组时,如果有任何一个分组使用了杂采材料信息,显示杂采来源 + const hasMiscSource = ctx.materialSpecs.some((s) => s.minUnitPriceFromMisc) + if (hasMiscSource) { + // 找到第一个使用杂采的分组的厂区信息 + const miscSpec = ctx.materialSpecs.find((s) => s.minUnitPriceFromMisc) + if (miscSpec) { + return { + kind: 'misc_materials', + sourceFactory: miscSpec.miscMinFactory + } + } } - return { - kind: 'material_cost_split', - weight: w, - unitPrice: up, - weightSource: weightSrc || '—', - unitPriceSource: unitSrc || '—' + // 多个材质分组,显示拆分来源(使用第一个分组的来源信息作为代表) + const firstSpec = ctx.materialSpecs[0] + if (firstSpec) { + const quoteLabel = (qid: string | number | undefined) => { + if (qid == null || qid === '') return '' + const q = quotes.find((x) => String(x.autoid ?? x.id) === String(qid)) + const name = String(q?.supplier_name || q?.supplierName || '').trim() + const code = String(q?.supplier_code || q?.supplierCode || '').trim() + return name || code || `报价单 #${qid}` + } + return { + kind: 'material_cost_split', + weight: firstSpec.minW ?? 0, + unitPrice: firstSpec.minUp ?? 0, + weightSource: quoteLabel(firstSpec.minWeightQuotationId) || '—', + unitPriceSource: quoteLabel(firstSpec.minUnitPriceQuotationId) || '—' + } } + + return null } -/** 展开仍按规格分行时,「制程最低价」列用全局口径覆盖该行 min */ +/** 展开仍按规格分行时,「制程最低价」列按材质分组口径覆盖该行 min */ export function applyMaterialDetailLowPriceMin( line: { min?: number; minLink?: CompareMinLink | null; values: Record }, m: ComparisonDetailMetric, ctx: LowPriceMinContext, - supplierKeys?: string[] + supplierKeys?: string[], + /** 当前行的材质规格,用于查找对应分组的最低价数据 */ + spec?: string ): 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 - const qid = ctx.minWeightQuotationId + + // 找到当前材质规格对应的最低价数据 + const specData = spec + ? ctx.materialSpecs.find((s) => s.spec === spec) + : ctx.materialSpecs[0] + + if (!specData) return + + // 重量行:使用该材质分组的最低重量 + if (specData.minW !== undefined && (fk === 'weight' || /用量|重量/.test(label))) { + line.min = specData.minW + const qid = specData.minWeightQuotationId line.minLink = qid != null && qid !== '' ? { kind: 'quotation', id: qid } @@ -523,17 +610,19 @@ export function applyMaterialDetailLowPriceMin( } return } + + // 单价行:使用该材质分组的最低单价 const isUnitPriceRow = fk === 'unitprice' || /材料单价/.test(label) || label === '单价' - if (ctx.minUp !== undefined && isUnitPriceRow) { - line.min = ctx.minUp - if (ctx.minUnitPriceFromMisc) { + if (specData.minUp !== undefined && isUnitPriceRow) { + line.min = specData.minUp + if (specData.minUnitPriceFromMisc) { line.minLink = { kind: 'misc_materials', - sourceFactory: ctx.miscMinFactory + sourceFactory: specData.miscMinFactory } } else { - const qid = ctx.minUnitPriceQuotationId + const qid = specData.minUnitPriceQuotationId line.minLink = qid != null && qid !== '' ? { kind: 'quotation', id: qid } @@ -542,14 +631,16 @@ export function applyMaterialDetailLowPriceMin( if ( supplierKeys?.length && allCompareSupplierValuesEqual(line.values, supplierKeys) && - !ctx.minUnitPriceFromMisc + !specData.minUnitPriceFromMisc ) { line.minLink = null } return } - if (ctx.materialProduct !== undefined && (fk === 'materialcost' || /材料费用|材料费/.test(label))) { - line.min = ctx.materialProduct + + // 材料费用行:使用该材质分组的材料费用(最低重量 × 最低单价 × 数量) + if (specData.materialProduct !== undefined && (fk === 'materialcost' || /材料费用|材料费/.test(label))) { + line.min = specData.materialProduct line.minLink = null } } From 32c1488d5f52d29439b1b628a709573a5993c857 Mon Sep 17 00:00:00 2001 From: nebula_chen Date: Fri, 3 Apr 2026 17:32:29 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(pisadmin)=EF=BC=9A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=9D=82=E9=87=87=E6=9D=90=E6=96=99/=E5=8A=A0=E5=B7=A5?= =?UTF-8?q?=E5=88=B6=E7=A8=8B=E6=9C=80=E4=BD=8E=E4=BF=A1=E6=81=AF=E8=A1=A8?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=AE=9A=E4=B9=89=E5=8F=8A=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E5=8C=96=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/pisadmin/miscprocurement/models.py | 54 +++++++++++++++++ .../pisadmin/miscprocurement/serializers.py | 34 +++++++++++ .../apps/pisadmin/miscprocurement/views.py | 59 ++++++++++++++++++- 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/backend/apps/pisadmin/miscprocurement/models.py b/backend/apps/pisadmin/miscprocurement/models.py index 8fd763c..9f90360 100644 --- a/backend/apps/pisadmin/miscprocurement/models.py +++ b/backend/apps/pisadmin/miscprocurement/models.py @@ -923,3 +923,57 @@ class RFQOperationLogs(CoreModel): def __str__(self) -> str: return f"{self.inquiry_no}-{self.quotation_no}" + + +class MiscProcMaterialMinPrices(CoreModel): + """杂采材料制程最低价信息表(""" + + ITEM_NUM_CHOICES = ( + ("1", "重量"), + ("2", "单价"), + ) + + part_id = models.CharField(max_length=50, db_column="partid", verbose_name="采购价料号") + material_spec = models.CharField(max_length=20, db_column="material_spec", verbose_name="材料规格") + currency_code = models.CharField(max_length=50, db_column="currency_code", verbose_name="货币代码") + item_num = models.CharField(max_length=20, db_column="item_num", verbose_name="项次名") + lowest_value = models.CharField(max_length=20, db_column="lowest_value", verbose_name="最低值") + source_number = models.CharField(max_length=50, db_column="source_number", verbose_name="报价来源单号") + quotation_time = models.DateTimeField(db_column="quotation_time", verbose_name="报价时间") + + class Meta: + db_table = table_prefix + "misc_proc_material_min_prices" + verbose_name = "杂采材料制程最低价信息表" + verbose_name_plural = verbose_name + ordering = ("-create_datetime", "id") + indexes = [ + models.Index(fields=["part_id", "material_spec", "currency_code", "item_num"]), + ] + + def __str__(self) -> str: + return f"{self.part_id}-{self.material_spec}-{self.currency_code}-{self.item_num}" + + +class MiscProcProcessingMinPrices(CoreModel): + """杂采加工费用最低价信息表 + + 按采购件料号、货币代码存储各报价单加工费合计的最低值。 + """ + + part_id = models.CharField(max_length=50, db_column="partid", verbose_name="采购件料号") + currency_code = models.CharField(max_length=50, db_column="currencycode", verbose_name="货币代码") + lowest_value = models.CharField(max_length=20, db_column="lowest_vaule", verbose_name="加工费用最低价") + source_number = models.CharField(max_length=50, null=True, blank=True, db_column="sourcenumber", verbose_name="报价来源单号") + quotation_time = models.DateTimeField(null=True, blank=True, db_column="quotationtime", verbose_name="报价时间") + + class Meta: + db_table = table_prefix + "misc_proc_processing_min_prices" + verbose_name = "杂采加工费用最低价信息表" + verbose_name_plural = verbose_name + ordering = ("-create_datetime", "id") + indexes = [ + models.Index(fields=["part_id", "currency_code"]), + ] + + def __str__(self) -> str: + return f"{self.part_id}-{self.currency_code}-{self.lowest_value}" \ No newline at end of file diff --git a/backend/apps/pisadmin/miscprocurement/serializers.py b/backend/apps/pisadmin/miscprocurement/serializers.py index 3cd62af..b055f1a 100644 --- a/backend/apps/pisadmin/miscprocurement/serializers.py +++ b/backend/apps/pisadmin/miscprocurement/serializers.py @@ -23,6 +23,8 @@ from .models import ( InquiryRfqItem, CostEstimateTemplateHead, CostEstimateTemplateBody, + MiscProcMaterialMinPrices, + MiscProcProcessingMinPrices, ) from apps.pisadmin.basicinfo.models import Company from apps.pisadmin.basicinfo.models import Unit @@ -1600,3 +1602,35 @@ class InquirySerializer(CustomModelSerializer): self._upsert_rfq_items(inquiry, rfq_items) return inquiry + + +class MiscProcMaterialMinPricesSerializer(CustomModelSerializer): + """杂采材料制程最低价信息表序列化器""" + + class Meta: + model = MiscProcMaterialMinPrices + fields = "__all__" + read_only_fields = [ + "id", + "create_datetime", + "update_datetime", + "creator", + "modifier", + "dept_belong_id", + ] + + +class MiscProcProcessingMinPricesSerializer(CustomModelSerializer): + """杂采加工费用最低价信息表序列化器""" + + class Meta: + model = MiscProcProcessingMinPrices + fields = "__all__" + read_only_fields = [ + "id", + "create_datetime", + "update_datetime", + "creator", + "modifier", + "dept_belong_id", + ] diff --git a/backend/apps/pisadmin/miscprocurement/views.py b/backend/apps/pisadmin/miscprocurement/views.py index 5ff8af7..8576234 100644 --- a/backend/apps/pisadmin/miscprocurement/views.py +++ b/backend/apps/pisadmin/miscprocurement/views.py @@ -114,6 +114,8 @@ from .models import ( InquiryRfqItem, CostEstimateTemplateHead, CostEstimateTemplateBody, + MiscProcMaterialMinPrices, + MiscProcProcessingMinPrices, ) from .serializers import ( MiscMaterialSerializer, @@ -138,6 +140,8 @@ from .serializers import ( CostEstimateTemplateHeadSerializer, CostEstimateTemplateNewVersionSerializer, create_cost_template_new_version, + MiscProcMaterialMinPricesSerializer, + MiscProcProcessingMinPricesSerializer, ) @@ -1666,4 +1670,57 @@ class RFQOperationLogsViewSet(CustomModelViewSet): if buyer: nos = Inquiry.objects.filter(buyer__icontains=buyer).values_list("inquiry_no", flat=True) queryset = queryset.filter(inquiry_no__in=list(nos)) - return queryset \ No newline at end of file + return queryset + + +class MiscProcMaterialMinPricesViewSet(CustomModelViewSet): + """杂采材料制程最低价信息表视图集 + + 按采购件料号、材料规格、货币代码、项次存储最低重量(1)和最低单价(2)信息。 + """ + + queryset = MiscProcMaterialMinPrices.objects.all() + serializer_class = MiscProcMaterialMinPricesSerializer + filter_fields = ( + "part_id", + "material_spec", + "currency_code", + "item_num", + ) + search_fields = ( + "part_id", + "material_spec", + "source_number", + ) + ordering = ("-create_datetime", "id") + + def perform_create(self, serializer): + serializer.save() + + def perform_update(self, serializer): + serializer.save() + + +class MiscProcProcessingMinPricesViewSet(CustomModelViewSet): + """杂采加工费用最低价信息表视图集 + + 按采购件料号、货币代码存储各报价单加工费合计的最低值。 + """ + + queryset = MiscProcProcessingMinPrices.objects.all() + serializer_class = MiscProcProcessingMinPricesSerializer + filter_fields = ( + "part_id", + "currency_code", + ) + search_fields = ( + "part_id", + "source_number", + ) + ordering = ("-create_datetime", "id") + + def perform_create(self, serializer): + serializer.save() + + def perform_update(self, serializer): + serializer.save() \ No newline at end of file