This commit is contained in:
2026-10-14 11:43:32 +08:00
parent 6a905419fc
commit 0830d7a3a3
10 changed files with 177 additions and 12 deletions
+16
View File
@@ -0,0 +1,16 @@
<!doctype html><meta charset="utf-8"><title>本地合箱接口验收</title>
<h1>本地合箱接口验收</h1><p>复用当前平台会话,只调用正式业务接口。</p>
<label>请求 JSON<textarea id="request" aria-label="请求 JSON" style="display:block;width:95%;height:200px"></textarea></label>
<button id="run">执行验收请求</button><pre id="result" style="white-space:pre-wrap"></pre>
<script type="module">
import {http} from '/src/shared/api/http.ts'
import {createPinia} from 'pinia'
import {useAuthStore} from '/src/core/auth/auth.ts'
const auth=useAuthStore(createPinia()); await auth.restoreSession()
document.querySelector('#run').onclick=async()=>{
const out=document.querySelector('#result');out.textContent='执行中';
try {const requests=JSON.parse(document.querySelector('#request').value);const results=[];
for(const request of Array.isArray(requests)?requests:[requests]) results.push(await http.request(request));
out.textContent=JSON.stringify(results,null,2)}catch(e){out.textContent=JSON.stringify({error:e.message,response:e.response?.data},null,2)}
}
</script>
@@ -0,0 +1,15 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { downloadCargoDiscardPhoto, getCargoDiscard } from './cargoDiscards'
vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
beforeEach(() => vi.mocked(http.get).mockReset())
it('reads submitted photos from detail and downloads a blob in the explicit customer scope', async () => {
const photos = [{ fileId: '101', palletNo: 'PALLET-A', fileName: 'discard.png', createdAt: '2026-10-14T10:00:00', submittedAt: '2026-10-14T10:01:00' }]
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { summary: { discardNo: 'CD/1', customerId: '7' }, photos, items: [], histories: [] } })
expect((await getCargoDiscard('CD/1', '7')).photos).toEqual(photos)
const blob = new Blob(['image'], { type: 'image/png' })
vi.mocked(http.get).mockResolvedValueOnce(blob)
expect(await downloadCargoDiscardPhoto('CD/1', '101', '7')).toBe(blob)
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/cargo-discards/CD%2F1/photos/files/101/download', { params: { customerId: '7' }, responseType: 'blob' })
})
@@ -4,7 +4,8 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
export interface CargoDiscardItem { id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
export interface CargoDiscardDetail { summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] }
export interface CargoDiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
export interface CargoDiscardDetail { photos: CargoDiscardPhoto[]; summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] }
export interface CargoDiscardCandidate { orderId?: string | null; sourceType: string; returnCartonId?: string | null; cartonMark?: string | null; inboundCode?: string | null; inboundOrderNo?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; availablePackages?: number; skus: { skuCode: string; productName?: string | null; availablePackages?: number }[] }
export async function listCargoDiscards(query: CargoDiscardQuery) {
@@ -24,3 +25,7 @@ export async function completeCargoDiscard(discardNo: string, customerId?: strin
return unwrapApiResponse(response)
}
export function downloadCargoDiscardPhoto(discardNo: string, fileId: string, customerId: string) {
return http.get<Blob, Blob>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/photos/files/${encodeURIComponent(fileId)}/download`, { params: { customerId }, responseType: 'blob' })
}
@@ -360,6 +360,8 @@ export interface LastMileOrderDetailDto {
items?: Array<{ skuCode: string; productNameZh?: string; expectedQuantity: number; receivedQuantity: number }>
receipts: Array<{ receiptNo: string; unitType: string; unitCode?: string; palletCode?: string; receivedQuantity: number; boxCount: number; signedAt: string; length?: number | null; width?: number | null; height?: number | null; weight?: number | null }>
cartons?: Array<{ receiptNo: string; palletCode?: string; cartonMark: string; countStatus: string; countedAt?: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }>; length?: number | null; width?: number | null; height?: number | null; weight?: number | null }>
packingDetails?: import('./returnInventory').ReturnCartonMergeHistory[]
currentCartons?: Array<{ carton: import('./returnInventory').ReturnCartonDetail; ownership: Array<{ returnNo: string; skuCode: string; quantity: number }>; orderQuantity: number; labelStatus: string; canRelabel: boolean; blockReason?: string }>
} | null
order: LastMileOrderDto
manualNodes?: ManualNodesResponse['nodes']
@@ -160,6 +160,10 @@ export interface ReturnPackingPlanDto extends Omit<ReturnPackingPlanPayload, 're
pushedAt?: string | null
completedAt?: string | null
resultCartons?: import('./returnInventory').ReturnCartonDetail[]
sources?: Array<{ cartonMark: string; sourceReturnNo?: string; participation: 'FULL' | 'PARTIAL' | 'UNKNOWN'; items: ReturnPackingPlanBoxItem[]; originalItems: Array<{ skuCode: string; productNameZh?: string; quantity: number }> }>
completionCartons?: import('./returnInventory').ReturnCartonLabel[]
transfers?: import('./returnInventory').ReturnCartonMergeHistory[]
completedBy?: string
}
export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) {
@@ -1,6 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -9,24 +9,42 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { overseasWarehousePermissions as permission } from '../permissions'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: 'PDA处理中', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '总箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '客户已确认', REJECTED: '已驳回', CANCELLED: '已取消' }
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待仓库确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
const photoUrls = reactive<Record<string, string>>({}); const photoLoading = reactive<Record<string, boolean>>({}); const photoErrors = reactive<Record<string, boolean>>({})
const photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean))
let photoGeneration = 0
function clearPhotos() { photoGeneration++; Object.values(photoUrls).forEach(URL.revokeObjectURL); for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key]) }
async function loadPhoto(fileId: string) {
const current = detail.value; const generation = photoGeneration
if (!current || photoLoading[fileId]) return
photoLoading[fileId] = true; photoErrors[fileId] = false
try { const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, String(current.summary.customerId)); if (photoGeneration !== generation || detail.value !== current || !detailOpen.value) return; if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId]); photoUrls[fileId] = URL.createObjectURL(blob) }
catch { if (generation === photoGeneration) photoErrors[fileId] = true }
finally { if (generation === photoGeneration) photoLoading[fileId] = false }
}
watch(detailOpen, value => { if (!value) clearPhotos() })
onBeforeUnmount(clearPhotos)
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; try { const result = await listCargoDiscards({ page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }); rows.value = result.records; total.value = result.total } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单列表加载失败').message) } finally { loading.value = false } }
async function openDetail(row: CargoDiscardSummary) { try { detail.value = await getCargoDiscard(row.discardNo, row.customerId || undefined); detailOpen.value = true } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单详情加载失败').message) } }
async function openDetail(row: CargoDiscardSummary) { try { detail.value = await getCargoDiscard(row.discardNo, row.customerId || undefined); detailOpen.value = true; clearPhotos(); void Promise.all(detail.value.photos.map(photo => loadPhoto(photo.fileId))) } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单详情加载失败').message) } }
async function audit(row: CargoDiscardSummary) { try { await ElMessageBox.confirm('审核通过后将推送 PDA 执行废弃,确认继续?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消' }); await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() } catch (e) { if (e !== 'cancel') ElMessage.error(getApiErrorDetails(e, '审核失败').message) } }
onMounted(async () => { try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))]; await load() } catch (e) { ElMessage.error(getApiErrorDetails(e, '客户列表加载失败').message) } })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button size="small" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || String((row as CargoDiscardSummary).status)" /></template><template #pagination-left>共 {{ total }} 条废弃单</template></DataTable></section></ListPageLayout></AdminPage>
<AppDialog v-model="detailOpen" title="废弃单详情" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))"><template v-if="detail"><el-descriptions :column="3" border><el-descriptions-item label="废弃单号">{{ detail.summary.discardNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[String(detail.summary.status)] }}</el-descriptions-item><el-descriptions-item label="仓库">{{ detail.summary.overseasWarehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="入仓码" :span="2">{{ detail.summary.inboundCodes || '—' }}</el-descriptions-item><el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item></el-descriptions><h3>废弃明细</h3><el-table :data="detail.items" border><el-table-column prop="inboundCode" label="入仓码"/><el-table-column prop="skuCode" label="SKU"/><el-table-column prop="productName" label="品名"/><el-table-column prop="discardPackages" label="申请箱数" width="100"/></el-table><h3>状态流转</h3><el-timeline><el-timeline-item v-for="(item, index) in detail.histories" :key="index" :timestamp="item.createdAt || ''">{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}</el-timeline-item></el-timeline></template></AppDialog>
<AppDialog v-model="detailOpen" title="废弃单详情" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))"><template v-if="detail"><el-descriptions :column="3" border><el-descriptions-item label="废弃单号">{{ detail.summary.discardNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[String(detail.summary.status)] }}</el-descriptions-item><el-descriptions-item label="仓库">{{ detail.summary.overseasWarehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="入仓码" :span="2">{{ detail.summary.inboundCodes || '—' }}</el-descriptions-item><el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item></el-descriptions><h3>废弃明细</h3><el-table :data="detail.items" border><el-table-column prop="inboundCode" label="入仓码"/><el-table-column prop="skuCode" label="SKU"/><el-table-column prop="productName" label="品名"/><el-table-column prop="discardPackages" label="申请箱数" width="100"/></el-table><h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item"><el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported /><span v-else-if="photoLoading[photo.fileId]">照片加载中…</span><el-button v-else @click="loadPhoto(photo.fileId)">{{ photoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small></div></div><p v-else>暂无已提交的废弃现场照片</p><h3>状态流转</h3><el-timeline><el-timeline-item v-for="(item, index) in detail.histories" :key="index" :timestamp="item.createdAt || ''">{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}</el-timeline-item></el-timeline></template></AppDialog>
</template>
<style scoped>
.discard-photo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.discard-photo-item{display:flex;flex-direction:column;gap:6px}.discard-photo-item .el-image{width:100%;height:150px;border-radius:6px}.discard-photo-item small{color:var(--cc-text-secondary)}
</style>
@@ -274,6 +274,28 @@ onMounted(load)
</el-collapse-item>
</el-collapse>
<el-empty v-else description="暂无签收箱号明细" />
<h3>合箱记录</h3>
<el-table :data="detail.returnReceipt.packingDetails || []" size="small" border empty-text="暂无合箱记录">
<el-table-column prop="planNo" label="合箱计划" min-width="210" />
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220" />
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="quantity" label="本单合入数量" width="130" />
</el-table>
<h3>当前关联实物箱</h3>
<el-collapse v-if="detail.returnReceipt.currentCartons?.length">
<el-collapse-item v-for="current in detail.returnReceipt.currentCartons" :key="current.carton.cartonMark" :name="`current-${current.carton.cartonMark}`">
<template #title>{{ current.carton.cartonMark }} · 整箱 {{ current.carton.totalQuantity }} 件 · 本单 {{ current.orderQuantity }} 件 · {{ current.carton.sourceReturnNo?.includes(',') ? '跨来源单合箱' : '单一来源' }}</template>
<p>来源单:{{ current.carton.sourceReturnNo }} · 状态:{{ current.carton.status }} · 我方箱唛:{{ current.labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}</p>
<el-table :data="current.ownership" size="small" border>
<el-table-column prop="returnNo" label="来源退货单" min-width="200" />
<el-table-column prop="skuCode" label="SKU" min-width="190" />
<el-table-column prop="quantity" label="来源贡献数量" width="130" />
</el-table>
<p>{{ current.canRelabel ? '后续贴标按此当前箱唛选择整箱;跨来源单同 SKU 的部分贴标须先现场分拣确认。' : current.blockReason }}</p>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="暂无当前关联实物箱" />
</section>
<el-empty v-else description="暂无退货签收记录" />
</el-tab-pane>
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OutboundsView from './OutboundsView.vue'
import { listPlatformTenants } from '@/modules/iam'
const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn(), returnCandidates: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn() }))
const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn(), returnCandidates: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn(), transfers: vi.fn(), bindDestination: vi.fn() }))
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.carton }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
@@ -14,6 +14,7 @@ vi.mock('../api/outbounds', () => ({
listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }),
listOneClickOutboundCandidates: api.candidates,
createOneClickDropshipOutbound: api.create,
listWarehouseTransferOutboundCandidates: api.transfers, bindWarehouseTransferDestination: api.bindDestination,
cancelOutbound: vi.fn(), createReturnOutbound: vi.fn(), listReturnOutboundCandidates: api.returnCandidates,
}))
@@ -84,6 +85,8 @@ describe('outbound candidate search and selection', () => {
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
api.candidates.mockReset().mockResolvedValue(fixtures.map(item => ({ ...item })))
api.create.mockReset().mockResolvedValue({})
api.transfers.mockReset().mockResolvedValue([])
api.bindDestination.mockReset().mockResolvedValue([])
api.warehouses.mockReset().mockResolvedValue({ records: [
{ id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
{ id: 'warehouse-b', warehouseName: '纽约仓', warehouseCode: 'US-NY-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
@@ -91,6 +94,40 @@ describe('outbound candidate search and selection', () => {
})
afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() })
it('groups pallet candidates by customer and transfer and binds each selected transfer once', async () => {
const transfer = { customerId: 'customer-a', transferNo: 'WT-TEST', overseasWarehouseId: 'warehouse-a', transferStatus: 'WAIT_FBA_DESTINATION', needRelabel: false }
api.transfers.mockResolvedValue([
{ ...transfer, packageCount: 4, volumeCbm: 0.15 },
{ ...transfer, packageCount: 6, volumeCbm: 0.08 },
{ ...transfer, customerId: 'customer-b', packageCount: 2, volumeCbm: 0.1 },
])
api.warehouses.mockResolvedValue({ records: [
{ id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
{ id: 'fba-a', warehouseName: 'ABE5', warehouseCode: 'ABE5', warehouseType: 'FBA', status: 'ACTIVE' },
] })
await openCreate()
const type = wrapper.findComponent({ name: 'ElRadioGroup' })
type.vm.$emit('update:modelValue', 'WAREHOUSE_TRANSFER_OUTBOUND')
type.vm.$emit('change')
await flushPromises()
const table = wrapper.findAllComponents({ name: 'ElTable' }).find(row => row.classes().includes('dropship-candidate-table'))!
expect(table.props('data')).toEqual([
expect.objectContaining({ customerId: 'customer-a', packageCount: 10, volumeCbm: 0.23 }),
expect.objectContaining({ customerId: 'customer-b', packageCount: 2, volumeCbm: 0.1 }),
])
for (const checkbox of table.findAllComponents(ElCheckbox)) checkbox.vm.$emit('change', true)
const destination = wrapper.findAllComponents(ElSelect).find(row => row.props('placeholder') === '请选择FBA目的仓')!
destination.vm.$emit('update:modelValue', 'fba-a')
await flushPromises()
await wrapper.find('[data-test="submit-outbound"]').trigger('click')
await flushPromises()
expect(api.bindDestination).toHaveBeenCalledOnce()
expect(api.bindDestination).toHaveBeenCalledWith(expect.objectContaining({ transfers: [
{ customerId: 'customer-a', transferNo: 'WT-TEST' },
{ customerId: 'customer-b', transferNo: 'WT-TEST' },
], destinationWarehouseCode: 'ABE5' }))
})
it('offers enabled customers from later pages when creating a return outbound', async () => {
vi.mocked(listPlatformTenants)
.mockResolvedValueOnce({ records: [{ id: 'customer-a', tenantName: '柏悦物流demo2', status: 'ACTIVE' }], page: 1, size: 200, total: 201, pages: 2 } as Awaited<ReturnType<typeof listPlatformTenants>>)
@@ -138,7 +138,19 @@ async function changeOutboundType() { selectedReturnNo.value = ''; selectedRetur
async function loadTransferCandidates() {
inventoryLoading.value = true
try {
transferCandidates.value = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined })
const pallets = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined })
// The API returns pallet rows, but destination binding applies to the whole transfer.
const transfers = new Map<string, WarehouseTransferOutboundCandidate>()
for (const pallet of pallets) {
const key = transferSelectionKey(pallet)
const transfer = transfers.get(key)
if (transfer) {
transfer.packageCount += pallet.packageCount
transfer.grossWeightKg = sumTransferMeasurement(transfer.grossWeightKg, pallet.grossWeightKg)
transfer.volumeCbm = sumTransferMeasurement(transfer.volumeCbm, pallet.volumeCbm)
} else transfers.set(key, { ...pallet })
}
transferCandidates.value = [...transfers.values()]
const candidateKeys = new Set(transferCandidates.value.map(transferSelectionKey))
selectedTransferPalletIds.value = selectedTransferPalletIds.value.filter(key => candidateKeys.has(key))
if (transferCandidates.value.length === 1) selectedTransferPalletIds.value = [transferSelectionKey(transferCandidates.value[0])]
@@ -149,6 +161,9 @@ async function loadTransferCandidates() {
} finally { inventoryLoading.value = false }
}
function transferSelectionKey(row: WarehouseTransferOutboundCandidate) { return `${row.customerId}:${row.transferNo}` }
function sumTransferMeasurement(left?: number | null, right?: number | null) {
return left == null && right == null ? null : Number(((left ?? 0) + (right ?? 0)).toFixed(6))
}
function toggleTransferPallet(row: WarehouseTransferOutboundCandidate, checked: boolean | string | number) { const key = transferSelectionKey(row); selectedTransferPalletIds.value = checked ? [...new Set([...selectedTransferPalletIds.value, key])] : selectedTransferPalletIds.value.filter(id => id !== key) }
function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) {
if (row.destinationType === 'PRIVATE' || row.privateDestinationAddress?.trim()) {
@@ -177,9 +177,18 @@ function openPlanDetail(row: Record<string, unknown>) {
planDetail.value = planRows.value.find(plan => plan.planNo === row.planNo);
planDetailOpen.value = !!planDetail.value;
}
const planCartonItemRows = computed(() => (planDetail.value?.resultCartons || []).flatMap(carton =>
const planCartonItemRows = computed(() => (planDetail.value?.completionCartons || []).flatMap(carton =>
carton.items.map(item => ({ ...carton, skuCode: item.skuCode, quantity: item.quantity })),
));
const planSourceRows = computed(() => (planDetail.value?.sources || []).flatMap(source => {
const original = source.originalItems?.length ? source.originalItems : source.items;
return original.map(item => {
const take = source.items.find(line => line.skuCode === item.skuCode)?.quantity || 0;
return { cartonMark: source.cartonMark, sourceReturnNo: source.sourceReturnNo, participation: source.participation,
skuCode: item.skuCode, originalQuantity: source.originalItems?.length ? item.quantity : null, take,
remaining: source.originalItems?.length ? item.quantity - take : null };
});
}));
const plannedItemRows = computed(() => (planDetail.value?.boxes || []).flatMap((box, index) =>
(box.items || []).map(item => ({ boxNo: index + 1, skuCode: item.skuCode, quantity: item.quantity })),
));
@@ -858,12 +867,34 @@ const tableColumns = computed(() =>
><el-descriptions-item label="备注" :span="2">{{
planDetail.remark || "—"
}}</el-descriptions-item></el-descriptions
><el-table v-if="planDetail.resultCartons?.length" :data="planCartonItemRows" class="detail-items">
>
<h3>计划来源箱明细</h3>
<el-table :data="planSourceRows" class="detail-items" empty-text="暂无来源箱明细">
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="210" />
<el-table-column prop="cartonMark" label="来源箱唛" min-width="230" />
<el-table-column label="参与方式" width="90"><template #default="{ row }">{{ row.participation === 'FULL' ? '整箱' : row.participation === 'PARTIAL' ? '部分' : '未留存' }}</template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="190" />
<el-table-column label="合箱前数量" width="110"><template #default="{ row }">{{ row.originalQuantity ?? '—' }}</template></el-table-column>
<el-table-column prop="take" label="计划取出" width="100" />
<el-table-column label="预计剩余" width="100"><template #default="{ row }">{{ row.remaining ?? '—' }}</template></el-table-column>
</el-table>
<template v-if="planDetail.status === 'COMPLETED'">
<h3>实际合箱流向</h3>
<el-table :data="planDetail.transfers || []" class="detail-items" empty-text="暂无合箱流向">
<el-table-column prop="returnNo" label="来源退货单" min-width="210" />
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="230" />
<el-table-column prop="targetCartonMark" label="实际目标箱唛" min-width="230" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="quantity" label="本次合入" width="100" />
</el-table>
<h3>合箱完成时结果({{ planDetail.completedBy || '操作员未留存' }})</h3>
<p>结果数量为完成时快照;目标原箱内容包含在整箱数量中,本次合入数量见上表。</p>
</template>
<el-table v-if="planDetail.completionCartons?.length" :data="planCartonItemRows" class="detail-items">
<el-table-column prop="cartonMark" label="实际箱唛" min-width="250" />
<el-table-column prop="skuCode" label="SKU" min-width="300" />
<el-table-column prop="quantity" label="数量" width="90" align="right" />
<el-table-column prop="locationCode" label="库位" min-width="100" />
<el-table-column prop="palletCode" label="载体托盘" min-width="120" />
<el-table-column label="箱规 / 重量" min-width="190"><template #default="{ row }">{{ row.length ?? '—' }} × {{ row.width ?? '—' }} × {{ row.height ?? '—' }} cm / {{ row.weight ?? '—' }} kg</template></el-table-column>
<el-table-column label="合箱完成时间" min-width="170"><template #default>{{ planDetail.completedAt ? dayjs(planDetail.completedAt).format('YYYY-MM-DD HH:mm:ss') : '—' }}</template></el-table-column>
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button class="density-action" data-tone="neutral" @click="printLabel(row.cartonMark)">打印箱唛</el-button></template></el-table-column>
</el-table>