| | |
| | | |
| | | import { Page } from '@vben/common-ui'; |
| | | |
| | | import { message, Modal } from 'ant-design-vue'; |
| | | import { message } from 'ant-design-vue'; |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | |
| | | import { |
| | | getTenderProject, |
| | | getMaterialList, createMaterial, updateMaterial, deleteMaterial, |
| | | getBidListByProject, createBid, withdrawBid, |
| | | getBidListByProject, createBid, withdrawBid, updateBidTotal, |
| | | getQuoteList, createQuote, updateQuote, |
| | | createBidOpen, getBidOpen, |
| | | getEvaluationList, createBidEvaluation, updateBidEvaluation, calculateRanking, |
| | |
| | | const quoteFormVisible = ref(false); |
| | | const quoteForm = ref<SrmTenderApi.QuoteVO>({ bidId: undefined!, tenderMaterialId: undefined! }); |
| | | const selectedBidId = ref<number>(); |
| | | /** 投标总金额草稿:进入报价页时带出已保存总价,可自动带出报价合计后人工微调 */ |
| | | const bidTotalDraft = ref<number>(); |
| | | |
| | | const availableMaterials = computed(() => { |
| | | const quotedIds = new Set( |
| | |
| | | selectedBidId.value = bidId; |
| | | quoteLoading.value = true; |
| | | try { quoteList.value = await getQuoteList(bidId); } finally { quoteLoading.value = false; } |
| | | // 带出该投标已保存的投标总金额(为空则让用户录入/自动带出) |
| | | const bid = bidList.value.find((b) => b.id === bidId); |
| | | bidTotalDraft.value = bid?.bidTotalAmount ?? undefined; |
| | | } |
| | | async function handleSaveQuote() { |
| | | // 校验:报价金额与投标金额一致性 |
| | | const bid = bidList.value.find((b) => b.id === quoteForm.value.bidId); |
| | | if (bid?.bidTotalAmount != null) { |
| | | const otherQuotesTotal = quoteList.value |
| | | .filter((q) => q.id !== quoteForm.value.id) |
| | | .reduce((sum, q) => sum + (q.quotePrice ?? 0), 0); |
| | | const newTotal = otherQuotesTotal + (quoteForm.value.quotePrice ?? 0); |
| | | if (newTotal !== bid.bidTotalAmount) { |
| | | await new Promise<void>((resolve, reject) => { |
| | | Modal.confirm({ |
| | | title: '金额不一致提示', |
| | | content: `报价总金额(${newTotal.toLocaleString()})与投标金额(${bid.bidTotalAmount!.toLocaleString()})不一致,是否继续提交?`, |
| | | okText: '继续提交', |
| | | cancelText: '取消', |
| | | onOk: () => { resolve(); }, |
| | | onCancel: () => { reject(new Error('cancel')); }, |
| | | }); |
| | | }); |
| | | } |
| | | } |
| | | |
| | | if (quoteForm.value.id) { |
| | | await updateQuote(quoteForm.value); |
| | | } else { |
| | |
| | | quoteForm.value = { ...row }; |
| | | quoteFormVisible.value = true; |
| | | } |
| | | |
| | | /** 招标物料 Map:报价行按 tenderMaterialId 反查物料数量 */ |
| | | const materialMap = computed(() => { |
| | | const map = new Map<number, SrmTenderApi.TenderMaterialVO>(); |
| | | materialList.value.forEach((m) => { |
| | | if (m.id != null) map.set(m.id, m); |
| | | }); |
| | | return map; |
| | | }); |
| | | |
| | | /** 报价明细对应的招标物料数量 */ |
| | | function materialQuantityOf(quote: SrmTenderApi.QuoteVO): number { |
| | | return materialMap.value.get(quote.tenderMaterialId!)?.quantity ?? 0; |
| | | } |
| | | |
| | | /** 报价行金额 = 报价单价 × 物料数量 */ |
| | | function quoteAmountOf(quote: SrmTenderApi.QuoteVO): number { |
| | | return (quote.quotePrice ?? 0) * materialQuantityOf(quote); |
| | | } |
| | | |
| | | /** 选中投标下已录报价的合计金额 */ |
| | | const selectedQuoteTotalAmount = computed(() => |
| | | quoteList.value.reduce((sum, q) => sum + quoteAmountOf(q), 0), |
| | | ); |
| | | |
| | | /** 维护(整单)投标总金额:可自动带出报价合计后人工微调(用于整单让利等) */ |
| | | async function handleSaveBidTotal() { |
| | | if (!selectedBidId.value) return; |
| | | if (bidTotalDraft.value == null) { |
| | | message.warning('请填写投标总金额'); |
| | | return; |
| | | } |
| | | await updateBidTotal(selectedBidId.value, bidTotalDraft.value); |
| | | message.success('投标总金额已保存'); |
| | | loadBids(); |
| | | } |
| | | |
| | | /** 已保存的投标总金额 与 报价合计 的差异提示(只提示,不拦截) */ |
| | | const bidTotalCompareHint = computed<{ text: string; tone: 'success' | 'warning' } | null>(() => { |
| | | const sum = selectedQuoteTotalAmount.value; |
| | | if (sum <= 0) { |
| | | return null; // 尚未录报价,暂不比较 |
| | | } |
| | | const stored = selectedBid.value?.bidTotalAmount; |
| | | if (stored == null) { |
| | | return { text: '该投标尚未维护总金额:可点「自动带出报价合计」填入后保存', tone: 'warning' }; |
| | | } |
| | | const diff = Math.abs(stored - sum); |
| | | if (diff < 0.01) { |
| | | return { text: `投标总金额 ¥${stored.toLocaleString()} 与报价合计一致`, tone: 'success' }; |
| | | } |
| | | return { |
| | | text: `投标总金额 ¥${stored.toLocaleString()} 与报价合计 ¥${sum.toLocaleString()} 不一致(差额 ¥${diff.toLocaleString()})。如需整单让利/调价,直接在投标总金额处修改并保存。`, |
| | | tone: 'warning', |
| | | }; |
| | | }); |
| | | |
| | | // ========== 开标 ========== |
| | | const bidOpen = ref<any>(null); |
| | |
| | | const quoteColumns = [ |
| | | { title: '物料', dataIndex: 'materialName', width: 120 }, |
| | | { title: '报价单价', dataIndex: 'quotePrice', width: 100 }, |
| | | { title: '数量', dataIndex: 'quantity', width: 70 }, |
| | | { title: '报价金额', dataIndex: 'quoteAmount', width: 110 }, |
| | | { title: '税率(%)', dataIndex: 'taxRate', width: 80 }, |
| | | { title: '交期(天)', dataIndex: 'deliveryCycle', width: 80 }, |
| | | { title: '付款条件', dataIndex: 'paymentTerms', width: 120 }, |
| | |
| | | <span class="text-gray-400">供应商:<b class="text-gray-700">{{ selectedBid.supplierName }}</b></span> |
| | | <a-button size="small" type="link" class="ml-auto" @click="activeTab = 'bid'">切换投标</a-button> |
| | | </div> |
| | | <div class="text-right mb-3"> |
| | | <!-- 投标总金额(整单总价):可与报价合计自动带出并人工调整 --> |
| | | <div v-if="(isPublishedOrBidding || isDraft || isPriceComparing) && selectedBid?.bidStatus !== BID_STATUS.WITHDRAWN" class="flex flex-wrap items-center gap-x-3 gap-y-2 mb-2 px-3 py-2 bg-blue-50/70 rounded text-xs"> |
| | | <span class="text-gray-600 font-medium">投标总金额</span> |
| | | <a-input-number v-model:value="bidTotalDraft" :min="0" :precision="2" class="!w-40" placeholder="整单总价"> |
| | | <template #addonAfter>元</template> |
| | | </a-input-number> |
| | | <a-button size="small" type="link" :disabled="selectedQuoteTotalAmount <= 0" @click="bidTotalDraft = selectedQuoteTotalAmount"> |
| | | 自动带出报价合计 ¥{{ selectedQuoteTotalAmount.toLocaleString() }} |
| | | </a-button> |
| | | <a-button size="small" type="primary" @click="handleSaveBidTotal">保存总价</a-button> |
| | | </div> |
| | | <a-alert |
| | | v-if="bidTotalCompareHint" |
| | | :type="bidTotalCompareHint.tone" |
| | | :show-icon="true" |
| | | class="mb-3" |
| | | :message="bidTotalCompareHint.text" |
| | | /> |
| | | <div class="flex items-center justify-between mb-3"> |
| | | <span class="text-xs text-gray-400"> |
| | | 已报价 <b class="text-gray-600">{{ quoteList.length }}</b> / <b class="text-gray-600">{{ materialList.length }}</b> 项物料,报价合计 |
| | | <b class="text-blue-500">{{ selectedQuoteTotalAmount.toLocaleString() }}</b> 元 |
| | | </span> |
| | | <a-button v-if="(isPublishedOrBidding || isDraft || isPriceComparing) && selectedBid?.bidStatus !== BID_STATUS.WITHDRAWN" type="primary" size="small" @click="handleAddQuote"> |
| | | <template #icon><IconifyIcon icon="ant-design:plus-outlined" /></template> |
| | | 新增报价 |
| | |
| | | <template #bodyCell="{ column, record }"> |
| | | <template v-if="column.dataIndex === 'quotePrice'"> |
| | | <span class="font-medium text-blue-600">{{ record.quotePrice?.toLocaleString() }}</span> |
| | | </template> |
| | | <template v-if="column.dataIndex === 'quantity'"> |
| | | {{ materialQuantityOf(record) || '-' }} |
| | | </template> |
| | | <template v-if="column.dataIndex === 'quoteAmount'"> |
| | | <span class="font-medium text-blue-600">{{ quoteAmountOf(record).toLocaleString() }}</span> |
| | | </template> |
| | | <template v-if="column.dataIndex === 'taxRate'"> |
| | | {{ record.taxRate != null ? `${record.taxRate}%` : '-' }} |
| | |
| | | </a-select> |
| | | </a-form-item> |
| | | <a-form-item label="投标总金额"> |
| | | <a-input-number v-model:value="bidForm.bidTotalAmount" :min="0" class="!w-full" placeholder="投标总金额"><template #addonAfter>元</template></a-input-number> |
| | | <div class="text-xs text-gray-400 leading-5"> |
| | | 可暂不填:报价明细录完后,在「报价管理」页按报价合计自动带出、再人工微调保存。 |
| | | </div> |
| | | </a-form-item> |
| | | </a-form> |
| | | </a-modal> |