diff --git a/src/modules/first-leg/api/customs.ts b/src/modules/first-leg/api/customs.ts new file mode 100644 index 0000000..bf89f41 --- /dev/null +++ b/src/modules/first-leg/api/customs.ts @@ -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({ + method: 'GET', + url: `/merchant/v1/customs/orders/${orderId}`, + }) +} + +export function isMerchantCustomsUnavailable(error: unknown) { + return error instanceof ApiError && (error.status === 404 || error.code === 'A0404') +} diff --git a/src/modules/first-leg/components/OrderProgressPanel.vue b/src/modules/first-leg/components/OrderProgressPanel.vue index 66f596b..05ea410 100644 --- a/src/modules/first-leg/components/OrderProgressPanel.vue +++ b/src/modules/first-leg/components/OrderProgressPanel.vue @@ -39,7 +39,7 @@ function formatDateTime(value: string) {

订单进度

- 从订单提交到派送完成 + 国内装柜进度及后续履约同步
{{ getFirstLegStatusLabel(order.status) }} diff --git a/src/modules/first-leg/routes.ts b/src/modules/first-leg/routes.ts index 8d8234b..3149881 100644 --- a/src/modules/first-leg/routes.ts +++ b/src/modules/first-leg/routes.ts @@ -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 }, }, { diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts index 67cc7f3..66561bc 100644 --- a/src/modules/first-leg/store/firstLegOrders.ts +++ b/src/modules/first-leg/store/firstLegOrders.ts @@ -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 ?? '', diff --git a/src/modules/first-leg/types/firstLegOrder.ts b/src/modules/first-leg/types/firstLegOrder.ts index 8117cab..18bf941 100644 --- a/src/modules/first-leg/types/firstLegOrder.ts +++ b/src/modules/first-leg/types/firstLegOrder.ts @@ -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 diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs index d40c11a..633d172 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs +++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs @@ -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, /客户确认|差异处理中|派送中/) }) diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue index 60155c5..583eb22 100644 --- a/src/modules/first-leg/views/FirstLegOrdersView.vue +++ b/src/modules/first-leg/views/FirstLegOrdersView.vue @@ -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(null) +const customsLoading = ref(false) +const customsUnavailable = ref(false) const supportingDataLoading = ref(false) const supportingDataErrors = ref([]) 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)[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) {
{{ item.label }}
{{ item.value }}
+
+

柜次进度

+
+
+
{{ cabinet.cabinetNo || '柜次待分配' }}{{ cabinet.status || '待更新' }}
+
+
柜号
{{ cabinet.cabinetNo || '待分配' }}
+
船名 / 航次
{{ [cabinet.vesselName, cabinet.voyageNo].filter(Boolean).join(' · ') || '待更新' }}
+
预计船期
{{ cabinet.sailingAt ? formatDateTime(cabinet.sailingAt) : '待更新' }}
+
+
+
+ +
+
+

报关回执

+ + +
+
+ {{ receipt.receiptType }}版本 {{ receipt.receiptVersion ?? '-' }}{{ receipt.uploadedAt ? formatDateTime(receipt.uploadedAt) : '时间待更新' }} +
+
+

履约快递信息

快递公司{{ detailOrder.expressCompanyName || '待平台补充' }}
快递单号{{ detailOrder.trackingNumber || '待平台补充' }}
@@ -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) {