Files
client-web/src/modules/first-leg/components/InboundBillingPanel.vue
T
2026-09-18 16:48:35 +08:00

332 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
import { confirmMerchantInboundFee } from '@/modules/first-leg/api/inboundBilling'
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
import {
formatConversionPrecision,
formatInboundDateTime,
formatInboundMeasure,
formatInboundMoney,
formatInboundNumber,
getChargeModeLabel,
getTaxModeLabel,
} from '@/modules/first-leg/utils/inboundBilling'
const props = withDefaults(defineProps<{
data?: MerchantInboundBilling | null
loading?: boolean
unavailable?: boolean
unavailableMessage?: string
error?: string
autoOpen?: boolean
dialogOnly?: boolean
canConfirm?: boolean
}>(), {
data: null,
loading: false,
unavailable: false,
unavailableMessage: '还未测量推送',
error: '',
autoOpen: false,
dialogOnly: false,
canConfirm: false,
})
const emit = defineEmits<{
retry: []
confirmed: []
closed: []
}>()
const feeDialogOpen = ref(false)
const confirming = ref(false)
watch(() => props.autoOpen, (autoOpen) => {
if (autoOpen) feeDialogOpen.value = true
}, { immediate: true })
async function confirmFee() {
const fee = props.data?.latestFee
const orderId = props.data?.inbound.summary.orderId
if (!fee || !orderId || !props.canConfirm || confirming.value) return
if (fee.customerConfirmationStatus === 'CONFIRMED') {
ElMessage.info('当前费用已经确认,无需重复操作')
return
}
try {
await ElMessageBox.confirm(
`请再次确认费用单 ${fee.feeNo},合计 ${formatInboundMoney(fee.totalAmount, fee.currency)}。确认后该费用将锁定,平台不能再修改。`,
'二次确认费用',
{ type: 'warning', confirmButtonText: '确认费用无误', cancelButtonText: '返回核对' },
)
} catch {
return
}
confirming.value = true
try {
await confirmMerchantInboundFee(orderId, fee.feeVersion)
ElMessage.success('费用确认成功,当前费用已锁定')
feeDialogOpen.value = false
emit('confirmed')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '费用确认失败,请刷新后重试')
} finally {
confirming.value = false
}
}
</script>
<template>
<section v-if="!props.dialogOnly" class="inbound-billing-panel" aria-labelledby="inbound-billing-title">
<header class="inbound-billing-heading">
<h3 id="inbound-billing-title">入库与费用</h3>
<div v-if="props.data" class="inbound-billing-actions">
<el-tag :type="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' ? 'success' : 'warning'" effect="light">
{{ props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' ? '客户已确认 · 费用已锁定' : '费用已发布 · 待确认' }}
</el-tag>
<el-button v-if="props.data.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" @click="feeDialogOpen = true">确认费用</el-button>
<el-button v-else plain @click="feeDialogOpen = true">查看费用</el-button>
</div>
</header>
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
<el-skeleton :rows="4" animated />
<span class="sr-only">正在加载入库与费用结果</span>
</div>
<div v-else-if="props.error" class="inbound-billing-state is-error" role="alert">
<TriangleAlert :size="22" />
<div>
<strong>入库与费用加载失败</strong>
<span>{{ props.error }}</span>
</div>
<el-button @click="$emit('retry')"><RefreshCw :size="15" />重新加载</el-button>
</div>
<div v-else-if="props.unavailable || !props.data" class="inbound-billing-state is-pending" role="status">
<Clock3 :size="22" />
<div>
<strong>{{ props.unavailableMessage }}</strong>
<span>平台完成测量审核并确认推送费用后,将在这里展示测量与计费结果。</span>
</div>
</div>
<template v-else>
<dl class="inbound-overview">
<div><dt>入仓码</dt><dd>{{ props.data.inbound.summary.inboundCode }}</dd></div>
<div><dt>入库完成</dt><dd>{{ formatInboundDateTime(props.data.inbound.summary.inboundedAt) }}</dd></div>
<div><dt>平台收货仓</dt><dd>{{ props.data.inbound.summary.warehouseName || '-' }}</dd></div>
<div><dt>目的仓</dt><dd>{{ props.data.inbound.summary.destinationCode || props.data.inbound.summary.destinationName || '-' }}</dd></div>
</dl>
<div class="inbound-result-section">
<div class="inbound-section-title"><h4>测量数据</h4><el-tag size="small" type="warning" effect="light">后台推送</el-tag></div>
<dl class="inbound-measurements">
<div><dt>实际箱数</dt><dd>{{ formatInboundNumber(props.data.inbound.summary.totalPackageCount, 0) }}</dd></div>
<div><dt>实际毛重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.actualGrossWeightKg, 'kg') }}</dd></div>
<div><dt>实际净重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.actualNetWeightKg, 'kg') }}</dd></div>
<div><dt>实测体积</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.actualVolumeCbm, 'm³') }}</dd></div>
<div><dt>材积重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.volumeWeightKg, 'kg') }}</dd></div>
<div><dt>测量完成</dt><dd>{{ formatInboundDateTime(props.data.inbound.summary.measureCompletedAt) }}</dd></div>
</dl>
<el-table v-if="props.data.measurementItems.length" class="measurement-detail-table" :data="props.data.measurementItems" border size="small">
<el-table-column type="index" label="#" width="54" />
<el-table-column prop="lengthCm" label="箱规长(cm)" min-width="105" />
<el-table-column prop="widthCm" label="箱规宽(cm)" min-width="105" />
<el-table-column prop="heightCm" label="箱规高(cm)" min-width="105" />
<el-table-column prop="packageCount" label="箱数" min-width="76" />
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
</el-table>
</div>
<div class="inbound-result-section">
<div class="inbound-section-title">
<h4>托盘与库位</h4>
<span>{{ props.data.inbound.palletLocations.length }} 个托盘</span>
</div>
<div v-if="props.data.inbound.palletLocations.length" class="pallet-location-list">
<article v-for="item in props.data.inbound.palletLocations" :key="`${item.palletId}-${item.locationId}`">
<PackageCheck :size="18" />
<div class="pallet-location-main">
<strong>{{ item.palletCode }}</strong>
<span>库位 {{ item.locationCode }}</span>
</div>
<div class="pallet-location-quantity">
<span>{{ formatInboundMeasure(item.allocatedVolumeCbm, 'm³') }}</span>
<span>{{ formatInboundNumber(item.allocatedPackageCount, 0) }} 箱</span>
</div>
</article>
</div>
<p v-else class="inbound-inline-empty">暂无托盘和库位明细</p>
</div>
<div class="inbound-result-section fee-result">
<div class="inbound-section-title">
<h4>费用结果</h4>
<span>{{ props.data.latestFee.feeNo }} · 第 {{ props.data.latestFee.feeVersion }} 版</span>
</div>
<dl class="fee-calculation-grid">
<div><dt>计费方式</dt><dd>{{ getChargeModeLabel(props.data.latestFee.chargeMode) }} · {{ getTaxModeLabel(props.data.latestFee.taxMode) }}</dd></div>
<div>
<dt>换算与精度</dt>
<dd>{{ formatConversionPrecision(
props.data.latestFee.conversionFactor,
props.data.latestFee.roundingScale,
props.data.latestFee.roundingMode,
) }}</dd>
</div>
<div><dt>实际重量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualWeightKg, 'kg') }}</dd></div>
<div><dt>实际体积</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualVolumeCbm, 'm³') }}</dd></div>
<div><dt>体积重量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.convertedQuantity, props.data.latestFee.chargeUnit) }}</dd></div>
<div><dt>最终计费量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.chargeableQuantity, props.data.latestFee.chargeUnit) }}</dd></div>
<div><dt>单价</dt><dd>{{ formatInboundMoney(props.data.latestFee.unitPrice, props.data.latestFee.currency) }} / {{ props.data.latestFee.chargeUnit }}</dd></div>
<div><dt>基础费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.subtotalAmount, props.data.latestFee.currency) }}</dd></div>
<div><dt>附加费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.additionalAmount, props.data.latestFee.currency) }}</dd></div>
</dl>
<div class="fee-total">
<div>
<span>费用合计</span>
<small>{{ props.data.latestFee.ruleCode }} v{{ props.data.latestFee.chargeRuleVersion }} · {{ props.data.latestFee.rateCode }} v{{ props.data.latestFee.carrierRateVersion }}</small>
</div>
<strong>{{ formatInboundMoney(props.data.latestFee.totalAmount, props.data.latestFee.currency) }}</strong>
</div>
<p class="fee-sent-time">发布于 {{ formatInboundDateTime(props.data.latestFee.sentAt) }}</p>
</div>
<div v-if="props.data.inbound.publicRemark" class="inbound-public-remark">
<strong>平台备注</strong>
<p>{{ props.data.inbound.publicRemark }}</p>
</div>
</template>
</section>
<el-dialog v-model="feeDialogOpen" title="确认测量与计费数据" width="min(980px, 94vw)" append-to-body @closed="emit('closed')">
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
<el-skeleton :rows="4" animated />
<span class="sr-only">正在加载计费重</span>
</div>
<div v-else-if="props.error" class="inbound-billing-state is-error" role="alert">
<TriangleAlert :size="22" />
<div><strong>计费重加载失败</strong><span>{{ props.error }}</span></div>
<el-button @click="$emit('retry')"><RefreshCw :size="15" />重新加载</el-button>
</div>
<div v-else-if="props.unavailable || !props.data" class="inbound-billing-state is-pending" role="status">
<Clock3 :size="22" />
<div><strong>{{ props.unavailableMessage }}</strong><span>平台推送计费重后,可在这里核对并确认。</span></div>
</div>
<template v-else>
<dl class="inbound-measurements fee-dialog-summary">
<div><dt>计费总箱数</dt><dd>{{ formatInboundNumber(props.data.measurementSummary.actualPackageCount, 0) }}</dd></div>
<div><dt>计费总毛重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualGrossWeightKg, 'kg') }}</dd></div>
<div><dt>外箱规总体积</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualVolumeCbm, 'm³') }}</dd></div>
<div><dt>外箱规总材积重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.volumeWeightKg, 'kg') }}</dd></div>
<div><dt>计费体积</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableVolumeCbm, 'm³') }}</dd></div>
<div><dt>计费重量</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableWeightKg, 'kg') }}</dd></div>
<div><dt>对客单价</dt><dd>{{ formatInboundMoney(props.data.latestFee.unitPrice, props.data.latestFee.currency) }} / {{ props.data.latestFee.chargeUnit }}</dd></div>
<div><dt>基础费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.subtotalAmount, props.data.latestFee.currency) }}</dd></div>
<div><dt>附加费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.additionalAmount, props.data.latestFee.currency) }}</dd></div>
<div><dt>费用合计</dt><dd>{{ formatInboundMoney(props.data.latestFee.totalAmount, props.data.latestFee.currency) }}</dd></div>
</dl>
<el-table v-if="props.data?.measurementItems.length" class="measurement-detail-table" :data="props.data.measurementItems" border size="small">
<el-table-column type="index" label="#" width="54" />
<el-table-column prop="lengthCm" label="箱规长(cm)" min-width="105" />
<el-table-column prop="widthCm" label="箱规宽(cm)" min-width="105" />
<el-table-column prop="heightCm" label="箱规高(cm)" min-width="105" />
<el-table-column prop="packageCount" label="箱数" min-width="76" />
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
</el-table>
<el-alert
v-if="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED'"
class="fee-confirmed-alert"
:title="`客户已于 ${formatInboundDateTime(props.data.latestFee.customerConfirmedAt)} 确认,费用已锁定,不允许修改。`"
type="success"
:closable="false"
show-icon
/>
<el-alert v-else class="fee-confirmed-alert" title="请核对计费量、对客单价和费用合计。确认后当前费用版本将锁定。" type="warning" :closable="false" show-icon />
</template>
<template #footer>
<el-button @click="feeDialogOpen = false">关闭</el-button>
<el-button v-if="props.data?.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" :loading="confirming" @click="confirmFee">确认计费量和费用</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.inbound-billing-panel { margin-top: 14px; }
.inbound-billing-heading, .inbound-section-title, .fee-total { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.inbound-billing-actions { display: flex; align-items: center; gap: 10px; }
.inbound-billing-heading h3, .inbound-result-section h4 { margin: 0; color: var(--navy); }
.inbound-billing-heading h3 { font-size: 13px; }
.inbound-result-section h4 { font-size: 13px; }
.inbound-billing-loading { min-height: 180px; padding: 18px 0; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.inbound-billing-state { display: grid; min-height: 48px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; margin-top: 8px; padding: 8px 10px; border: 1px dashed #cbd5da; }
.inbound-billing-state > svg { color: var(--primary); }
.inbound-billing-state > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.inbound-billing-state strong { color: var(--navy); font-size: 13px; }
.inbound-billing-state span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; }
.inbound-billing-state.is-error { border-style: solid; border-color: #e8c5bd; background: #fff8f6; }
.inbound-billing-state.is-error > svg { color: #b5472f; }
.inbound-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 12px 0 0; border: 1px solid var(--border); }
.inbound-overview > div { min-width: 0; padding: 13px; border-bottom: 1px solid var(--border); }
.inbound-overview > div:nth-child(odd) { border-right: 1px solid var(--border); }
.inbound-overview > div:nth-last-child(-n + 2) { border-bottom: 0; }
.inbound-overview dt, .inbound-measurements dt, .fee-calculation-grid dt { color: var(--muted); font-size: 10px; }
.inbound-overview dd, .inbound-measurements dd, .fee-calculation-grid dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 650; }
.inbound-result-section { margin-top: 18px; }
.inbound-section-title > span { color: var(--muted); font-size: 10px; text-align: right; }
.inbound-measurements { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 10px 0 0; background: #f6f8f8; }
.inbound-measurements > div { min-width: 0; padding: 12px; }
.measurement-detail-table { margin-top: 12px; }
.fee-dialog-summary { margin-top: 0; }
.fee-confirmed-alert { margin-top: 14px; }
.pallet-location-list { margin-top: 9px; border-top: 1px solid var(--border); }
.pallet-location-list article { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 12px 0; border-bottom: 1px solid var(--border); }
.pallet-location-list article > svg { color: var(--primary); }
.pallet-location-main, .pallet-location-quantity { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.pallet-location-main strong { overflow-wrap: anywhere; color: var(--navy); font-size: 12px; }
.pallet-location-main span, .pallet-location-quantity span { color: var(--muted); font-size: 10px; }
.pallet-location-quantity { align-items: flex-end; }
.inbound-inline-empty { margin: 9px 0 0; padding: 14px; background: #f6f8f8; color: var(--muted); font-size: 11px; text-align: center; }
.fee-result { padding-top: 18px; border-top: 1px solid var(--border); }
.fee-calculation-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 10px 0 0; border: 1px solid var(--border); }
.fee-calculation-grid > div { min-width: 0; padding: 11px 12px; border-bottom: 1px solid var(--border); }
.fee-calculation-grid > div:not(:nth-child(3n)) { border-right: 1px solid var(--border); }
.fee-calculation-grid > div:nth-last-child(-n + 3) { border-bottom: 0; }
.fee-total { padding: 15px 0 0; }
.fee-total > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.fee-total span { color: var(--navy); font-size: 12px; font-weight: 650; }
.fee-total small, .fee-sent-time { color: var(--muted); font-size: 10px; }
.fee-total strong { flex: 0 0 auto; color: var(--primary); font-size: 21px; }
.fee-sent-time { margin: 5px 0 0; text-align: right; }
.inbound-public-remark { margin-top: 16px; padding: 13px 14px; background: #f2f7f6; }
.inbound-public-remark strong { color: var(--navy); font-size: 11px; }
.inbound-public-remark p { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; white-space: pre-wrap; }
@media (max-width: 720px) {
.inbound-billing-state { grid-template-columns: auto minmax(0, 1fr); }
.inbound-billing-state .el-button { grid-column: 1 / -1; width: 100%; min-height: 44px; }
.inbound-overview, .inbound-measurements, .fee-calculation-grid { grid-template-columns: 1fr; }
.inbound-overview > div, .inbound-overview > div:nth-child(odd), .fee-calculation-grid > div, .fee-calculation-grid > div:not(:nth-child(3n)) { border-right: 0; border-bottom: 1px solid var(--border); }
.inbound-overview > div:last-child, .fee-calculation-grid > div:last-child { border-bottom: 0; }
.pallet-location-list article { grid-template-columns: auto minmax(0, 1fr); }
.pallet-location-quantity { grid-column: 2; align-items: flex-start; flex-direction: row; flex-wrap: wrap; gap: 8px; }
}
@media (max-width: 360px) {
.inbound-billing-heading { align-items: flex-start; flex-direction: column; }
.fee-total { align-items: flex-start; flex-direction: column; }
.fee-total strong { font-size: 19px; }
.fee-sent-time { text-align: left; }
}
</style>