新增 杂采比议价、询价履历记录 实现;调整部分ui效果

This commit is contained in:
nebula_chen
2026-03-27 14:32:50 +08:00
parent 7af8dbde95
commit 6f2000041f
12 changed files with 2004 additions and 73 deletions
@@ -10,6 +10,27 @@ export const DelObj = (id: string | number) => request({ url: baseUrl + id + '/'
export const ConfirmObj = (id: string | number) => request({ url: `${baseUrl}${id}/confirm/`, method: 'put' })
export const RestoreObj = (id: string | number) => request({ url: `${baseUrl}${id}/restore/`, method: 'put' })
export const PublishObj = (id: string | number) => request({ url: `${baseUrl}${id}/publish/`, method: 'put' })
export const StartBargainingObj = (id: string | number) => request({ url: `${baseUrl}${id}/start_bargaining/`, method: 'put' })
export const ConfirmNegotiationObj = (id: string | number) => request({ url: `${baseUrl}${id}/confirm_negotiation/`, method: 'put' })
export const SubmitPriceAuditObj = (id: string | number) => request({ url: `${baseUrl}${id}/submit_price_audit/`, method: 'put' })
/** 查询杂采议价记录(比价议价价格存此表,非报价明细「中标价格」) */
export const GetNegotiationRecordsObj = (id: string | number, params: { part_id?: string }) =>
request({ url: `${baseUrl}${id}/negotiation_records/`, method: 'get', params })
/** 保存比价中的议价后价格、中标否至杂采议价记录表 */
export const SaveNegotiationRecordsObj = (
id: string | number,
data: {
part_id: string
records: {
quotation_no: string
supplier_code?: string
is_awarded: number
bargaining_price: number | null
total_price_excl_tax?: number | null
total_price_incl_tax?: number | null
}[]
}
) => request({ url: `${baseUrl}${id}/save_negotiation_records/`, method: 'put', data })
export const UploadFile = (data: FormData) =>
request({
url: '/api/system/file/',
@@ -15,6 +15,11 @@ const statusDict = [
{ value: 0, label: '作废' }
]
const buyingMethodDict = [
{ value: 1, label: '询价' },
{ value: 2, label: '招标' }
]
const paymentMethods = [
{ value: 1, label: '月结30天' },
{ value: 2, label: '月结60天' },
@@ -59,8 +64,12 @@ type ExtraHooks = {
onAdd?: () => void
onEdit?: (row: any) => void
onView?: (row: any) => void
/** 打开比价/议价弹窗(比议价中、价格审核、核价通过);保存时按报价单写入杂采议价记录(议价前总价快照等) */
onComparison?: (row: any) => void
/** 列表多选变化(用于后续多询价单比价等) */
onTableSelectionChange?: (rows: any[]) => void
/** 与勾选列同步:由页面 ref 维护当前选中行,工具栏按钮据此取行(fast-crud 的 tableRef 往往拿不到 selection) */
getTableSelection?: () => any[]
}
const STATUS_OPEN = 1
@@ -97,6 +106,10 @@ const getRowStatus = (row: any) => Number(row?.status)
const isOpenStatus = (row: any) => getRowStatus(row) === STATUS_OPEN
const isConfirmedStatus = (row: any) => getRowStatus(row) === STATUS_CONFIRMED
const canPublishStatus = (row: any) => isConfirmedStatus(row)
/** 报价中(4)、报价结束(5) 可开启比价 */
const isQuotingOrEndedStatus = (row: any) => [4, 5].includes(getRowStatus(row))
/** 比议价中(6)、价格审核(7)、核价通过(8) 可打开比价窗口 */
const canOpenComparison = (row: any) => [6, 7, 8].includes(getRowStatus(row))
const getErrorMessage = (err: any, fallback: string) => formatRfqApiErrorMessage(err, fallback)
/** 列表行含 `suppliers` 时可先做提示;未返回嵌套时交由接口校验 */
@@ -106,12 +119,12 @@ const rowHasSuppliersList = (row: any): boolean | null => {
return list.length > 0
}
/** 成本模板版本号两位展示(与 pricetemplate 一致) */
export const formatCostTemplateVersionTwoDigits = (v: unknown) => {
if (v == null || v === '') return '00'
/** 成本模板版本号展示(不做位数补零) */
export const formatCostTemplateVersion = (v: unknown) => {
if (v == null || v === '') return ''
const n = Number(v)
if (!Number.isFinite(n)) return String(v)
return String(Math.trunc(n)).padStart(2, '0')
if (!Number.isFinite(n)) return String(v).trim()
return String(Math.trunc(n))
}
export const createCrudOptions = function ({
@@ -120,27 +133,23 @@ export const createCrudOptions = function ({
onAdd,
onEdit,
onView,
onTableSelectionChange
onComparison,
onTableSelectionChange,
getTableSelection
}: Partial<CreateCrudOptionsProps> & ExtraHooks): CreateCrudOptionsRet {
void context
const normalizeSelection = (raw: unknown): any[] => {
if (Array.isArray(raw)) return raw
if (raw != null && typeof raw === 'object') return [raw as any]
return []
}
const getSelectedRows = () => {
if (typeof getTableSelection === 'function') {
const rows = normalizeSelection(getTableSelection())
if (rows.length > 0) return rows
const rows = getTableSelection()
if (Array.isArray(rows) && rows.length > 0) return rows
}
const expose = crudExpose as any
// Element Plus:当前勾选行(与列表第一列选择列一致)
const baseTable = expose?.getBaseTableRef?.()
if (baseTable?.getSelectionRows) {
const fromEl = normalizeSelection(baseTable.getSelectionRows())
if (fromEl.length) return fromEl
const fromEl = baseTable.getSelectionRows()
if (Array.isArray(fromEl) && fromEl.length) return fromEl
}
const tableRef: any = expose?.getTableRef?.() || expose?.tableRef
const selection =
@@ -149,20 +158,20 @@ export const createCrudOptions = function ({
tableRef?.getSelections?.() ||
tableRef?.getSelected?.() ||
[]
return normalizeSelection(selection)
}
if (Array.isArray(selection)) return selection
return [] }
/** 工具栏批量操作:取当前勾选的全部行(与列表选择列一致) */
const pickSelectedRows = (): any[] | null => {
const pickSelectedRow = (): any | null => {
const rows = getSelectedRows()
if (!rows.length) {
ElMessage.warning('请先选择询价单')
return null
}
// if (rows.length > 1) {
// ElMessage.warning('仅支持单条操作')
// return null
// }
if (rows.length > 1) {
ElMessage.warning('仅支持单条操作')
return null
}
return rows[0]
}
@@ -208,12 +217,39 @@ export const createCrudOptions = function ({
click() {
onAdd && onAdd()
}
}
},
startBargaining: {
show: true,
text: '开启比价',
order: 2,
type: 'warning',
async click() {
const row = pickSelectedRow()
if (!row) return
if (!isQuotingOrEndedStatus(row)) {
ElMessage.warning('仅报价中或报价结束状态可开启比价')
return
}
try {
await ElMessageBox.confirm('确认将状态改为【比议价中】?', '提示', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
await api.StartBargainingObj(row.id)
onComparison && onComparison(row)
crudExpose?.doRefresh?.()
} catch (err: any) {
if (err === 'cancel' || err === 'close') return
ElMessage.error(getErrorMessage(err, '开启比价失败'))
}
}
},
}
},
rowHandle: {
fixed: 'right',
width: 420,
width: 500,
buttons: {
view: { show: false },
edit: { show: false },
@@ -321,6 +357,17 @@ export const createCrudOptions = function ({
throw err
}
}
},
viewComparison: {
text: '比价',
type: compute(({ row }) => (canOpenComparison(row) ? 'primary' : 'info')),
order: 0.25,
show: true,
disabled: compute(({ row }) => !canOpenComparison(row)),
click({ row }) {
if (!canOpenComparison(row)) return
onComparison && onComparison(row)
}
}
}
},
@@ -337,7 +384,7 @@ export const createCrudOptions = function ({
columnSetDisabled: true
}
},
company_short_name: {
company_short_name: {
title: '交易厂区',
type: 'text',
form: { show: false },
@@ -351,6 +398,25 @@ company_short_name: {
}
}
},
buying_method: {
title: '采购方式',
type: 'dict-select',
dict: dict({ data: buyingMethodDict }),
search: {
show: true,
component: { props: { placeholder: '采购方式', clearable: true } }
},
form: { show: false },
column: {
width: 100,
formatter: ({ row, value }: { row: any; value: unknown }) => {
const v = value ?? row?.buying_method
const n = Number(v)
if (!Number.isFinite(n)) return v != null && v !== '' ? String(v) : ''
return buyingMethodDict.find((d) => d.value === n)?.label ?? String(v)
}
}
},
inquiry_no: {
title: '询价单号',
type: 'input',
@@ -361,7 +427,7 @@ company_short_name: {
form: {
show: false
},
column: { minWidth: 140 }
column: { minWidth: 120, showOverflowTooltip: true }
},
title: {
title: '询价单名称',
@@ -377,13 +443,13 @@ company_short_name: {
title: '询价模版',
type: 'input',
column: {
minWidth: 180,
minWidth: 120,
showOverflowTooltip: true,
formatter: ({ row, value }: { row: any; value: unknown }) => {
const code = String(value ?? row?.template ?? row?.template_code ?? '').trim()
const verRaw = row?.template_version ?? row?.templateVersion ?? row?.cost_template_version
if (!code) return ''
if (verRaw != null && verRaw !== '') return `${code}(V${formatCostTemplateVersionTwoDigits(verRaw)})`
if (verRaw != null && verRaw !== '') return `${code}(V${formatCostTemplateVersion(verRaw)})`
return code
}
}
@@ -392,10 +458,28 @@ company_short_name: {
title: '报价截止时',
type: 'datetime',
column: {
width: 160,
width: 150,
formatter: ({ value }: { value: unknown }) => formatQuoteDeadlineDisplay(value)
}
},
bid_start_time: {
title: '投标开始时间',
type: 'datetime',
column: {
width: 150,
formatter: ({ row, value }: { row: any; value: unknown }) =>
Number(row?.buying_method) === 1 ? '-' : formatQuoteDeadlineDisplay(value)
}
},
bid_end_time: {
title: '投标截止时间',
type: 'datetime',
column: {
width: 150,
formatter: ({ row, value }: { row: any; value: unknown }) =>
Number(row?.buying_method) === 1 ? '-' : formatQuoteDeadlineDisplay(value)
}
},
buyer: {
title: '采购负责人',
type: 'input',
File diff suppressed because it is too large Load Diff
+74 -1
View File
@@ -39,6 +39,11 @@ type Quote = {
inquiryStatus: string
/** 询价主表 `Inquiry.status`(数字),用于中标列与 status=9 判断 */
inquiryStatusCode?: number | null
/** 报价主表 `QuotationMaster.buying_method`(发布时自询价单拷贝) */
buyingMethod?: number | null
/** 报价主表投标时间(询价=1 时列表/表单显示「-」) */
bidStartTime?: string
bidEndTime?: string
template: string
currency: string
quoteDeadline: string
@@ -75,6 +80,33 @@ type Quote = {
type SummaryRow = { section: string; amount: number; isSubtotal?: boolean }
/** 与杂采询价列表列展示一致 */
export const buyingMethodDict = [
{ value: 1, label: '询价' },
{ value: 2, label: '招标' }
]
export function 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 dateOnly = text.match(/^(\d{4}-\d{2}-\d{2})$/)
if (dateOnly) {
return `${dateOnly[1]} 00:00`
}
return text
}
/** 列表「投标开始/截止」列:采购方式=询价 时显示「-」 */
export function formatBidTimeColumn(row: any, value: unknown) {
if (Number(row?.buyingMethod ?? row?.buying_method) === 1) return '-'
return formatQuoteDeadlineDisplay(value)
}
export type InquiryAttachmentRow = {
id?: number | string
part_id?: string
@@ -1048,6 +1080,9 @@ function blankQuote(): Quote {
inquiryStatusCode: undefined,
template: '',
currency: 'CNY',
buyingMethod: 1,
bidStartTime: '',
bidEndTime: '',
quoteDeadline: '',
quoteAmount: '',
quoteTime: '',
@@ -1072,6 +1107,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) {
const filters = reactive<{
inquiryPlant: string
isAwarded: string | number | ''
buyingMethod: string | number | ''
quoteNo?: string
inquiryCode?: string
inquiryTitle?: string
@@ -1080,6 +1116,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) {
}>({
inquiryPlant: '',
isAwarded: '',
buyingMethod: '',
quoteNo: '',
inquiryCode: '',
inquiryTitle: '',
@@ -1303,6 +1340,14 @@ export function useQuoteCrud(options?: { onChange?: () => void }) {
if (tpl) q.template = tpl
if (inv.currency) q.currency = inv.currency
if (inv.quote_deadline) q.quoteDeadline = inv.quote_deadline
if (q.buyingMethod == null && inv.buying_method != null && inv.buying_method !== '') {
const n = Number(inv.buying_method)
if (Number.isFinite(n)) q.buyingMethod = n
}
if (Number(q.buyingMethod) !== 1) {
if (!q.bidStartTime && inv.bid_start_time) q.bidStartTime = String(inv.bid_start_time).trim()
if (!q.bidEndTime && inv.bid_end_time) q.bidEndTime = String(inv.bid_end_time).trim()
}
const cc = inv.company_code != null && inv.company_code !== '' ? String(inv.company_code).trim() : ''
if (cc) q.inquiryCompanyCode = cc
const invSt = inv.status ?? inv.inquiry_status
@@ -1338,6 +1383,20 @@ export function useQuoteCrud(options?: { onChange?: () => void }) {
/** 询价主表 `Inquiry.template` 存模板编号,与报价主表无该字段时需关联询价补全 */
template: item.template || item.template_code || '',
currency: item.currency || 'CNY',
buyingMethod: (() => {
const raw = item.buying_method ?? item.buyingMethod
if (raw === null || raw === undefined || raw === '') return null
const n = Number(raw)
return Number.isFinite(n) ? n : null
})(),
bidStartTime: (() => {
const raw = item.bid_start_time ?? item.bidStartTime
return raw != null && String(raw).trim() !== '' ? String(raw).trim() : ''
})(),
bidEndTime: (() => {
const raw = item.bid_end_time ?? item.bidEndTime
return raw != null && String(raw).trim() !== '' ? String(raw).trim() : ''
})(),
quoteDeadline: item.quote_deadline || item.quoteDeadline || '',
quoteAmount:
item.quote_amount ??
@@ -1498,6 +1557,7 @@ export function useQuoteCrud(options?: { onChange?: () => void }) {
const resetFilter = () => {
filters.inquiryPlant = ''
filters.isAwarded = ''
filters.buyingMethod = ''
filters.quoteNo = ''
filters.inquiryCode = ''
filters.inquiryTitle = ''
@@ -1521,8 +1581,21 @@ export function useQuoteCrud(options?: { onChange?: () => void }) {
filters.isAwarded === '' || filters.isAwarded === undefined || filters.isAwarded === null
? true
: Number(q.isAwarded) === Number(filters.isAwarded)
const matchBuying =
filters.buyingMethod === '' || filters.buyingMethod === undefined || filters.buyingMethod === null
? true
: Number(q.buyingMethod) === Number(filters.buyingMethod)
const matchDate = !start || !end || (new Date(q.quoteDeadline) >= start && new Date(q.quoteDeadline) <= end)
return matchQuoteNo && matchInquiryCode && matchInquiryTitle && matchCurrency && matchPlant && matchAwarded && matchDate
return (
matchQuoteNo &&
matchInquiryCode &&
matchInquiryTitle &&
matchCurrency &&
matchPlant &&
matchAwarded &&
matchBuying &&
matchDate
)
})
})
+109 -31
View File
@@ -30,41 +30,57 @@
<el-tabs v-model="activeTab" type="card" class="tabs-fill quote-dialog-tabs">
<el-tab-pane label="询价单信息" name="inquiry">
<el-descriptions :column="2" border size="small" class="mb8">
<el-descriptions-item label="询价单号">{{ current.inquiryCode }}</el-descriptions-item>
<el-descriptions-item label="询价单名称">{{ current.inquiryTitle }}</el-descriptions-item>
<el-descriptions-item label="询价模板">{{ templateLabel(current.template) }}</el-descriptions-item>
<el-descriptions-item label="交易币别">{{ current.currency }}</el-descriptions-item>
<el-descriptions-item label="报价截止日">{{ current.quoteDeadline }}</el-descriptions-item>
<el-descriptions-item label="交易厂区">{{ current.companyShortName || current.inquiryCompanyCode || '—' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">询价附件</el-divider>
<div class="inquiry-attachments">
<div v-for="group in inquiryAttachmentGroups" :key="group.fileType" class="inquiry-attachments__block">
<div class="inquiry-attachments__type">{{ group.label }}</div>
<div class="inquiry-attachments__links">
<template v-for="(file, idx) in group.items" :key="`${group.fileType}-${file.id ?? idx}-${file.file_name}`">
<el-link
v-if="inquiryAttachmentHref(file) !== '#'"
type="primary"
:href="inquiryAttachmentHref(file)"
target="_blank"
rel="noopener noreferrer"
class="inquiry-attachments__link"
>
{{ file.file_name || '(未命名)' }}
</el-link>
<span v-else class="inquiry-attachments__link inquiry-attachments__link--text">
{{ file.file_name || '(未命名)' }}
</span>
</template>
<el-divider content-position="left"></el-divider>
<div class="inquiry-tab-pane">
<el-form class="grid-form inquiry-form-readonly" label-width="120px">
<el-form-item label="询价单号">
<el-input :model-value="current.inquiryCode" disabled />
</el-form-item>
<el-form-item label="询价单名称">
<el-input :model-value="current.inquiryTitle" disabled />
</el-form-item>
<el-form-item label="询价模板">
<el-input :model-value="templateLabel(current.template)" disabled />
</el-form-item>
<el-form-item label="交易币别">
<el-input :model-value="current.currency" disabled />
</el-form-item>
<el-form-item label="报价截止日">
<el-input :model-value="current.quoteDeadline" disabled />
</el-form-item>
<el-form-item label="交易厂区">
<el-input :model-value="current.companyShortName || current.inquiryCompanyCode || '—'" disabled />
</el-form-item>
</el-form>
<el-divider content-position="left">询价附件</el-divider>
<div class="inquiry-attachments">
<div v-for="group in inquiryAttachmentGroups" :key="group.fileType" class="inquiry-attachments__block">
<div class="inquiry-attachments__type">{{ group.label }}</div>
<div class="inquiry-attachments__links">
<template v-for="(file, idx) in group.items" :key="`${group.fileType}-${file.id ?? idx}-${file.file_name}`">
<el-link
v-if="inquiryAttachmentHref(file) !== '#'"
type="primary"
:href="inquiryAttachmentHref(file)"
target="_blank"
rel="noopener noreferrer"
class="inquiry-attachments__link"
>
{{ file.file_name || '(未命名)' }}
</el-link>
<span v-else class="inquiry-attachments__link inquiry-attachments__link--text">
{{ file.file_name || '(未命名)' }}
</span>
</template>
</div>
</div>
<div v-if="!inquiryAttachmentGroups.length" class="inquiry-attachments__empty">暂无询价附件</div>
</div>
<div v-if="!inquiryAttachmentGroups.length" class="inquiry-attachments__empty">暂无询价附件</div>
</div>
</el-tab-pane>
<el-tab-pane label="报价基础信息" name="base">
<el-divider content-position="left"></el-divider>
<el-form :model="current.base" label-width="120px" class="grid-form">
<el-form-item label="联系人">
<el-input v-model="current.base.contact" :disabled="isReadOnly" />
@@ -299,7 +315,13 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Flag } from '@element-plus/icons-vue'
import { compute, dict, useCrud, useExpose } from '@fast-crud/fast-crud'
import { getBaseURL } from '/@/utils/baseUrl'
import { useQuoteCrud, formatAwardBidStatus, type InquiryAttachmentRow } from './crud'
import {
useQuoteCrud,
formatAwardBidStatus,
type InquiryAttachmentRow,
buyingMethodDict,
formatBidTimeColumn
} from './crud'
const INQUIRY_FILE_TYPE_ORDER = [1, 2, 3] as const
const INQUIRY_FILE_TYPE_LABELS: Record<number, string> = {
@@ -479,6 +501,10 @@ const syncFilters = (form: any = {}) => {
filters.inquiryCode = form.inquiryCode || ''
filters.inquiryTitle = form.inquiryTitle || ''
filters.currency = form.currency || ''
filters.buyingMethod =
form.buyingMethod !== undefined && form.buyingMethod !== null && form.buyingMethod !== ''
? form.buyingMethod
: ''
filters.isAwarded =
form.isAwarded !== undefined && form.isAwarded !== null && form.isAwarded !== '' ? form.isAwarded : ''
if (Array.isArray(form.quoteDeadline) && form.quoteDeadline.length === 2) {
@@ -558,6 +584,24 @@ const crudOptions = {
search: { show: false },
column: { minWidth: 100, showOverflowTooltip: true }
},
buyingMethod: {
title: '采购方式',
type: 'dict-select',
dict: dict({ data: buyingMethodDict }),
search: {
show: true,
component: { props: { placeholder: '采购方式', clearable: true } }
},
column: {
width: 100,
formatter: ({ row, value }: any) => {
const v = value ?? row?.buyingMethod
const n = Number(v)
if (!Number.isFinite(n)) return v != null && v !== '' ? String(v) : ''
return buyingMethodDict.find((d) => d.value === n)?.label ?? String(v)
}
}
},
quoteNo: {
title: '报价单号',
type: 'text',
@@ -601,7 +645,23 @@ const crudOptions = {
}
}
},
column: { width: 180 }
column: { width: 150 }
},
bidStartTime: {
title: '投标开始时间',
type: 'datetime',
column: {
width: 150,
formatter: ({ row, value }: any) => formatBidTimeColumn(row, value)
}
},
bidEndTime: {
title: '投标截止时间',
type: 'datetime',
column: {
width: 150,
formatter: ({ row, value }: any) => formatBidTimeColumn(row, value)
}
},
status: {
title: '报价状态',
@@ -701,7 +761,9 @@ onMounted(() => {
padding-top: 12px;
padding-bottom: 8px;
}
/* 与 max-height 相同,避免短内容页签(如询价单信息)把弹窗整体压矮 */
.quote-dialog-scroll {
min-height: min(72vh, calc(100vh - 200px));
max-height: min(72vh, calc(100vh - 200px));
overflow-x: hidden;
overflow-y: auto;
@@ -710,6 +772,22 @@ onMounted(() => {
background: var(--el-bg-color, #fff);
isolation: isolate;
}
.inquiry-tab-pane {
display: flex;
flex-direction: column;
min-height: 100%;
}
.inquiry-form-readonly :deep(.el-form-item) {
margin-bottom: 0;
}
.inquiry-form-readonly :deep(.el-input.is-disabled .el-input__wrapper) {
box-shadow: 0 0 0 1px var(--el-border-color) inset;
background-color: var(--el-fill-color-blank);
}
.inquiry-form-readonly :deep(.el-input.is-disabled .el-input__inner) {
color: var(--el-text-color-primary);
-webkit-text-fill-color: var(--el-text-color-primary);
}
.quote-dialog-sticky-status {
position: sticky;
top: 0;