feat: expose phase three client progress

This commit is contained in:
2026-08-10 12:05:43 +08:00
parent c1f2411c6c
commit 79435fe474
7 changed files with 111 additions and 7 deletions
+31
View File
@@ -0,0 +1,31 @@
import { ApiError, apiRequest } from '@/api/http'
export interface MerchantCustomsReceipt {
id: number | string
receiptType: 'RECEIVED' | 'DECLARED' | 'REJECTED' | 'INSPECTION' | 'RELEASED' | 'OTHER' | string
receiptVersion: number | null
fileName: string | null
uploadedAt: string | null
}
export interface MerchantCustomsCaseDetail {
customsCase: {
id: number | string
customsNo: string | null
orderId: number | string
status: string | null
updatedAt: string | null
}
receipts: MerchantCustomsReceipt[]
}
export function getMerchantCustomsByOrder(orderId: number | string) {
return apiRequest<MerchantCustomsCaseDetail>({
method: 'GET',
url: `/merchant/v1/customs/orders/${orderId}`,
})
}
export function isMerchantCustomsUnavailable(error: unknown) {
return error instanceof ApiError && (error.status === 404 || error.code === 'A0404')
}
@@ -39,7 +39,7 @@ function formatDateTime(value: string) {
<div class="progress-heading">
<div>
<h3 id="order-progress-title">订单进度</h3>
<span>从订单提交到派送完成</span>
<span>国内装柜进度及后续履约同步</span>
</div>
<el-tag :type="order.status === 'DELIVERED' ? 'success' : order.status === 'DRAFT' || order.status === 'PENDING_REVIEW' ? 'warning' : 'info'" effect="light">
{{ getFirstLegStatusLabel(order.status) }}
+1 -1
View File
@@ -11,7 +11,7 @@ export const firstLegModule = {
component: () => import('./views/FirstLegOrdersView.vue'),
props: { createPage: false },
release: 'active',
meta: { title: '订单列表', description: '创建订单并跟进从提交到派送完成的统一进度', permission: 'order:first-leg:list' },
meta: { title: '订单列表', description: '创建订单并查看国内装柜及后续履约同步进度', permission: 'order:first-leg:list' },
navigation: { key: 'first-leg-orders', title: '订单列表', icon: FileText },
},
{
@@ -222,6 +222,8 @@ function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['busi
cabinetNo: summary?.transport?.cabinetNo ?? '',
vesselName: summary?.transport?.vesselName ?? '',
voyageNo: summary?.transport?.voyageNo ?? '',
sailingAt: summary?.transport?.sailingAt ?? '',
portArrivalAt: summary?.transport?.portArrivalAt ?? '',
inboundId: summary?.inboundId == null ? '' : String(summary.inboundId),
inboundVersion: summary?.inboundVersion ?? null,
inboundCode: summary?.inboundCode ?? '',
@@ -75,6 +75,7 @@ export interface FirstLegOrderBusinessSummary {
expectedPickCount: number | null
pickedCount: number | null
evidenceCount: number | null
/** Internal evidence/seal fields are retained only for backwards-compatible API parsing; never render in Client. */
sealNo: string
deliveryStatus: string
deliveryCompletedAt: string
@@ -83,6 +84,8 @@ export interface FirstLegOrderBusinessSummary {
cabinetNo: string
vesselName: string
voyageNo: string
sailingAt: string
portArrivalAt: string
inboundId: string
inboundVersion: number | null
inboundCode: string
@@ -90,10 +90,14 @@ test('shows one unified progress line, C02 business summaries, and optional cust
assert.match(source, /summary\.expectedPickCount/)
assert.match(source, /summary\.deliveryTrackingNo/)
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
assert.match(progressSource, /从订单提交到派送完成/)
assert.match(progressSource, /国内装柜进度及后续履约同步/)
assert.match(progressSource, /item\.kind === 'CUSTOM_NODE'/)
assert.match(progressSource, /业务节点/)
assert.match(progressSource, /暂无进度记录/)
assert.match(source, /报关回执/)
assert.match(source, /船期摘要/)
assert.match(source, /柜次进度/)
assert.doesNotMatch(source, /summary\.sealNo \|\|/)
assert.doesNotMatch(progressSource, /客户确认|差异处理中|派送中/)
})
@@ -11,6 +11,7 @@ import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrd
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBilling'
import { getMerchantCustomsByOrder, isMerchantCustomsUnavailable, type MerchantCustomsCaseDetail } from '@/modules/first-leg/api/customs'
import {
estimateMerchantFirstLegPrice,
downloadMerchantProductImage,
@@ -89,6 +90,9 @@ const inboundBillingLoading = ref(false)
const inboundBillingUnavailable = ref(false)
const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布')
const inboundBillingError = ref('')
const customsDetail = ref<MerchantCustomsCaseDetail | null>(null)
const customsLoading = ref(false)
const customsUnavailable = ref(false)
const supportingDataLoading = ref(false)
const supportingDataErrors = ref<string[]>([])
const destinationWarehousesLoading = ref(false)
@@ -300,7 +304,7 @@ const progressSummaryItems = computed(() => {
const summary = order.businessSummary
const formatMetric = (value: number | null | undefined, unit = '') => value == null ? '待更新' : `${value.toLocaleString()}${unit ? ` ${unit}` : ''}`
const preloadStatus = ({ PENDING: '待确认', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[summary.preloadStatus] ?? summary.preloadStatus
const transportReference = [summary.containerNo, summary.cabinetNo, summary.vesselName, summary.voyageNo].filter(Boolean).join(' · ')
const transportReference = [summary.vesselName, summary.voyageNo].filter(Boolean).join(' · ')
const pickingProgress = summary.expectedPickCount == null
? '待更新'
: `${summary.pickedCount ?? 0} / ${summary.expectedPickCount}`
@@ -310,12 +314,18 @@ const progressSummaryItems = computed(() => {
{ label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') },
{ label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' },
{ label: '拣货进度', value: pickingProgress },
{ label: '装柜资料', value: transportReference || summary.sealNo || '待更新' },
{ label: '作业证据', value: formatMetric(summary.evidenceCount, '项') },
{ label: '柜次进度', value: summary.cabinetNo || summary.containerNo || '待更新' },
{ label: '报关回执', value: customsDetail.value?.receipts.length ? `${customsDetail.value.receipts.length} 份回执` : customsUnavailable.value ? '暂无回执' : '查询中' },
{ label: '船期摘要', value: transportReference || '待更新' },
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
{ label: '派送信息', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `完成于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待更新') },
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') },
]
})
const cabinetProgressItems = computed(() => {
const summary = detailOrder.value?.businessSummary
if (!summary || (!summary.cabinetNo && !summary.containerNo)) return []
return [{ cabinetId: summary.cabinetNo || summary.containerNo, cabinetNo: summary.cabinetNo || summary.containerNo, status: summary.loadingStatus || '待更新', vesselName: summary.vesselName, voyageNo: summary.voyageNo, sailingAt: summary.sailingAt }]
})
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
const selectedDestinationWarehouse = computed(() => store.warehouses.find((item) => item.id === form.destinationWarehouseId && item.warehouseType === form.destinationType))
const selectedPricingChannel = computed(() => channelOptions.value.find((item) => item.channelCode === form.channelCode))
@@ -396,6 +406,9 @@ watch(isCreatePage, (isCreate, wasCreate) => {
watch(detailOpen, (open) => {
if (!open) {
resetInboundBillingState()
customsDetail.value = null
customsLoading.value = false
customsUnavailable.value = false
if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items)
}
})
@@ -838,6 +851,7 @@ async function openDetail(order: FirstLegOrder) {
detailLoading.value = true
detailError.value = ''
void loadInboundBilling(order)
void loadCustoms(order)
try {
const detailedOrder = await store.loadDetail(order.id)
await Promise.all(detailedOrder.items.map(ensureProductImagePreview))
@@ -901,6 +915,21 @@ async function loadInboundBilling(order: FirstLegOrder) {
}
}
async function loadCustoms(order: FirstLegOrder) {
customsDetail.value = null
customsUnavailable.value = false
if (order.status === 'DRAFT') return
customsLoading.value = true
try {
customsDetail.value = await getMerchantCustomsByOrder(order.id)
} catch (error) {
if (isMerchantCustomsUnavailable(error)) customsUnavailable.value = true
else ElMessage.warning(error instanceof Error ? error.message : '报关回执加载失败')
} finally {
customsLoading.value = false
}
}
async function retryInboundBilling() {
const order = detailId.value ? store.getById(detailId.value) : undefined
if (order) await loadInboundBilling(order)
@@ -1307,6 +1336,30 @@ async function searchOrders(model: QueryModel) {
<div v-for="item in progressSummaryItems" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div>
</dl>
</section>
<section v-if="detailOrder.status !== 'DRAFT'" class="detail-section">
<h3>柜次进度</h3>
<div v-if="cabinetProgressItems.length" class="cabinet-progress-list">
<article v-for="cabinet in cabinetProgressItems" :key="cabinet.cabinetId" class="cabinet-progress-card">
<header><strong>{{ cabinet.cabinetNo || '柜次待分配' }}</strong><el-tag size="small" effect="plain">{{ cabinet.status || '待更新' }}</el-tag></header>
<dl>
<div><dt>柜号</dt><dd>{{ cabinet.cabinetNo || '待分配' }}</dd></div>
<div><dt>船名 / 航次</dt><dd>{{ [cabinet.vesselName, cabinet.voyageNo].filter(Boolean).join(' · ') || '待更新' }}</dd></div>
<div><dt>预计船期</dt><dd>{{ cabinet.sailingAt ? formatDateTime(cabinet.sailingAt) : '待更新' }}</dd></div>
</dl>
</article>
</div>
<el-empty v-else description="暂无柜次分配信息" :image-size="50" />
</section>
<section v-if="detailOrder.status !== 'DRAFT'" class="detail-section">
<h3>报关回执</h3>
<el-skeleton v-if="customsLoading" :rows="2" animated />
<el-empty v-else-if="customsUnavailable || !customsDetail?.receipts.length" description="暂无客户可见报关回执" :image-size="50" />
<div v-else class="customs-receipt-list">
<article v-for="receipt in customsDetail.receipts" :key="receipt.id">
<strong>{{ receipt.receiptType }}</strong><span>版本 {{ receipt.receiptVersion ?? '-' }}</span><span>{{ receipt.uploadedAt ? formatDateTime(receipt.uploadedAt) : '时间待更新' }}</span>
</article>
</div>
</section>
<section class="detail-section">
<h3>履约快递信息</h3>
<div v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><PackageCheck :size="20" /><div><span>快递公司</span><strong>{{ detailOrder.expressCompanyName || '待平台补充' }}</strong></div><div><span>快递单号</span><strong>{{ detailOrder.trackingNumber || '待平台补充' }}</strong></div></div>
@@ -1488,6 +1541,16 @@ async function searchOrders(model: QueryModel) {
.fulfillment-summary > div:nth-last-child(-n + 3) { border-bottom: 0; }
.fulfillment-summary dt { color: var(--muted); font-size: 10px; }
.fulfillment-summary dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 650; }
.cabinet-progress-list { display: grid; gap: 10px; }
.cabinet-progress-card { padding: 14px 15px; border: 1px solid var(--border); background: #fbfcfc; }
.cabinet-progress-card header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.cabinet-progress-card header strong { color: var(--navy); font-size: 13px; }
.cabinet-progress-card dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 13px 0 0; }
.cabinet-progress-card dt { color: var(--muted); font-size: 10px; }
.cabinet-progress-card dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 11px; font-weight: 600; }
.customs-receipt-list { display: grid; gap: 8px; }
.customs-receipt-list article { display: flex; flex-wrap: wrap; gap: 10px; padding: 11px 13px; border: 1px solid var(--border); color: var(--muted); font-size: 11px; }
.customs-receipt-list strong { color: var(--navy); }
.express-information { display: grid; grid-template-columns: auto repeat(2, minmax(0, 1fr)); align-items: center; gap: 16px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
.express-information > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.express-information span { color: var(--muted); font-size: 11px; }
@@ -1592,6 +1655,7 @@ async function searchOrders(model: QueryModel) {
.cargo-detail-list header { grid-template-columns: 1fr; gap: 4px; }
.cargo-detail-list header small { text-align: left; }
.cargo-detail-list dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cabinet-progress-card dl { grid-template-columns: repeat(2, minmax(0, 1fr)); }
:global(.first-leg-editor), :global(.first-leg-detail) { width: 100% !important; }
}
@media (max-width: 360px) {