This commit is contained in:
2026-08-30 17:43:03 +08:00
parent de1b956d43
commit ae52141952
45 changed files with 404 additions and 82 deletions
+1
View File
@@ -48,6 +48,7 @@
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
| 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` |
| 货柜、排柜、装柜清单与整柜报关 | `modules/container-loading/`、`modules/loading-lists/`;详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue`、`api/platformContainerCustoms.ts` | 装柜完成后按 `loadingPlanId` 展示整柜退税/买单覆盖;平台开始报关即冻结客户资料版本与入仓码范围,逐资料上传一对一完成文件,最后一份要求二次确认;全买单柜使用 `complete-all-buy`。Backend `firstleg/customs/container/`、`customs-v1.yaml`,文件归 `file/` |
| 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 |
| 三期订舱、关联候选与出仓前船期 | `modules/ocean-bookings/`、`modules/container-loading/`、`modules/port-schedules/` | `platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页;列表 `containerCount` 是柜型需求 `quantity` 总和,不是已关联柜数;Backend `firstleg/oceanbooking/`、`firstleg/portschedule/`,出仓后节点归四期 |
| 拖车、历史报关协同、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料改从货柜详情进入,不得在旧模块复制一套;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
@@ -138,7 +138,7 @@ async function exportAllBuy() {
actionLoading.value = true; actionError.value = null
try {
const task = await createPlatformExportTask({
exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX', taskName: `买单报关-${customs.value?.planNo || props.loadingPlanId}`,
exportType: 'LOADING_LIST_BUY_DECLARATION_ITEMS', format: 'XLSX', taskName: `买单报关-${customs.value?.planNo || '柜级明细'}`,
parameters: { loadingPlanId: String(props.loadingPlanId) },
})
ElMessage.success(`买单报关导出任务已创建:${task.taskNo}`)
@@ -11,8 +11,12 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types'
import {
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
type ContainerOutboundDispatchDetail,
type ContainerOutboundGateItem,
} from '@/modules/outbound-dispatch'
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import {
@@ -88,7 +88,7 @@ type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: Smar
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
for (const member of members) {
const current = orders.get(member.orderId) || { label: member.orderNo || member.orderId, inboundCodes: [] }
const current = orders.get(member.orderId) || { label: member.orderNo || '订单编码待补充', inboundCodes: [] }
const inboundCode = member.inboundCode || '入仓码暂缺'
if (!current.inboundCodes.includes(inboundCode)) current.inboundCodes.push(inboundCode)
orders.set(member.orderId, current)
@@ -626,7 +626,7 @@ onMounted(() => { void load(); void loadCustomers() })
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || item.warehouseId || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<span>仓库:{{ item.warehouseName || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<!-- 空柜的操作时机由状态和操作按钮表达,不在每张柜次卡片重复提示。 -->
</button>
</div>
@@ -49,7 +49,7 @@ type SmartLoadingCandidateRow = SmartLoadingCandidate & { sourceCandidates: Smar
function memberOrderSummaries<T extends { orderId: string; orderNo?: string | null; inboundCode?: string | null }>(members: T[]) {
const orders = new Map<string, { label: string; inboundCodes: string[] }>()
for (const member of members) {
const current = orders.get(member.orderId) || { label: member.orderNo || member.orderId, inboundCodes: [] }
const current = orders.get(member.orderId) || { label: member.orderNo || '订单编码待补充', inboundCodes: [] }
const inboundCode = member.inboundCode || '入仓码暂缺'
if (!current.inboundCodes.includes(inboundCode)) current.inboundCodes.push(inboundCode)
orders.set(member.orderId, current)
+7 -1
View File
@@ -1,3 +1,9 @@
export { financeRoutes } from './routes'
export { default as CustomerStatementsPanel } from './components/CustomerStatementsPanel.vue'
export { getFinanceCustomerAccountSummary, type FinanceCustomerAccountSummaryResource } from './api/finance'
export {
getFinanceCustomerAccountSummary,
getFinanceSettlement,
listFinancePayableSettlements,
listFinanceReceivableSettlements,
type FinanceCustomerAccountSummaryResource,
} from './api/finance'
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Ban, Download, Eye, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
import { ElMessage } from 'element-plus'
@@ -78,6 +78,7 @@ const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const exportBusy = ref(false)
const customerSelectVisible = ref(false)
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
@@ -108,6 +109,7 @@ const queryFields = computed<QueryField[]>(() => [
filterable: true,
clearable: true,
loading: customersLoading.value,
onVisibleChange: onCustomerSelectVisible,
},
options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })),
},
@@ -118,6 +120,12 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '请输入业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
])
function onCustomerSelectVisible(visible: boolean) { customerSelectVisible.value = visible }
function closeCustomerSelectOnScroll() {
if (!customerSelectVisible.value) return
const active = document.activeElement
if (active instanceof HTMLElement) active.blur()
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: isSelectableReceivable }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
@@ -332,11 +340,13 @@ async function restoreFeeSourceDetail(feeSourceId: string) {
}
onMounted(async () => {
document.addEventListener('scroll', closeCustomerSelectOnScroll, true)
const feeSourceId = String(route.params.feeSourceId || '')
if (route.name === 'finance-customer-arap-detail' && feeSourceId) { if (canListCustomers.value) await loadCustomers(); await restoreFeeSourceDetail(feeSourceId); return }
if (canListCustomers.value) await loadCustomers()
await load()
})
onBeforeUnmount(() => document.removeEventListener('scroll', closeCustomerSelectOnScroll, true))
watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => {
if (!previous) return
if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(String(feeSourceId))
@@ -2,7 +2,7 @@
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheckBig, Eye, Ruler, SearchCheck } from '@lucide/vue'
import { CircleCheckBig, Eye, Link2, Ruler, SearchCheck } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses } from '@/modules/warehouses'
@@ -12,6 +12,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import InboundCodeRelationshipDrawer from '@/shared/components/InboundCodeRelationshipDrawer.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listPlatformInbounds } from '../core/api'
import type { InboundOrderSummary } from '../core/types'
@@ -44,6 +45,8 @@ const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
const selectedInbound = ref<InboundOrderSummary | null>(null)
const measurementDialogOpen = ref(false)
const reviewDialogOpen = ref(false)
const relationshipOpen = ref(false)
const relationshipInboundCode = ref('')
const batchReviewLoading = ref(false)
const selectedReviewRows = ref<InboundOrderSummary[]>([])
let requestSerial = 0
@@ -190,6 +193,13 @@ function openDetail(row: InboundOrderSummary) {
void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } })
}
function openRelationship(row: InboundOrderSummary) {
const code = row.inboundCode?.trim()
if (!code) return
relationshipInboundCode.value = code
relationshipOpen.value = true
}
function handleTaskChanged() {
reviewDialogOpen.value = false
selectedInbound.value = null
@@ -224,12 +234,12 @@ async function batchApproveMeasurements() {
successCount += 1
if (!approval.initialPricingSucceeded) {
pricingWarnings.push(
`${row.inboundCode || row.id}:${approval.initialPricingMessage || '费用初算未生成'}`,
`${row.inboundCode || '入仓码待补充'}:${approval.initialPricingMessage || '费用初算未生成'}`,
)
}
} catch (error) {
failureCount += 1
failures.push(getApiErrorDetails(error, `${row.inboundCode || row.id} 审核失败`).message)
failures.push(getApiErrorDetails(error, `${row.inboundCode || '入仓码待补充'} 审核失败`).message)
}
}
await loadTasks()
@@ -329,7 +339,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span>
</div>
</template>
<template #inboundCode="{ row }"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></template>
<template #inboundCode="{ row }"><div class="inbound-code-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><el-button v-if="(row as InboundOrderSummary).inboundCode" link type="primary" @click="openRelationship(row as InboundOrderSummary)"><Link2 :size="14" />关联数据</el-button></div></template>
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
@@ -352,7 +362,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<el-dialog
v-if="isReview"
v-model="reviewDialogOpen"
:title="selectedInbound ? `${taskLabel} · ${selectedInbound.inboundCode || selectedInbound.id}` : taskLabel"
:title="selectedInbound ? `${taskLabel} · ${selectedInbound.inboundCode || '入仓码待补充'}` : taskLabel"
width="min(1180px, calc(100vw - 32px))"
top="4vh"
destroy-on-close
@@ -381,6 +391,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
@submitted="handleMeasurementSubmitted"
@closed="clearSelectedInbound"
/>
<InboundCodeRelationshipDrawer v-if="relationshipOpen" v-model="relationshipOpen" :inbound-code="relationshipInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</AdminPage>
</template>
@@ -391,6 +402,8 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
.row-actions .el-button { margin: 0; }
.primary-cell { display: grid; gap: 3px; min-width: 0; }
.primary-cell strong, .primary-cell span { overflow-wrap: anywhere; }
.inbound-code-cell { display: grid; gap: 3px; min-width: 0; }
.inbound-code-cell .el-button { width: fit-content; margin: 0; padding: 0; }
.primary-cell span, .muted-action { color: var(--cc-muted); font-size: 12px; }
@media (max-width: 520px) {
.row-actions { align-items: stretch; flex-direction: column; }
@@ -35,7 +35,7 @@ const revision = ref(0)
let requestSerial = 0
let disposed = false
const title = computed(() => detail.value?.summary.inboundCode || props.inboundId)
const title = computed(() => detail.value?.summary.inboundCode || '入仓码待补充')
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
const statusLabel = computed(() => inboundStatusLabel(detail.value?.summary.processStatus, inboundProcessStatusLabels, '未知状态'))
@@ -22,7 +22,8 @@ describe('InboundMeasurementDialog', () => {
})
it('shows the inbound code once in the dialog title and not as a second body heading', () => {
expect(source).toContain('`测量 · ${props.inboundCode?.trim() || props.inboundId}`')
expect(source).toContain("`测量 · ${props.inboundCode?.trim() || '入仓码待补充'}`")
expect(source).not.toContain('props.inboundCode?.trim() || props.inboundId')
expect(source).toContain(':title="title"')
expect(panelSource).not.toContain('<span>入仓码</span>')
expect(panelSource).not.toContain('<strong>{{ detail.inboundCode')
@@ -19,7 +19,7 @@ const emit = defineEmits<{
}>()
const measurementPanelRef = ref<InstanceType<typeof InboundMeasurementPanel> | null>(null)
const title = computed(() => `测量 · ${props.inboundCode?.trim() || props.inboundId}`)
const title = computed(() => `测量 · ${props.inboundCode?.trim() || '入仓码待补充'}`)
async function mayClose() {
if (!measurementPanelRef.value?.isDirty) return true
@@ -115,7 +115,7 @@ const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'p2-inbound-measurement-editor',
label: computed(() => `入库单 ${detail.value?.inboundCode || props.inboundId} 的测量草稿`),
label: computed(() => `入仓码 ${detail.value?.inboundCode || '待补充'} 的测量草稿`),
isOpen: registrationOpen,
model: form,
requiredPermission,
@@ -2,7 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, Printer, RefreshCw, SlidersHorizontal } from '@lucide/vue'
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, Link2, Printer, RefreshCw, SlidersHorizontal } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -10,6 +10,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import InboundCodeRelationshipDrawer from '@/shared/components/InboundCodeRelationshipDrawer.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { firstLegBusinessTypeLabel, firstLegContainerModeLabel, firstLegDestinationTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -42,6 +43,8 @@ const pageSize = ref(20)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const printingInboundId = ref('')
const relationshipOpen = ref(false)
const relationshipInboundCode = ref('')
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
@@ -286,6 +289,7 @@ function openDetail(row: InboundOrderSummary) {
}
void router.push({ name: 'unified-order-detail', params: { orderId: row.orderId }, query: { tenantId: row.tenantId } })
}
function openRelationship(row: InboundOrderSummary) { const code = row.inboundCode?.trim(); if (!code) return; relationshipInboundCode.value = code; relationshipOpen.value = true }
async function printCartonLabels(row: InboundOrderSummary) {
if (printingInboundId.value) return
printingInboundId.value = row.id
@@ -294,7 +298,7 @@ async function printCartonLabels(row: InboundOrderSummary) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${row.inboundCode || row.id}-${row.totalPackageCount || '整单'}件-箱唛.pdf`
anchor.download = `${row.inboundCode || '入仓码待补充'}-${row.totalPackageCount || '整单'}件-箱唛.pdf`
anchor.click()
URL.revokeObjectURL(url)
ElMessage.success('箱唛已生成,重复打印将继续使用原箱号')
@@ -413,7 +417,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId +
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button size="small" type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button size="small" plain type="primary" :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button size="small" plain type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
<template #inbound="{ row }"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></template>
<template #inbound="{ row }"><div class="inbound-code-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><el-button v-if="(row as InboundOrderSummary).inboundCode" link type="primary" @click="openRelationship(row as InboundOrderSummary)"><Link2 :size="14" />关联数据</el-button></div></template>
<template #processStatus="{ row }"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag></template>
<template #orderStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</template>
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
@@ -456,12 +460,13 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId +
</DataTable>
</section>
</ListPageLayout>
<InboundCodeRelationshipDrawer v-if="relationshipOpen" v-model="relationshipOpen" :inbound-code="relationshipInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</AdminPage>
</template>
<style scoped>
.inbound-orders-page { min-width: 0; }
.inbound-code-cell { display: grid; gap: 3px; min-width: 0; }.inbound-code-cell .el-button { width: fit-content; margin: 0; padding: 0; }
.inbound-query-toolbar { align-items: stretch; border-radius: 8px; background: #f8fafc; }
.inbound-tabs { display: flex; width: max-content; max-width: 100%; min-width: 0; overflow-x: auto; border-bottom: 2px solid var(--cc-border); scrollbar-width: none; }
.inbound-tabs::-webkit-scrollbar { display: none; }
@@ -246,9 +246,9 @@ defineExpose({ confirmApprove, isPending, canReview, actionLoading })
:customer-id="customerId"
:inbound-id="inboundId"
:file-id="row.intelligentImageFileId"
:alt="`箱规 ${row.boxCode || row.itemNo || row.id} 的测量图片`"
:alt="`箱规 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的测量图片`"
empty-text="无图片"
@open="openImagePreview(row.intelligentImageFileId, row.boxCode || row.itemNo || row.id)"
@open="openImagePreview(row.intelligentImageFileId, row.boxCode || row.itemNo || '箱规编码待补充')"
/>
</template>
</el-table-column>
@@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance/api/finance'
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance'
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees'
import ContainerFeesPanel from './ContainerFeesPanel.vue'
@@ -14,7 +14,7 @@ vi.mock('../api/containerFees', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/containerFees')>()
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
})
vi.mock('@/modules/finance/api/finance', () => ({
vi.mock('@/modules/finance', () => ({
getFinanceSettlement: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(),
}))
@@ -27,7 +27,7 @@ async function mountPanel(permissions: string[], customers = [{ accountId: '11',
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', containerBookingId, customers }, global: { plugins: [pinia, ElementPlus] } })
const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', containerBookingId, customers, businessReferences: [{ orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001' }] }, global: { plugins: [pinia, ElementPlus] } })
await flushPromises(); return wrapper
}
@@ -89,6 +89,10 @@ describe('ContainerFeesPanel contract', () => {
expect(panel).toContain("cost.supplierName || '服务端未返回'")
expect(panel).toContain('尚无与该柜次关联的财务分摊结果')
expect(panel).not.toMatch(/allocatedAmount\s*=|allocationRate\s*=/)
expect(panel).toContain("orderNumbers.get(row.orderId) || '订单编码待补充'")
expect(panel).toContain("inboundCodes.get(row.inboundId) || '入仓码待补充'")
expect(panel).not.toContain("prop: 'orderId', label: '订单'")
expect(panel).not.toContain("prop: 'inboundId', label: '入库单'")
})
it('clears the previous customer context and ignores a late response', async () => {
@@ -9,10 +9,15 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance/api/finance'
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '@/modules/finance'
import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeSupplierOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees'
const props = defineProps<{ planId: string; containerBookingId: string; customers: Array<{ accountId: string; customerName: string | null }> }>()
const props = defineProps<{
planId: string
containerBookingId: string
customers: Array<{ accountId: string; customerName: string | null }>
businessReferences: Array<{ orderId: string; orderNo: string; inboundId: string; inboundCode: string | null }>
}>()
const access = useAccessPreviewStore()
const accountId = ref('')
const fees = ref<ContainerFeeResource[]>([])
@@ -56,6 +61,8 @@ const customerOptions = computed(() => {
return [...unique].map(([value, label]) => ({ value, label }))
})
const selectedCustomer = computed(() => customerOptions.value.find((item) => item.value === accountId.value))
const orderNumbers = computed(() => new Map(props.businessReferences.map((item) => [item.orderId, item.orderNo])))
const inboundCodes = computed(() => new Map(props.businessReferences.map((item) => [item.inboundId, item.inboundCode])))
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE'
? item.warehouseReceivable || item.pickupReceivable : item.warehousePayable || item.pickupPayable))
const selectedFeeItem = computed(() => feeItems.value.find((item) => item.id === form.feeItemId))
@@ -73,8 +80,8 @@ const columns: DataTableColumn[] = [
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
]
const allocationColumns: DataTableColumn[] = [
{ key: 'orderId', prop: 'orderId', label: '订单', fixed: 'left', minWidth: 150 },
{ key: 'inboundId', prop: 'inboundId', label: '入库单', minWidth: 150 },
{ key: 'orderNo', label: '订单编码', fixed: 'left', minWidth: 170, slotName: 'orderNo' },
{ key: 'inboundCode', label: '入仓码', minWidth: 170, slotName: 'inboundCode' },
{ key: 'volumeCbm', label: '订单体积(m³)', width: 125, align: 'right', slotName: 'volume' },
{ key: 'allocationRate', label: '分摊比例', width: 110, align: 'right', slotName: 'rate' },
{ key: 'allocatedAmount', label: '分摊金额(CNY)', width: 135, align: 'right', slotName: 'allocated' },
@@ -300,7 +307,7 @@ onMounted(() => void load())
<template #amount="{ row }">{{ money(row.amount) }}</template><template #reviewStatus="{ row }"><StatusBadge :status="row.reviewStatus === 'PENDING' ? '待审核' : row.reviewStatus === 'APPROVED' ? '已通过' : '已驳回'" /></template><template #sourceStatus="{ row }"><StatusBadge :status="row.sourceStatus === 'ACTIVE' ? '有效' : '已作废'" /></template>
</DataTable>
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #orderNo="{ row }">{{ orderNumbers.get(row.orderId) || '订单编码待补充' }}</template><template #inboundCode="{ row }">{{ row.inboundId ? (inboundCodes.get(row.inboundId) || '入仓码待补充') : '—' }}</template><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
</template>
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save">
@@ -81,6 +81,12 @@ const feeCustomers = computed(() => {
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
})
const feeBusinessReferences = computed(() => (props.detail?.items || []).map((item) => ({
orderId: item.orderId,
orderNo: item.orderNo,
inboundId: item.inboundId,
inboundCode: item.inboundCode,
})))
const title = computed(() => {
if (!summary.value) return props.mode === 'preload' ? '预装清单详情' : '装柜清单详情'
const number = props.mode === 'preload'
@@ -176,7 +182,7 @@ function formatNumber(value?: number | null, digits = 3) {
</DataTable>
</section>
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" />
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" :business-references="feeBusinessReferences" />
<section class="history-section" aria-label="操作历史">
<header><h2>操作历史</h2><span>{{ detail.histories.length }} 条</span></header>
+1
View File
@@ -1,2 +1,3 @@
export { outboundDispatchRoutes } from './routes'
export { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from './api/platformOutboundDispatch'
export * from './types'
@@ -27,4 +27,11 @@ describe('overseas inventory API', () => {
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/190000000000000099/positions', { params: {} })
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger', { params: { page: 1, size: 20, skuCode: 'SKU-1' } })
})
it('passes the inbound code as a business filter and preserves the returned customer name', async () => {
const record = { id: '2093539779795795969', customerId: '2093539779795795970', customerName: '华远贸易', inboundCode: 'NYEXX2608300001', beforePackages: 0, deltaPackages: 2, afterPackages: 2 }
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [record], page: 1, size: 20, total: 1, pages: 1 } })
await expect(listInventoryLedger({ page: 1, size: 20, inboundCode: 'NYEXX2608300001', customerName: '华远贸易' })).resolves.toMatchObject({ records: [record] })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/ledger', { params: { page: 1, size: 20, inboundCode: 'NYEXX2608300001', customerName: '华远贸易' } })
})
})
@@ -68,6 +68,7 @@ export interface InventoryPositionDto {
export interface InventoryLedgerDto {
id: string
customerId: string
customerName?: string | null
overseasWarehouseId?: string | null
destinationWarehouseCode?: string | null
inboundOrderId?: string | null
@@ -137,6 +138,7 @@ export async function listInventoryPositions(
export interface InventoryLedgerQuery {
page: number
size: number
customerName?: string
overseasWarehouseId?: string
destinationWarehouseCode?: string
businessType?: string
@@ -247,7 +247,7 @@ watch([warehouseTab, () => query.orderType], () => {
<div v-for="(container, index) in containers" :key="container.id" class="loading-container" :class="{ 'loading-container--overweight': containerWeightLb(container) > container.maxWeightLb * 1.1 }" @dragover.prevent @drop.prevent="dropInto($event, container.id)">
<div class="container-head"><strong>{{ container.vehicleLabel }}</strong><span>托数 <b>{{ container.palletIds.length }}</b>/{{ container.capacity }} · 重量 <b>{{ number(containerWeightLb(container), 0) }}LB</b>/{{ container.maxWeightLb }}LB <em v-if="containerWeightLb(container) > container.maxWeightLb * 1.1">超限10%</em></span><el-button text type="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)"><Trash2 :size="15" /></el-button></div>
<div class="container-body">
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet"><span><strong>{{ text(palletSnapshot(id)?.palletNo || id) }}</strong><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span><el-button text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button></div>
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet"><span><strong>{{ text(palletSnapshot(id)?.palletNo || '托盘号待补充') }}</strong><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span><el-button text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button></div>
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, RotateCcw } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -18,22 +18,24 @@ import { listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryRetur
import { updateDeliveryOrderStatus } from '../api/customerVisits'
import type { ExceptionRelation } from '../api/exceptionOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
const router = useRouter()
const access = useAccessPreviewStore()
const rows = ref<DeliveryOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]); const warehousesLoading = ref(false)
const filters = ref<QueryModel>({ documentNo: '', dispatchNo: '', waybillNo: '', warehouseId: '', status: '', vehiclePlate: '', driverName: '', deliveryMethod: '', plannedAt: [] })
const fields: QueryField[] = [
const fields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待派送', value: 'WAIT_DELIVERY' }, { label: '派送中', value: 'DELIVERING' }, { label: '派送完成', value: 'DELIVERED' }, { label: '已签收(历史)', value: 'SIGNED' }, { label: '已拒签', value: 'REJECTED' }, { label: '退货处理中', value: 'RETURN_PENDING' }, { label: '已取消', value: 'CANCELLED' }] },
{ key: 'documentNo', type: 'input', label: '派送单号', placeholder: '请输入派送单号' },
{ key: 'dispatchNo', type: 'input', label: '排车单号', placeholder: '请输入排车单号' },
{ key: 'waybillNo', type: 'input', label: '运单号', placeholder: '请输入运单号' },
{ key: 'warehouseId', type: 'input', label: '仓库', placeholder: '请输入仓库ID' },
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, loading: warehousesLoading.value } },
{ key: 'deliveryMethod', type: 'select', label: '派送方式', placeholder: '全部方式', options: [{ label: 'FBA自提', value: 'FBA_PICKUP' }, { label: '客户自提', value: 'CUSTOMER_PICKUP' }, { label: '卡派打托', value: 'TRUCK_PALLET' }, { label: '卡派地板', value: 'TRUCK_FLOOR' }, { label: '快递', value: 'EXPRESS' }, { label: '外配', value: 'OUTSOURCED' }, { label: '专车直送', value: 'DEDICATED_TRUCK' }, { label: '零担派送', value: 'LTL' }] },
{ key: 'vehiclePlate', type: 'input', label: '车牌号', placeholder: '请输入车牌号' },
{ key: 'driverName', type: 'input', label: '司机', placeholder: '请输入司机或车队' },
{ key: 'plannedAt', type: 'daterange', label: '预计派送时间' },
]
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'deliveryNo', prop: 'deliveryNo', label: '派送单号', minWidth: 180, fixed: 'left' },
@@ -71,7 +73,8 @@ function openStatus(row: DeliveryOrderRow) { statusRow.value = row; Object.assig
async function submitStatus() { if (!statusRow.value || !statusForm.status) return; statusSaving.value = true; try { const result = await updateDeliveryOrderStatus(statusRow.value.deliveryNo, statusRow.value.customerId, { status: statusForm.status, remark: statusForm.remark.trim() || undefined }); statusOpen.value = false; ElMessage.success(result.deliveryStatus === 'DELIVERED' ? '派送已完成,待回访记录已生成' : '派送状态已更新'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '派送状态更新失败').message) } finally { statusSaving.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
async function loadWarehouses() { if (!access.can(permission.warehouseList)) return; warehousesLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records } catch { warehouses.value = [] } finally { warehousesLoading.value = false } }
onMounted(() => { void load(); void loadWarehouses() })
</script>
<template><AdminPage><ListPageLayout aria-label="派送单列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="派送单筛选" preference-key="overseas-delivery-orders-query" @search="search" @reset="reset" /></template><template v-if="error" #state><LoadErrorState title="派送单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-delivery-orders" row-key="id" empty-text="暂无派送单" @update:page="load()" @update:page-size="page = 1; load()"><template #actions="{ row }"><el-button size="small" type="primary" plain @click="open(row as DeliveryOrderRow)"><Eye :size="15" />详情</el-button><el-button v-if="access.can(permission.deliveryStatus) && row.status !== 'RETURN_PENDING'" size="small" plain @click="openStatus(row as DeliveryOrderRow)"><Pencil :size="15" />编辑状态</el-button><el-button v-if="access.can(permission.deliveryReturn) && !['RETURN_PENDING','CANCELLED'].includes(String(row.status))" size="small" type="warning" plain @click="openReturn(row as DeliveryOrderRow)"><RotateCcw :size="15" />标记退货</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></section></ListPageLayout></AdminPage><AppDialog v-model="statusOpen" title="编辑派送状态" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving" @confirm="submitStatus"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="statusRow?.deliveryNo" readonly /></el-form-item><el-form-item label="派送状态" required><el-select v-model="statusForm.status"><el-option label="待派送" value="WAIT_DELIVERY" /><el-option label="派送中" value="DELIVERING" /><el-option label="派送完成" value="DELIVERED" /><el-option label="已拒签" value="REJECTED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="备注"><el-input v-model="statusForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item><el-alert v-if="statusForm.status === 'DELIVERED'" title="保存为派送完成后,将自动生成一条待回访记录。" type="info" :closable="false" /></el-form></AppDialog><AppDialog v-model="returnOpen" title="标记退货" width="min(720px, calc(100vw - 28px))" confirm-text="确认转入退货" :loading="returnLoading" :confirm-loading="returnSaving" @confirm="submitReturn"><el-form label-position="top"><el-form-item label="派送单号"><el-input :model-value="returnRow?.deliveryNo" readonly /></el-form-item><el-form-item label="对应入仓单" required><el-select v-model="returnForm.inboundOrderId" placeholder="请选择该派送单对应的入仓单"><el-option v-for="option in returnOptions" :key="String(option.inboundOrderId)" :value="String(option.inboundOrderId)" :label="`${option.inboundCode} · ${option.lastMileOrderNo || '无尾程单号'}`" /></el-select></el-form-item><el-form-item label="退货原因" required><el-input v-model="returnForm.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item><div class="return-grid"><el-form-item label="退货去向" required><el-select v-model="returnForm.returnDirection"><el-option label="退回海外仓" value="OVERSEAS_WAREHOUSE" /><el-option label="退回客户" value="CUSTOMER" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="退回物流单号"><el-input v-model="returnForm.returnLogisticsNo" placeholder="可安排物流后补充" /></el-form-item><el-form-item label="箱数"><el-input-number v-model="returnForm.packageCount" :min="1" /></el-form-item><el-form-item label="SKU(可自由填写)"><el-input v-model="returnForm.skuCode" /></el-form-item></div><el-alert title="确认后会生成异常退货单,并将派送单标记为退货处理中。" type="warning" :closable="false" /></el-form></AppDialog></template>
@@ -117,7 +117,7 @@ function toListItem(dto: DispatchSummaryDto): DispatchListItem {
carrierName: text(dto.carrierName),
orderNos: text(dto.orderNos),
route: text(dto.route),
overseasWarehouse: text(dto.overseasWarehouseName || dto.overseasWarehouseId),
overseasWarehouse: text(dto.overseasWarehouseName || '仓库名称待补充'),
departAt: text(dto.departAt),
fbaAppointment: appointmentLabel(dto.fbaAppointmentNo, dto.fbaAppointmentStatus),
vehicleAppointment: text(dto.vehicleAppointmentStatus),
@@ -88,7 +88,7 @@ onMounted(load)
<el-form-item label="箱数"><el-input-number v-model="form.packageCount" :min="1" :max="relation?.availablePackages || 999999" /></el-form-item>
<el-form-item label="SKU(可自由填写)"><el-input v-model="form.skuCode" maxlength="128" /></el-form-item>
</div><el-form-item label="异常原因" required><el-input v-model="form.reason" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item>
<el-alert v-if="relation" type="success" :closable="false"><template #title><span class="relation-alert-title">已关联:<OrderReferenceText :value="relation.lastMileOrderNo || '无尾程单'" /> / {{ relation.inboundCode || '无入仓单' }},客户 {{ relation.customerName || relation.customerId }}</span></template></el-alert>
<el-alert v-if="relation" type="success" :closable="false"><template #title><span class="relation-alert-title">已关联:<OrderReferenceText :value="relation.lastMileOrderNo || '无尾程单'" /> / {{ relation.inboundCode || '无入仓单' }},客户 {{ relation.customerName || '客户信息待补充' }}</span></template></el-alert>
<el-alert v-else-if="relationError" :title="relationError" type="error" :closable="false" />
</el-form>
</AppDialog>
@@ -3,32 +3,39 @@ import { computed, onMounted, ref } from 'vue'
import { ArrowLeft } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import InboundCodeRelationshipDrawer from '@/shared/components/InboundCodeRelationshipDrawer.vue'
import { listInventoryLedger } from '../api/inventory'; import { formatOverseasDateTime } from '../lastMilePresentation'; import type { InventoryTransactionListItem } from '../types'
const route = useRoute(); const router = useRouter(); const rows = ref<InventoryTransactionListItem[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ occurredDate: [], warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundOrderNo: '', outboundOrderNo: '' })
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore(); const rows = ref<InventoryTransactionListItem[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const relationshipOpen = ref(false); const relationshipInboundCode = ref('')
const filters = ref<QueryModel>({ occurredDate: [], customerName: '', warehouse: '', sku: '', businessType: '', businessOrderNo: '', inboundCode: '', inboundOrderNo: '', outboundOrderNo: '' })
const fields = computed<QueryField[]>(() => [
{ key: 'occurredDate', type: 'daterange', label: '发生日期', width: 260 },
{ key: 'customerName', type: 'input', label: '客户名称', placeholder: '请输入客户名称', width: 190 },
{ key: 'warehouse', type: 'input', label: '目的仓代码', placeholder: '请输入目的仓代码', width: 190 }, { key: 'sku', type: 'input', label: 'SKU编码', placeholder: '请输入 SKU 编码', width: 170 },
{ key: 'businessType', type: 'input', label: '业务类型', placeholder: '请输入业务类型代码', width: 170 }, { key: 'businessOrderNo', type: 'input', label: '业务订单号', placeholder: '请输入业务订单号', width: 180 },
{ key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 }, { key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '只读关联筛选', width: 180 },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 190 }, { key: 'inboundOrderNo', type: 'input', label: '入库单号', placeholder: '请输入入库单号', width: 180 }, { key: 'outboundOrderNo', type: 'input', label: '出库单号', placeholder: '只读关联筛选', width: 180 },
])
const columns: DataTableColumn[] = [
{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'owner', prop: 'owner', label: '客户', minWidth: 160 }, { key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 180 },
{ key: 'actions', label: '操作', width: 108, fixed: 'left', slotName: 'actions' },
{ key: 'occurredAt', prop: 'occurredAt', label: '发生时间', minWidth: 160 }, { key: 'owner', prop: 'owner', label: '客户', minWidth: 160 }, { key: 'warehouse', prop: 'warehouse', label: '目的仓编码', minWidth: 180 },
{ key: 'sku', prop: 'sku', label: 'SKU编码', minWidth: 150 }, { key: 'productName', prop: 'productName', label: '中文品名', minWidth: 180 }, { key: 'businessType', prop: 'businessType', label: '业务类型', width: 120 },
{ key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
{ key: 'businessOrderNo', prop: 'businessOrderNo', label: '业务订单号', minWidth: 170 }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 }, { key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 160 }, { key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 160 },
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库数量', width: 110, align: 'right' }, { key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库数量', width: 110, align: 'right' }, { key: 'balance', prop: 'balance', label: '结存', width: 100, align: 'right' },
{ key: 'operator', prop: 'operator', label: '操作人', minWidth: 120 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
]
function text(value: unknown) { return String(value ?? '').trim() || '—' }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ page: page.value, size: pageSize.value, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), owner: text(item.customerId), warehouse: text(item.destinationWarehouseCode), sku: text(item.skuCode), productName: '—', businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: text(item.operatorId), remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '进销存记录加载失败,请重试') } finally { loading.value = false } }
function openRelationship(row: InventoryTransactionListItem) { if (row.inboundCode === '—') return; relationshipInboundCode.value = row.inboundCode; relationshipOpen.value = true }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; loading.value = true; loadError.value = null; try { const result = await listInventoryLedger({ page: page.value, size: pageSize.value, customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }); rows.value = result.records.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), owner: text(item.customerName || '客户信息待补充'), warehouse: text(item.destinationWarehouseCode), sku: text(item.skuCode), productName: '—', businessType: text(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundCode: text(item.inboundCode), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: item.operatorId ? '操作人名称待补充' : '—', remark: text(item.remark) })); total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '进销存记录加载失败,请重试') } finally { loading.value = false } }
function search(model: QueryModel) { page.value = 1; void load(model) }; function reset(model: QueryModel) { page.value = 1; void load(model) }; function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load)
</script>
<template><AdminPage><ListPageLayout aria-label="进销存记录列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-else #state><p class="read-only-boundary">出库相关字段仅展示既有库存流水事实;本页面不提供出库单跳转、库存扣减或其他离仓操作。当前流水 DTO 未提供产品名称与海外仓名称,对应列显示空值。</p></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" empty-text="暂无进销存记录" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" empty-text="暂无进销存记录" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><el-button link type="primary" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<InboundCodeRelationshipDrawer v-if="relationshipOpen" v-model="relationshipOpen" :inbound-code="relationshipInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</ListPageLayout></AdminPage></template>
<style scoped>.read-only-boundary{margin:8px 2px 0;color:var(--cc-muted);font-size:12px;line-height:1.5}</style>
@@ -39,7 +39,7 @@ function text(value: unknown) { return String(value ?? '').trim() || '—' }
function yesNo(value: boolean | null | undefined) { return value == null ? '—' : value ? '是' : '否' }
function feeStatusLabel(value: string) { return ({ PENDING_REVIEW: '待审核', APPROVED: '审核通过', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
const referenceValue = computed(() => text(detail.value?.order.orderNo || orderId.value))
const referenceValue = computed(() => text(detail.value?.order.orderNo || '订单编码待补充'))
const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order
return [
@@ -36,5 +36,5 @@ onMounted(load)
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" filterable placeholder="请选择有可用库存的托盘"><el-option v-for="item in positionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || item.position.id} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog>
<AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" filterable placeholder="请选择有可用库存的托盘"><el-option v-for="item in positionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || '托盘号待补充'} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog>
</template>
@@ -57,12 +57,8 @@ describe('PalletsView', () => {
await wrapper.findAll('button').find((button) => button.text().includes('新增托盘'))!.trigger('click')
await flushPromises()
const warehouse = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择海外仓')!
const fbaWarehouse = wrapper.findAllComponents({ name: 'ElSelect' }).find((item) => item.props('placeholder') === '请选择所属 FBA 仓')!
const palletCode = wrapper.findAllComponents({ name: 'ElInput' }).find((item) => item.props('placeholder') === '请输入托盘编码')!
warehouse.vm.$emit('update:modelValue', '200')
fbaWarehouse.vm.$emit('update:modelValue', 'ABE3')
palletCode.vm.$emit('update:modelValue', 'PLT-LAX-ABE3')
const setupState = wrapper.vm.$.setupState as { form: { warehouseId: string; fbaWarehouseCode: string; code: string } }
Object.assign(setupState.form, { warehouseId: '200', fbaWarehouseCode: 'ABE3', code: 'PLT-LAX-ABE3' })
await flushPromises()
wrapper.findComponent(AppDialog).vm.$emit('confirm')
await flushPromises()
@@ -25,7 +25,7 @@ function status(value: unknown) {
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
return labels[String(value || '')] || text(value)
}
const referenceValue = computed(() => text(detail.value?.application.relabelNo || orderId.value))
const referenceValue = computed(() => text(detail.value?.application.relabelNo || '换标单号待补充'))
const fieldValues = computed<Record<string, string | number>>(() => {
const value = detail.value
const items = value?.items || []
@@ -354,8 +354,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
'actions', 'sku', 'productName', 'englishName', 'category', 'length', 'width', 'height', 'weight',
])
expect(columnKeys('InventoryTransactionsView.vue')).toEqual([
'occurredAt', 'owner', 'warehouse', 'sku', 'productName', 'businessType', 'businessOrderNo',
'inboundOrderNo', 'outboundOrderNo', 'inboundQuantity', 'outboundQuantity', 'balance', 'operator', 'remark',
'actions', 'occurredAt', 'owner', 'warehouse', 'sku', 'productName', 'businessType', 'businessOrderNo',
'inboundCode', 'inboundOrderNo', 'outboundOrderNo', 'inboundQuantity', 'outboundQuantity', 'balance', 'operator', 'remark',
])
})
})
@@ -25,10 +25,10 @@ describe('overseas warehouse mounted real API pages', () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ONT8', destinationWarehouseName: '洛杉矶仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', destinationWarehouseCode: 'LAX', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', customerName: '客户甲', destinationWarehouseCode: 'LAX', inboundCode: 'NYEXX2608300001', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] })
})
it('mounts inbound detail with a string-safe source order id', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('9007199254740995'); expect(wrapper.text()).toContain('洛杉矶仓') })
it('mounts inventory ledger as read-only facts', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('不提供出库单跳转') })
it('mounts inventory ledger as read-only business facts without exposing customer ids', async () => { const wrapper = mount(InventoryTransactionsView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(listInventoryLedger).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 10 })); expect(listInventoryLedger).not.toHaveBeenCalledWith(expect.objectContaining({ customerId: expect.anything() })); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('NYEXX2608300001'); expect(wrapper.text()).not.toContain('10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('10'); expect(wrapper.text()).toContain('不提供出库单跳转') })
it('renders the backend owner and complete short order number in last-mile detail', async () => { routing.route.params = { orderId: '9007199254740999' }; const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises(); expect(getLastMileOrder).toHaveBeenCalledWith('9007199254740999'); expect(wrapper.text()).toContain('华远贸易'); expect(wrapper.text()).toContain('LMO-260827-0001'); expect(wrapper.get('.order-reference-text').attributes('title')).toBe('LMO-260827-0001') })
})
+1
View File
@@ -176,6 +176,7 @@ export interface InventoryTransactionListItem {
productName: string
businessType: string
businessOrderNo: string
inboundCode: string
inboundOrderNo: string
/** Read-only inventory fact; it is not an outbound-workflow link. */
outboundOrderNo: string
@@ -9,7 +9,7 @@ function normalizeSupplier(raw: Supplier): Supplier {
return {
...raw,
id: String(raw.id), tenantId: String(raw.tenantId),
carrierCodes: [...raw.carrierCodes],
carrierCodes: [...(raw.carrierCodes || [])],
licenseFileId: id(raw.licenseFileId), createdBy: id(raw.createdBy), updatedBy: id(raw.updatedBy),
version: Number(raw.version), deleted: Boolean(raw.deleted),
}
@@ -44,7 +44,7 @@ onMounted(() => { void load() })
<el-table-column label="已拣" width="90"><template #default="{ row }">{{ row.pickedPackageCount ?? '—' }}</template></el-table-column>
<el-table-column label="未拣" width="90"><template #default="{ row }">{{ row.unpickedPackageCount ?? '—' }}</template></el-table-column>
<el-table-column label="状态" width="130"><template #default="{ row }"><StatusBadge :status="unifiedOrderFulfillmentStatusLabel(row.status, row.splitRole)" /></template></el-table-column>
<el-table-column label="柜次" min-width="150"><template #default="{ row }">{{ row.containerBookingId || '—' }}</template></el-table-column>
<el-table-column label="柜次" min-width="150"><template #default="{ row }">{{ row.containerBookingId ? '已关联柜次' : '—' }}</template></el-table-column>
</el-table>
</template>
<el-empty v-else description="暂无拆分履约数据" :image-size="64" />
@@ -92,9 +92,9 @@ describe('UnifiedOrdersView co-load group behavior', () => {
await wrapper.get('[aria-label="查看同柜组 CLG-9001 详情"]').trigger('click')
expect(wrapper.text()).toContain('2 票 · 3 个入仓片段 · 未锁定')
expect(wrapper.text()).toContain('13 箱')
expect(wrapper.text()).toContain('7003')
expect(wrapper.findAll('[data-copy="7003"]')).toHaveLength(1)
expect(wrapper.findAll('[data-copy="5001"]')).toHaveLength(2)
expect(wrapper.text()).toContain('入仓码待补充')
expect(wrapper.text()).not.toContain('7003')
expect(wrapper.text()).not.toContain('5001')
await wrapper.findAll('button').find((button) => button.text().includes('取消同柜组'))!.trigger('click')
await flushPromises()
expect(cancelPlatformCoLoadGroup).toHaveBeenCalledWith('9001', 0, '计划调整')
@@ -183,10 +183,12 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('coLoadGroupOrderCount(coLoadGroup(row as UnifiedOrderResource)!)')
expect(source).not.toContain('<span v-else>—</span></template>\n <template #orderStatus')
expect(source).toContain('v-for="member in coLoadDetailGroup.members"')
expect(source).toContain('入仓码 / 入仓 ID')
expect(source).toContain('订单编码 / ID')
expect(source).toContain('<CopyButton :value="member.inboundId"')
expect(source).toContain('<CopyButton :value="member.orderId"')
expect(source).toContain('<th>入仓码</th><th>订单编码</th>')
expect(source).toContain("visibleInboundCode(member.inboundId) || '入仓码待补充'")
expect(source).toContain("visibleOrderNo(member.orderId) || '订单编码待补充'")
expect(source).not.toContain('入仓码 / 入仓 ID')
expect(source).not.toContain('订单编码 / ID')
expect(source).not.toContain('客户 ID')
})
it('keeps editable query values separate from the applied list request', () => {
@@ -276,7 +278,8 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('<el-table :data="measurementDetail?.items || []" row-key="id"')
expect(source).toContain('<el-table-column label="箱号" prop="boxCode"')
expect(source).toContain("row.boxCode || '—'")
expect(source).toContain('`箱号 ${row.boxCode || row.itemNo || row.id} 的尺寸图片`')
expect(source).toContain("`箱号 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的尺寸图片`")
expect(source).not.toContain('row.boxCode || row.itemNo || row.id')
expect(source).toContain('label="尺寸图片"')
expect(source).toContain(':file-id="row.intelligentImageFileId"')
expect(source).toContain('openMeasurementEvidence(row.intelligentImageFileId')
@@ -31,12 +31,12 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import CopyButton from '@/shared/components/CopyButton.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import InboundCodeRelationshipDrawer from '@/shared/components/InboundCodeRelationshipDrawer.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import {
@@ -141,6 +141,8 @@ const noteContent = ref('')
const noteOrder = ref<UnifiedOrderResource | null>(null)
const relationOpen = ref(false)
const relationOrder = ref<UnifiedOrderResource | null>(null)
const inboundRelationshipOpen = ref(false)
const inboundRelationshipCode = ref('')
const measurementDetail = ref<InboundMeasurementDetail | null>(null)
const measurementLoading = ref(false)
const measurementError = ref<ApiErrorDetails | null>(null)
@@ -344,7 +346,7 @@ function can(permission: string) {
}
function customerName(row: UnifiedOrderResource) {
return row.customerName?.trim() || customers.value.find((item) => String(item.id) === String(row.tenantId))?.tenantName || `客户 ${row.tenantId}`
return row.customerName?.trim() || customers.value.find((item) => String(item.id) === String(row.tenantId))?.tenantName || '客户名称待补充'
}
function orderRelationLabel(row: UnifiedOrderResource) {
@@ -788,6 +790,13 @@ function handleOrderSelectionChange(rows: UnifiedOrderResource[]) {
selectedApprovalOrders.value = rows.filter(canApprove)
}
function openInboundRelationship(row: UnifiedOrderResource) {
const code = row.inboundCode?.trim()
if (!code) return
inboundRelationshipCode.value = code
inboundRelationshipOpen.value = true
}
function coLoadGroup(row: UnifiedOrderResource) {
return coLoadGroupsByOrderId.value.get(String(row.orderId)) || null
}
@@ -1197,6 +1206,7 @@ onBeforeUnmount(() => {
<UnifiedOrderWorkflowPanel
:tenant-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:inbound-code="detail.summary.inboundCode"
:order-id="String(detail.summary.orderId)"
:main-status="detail.summary.mainStatus"
:inbound-version="detail.summary.inboundVersion"
@@ -1242,9 +1252,9 @@ onBeforeUnmount(() => {
:customer-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:file-id="row.intelligentImageFileId"
:alt="`箱号 ${row.boxCode || row.itemNo || row.id} 的尺寸图片`"
:alt="`箱号 ${row.boxCode || row.itemNo || '箱规编码待补充'} 的尺寸图片`"
empty-text="未绑定尺寸图片"
@open="openMeasurementEvidence(row.intelligentImageFileId, `尺寸图片 · ${row.boxCode || row.itemNo || row.id}`)"
@open="openMeasurementEvidence(row.intelligentImageFileId, `尺寸图片 · ${row.boxCode || row.itemNo || '箱规编码待补充'}`)"
/>
</template>
</el-table-column>
@@ -1354,6 +1364,7 @@ onBeforeUnmount(() => {
<template #inboundCode="{ row }">
<div class="inbound-relation-cell">
<span>{{ (row as UnifiedOrderResource).inboundCode || '—' }}</span>
<el-button v-if="(row as UnifiedOrderResource).inboundCode" class="order-relation-link" link type="primary" aria-label="查看入仓码关联数据" @click="openInboundRelationship(row as UnifiedOrderResource)"><Link2 :size="14" />关联数据</el-button>
<el-button
v-if="(row as UnifiedOrderResource).relation"
class="order-relation-link"
@@ -1364,12 +1375,12 @@ onBeforeUnmount(() => {
><GitBranch :size="14" />{{ orderRelationLabel(row as UnifiedOrderResource) }}</el-button>
</div>
</template>
<template #orderNo="{ row }"><div class="order-code-cell"><strong>{{ (row as UnifiedOrderResource).orderNo || (row as UnifiedOrderResource).orderId }}</strong><button v-if="coLoadGroup(row as UnifiedOrderResource)" type="button" class="coload-tag-trigger" :aria-label="`查看同柜组 ${coLoadGroup(row as UnifiedOrderResource)?.groupNo} 详情`" @click.stop="openCoLoadDetail(coLoadGroup(row as UnifiedOrderResource)!)"><el-tag size="small" :type="coLoadGroup(row as UnifiedOrderResource)?.status === 'ACTIVE' ? 'warning' : 'info'">{{ coLoadGroup(row as UnifiedOrderResource)?.groupNo }} · {{ coLoadGroupOrderCount(coLoadGroup(row as UnifiedOrderResource)!) }} 票 · {{ coLoadGroupStatusLabel(coLoadGroup(row as UnifiedOrderResource)!) }}</el-tag></button></div></template>
<template #orderNo="{ row }"><div class="order-code-cell"><strong>{{ (row as UnifiedOrderResource).orderNo || '订单编码待补充' }}</strong><button v-if="coLoadGroup(row as UnifiedOrderResource)" type="button" class="coload-tag-trigger" :aria-label="`查看同柜组 ${coLoadGroup(row as UnifiedOrderResource)?.groupNo} 详情`" @click.stop="openCoLoadDetail(coLoadGroup(row as UnifiedOrderResource)!)"><el-tag size="small" :type="coLoadGroup(row as UnifiedOrderResource)?.status === 'ACTIVE' ? 'warning' : 'info'">{{ coLoadGroup(row as UnifiedOrderResource)?.groupNo }} · {{ coLoadGroupOrderCount(coLoadGroup(row as UnifiedOrderResource)!) }} 票 · {{ coLoadGroupStatusLabel(coLoadGroup(row as UnifiedOrderResource)!) }}</el-tag></button></div></template>
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
<template #field="{ row, column }"><LineClampText v-if="['customerRemark', 'latestInternalNote'].includes(String(column.property))" :content="displayField(row as UnifiedOrderResource, column.property)" :lines="2" /><template v-else>{{ displayField(row as UnifiedOrderResource, column.property) }}</template></template>
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong><span>客户 ID {{ (row as UnifiedOrderResource).tenantId }}</span></div></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong></div></template>
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
<template #destinationWarehouseName="{ row }">{{ (row as UnifiedOrderResource).destinationWarehouseName || '—' }}</template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).transport?.containerNo || unifiedOrderLoadingStatusLabel((row as UnifiedOrderResource).transport?.loadingStatus) }}</strong><span v-if="(row as UnifiedOrderResource).transport?.vesselName">{{ (row as UnifiedOrderResource).transport?.vesselName }} · {{ (row as UnifiedOrderResource).transport?.voyageNo || '—' }}</span></div></template>
@@ -1384,7 +1395,7 @@ onBeforeUnmount(() => {
<SmartLoadingDialog v-model="smartLoadingOpen" />
<AppDialog v-model="coLoadCreateOpen" title="设为同柜不可拆组" confirm-text="确认建组" :confirm-loading="coLoadSaving" :confirm-disabled="selectedOrders.length < 2" @confirm="createCoLoadGroup">
<el-alert type="warning" :closable="false" show-icon :title="`所选 ${selectedOrders.length} 票须属于同一国内仓、同一渠道,并全量进入同一柜;目的仓或目的港可以不同。资格由服务端按完整入仓片段校验。`" />
<div class="coload-selection"><span v-for="row in selectedOrders" :key="String(row.orderId)">{{ row.orderNo || row.inboundCode || row.orderId }}</span></div>
<div class="coload-selection"><span v-for="row in selectedOrders" :key="String(row.orderId)">{{ row.orderNo || row.inboundCode || '业务编码待补充' }}</span></div>
<div v-if="coLoadError" class="coload-error" role="alert"><span>{{ coLoadError.message }}</span><ApiErrorMeta :details="coLoadError" /></div>
</AppDialog>
<AppDialog :model-value="Boolean(coLoadDetailGroup)" :title="coLoadDetailGroup ? `同柜组 ${coLoadDetailGroup.groupNo}` : '同柜组详情'" :show-confirm="false" cancel-text="关闭" width="min(760px, calc(100vw - 32px))" @update:model-value="coLoadDetailGroup = null">
@@ -1392,8 +1403,8 @@ onBeforeUnmount(() => {
<div class="coload-detail-summary"><el-tag size="small" :type="coLoadDetailGroup.status === 'ACTIVE' ? 'warning' : 'info'">{{ coLoadGroupOrderCount(coLoadDetailGroup) }} 票 · {{ coLoadDetailGroup.members.length }} 个入仓片段 · {{ coLoadGroupStatusLabel(coLoadDetailGroup) }}</el-tag><span>共 {{ coLoadDetailGroup.fullPackageCount }} 箱</span></div>
<div class="coload-detail-scroll">
<table class="coload-detail-table">
<thead><tr><th>入仓码 / 入仓 ID</th><th>订单编码 / ID</th><th>箱数</th></tr></thead>
<tbody><tr v-for="member in coLoadDetailGroup.members" :key="`${member.orderId}:${member.inboundId}`"><td><strong>{{ visibleInboundCode(member.inboundId) || member.inboundId }}</strong><span v-if="visibleInboundCode(member.inboundId)">ID {{ member.inboundId }}</span><CopyButton :value="member.inboundId" label="入仓 ID" button-text="复制 ID" size="small" text /></td><td><strong>{{ visibleOrderNo(member.orderId) || member.orderId }}</strong><span v-if="visibleOrderNo(member.orderId)">ID {{ member.orderId }}</span><CopyButton :value="member.orderId" label="订单 ID" button-text="复制 ID" size="small" text /></td><td>{{ member.fullPackageCount }} 箱</td></tr></tbody>
<thead><tr><th>入仓码</th><th>订单编码</th><th>箱数</th></tr></thead>
<tbody><tr v-for="member in coLoadDetailGroup.members" :key="`${member.orderId}:${member.inboundId}`"><td><strong>{{ visibleInboundCode(member.inboundId) || '入仓码待补充' }}</strong></td><td><strong>{{ visibleOrderNo(member.orderId) || '订单编码待补充' }}</strong></td><td>{{ member.fullPackageCount }} 箱</td></tr></tbody>
</table>
</div>
</template>
@@ -1408,6 +1419,7 @@ onBeforeUnmount(() => {
:inbound-code="relationOrder.inboundCode"
/>
</AppDrawer>
<InboundCodeRelationshipDrawer v-if="inboundRelationshipOpen" v-model="inboundRelationshipOpen" :inbound-code="inboundRelationshipCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
<AppDialog v-model="noteOpen" title="添加内部备注" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
<el-input v-model="noteContent" type="textarea" :rows="5" maxlength="2000" show-word-limit placeholder="请输入仅平台内部可见的备注" aria-label="内部备注内容" />
@@ -275,7 +275,7 @@ onMounted(() => { void load() })
<template>
<section class="smart-loading" aria-label="智能排柜工作台">
<header class="smart-loading__heading">
<div class="smart-loading__context"><span>当前柜次</span><strong>{{ container?.cabinetNo || container?.containerNo || `柜次 ${bookingId}` }}</strong><small>{{ container?.warehouseName || '仓库待加载' }}</small></div>
<div class="smart-loading__context"><span>当前柜次</span><strong>{{ container?.cabinetNo || container?.containerNo || '柜次信息待补充' }}</strong><small>{{ container?.warehouseName || '仓库待加载' }}</small></div>
<div class="smart-loading__actions">
<el-button circle :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button>
<el-button v-if="!plan" type="primary" :disabled="!canCreate || loading" :loading="action === 'create'" @click="createPlan"><Plus :size="15" />创建装柜草稿</el-button>
@@ -45,6 +45,7 @@ function mountPanel(permissions: string[], mainStatus: 'WAIT_SPLIT' | 'LOADING'
return mount(UnifiedOrderWorkflowPanel, {
props: {
tenantId: '2086085512165634052', inboundId: '2086085512165634051', orderId: '2086085512165634053',
inboundCode: 'NYEXX2608300001',
mainStatus, inboundVersion: 4,
},
global: { plugins: [pinia, ElementPlus] },
@@ -61,6 +62,14 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
vi.mocked(voidEvidence).mockReset()
})
it('uses the inbound code as the visible workflow identifier', async () => {
const wrapper = mountPanel([])
await flushPromises()
expect(wrapper.text()).toContain('入库作业')
expect(wrapper.text()).toContain('NYEXX2608300001')
expect(wrapper.text()).not.toContain('#2086085512165634051')
})
it('keeps workflow business actions as outlined semantic buttons with icons', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue'), 'utf8')
expect(source).toContain('size="small" plain type="primary"')
@@ -38,6 +38,7 @@ import type { UnifiedOrderMainStatus } from '@/modules/unified-orders/api/unifie
const props = withDefaults(defineProps<{
tenantId: string
inboundId: string | number
inboundCode?: string | null
orderId?: string | number | null
mainStatus: UnifiedOrderMainStatus
inboundVersion?: number | null
@@ -411,7 +412,7 @@ onMounted(() => { void refresh() })
<template>
<section class="workflow-panel" aria-label="订单后半程作业">
<div class="workflow-heading" aria-label="订单作业上下文">
<div class="workflow-context"><span>入库作业</span><strong>#{{ props.inboundId }}</strong></div>
<div class="workflow-context"><span>入库作业</span><strong>{{ props.inboundCode || '入仓码待补充' }}</strong></div>
<el-tooltip content="刷新作业数据" placement="top"><el-button text :loading="loading" aria-label="刷新作业数据" @click="refresh"><RefreshCw :size="15" /></el-button></el-tooltip>
</div>
@@ -507,7 +508,7 @@ onMounted(() => { void refresh() })
</div>
<div v-if="canEvidenceView" class="evidence-files">
<div v-for="item in activeEvidences(entry.type)" :key="String(item.id)" class="evidence-file">
<span>{{ item.fileName || `证据 #${item.id}` }}<template v-if="item.sealNo"> · 锁码 {{ item.sealNo }}</template></span>
<span>{{ item.fileName || '证据文件名待补充' }}<template v-if="item.sealNo"> · 锁码 {{ item.sealNo }}</template></span>
<div class="evidence-file__actions">
<el-button size="small" plain type="primary" :disabled="Boolean(busy)" @click="previewEvidence(item)"><Eye :size="14" />查看</el-button>
<el-button v-if="canEvidence" size="small" plain type="danger" :disabled="Boolean(busy)" @click="markEvidenceVoided(item)"><Trash2 :size="14" />作废</el-button>
@@ -47,6 +47,7 @@ onMounted(() => { void load() })
v-else-if="summary && summary.inboundId"
:tenant-id="tenantId"
:inbound-id="summary.inboundId"
:inbound-code="summary.inboundCode"
:order-id="summary.orderId"
:main-status="summary.mainStatus"
:inbound-version="summary.inboundVersion"
@@ -0,0 +1,27 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from './http'
import { overseasHttp } from './overseasHttp'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, getPlatformInboundCodeAssociation } from './inboundCodeRelations'
vi.mock('./http', async (original) => ({ ...(await original<typeof import('./http')>()), http: { get: vi.fn() } }))
vi.mock('./overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
describe('inbound-code relationship APIs', () => {
beforeEach(() => vi.clearAllMocks())
it('uses the frozen domestic and overseas read models and encodes the business code', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', orderNo: 'ORD-1' } })
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', found: false, taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [] } })
await getPlatformInboundCodeAssociation('IN/01')
await getLoadingAssociationSummary('IN/01')
await getOverseasInboundCodeRelationship('IN/01')
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/inbound-code-associations/IN%2F01')
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/loading-associations/summary', { params: { inboundCode: 'IN/01' } })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/inbound-code-relationships/IN%2F01')
})
it('does not accept customer or internal ids as lookup parameters', () => {
const source = `${getPlatformInboundCodeAssociation}${getLoadingAssociationSummary}${getOverseasInboundCodeRelationship}`
expect(source).not.toMatch(/customerId|inboundId|orderId|warehouseId/)
})
})
+67
View File
@@ -0,0 +1,67 @@
import { http, unwrapApiResponse, type ApiEnvelope } from './http'
import { overseasHttp } from './overseasHttp'
export interface PlatformInboundCodeAssociation {
inboundCode: string
orderNo: string
orderStatus?: string | null
orderId: string
customerId: string
customerName: string
destinationType?: string | null
destinationCountryCode?: string | null
destinationWarehouseId?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
}
export interface OverseasInboundCodeRelationship {
inboundCode: string
found: boolean
customer: { customerId: string; customerName: string } | null
overseasWarehouse: { warehouseId: string; warehouseCode: string; warehouseName: string } | null
overseasReceivingStatus?: string | null
taillegOrders: Array<{ orderNo: string; orderStatus?: string | null }>
inboundPlans: Array<{ planNo: string; planStatus?: string | null; receivingStatus?: string | null }>
palletCodes: string[]
inventory: Array<{ skuCode: string; productNameZh?: string | null; productNameEn?: string | null; packageCount: number }>
}
export interface LoadingAssociationSummary {
inboundId: string
inboundCode: string
orderNo?: string | null
loadingPlanId?: string | null
loadingPlanNo?: string | null
containerBookingId?: string | null
cabinetNo?: string | null
containerNo?: string | null
loadingStatus?: string | null
taskId?: string | null
pdaTaskStatus?: string | null
coLoadGroupId?: string | null
coLoadGroupNo?: string | null
items: Array<{ itemId: string }>
}
export async function getPlatformInboundCodeAssociation(inboundCode: string) {
const response = await http.get<ApiEnvelope<PlatformInboundCodeAssociation>, ApiEnvelope<PlatformInboundCodeAssociation>>(
`/platform/v1/orders/first-leg/inbound-code-associations/${encodeURIComponent(inboundCode)}`,
)
return unwrapApiResponse(response)
}
export async function getLoadingAssociationSummary(inboundCode: string) {
const response = await http.get<ApiEnvelope<LoadingAssociationSummary>, ApiEnvelope<LoadingAssociationSummary>>(
'/platform/v1/first-leg/loading-associations/summary',
{ params: { inboundCode } },
)
return unwrapApiResponse(response)
}
export async function getOverseasInboundCodeRelationship(inboundCode: string) {
const response = await overseasHttp.get<ApiEnvelope<OverseasInboundCodeRelationship>, ApiEnvelope<OverseasInboundCodeRelationship>>(
`/platform/v1/last-leg/inventory/inbound-code-relationships/${encodeURIComponent(inboundCode)}`,
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/InboundCodeRelationshipDrawer.vue'), 'utf8')
describe('InboundCodeRelationshipDrawer contract', () => {
it('loads only permission-authorized server read models and keeps failures isolated', () => {
expect(source).toContain('props.canReadBase')
expect(source).toContain('props.canReadLoading')
expect(source).toContain('props.canReadOverseas')
expect(source).toContain('getPlatformInboundCodeAssociation(code)')
expect(source).toContain('getLoadingAssociationSummary(code)')
expect(source).toContain('getOverseasInboundCodeRelationship(code)')
expect(source).toContain('await Promise.all(tasks)')
expect(source).toContain('<ApiErrorMeta :details="baseError"')
expect(source).toContain('<ApiErrorMeta :details="overseasError"')
expect(source).toContain('服务端未找到该入仓码的海外关联数据')
expect(source).not.toMatch(/listPlatform|customerId\)|warehouseId\)/)
})
it('shows business numbers and names without rendering internal ids', () => {
expect(source).toContain('baseAssociation.inboundCode')
expect(source).toContain('baseAssociation.orderNo')
expect(source).toContain('baseAssociation.customerName')
expect(source).toContain('baseAssociation.destinationWarehouseName')
expect(source).not.toContain('{{ baseAssociation.orderId')
expect(source).not.toContain('{{ baseAssociation.customerId')
expect(source).not.toContain('{{ baseAssociation.destinationWarehouseId')
expect(source).toContain('text-overflow:ellipsis')
expect(source).toContain(':title="display(baseAssociation.inboundCode)"')
})
})
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, getPlatformInboundCodeAssociation, type LoadingAssociationSummary, type OverseasInboundCodeRelationship, type PlatformInboundCodeAssociation } from '@/shared/api/inboundCodeRelations'
import ApiErrorMeta from './ApiErrorMeta.vue'
import AppDrawer from './AppDrawer.vue'
import LoadErrorState from './LoadErrorState.vue'
import StatusBadge from './StatusBadge.vue'
const props = defineProps<{ modelValue: boolean; inboundCode: string; canReadBase: boolean; canReadLoading: boolean; canReadOverseas: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const baseAssociation = ref<PlatformInboundCodeAssociation | null>(null)
const overseas = ref<OverseasInboundCodeRelationship | null>(null)
const loadingAssociation = ref<LoadingAssociationSummary | null>(null)
const baseError = ref<ApiErrorDetails | null>(null)
const overseasError = ref<ApiErrorDetails | null>(null)
const loadingAssociationError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
let serial = 0
const canReadBase = computed(() => props.canReadBase)
const canReadLoading = computed(() => props.canReadLoading)
const canReadOverseas = computed(() => props.canReadOverseas)
const normalizedCode = computed(() => props.inboundCode.trim())
function display(value: unknown, fallback = '—') { return String(value ?? '').trim() || fallback }
function close(value: boolean) { emit('update:modelValue', value) }
async function load() {
const code = normalizedCode.value
const request = ++serial
baseAssociation.value = null; overseas.value = null; loadingAssociation.value = null; baseError.value = null; overseasError.value = null; loadingAssociationError.value = null
if (!code || (!canReadBase.value && !canReadLoading.value && !canReadOverseas.value)) return
loading.value = true
const tasks: Promise<void>[] = []
if (canReadBase.value) tasks.push(getPlatformInboundCodeAssociation(code).then((value) => { if (request === serial) baseAssociation.value = value }).catch((cause) => { if (request === serial) baseError.value = getApiErrorDetails(cause, '订单基础关联数据加载失败') }))
if (canReadLoading.value) tasks.push(getLoadingAssociationSummary(code).then((value) => { if (request === serial) loadingAssociation.value = value }).catch((cause) => { if (request === serial) loadingAssociationError.value = getApiErrorDetails(cause, '装柜与PDA关联数据加载失败') }))
if (canReadOverseas.value) tasks.push(getOverseasInboundCodeRelationship(code).then((value) => { if (request === serial) overseas.value = value }).catch((cause) => { if (request === serial) overseasError.value = getApiErrorDetails(cause, '海外入仓关联数据加载失败') }))
await Promise.all(tasks)
if (request === serial) loading.value = false
}
watch(() => [props.modelValue, props.inboundCode, props.canReadBase, props.canReadLoading, props.canReadOverseas] as const, ([open]) => {
if (open) void load()
else serial += 1
})
</script>
<template>
<AppDrawer :model-value="modelValue" :title="`入仓码关联数据 · ${normalizedCode || '未提供'}`" size="min(860px, 94vw)" :loading="loading" :show-footer="false" destroy-on-close @update:model-value="close">
<el-alert v-if="!normalizedCode" type="warning" :closable="false" show-icon title="缺少入仓码,已停止查询关联数据。" />
<el-alert v-else-if="!canReadBase && !canReadLoading && !canReadOverseas" type="warning" :closable="false" show-icon title="当前账号无入仓关联数据查看权限。" />
<div v-else class="relationship-sections">
<section v-if="canReadBase" class="relationship-section" aria-label="订单基础关联">
<header><h3>订单基础信息</h3><StatusBadge v-if="baseAssociation?.orderStatus" :status="baseAssociation.orderStatus" /></header>
<LoadErrorState v-if="baseError" title="订单基础关联加载失败" :message="baseError.message" @retry="load"><template #details><ApiErrorMeta :details="baseError" /></template></LoadErrorState>
<dl v-else-if="baseAssociation" class="compact-facts">
<div><dt>入仓码</dt><dd :title="display(baseAssociation.inboundCode)">{{ display(baseAssociation.inboundCode) }}</dd></div><div><dt>订单编码</dt><dd :title="display(baseAssociation.orderNo, '订单编码待补充')">{{ display(baseAssociation.orderNo, '订单编码待补充') }}</dd></div>
<div><dt>客户</dt><dd :title="display(baseAssociation.customerName, '客户名称待补充')">{{ display(baseAssociation.customerName, '客户名称待补充') }}</dd></div><div><dt>目的类型</dt><dd>{{ display(baseAssociation.destinationType) }}</dd></div>
<div><dt>目的国家</dt><dd>{{ display(baseAssociation.destinationCountryCode) }}</dd></div><div><dt>目的仓</dt><dd>{{ display(baseAssociation.destinationWarehouseName, '目的仓名称待补充') }}<small v-if="baseAssociation.destinationWarehouseCode">{{ baseAssociation.destinationWarehouseCode }}</small></dd></div>
</dl>
</section>
<section v-if="canReadLoading" class="relationship-section" aria-label="装柜与PDA关联">
<header><h3>装柜与PDA</h3><StatusBadge v-if="loadingAssociation?.loadingStatus" :status="loadingAssociation.loadingStatus" /></header>
<LoadErrorState v-if="loadingAssociationError" title="装柜关联加载失败" :message="loadingAssociationError.message" @retry="load"><template #details><ApiErrorMeta :details="loadingAssociationError" /></template></LoadErrorState>
<dl v-else-if="loadingAssociation" class="compact-facts">
<div><dt>订单编码</dt><dd :title="display(loadingAssociation.orderNo, '订单编码待补充')">{{ display(loadingAssociation.orderNo, '订单编码待补充') }}</dd></div><div><dt>装柜计划</dt><dd :title="display(loadingAssociation.loadingPlanNo, '尚未进入装柜')">{{ display(loadingAssociation.loadingPlanNo, '尚未进入装柜') }}</dd></div>
<div><dt>柜号</dt><dd :title="display(loadingAssociation.containerNo || loadingAssociation.cabinetNo, '尚未关联柜号')">{{ display(loadingAssociation.containerNo || loadingAssociation.cabinetNo, '尚未关联柜号') }}</dd></div><div><dt>同柜组</dt><dd :title="display(loadingAssociation.coLoadGroupNo)">{{ display(loadingAssociation.coLoadGroupNo) }}</dd></div>
<div><dt>装柜状态</dt><dd>{{ display(loadingAssociation.loadingStatus) }}</dd></div><div><dt>PDA任务状态</dt><dd>{{ display(loadingAssociation.pdaTaskStatus, '尚未生成PDA任务') }}</dd></div>
</dl>
</section>
<section v-if="canReadOverseas" class="relationship-section" aria-label="海外入仓关联">
<header><h3>海外入仓与库存</h3><StatusBadge v-if="overseas?.overseasReceivingStatus" :status="overseas.overseasReceivingStatus" /></header>
<LoadErrorState v-if="overseasError" title="海外关联加载失败" :message="overseasError.message" @retry="load"><template #details><ApiErrorMeta :details="overseasError" /></template></LoadErrorState>
<el-empty v-else-if="overseas && !overseas.found" description="服务端未找到该入仓码的海外关联数据" :image-size="56" />
<template v-else-if="overseas">
<dl class="compact-facts"><div><dt>客户</dt><dd>{{ display(overseas.customer?.customerName, '客户名称待补充') }}</dd></div><div><dt>海外仓</dt><dd>{{ display(overseas.overseasWarehouse?.warehouseName, '仓库名称待补充') }}<small v-if="overseas.overseasWarehouse?.warehouseCode">{{ overseas.overseasWarehouse.warehouseCode }}</small></dd></div></dl>
<div class="compact-list"><strong>尾程订单</strong><span :title="overseas.taillegOrders.map((item) => item.orderNo).join('、')">{{ overseas.taillegOrders.map((item) => `${item.orderNo}${item.orderStatus ? `(${item.orderStatus})` : ''}`).filter(Boolean).join('、') || '—' }}</span></div>
<div class="compact-list"><strong>入库计划</strong><span :title="overseas.inboundPlans.map((item) => item.planNo).join('、')">{{ overseas.inboundPlans.map((item) => `${item.planNo}${item.planStatus ? `(${item.planStatus})` : ''}${item.receivingStatus ? ` · ${item.receivingStatus}` : ''}`).join('、') || '—' }}</span></div>
<div class="compact-list"><strong>托盘号</strong><span :title="overseas.palletCodes.join('、')">{{ overseas.palletCodes.join('、') || '—' }}</span></div>
<el-table v-if="overseas.inventory.length" :data="overseas.inventory" size="small" border class="inventory-table">
<el-table-column prop="skuCode" label="SKU编码" min-width="150" /><el-table-column label="产品名称" min-width="190"><template #default="{ row }">{{ display(row.productNameZh || row.productNameEn) }}</template></el-table-column><el-table-column prop="packageCount" label="库存箱数" width="110" align="right" />
</el-table>
</template>
</section>
</div>
</AppDrawer>
</template>
<style scoped>
.relationship-sections{display:grid;gap:14px}.relationship-section{border:1px solid var(--cc-border);border-radius:10px;padding:14px;background:var(--cc-surface)}.relationship-section>header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.relationship-section h3{margin:0;font-size:15px}.compact-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;margin:0;background:var(--cc-border)}.compact-facts>div{display:grid;grid-template-columns:92px minmax(0,1fr);gap:8px;padding:9px 10px;background:var(--cc-surface)}.compact-facts dt{color:var(--cc-muted)}.compact-facts dd{min-width:0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.compact-facts small{display:block;overflow:hidden;color:var(--cc-muted);margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.compact-list{display:grid;grid-template-columns:92px minmax(0,1fr);gap:8px;padding:9px 10px;border-top:1px solid var(--cc-border)}.compact-list span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.inventory-table{margin-top:10px}@media(max-width:720px){.compact-facts{grid-template-columns:1fr}}
</style>