feat(SRM): 报价页支持投标总金额自动带出人工维护,去掉金额强校验弹窗

- 移除报价保存时的“金额不一致”强制弹窗,逐条录入不再打断
- 报价明细展示 数量/报价金额(=单价×数量),页头展示已报x/y与报价合计
- 报价页新增“投标总金额”维护区:自动带出报价合计、可人工微调(让利),
保存调用 update-total;一致绿色提示、不一致黄色提示(差额)不拦截
- 新增投标表单说明同步更新
- API 新增 updateBidTotal

Co-Authored-By: Claude <noreply@anthropic.com>
已修改2个文件
127 ■■■■ 文件已修改
src/api/srm/tender/index.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/srm/tender/detail.vue 122 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/srm/tender/index.ts
@@ -198,6 +198,11 @@
  return requestClient.put(`/srm/tender-bid/withdraw?id=${id}`);
}
export function updateBidTotal(id: number, bidTotalAmount?: number | null) {
  const params = bidTotalAmount == null ? '' : `&bidTotalAmount=${bidTotalAmount}`;
  return requestClient.put(`/srm/tender-bid/update-total?id=${id}${params}`);
}
// ========== 报价管理 ==========
export function getQuoteList(bidId: number) {
src/views/srm/tender/detail.vue
@@ -6,13 +6,13 @@
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,
@@ -150,6 +150,8 @@
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(
@@ -169,29 +171,11 @@
  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 {
@@ -208,6 +192,62 @@
  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);
@@ -448,6 +488,8 @@
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 },
@@ -656,7 +698,29 @@
                  <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>
                    新增报价
@@ -666,6 +730,12 @@
                  <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}%` : '-' }}
@@ -892,7 +962,9 @@
            </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>