Files
admin-web/src/modules/overseas-business/pages/PurchaseOrderDetailView.vue
T

137 lines
18 KiB
Vue
Raw Normal View History

2026-08-19 16:45:51 +08:00
<script setup lang="ts">
2026-09-15 09:58:15 +08:00
import '@/styles/adminDensity.css'
2026-09-14 10:02:39 +08:00
import { computed, onActivated, reactive, ref, watch } from 'vue'
2026-08-19 16:45:51 +08:00
import { useRoute, useRouter } from 'vue-router'
2026-09-14 10:02:39 +08:00
import { ArrowLeft, Check, Upload, X } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-19 16:45:51 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
2026-08-20 09:10:36 +08:00
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-19 16:45:51 +08:00
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-09-15 09:58:15 +08:00
import StatusBadge from '@/shared/components/StatusBadge.vue'
2026-08-19 16:45:51 +08:00
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
2026-08-20 09:10:36 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
2026-09-14 10:02:39 +08:00
import { createOverseasPurchaseFee, getOverseasPurchase, reviewOverseasPurchase, uploadOverseasPurchaseFile } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasBusinessHistory, OverseasPurchaseDetail, OverseasPurchaseFile, OverseasPurchaseFee, OverseasPurchaseStatus } from '../types'
2026-08-19 16:45:51 +08:00
2026-08-20 09:10:36 +08:00
const route = useRoute(); const router = useRouter()
const access = useAccessPreviewStore()
2026-08-19 16:45:51 +08:00
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
2026-08-20 09:10:36 +08:00
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const detail = ref<OverseasPurchaseDetail | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
2026-08-30 16:40:03 +08:00
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_OUTBOUND: '待出库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUNDED: '全部出库' }
2026-08-20 09:10:36 +08:00
const productFields = computed(() => [['客户名称', detail.value?.customerName], ['采购仓库', detail.value?.purchaseWarehouseName], ['采购品类', detail.value?.purchaseCategory], ['商品名称', detail.value?.productName], ['商品规格', detail.value?.productSpecification], ['商品链接', detail.value?.productUrl]] as const)
2026-08-30 16:40:03 +08:00
const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity], ['出库量', detail.value?.outboundQuantity], ['库存量', detail.value?.inventoryQuantity], ['库存状态', detail.value ? (detail.value.inventoryQuantity > 0 ? '在库' : '无库存') : null]] as const)
2026-08-20 09:10:36 +08:00
const orderFields = computed(() => [['订单编号', detail.value?.orderNo], ['采购数量', detail.value?.purchaseQuantity], ['预算单价', detail.value ? formatAmount(detail.value.budgetUnitPrice, detail.value.currency) : null], ['订单状态', detail.value ? statusLabels[detail.value.status] : null], ['联系人', detail.value?.contactName], ['联系电话', detail.value?.contactPhone], ['收货地址', detail.value?.receivingAddress], ['订单备注', detail.value?.remark]] as const)
const courierFields = computed(() => [['国内承运商', detail.value?.domesticCarrier], ['国内运单号', detail.value?.domesticTrackingNo], ['海外承运商', detail.value?.overseasCarrier], ['海外运单号', detail.value?.overseasTrackingNo], ['预计到达时间', formatDateTime(detail.value?.estimatedArrivalAt)]] as const)
2026-08-30 16:40:03 +08:00
const outboundDetails = computed(() => detail.value?.outboundDetails || [])
const receiptInput = ref<HTMLInputElement | null>(null); const voucherInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false); const feeDialogOpen = ref(false); const savingFee = ref(false); const voucher = ref<OverseasPurchaseFile | null>(null)
const feeForm = reactive({ feeItemCode: 'COURIER', feeItemName: '快递费', amount: 0, remark: '' })
2026-09-14 10:02:39 +08:00
const reviewDialogOpen = ref(false); const reviewing = ref(false); const reviewDecision = ref<'APPROVE' | 'REJECT'>('APPROVE'); const reviewRemark = ref(''); const reviewKey = ref('')
const fileColumns: DataTableColumn[] = [{ key: 'category', label: '类型', width: 100, formatter: (row: OverseasPurchaseFile) => row.category === 'RECEIPT' ? '签收文件' : '费用凭证' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 220 }, { key: 'createdAt', label: '上传时间', minWidth: 155, formatter: (row: OverseasPurchaseFile) => formatDateTime(row.createdAt) }]
const feeColumns: DataTableColumn[] = [{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'feeItemName', prop: 'feeItemName', label: '费用项', minWidth: 120 }, { key: 'amount', label: '金额', width: 120, formatter: (row: OverseasPurchaseFee) => formatAmount(row.amount, row.currency) }, { key: 'voucherFileName', prop: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'walletStatus', label: '资金状态', minWidth: 130, formatter: (row: OverseasPurchaseFee) => walletStatus(row.walletStatus) }, { key: 'walletErrorMessage', label: '异常说明', minWidth: 180, formatter: (row: OverseasPurchaseFee) => row.walletErrorMessage || '—' }]
2026-08-20 18:02:52 +08:00
const historyColumns: DataTableColumn[] = [
{ key: 'createdAt', label: '时间', minWidth: 155, formatter: (row: OverseasBusinessHistory) => formatDateTime(row.createdAt) },
{ key: 'operationType', label: '操作类型', width: 110, formatter: (row: OverseasBusinessHistory) => operationLabels[row.operationType || ''] || row.operationType || '—' },
{ key: 'status', label: '状态变化', minWidth: 150, formatter: (row: OverseasBusinessHistory) => row.fromStatus ? `${statusLabel(row.fromStatus)} → ${statusLabel(row.toStatus)}` : statusLabel(row.toStatus) },
{ key: 'changeDetail', prop: 'changeDetail', label: '变更明细', minWidth: 260 },
2026-09-05 23:40:38 +08:00
{ key: 'operator', label: '操作人', minWidth: 120, formatter: () => '—' },
2026-08-19 16:45:51 +08:00
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
]
2026-08-20 18:02:52 +08:00
const operationLabels: Record<string, string> = { CREATE: '创建', REVIEW: '审核', UPDATE: '编辑' }
function statusLabel(status?: string | null) { return status ? (statusLabels[status as OverseasPurchaseStatus] || status) : '—' }
2026-08-20 09:10:36 +08:00
function display(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function formatAmount(value: string | null, currency: string) { return value == null ? '—' : `${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` }
function walletStatus(value: string) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[value] || value }
2026-09-14 10:02:39 +08:00
function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `purchase-review-${Date.now()}-${Math.random().toString(16).slice(2)}` }
function openReview(decision: 'APPROVE' | 'REJECT') {
reviewDecision.value = decision; reviewRemark.value = ''; reviewKey.value = idempotencyKey(); reviewDialogOpen.value = true
}
function closeReview() {
if (reviewing.value) return
reviewDialogOpen.value = false; reviewRemark.value = ''; reviewKey.value = ''
}
async function submitReview() {
if (!detail.value) return
reviewing.value = true
try {
detail.value = await reviewOverseasPurchase(detail.value.orderNo, customerId.value, {
version: detail.value.version,
decision: reviewDecision.value,
idempotencyKey: reviewKey.value || (reviewKey.value = idempotencyKey()),
remark: reviewRemark.value.trim() || undefined,
})
reviewDialogOpen.value = false; reviewRemark.value = ''; reviewKey.value = ''
ElMessage.success(reviewDecision.value === 'APPROVE' ? '采购订单审核已通过' : '采购订单已拒绝')
} catch (error) { ElMessage.error(getApiErrorDetails(error, '采购订单审核失败').message) }
finally { reviewing.value = false }
}
async function upload(category: 'RECEIPT' | 'VOUCHER', event: Event) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file || !detail.value) return
uploading.value = true
try { const saved = await uploadOverseasPurchaseFile(detail.value.orderNo, customerId.value, category, file); if (category === 'VOUCHER') { voucher.value = saved; feeDialogOpen.value = true } else { ElMessage.success('签收文件已上传'); await load() } }
catch (error) { ElMessage.error(getApiErrorDetails(error, '文件上传失败').message) } finally { uploading.value = false }
}
async function saveFee() {
if (!detail.value || !voucher.value || feeForm.amount <= 0 || !feeForm.feeItemName.trim()) { ElMessage.warning('请填写正确的费用名称和金额'); return }
savingFee.value = true
try { await createOverseasPurchaseFee(detail.value.orderNo, customerId.value, { feeItemCode: feeForm.feeItemCode.trim(), feeItemName: feeForm.feeItemName.trim(), amount: feeForm.amount, voucherFileId: voucher.value.fileId, remark: feeForm.remark.trim() || undefined }); feeDialogOpen.value = false; voucher.value = null; ElMessage.success('费用已登记并提交资金冻结'); await load() }
catch (error) { ElMessage.error(getApiErrorDetails(error, '费用登记失败').message) } finally { savingFee.value = false }
}
2026-08-20 09:10:36 +08:00
async function load() {
detail.value = null; loadError.value = null
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return }
loading.value = true
try { detail.value = await getOverseasPurchase(purchaseId.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单详情加载失败') } finally { loading.value = false }
}
2026-09-14 10:02:39 +08:00
let activatedOnce = false
2026-08-20 09:10:36 +08:00
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
2026-09-14 10:02:39 +08:00
onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
2026-08-19 16:45:51 +08:00
</script>
<template>
2026-09-15 09:58:15 +08:00
<AdminPage class="admin-density"><DetailPageLayout class="purchase-detail" aria-label="海外采购订单详情">
<template #header>
<div class="density-detail-header">
<div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.orderNo || '—' }}</h1><StatusBadge v-if="detail" :status="statusLabels[detail.status]" /></div>
<div v-if="detail?.status === 'PENDING_REVIEW' && access.can(overseasBusinessPermissions.purchaseReview)" class="header-actions"><el-button type="danger" plain @click="openReview('REJECT')"><X :size="16" />拒绝</el-button><el-button type="primary" @click="openReview('APPROVE')"><Check :size="16" />通过</el-button></div>
</div>
</template>
<template v-if="detail" #summary><dl class="density-detail-overview" aria-label="采购订单概览"><div><dt>客户</dt><dd>{{ display(detail.customerName) }}</dd></div><div><dt>采购仓库</dt><dd>{{ display(detail.purchaseWarehouseName) }}</dd></div><div><dt>采购数量</dt><dd>{{ display(detail.purchaseQuantity) }}</dd></div><div><dt>入库量</dt><dd>{{ display(detail.inboundQuantity) }}</dd></div><div><dt>出库量</dt><dd>{{ display(detail.outboundQuantity) }}</dd></div><div><dt>库存量</dt><dd>{{ display(detail.inventoryQuantity) }}</dd></div></dl></template>
2026-08-20 09:10:36 +08:00
<LoadErrorState v-if="loadError" title="采购订单详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
2026-09-15 09:58:15 +08:00
<div v-else v-loading="loading" class="detail-surface">
2026-08-19 16:45:51 +08:00
<div class="detail-grid">
2026-09-15 09:58:15 +08:00
<section><h3>商品信息</h3><dl class="density-fields"><div v-for="field in productFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>库存信息</h3><dl class="density-fields"><div v-for="field in stockFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>订单信息</h3><dl class="density-fields"><div v-for="field in orderFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>快递信息</h3><dl class="density-fields"><div v-for="field in courierFields" :key="field[0]" :class="{ wide: ['商品规格', '商品链接', '收货地址', '订单备注'].includes(field[0]) }"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
2026-08-30 16:40:03 +08:00
<section class="full"><div class="section-heading"><h3>出库明细</h3><span>共 {{ outboundDetails.length }} 条,出库量 {{ outboundDetails.reduce((sum, item) => sum + item.quantity, 0) }}</span></div><DataTable :data="outboundDetails" :columns="[{ key: 'quantity', label: '出库数量', width: 110 }, { key: 'carrier', label: '承运商', minWidth: 140 }, { key: 'trackingNo', label: '运单号', minWidth: 160 }, { key: 'shippedAt', label: '出库时间', minWidth: 155, formatter: (row: any) => formatDateTime(row.shippedAt) }, { key: 'remark', label: '备注', minWidth: 180 }]" row-key="id" empty-text="暂无出库明细" /></section>
<section class="full"><div class="section-heading"><h3>签收文件与费用凭证</h3><div v-if="access.can(overseasBusinessPermissions.purchaseEdit)" class="section-actions"><input ref="receiptInput" hidden type="file" @change="upload('RECEIPT', $event)"><input ref="voucherInput" hidden type="file" @change="upload('VOUCHER', $event)"><el-button :loading="uploading" @click="receiptInput?.click()"><Upload :size="15" />上传签收文件</el-button><el-button type="primary" :loading="uploading" :disabled="!detail || ['PENDING_REVIEW', 'REJECTED'].includes(detail.status)" @click="voucherInput?.click()"><Upload :size="15" />登记费用</el-button></div></div><DataTable :data="detail?.files || []" :columns="fileColumns" row-key="id" empty-text="暂无签收文件或费用凭证" /></section>
<section class="full"><div class="section-heading"><h3>费用明细</h3><span>每条费用独立冻结,订单全部入库后结算。</span></div><DataTable :data="detail?.fees || []" :columns="feeColumns" row-key="id" empty-text="暂无费用明细" /></section>
2026-08-30 16:40:03 +08:00
<section class="full"><div class="section-heading"><h3>操作流水</h3><span>展示创建、审核、入库与出库明细变更。</span></div><DataTable :data="detail?.histories || []" :columns="historyColumns" row-key="createdAt" empty-text="暂无操作流水" /></section>
2026-08-19 16:45:51 +08:00
</div>
</div>
2026-09-15 09:58:15 +08:00
</DetailPageLayout><AppDialog class="admin-density-dialog" v-model="reviewDialogOpen" :title="reviewDecision === 'APPROVE' ? '通过采购订单' : '拒绝采购订单'" width="min(520px, calc(100vw - 28px))" :confirm-text="reviewDecision === 'APPROVE' ? '确认通过' : '确认拒绝'" :confirm-loading="reviewing" @confirm="submitReview" @cancel="closeReview"><el-alert :title="reviewDecision === 'APPROVE' ? '通过后平台可维护采购入库、出库和费用信息。' : '拒绝后该采购订单将结束审核流程。'" :type="reviewDecision === 'APPROVE' ? 'success' : 'warning'" show-icon :closable="false" /><p class="review-reference">订单编号:{{ detail?.orderNo || '—' }}</p><el-form class="density-form review-form" label-position="top"><el-form-item label="审核备注"><el-input v-model="reviewRemark" type="textarea" :rows="4" maxlength="1000" show-word-limit :placeholder="reviewDecision === 'APPROVE' ? '请输入通过说明(选填)' : '请输入拒绝原因(选填)'" /></el-form-item></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="feeDialogOpen" title="登记采购费用" width="min(560px, calc(100vw - 28px))" confirm-text="确认登记" :confirm-loading="savingFee" @confirm="saveFee"><el-form class="density-form" label-position="top"><el-form-item label="费用项" required><el-input v-model="feeForm.feeItemName" maxlength="64" placeholder="请输入费用名称" /></el-form-item><el-form-item label="费用代码" required><el-input v-model="feeForm.feeItemCode" maxlength="64" placeholder="请输入费用代码" /></el-form-item><el-form-item label="费用金额(CNY)" required><el-input-number v-model="feeForm.amount" :min="0.01" :precision="2" controls-position="right" placeholder="请输入费用金额" /></el-form-item><el-form-item label="费用凭证"><el-input :model-value="voucher?.fileName" disabled /></el-form-item><el-form-item label="备注"><el-input v-model="feeForm.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入费用说明(选填)" /></el-form-item></el-form></AppDialog></AdminPage>
2026-08-19 16:45:51 +08:00
</template>
<style scoped>
2026-09-15 09:58:15 +08:00
.purchase-detail { gap: 0; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
.header-actions, .section-actions { display: flex; align-items: center; gap: var(--admin-space-3); }
.detail-surface { min-width: 0; padding: var(--admin-space-5); }
.detail-grid { display: grid; min-width: 0; gap: var(--admin-space-5); }
.detail-grid section { min-width: 0; }
.detail-grid h3 { margin: 0 0 var(--admin-space-3); font-size: var(--admin-font-section); }
.detail-grid .density-fields > .wide { grid-column: span 2; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); margin-bottom: var(--admin-space-3); }
.section-heading h3 { margin: 0; }
.section-heading span, .review-reference { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.review-reference { margin: var(--admin-space-3) 0; }
:deep(.el-input-number) { width: 100%; }
2026-08-19 16:45:51 +08:00
</style>