Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test

This commit is contained in:
2026-10-08 17:08:04 +08:00
10 changed files with 1307 additions and 116 deletions
@@ -0,0 +1,26 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
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 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) {
const response = await http.get<ApiEnvelope<OverseasPagePayload<CargoDiscardSummary>>, ApiEnvelope<OverseasPagePayload<CargoDiscardSummary>>>('/platform/v1/last-leg/cargo-discards', { params: query, headers: query.customerId ? { 'X-Customer-Id': query.customerId } : undefined })
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function getCargoDiscard(discardNo: string, customerId?: string) {
const response = await http.get<ApiEnvelope<CargoDiscardDetail>, ApiEnvelope<CargoDiscardDetail>>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}`, { params: customerId ? { customerId } : undefined })
return unwrapApiResponse(response)
}
export async function reviewCargoDiscard(discardNo: string, decision: 'APPROVE' | 'REJECT', remark?: string, customerId?: string) {
const response = await http.post<ApiEnvelope<CargoDiscardDetail>, ApiEnvelope<CargoDiscardDetail>>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/review`, { decision, remark }, { params: customerId ? { customerId } : undefined })
return unwrapApiResponse(response)
}
export async function completeCargoDiscard(discardNo: string, customerId?: string) {
const response = await http.post<ApiEnvelope<CargoDiscardDetail>, ApiEnvelope<CargoDiscardDetail>>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/complete`, undefined, { params: customerId ? { customerId } : undefined })
return unwrapApiResponse(response)
}
@@ -98,7 +98,7 @@ export interface RelabelItemDto {
relabelFileUrl?: string | null
shipmentNo?: string | null
trackingCode?: string | null
remark?: string | null
remark?: string
cartonMark?: string | null
fbaCartonMark?: string | null
destinationWarehouseId?: string | null
@@ -109,7 +109,7 @@ export interface RelabelHistoryDto {
fromStatus?: string | null
toStatus?: string | null
operatorId?: string | null
remark?: string | null
remark?: string
createdAt?: string | null
}
@@ -137,16 +137,24 @@ export interface ReturnPackingPlanBox {
export interface ReturnPackingPlanPayload {
returnNos: string[]
mode: 'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'
mode: 'BOX' | 'SKU' | 'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'
boxes: ReturnPackingPlanBox[]
remark?: string
idempotencyKey: string
sourceCartonMarks?: string[]
}
export interface ReturnPackingPlanDto extends ReturnPackingPlanPayload {
export interface ReturnPackingPlanDto extends Omit<ReturnPackingPlanPayload, 'remark'> {
planNo: string
customerId?: string
overseasWarehouseId?: string
status: string
boxes: ReturnPackingPlanBox[]
remark?: string | null
createdAt?: string | null
pushedAt?: string | null
completedAt?: string | null
resultCartons?: import('./returnInventory').ReturnCartonDetail[]
}
export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) {
@@ -156,6 +164,21 @@ export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload)
return unwrapApiResponse(response) as ReturnPackingPlanDto
}
export interface ReturnPackingPlanQuery { page: number; size: number; status?: string; planNo?: string; customerId?: string; overseasWarehouseId?: string; skuCode?: string }
export async function listReturnPackingPlans(query: ReturnPackingPlanQuery) {
const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnPackingPlanDto>>, ApiEnvelope<OverseasPagePayload<ReturnPackingPlanDto>>>('/platform/v1/last-leg/return-packing-plans', { params: query })
return normalizeOverseasPage(unwrapApiResponse(response))
}
export async function reviewReturnPackingPlan(planNo: string) {
const response = await http.post<ApiEnvelope<ReturnPackingPlanDto>, ApiEnvelope<ReturnPackingPlanDto>>(`/platform/v1/last-leg/return-packing-plans/${encodeURIComponent(planNo)}/review`)
return unwrapApiResponse(response) as ReturnPackingPlanDto
}
export async function cancelReturnPackingPlan(planNo: string) {
const response = await http.post<ApiEnvelope<ReturnPackingPlanDto>, ApiEnvelope<ReturnPackingPlanDto>>(`/platform/v1/last-leg/return-packing-plans/${encodeURIComponent(planNo)}/cancel`)
return unwrapApiResponse(response) as ReturnPackingPlanDto
}
export async function listRelabelOrders(query: RelabelOrderQuery) {
const response = await http.get<ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>, ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>>(
'/platform/v1/last-leg/relabel-applications', { params: query },
@@ -2,14 +2,17 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; cartonQuantity: number; updatedAt: string }
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }> }
export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string }
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
export interface ReturnCartonMergeHistory { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string }
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[] }
export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string }
export interface ReturnPackingSource { cartonMark: string; sourceReturnNo?: string; sourceUnitType?: string; items: ReturnCartonDetail['items'] }
export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string }
export interface ReturnInventoryQuery { page: number; size: number; customerId?: string; overseasWarehouseId?: string; skuCode?: string; cartonMark?: string; returnNo?: string; status?: string }
export async function listReturnStock(params: ReturnInventoryQuery) { const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnStockRow>>, ApiEnvelope<OverseasPagePayload<ReturnStockRow>>>('/platform/v1/last-leg/return-inventory', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
export async function listReturnPackingSources(params: ReturnInventoryQuery) { const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnPackingSource>>, ApiEnvelope<OverseasPagePayload<ReturnPackingSource>>>('/platform/v1/last-leg/return-inventory/packing-sources', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
export async function listReturnCartons(params: ReturnInventoryQuery) { const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnCartonRow>>, ApiEnvelope<OverseasPagePayload<ReturnCartonRow>>>('/platform/v1/last-leg/return-inventory/cartons', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
export async function listReturnInventoryMovements(params: ReturnInventoryQuery) { const response = await http.get<ApiEnvelope<OverseasPagePayload<ReturnInventoryMovement>>, ApiEnvelope<OverseasPagePayload<ReturnInventoryMovement>>>('/platform/v1/last-leg/return-inventory/movements', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
export async function getReturnCarton(cartonMark: string) { const response = await http.get<ApiEnvelope<ReturnCartonDetail>, ApiEnvelope<ReturnCartonDetail>>(`/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}`); return unwrapApiResponse(response) }
@@ -0,0 +1,32 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
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 { 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: '已取消' }
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 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>
</template>
@@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import DataTable from '@/shared/components/DataTable.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
import { getReturnCarton, listReturnCartons, listReturnInventoryMovements, listReturnStock } from '../api/returnInventory'
import { getReturnCarton, listReturnCartons, listReturnInventoryMovements, listReturnStock, listReturnPackingSources } from '../api/returnInventory'
import { listReturnPackingPlans } from '../api/relabelOrders'
import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
vi.mock('@/modules/iam', () => ({
@@ -14,6 +15,11 @@ vi.mock('@/modules/iam', () => ({
vi.mock('../api/returnInventory', () => ({
listReturnStock: vi.fn(), listReturnCartons: vi.fn(), listReturnInventoryMovements: vi.fn(),
getReturnCarton: vi.fn(), getReturnCartonLabel: vi.fn(),
listReturnPackingSources: vi.fn(),
}))
vi.mock('../api/relabelOrders', () => ({
listReturnPackingPlans: vi.fn(), createReturnPackingPlan: vi.fn(),
reviewReturnPackingPlan: vi.fn(), cancelReturnPackingPlan: vi.fn(),
}))
const wrappers: VueWrapper[] = []
const stock = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', skuCode: 'SKU-TEST', totalQuantity: 8, availableQuantity: 6, cartonQuantity: 1, updatedAt: '2026-09-30' }
@@ -34,6 +40,11 @@ beforeEach(() => {
vi.mocked(listReturnCartons).mockResolvedValue({ records: [carton], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(listReturnInventoryMovements).mockResolvedValue({ records: [{ ...stock, movementType: 'MERGE', referenceNo: 'MERGE-1', totalDelta: -2, availableDelta: -2, occurredAt: '2026-09-30' }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getReturnCarton).mockResolvedValue({ ...carton, items: [{ skuCode: 'SKU-TEST', quantity: 8 }] })
vi.mocked(listReturnPackingPlans).mockImplementation(async query => ({
records: query.status ? [] : [{ planNo: 'PLAN-NEW', returnNos: ['RETURN-1'], mode: 'SKU', boxes: [], status: 'PENDING_REVIEW', idempotencyKey: 'plan-key' }],
page: query.page, size: query.size, total: query.status ? 0 : 45, pages: 3,
}))
vi.mocked(listReturnPackingSources).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
})
afterEach(() => {
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
@@ -41,6 +52,127 @@ afterEach(() => {
})
describe('ReturnInventoryManagementView shared list', () => {
it('finds boxes by contained SKU while retaining mixed-box contents and selected cartons', async () => {
vi.mocked(listReturnPackingSources).mockResolvedValue({records:[
{cartonMark:'BOX-A',sourceUnitType:'BOX',items:[{skuCode:'SKU-APPLE',quantity:2},{skuCode:'SKU-OTHER',quantity:3}]},
{cartonMark:'BOX-B',sourceUnitType:'BOX',items:[{skuCode:'SKU-BANANA',quantity:4}]},
],page:1,size:200,total:2,pages:1})
const wrapper=await mountView('inventory')
await wrapper.findAll('button').find(button=>button.text().includes('创建合箱计划'))!.trigger('click');await flushPromises()
const form=wrapper.findAllComponents({name:'ElForm'}).find(form=>form.classes().includes('packing-form'))!
const customer=form.getComponent({name:'ElSelect'})
customer.vm.$emit('update:modelValue','customer-1');customer.vm.$emit('change','customer-1');await flushPromises()
const input=form.get('input[placeholder="按箱内 SKU 查找,多个 SKU 请用英文逗号分隔"]')
await input.setValue('APPLE')
expect(form.text()).toContain('BOX-A');expect(form.text()).toContain('SKU-OTHER');expect(form.text()).not.toContain('BOX-B')
const selection=form.getComponent({name:'ElCheckboxGroup'})
selection.vm.$emit('update:modelValue',['BOX-A']);await flushPromises()
await input.setValue('APPLE, BANANA')
expect(form.text()).toContain('BOX-A');expect(form.text()).toContain('BOX-B')
await input.setValue('NO-MATCH')
expect(form.text()).toContain('没有包含该 SKU 的可用箱子')
await input.setValue('')
expect(form.getComponent({name:'ElCheckboxGroup'}).props('modelValue')).toContain('BOX-A')
})
it('queries packing plan numbers through server pagination and clears the filter', async () => {
const wrapper = await mountView('inventory')
const tabs = wrapper.getComponent({ name: 'ElTabs' })
tabs.vm.$emit('update:modelValue', 'plans'); tabs.vm.$emit('tab-change', 'plans')
await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' }).filter(select => ['全部客户','全部海外仓'].includes(select.props('placeholder')))
expect(selects.map(select => select.props('placeholder'))).toEqual(['全部客户','全部海外仓'])
expect(wrapper.find('input[placeholder="SKU"]').exists()).toBe(true)
await wrapper.get('input[placeholder="合箱计划号"]').setValue(' PLAN-NEW ')
await wrapper.get('.query-panel button').trigger('click'); await flushPromises()
expect(listReturnPackingPlans).toHaveBeenLastCalledWith({page:1,size:20,planNo:'PLAN-NEW'})
wrapper.getComponent(DataTable).vm.$emit('update:page', 2); await flushPromises()
expect(listReturnPackingPlans).toHaveBeenLastCalledWith({page:2,size:20,planNo:'PLAN-NEW'})
await wrapper.get('input[placeholder="合箱计划号"]').setValue('')
await wrapper.get('.query-panel button').trigger('click'); await flushPromises()
expect(listReturnPackingPlans).toHaveBeenLastCalledWith({page:1,size:20})
selects[0]!.vm.$emit('update:modelValue','customer-1')
selects[1]!.vm.$emit('update:modelValue','warehouse-1')
await wrapper.get('input[placeholder="SKU"]').setValue(' SKU-TEST ')
await wrapper.get('.query-panel button').trigger('click'); await flushPromises()
expect(listReturnPackingPlans).toHaveBeenLastCalledWith({page:1,size:20,customerId:'customer-1',overseasWarehouseId:'warehouse-1',skuCode:'SKU-TEST'})
})
it('shows source carton, return ownership and moved quantities in carton merge history', async () => {
vi.mocked(getReturnCarton).mockResolvedValue({ ...carton, items: [{ skuCode: 'SKU-TEST', quantity: 8 }], mergeHistory: [{planNo:'PLAN-MERGED',sourceCartonMark:'SOURCE-BOX',targetCartonMark:carton.cartonMark,returnNo:'RETURN-SOURCE',skuCode:'SKU-TEST',quantity:2,createdAt:'2026-10-08T14:00:00'}] })
const wrapper = await mountView('cartons')
await wrapper.findAll('button').find(button => button.text() === '详情')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('合箱明细')
expect(document.body.textContent).toContain('SOURCE-BOX')
expect(document.body.textContent).toContain('RETURN-SOURCE')
expect(document.body.textContent).toContain('PLAN-MERGED')
expect(document.body.textContent).toContain('合入')
})
it('shows pending plans without applying the carton AVAILABLE status on tab changes and pagination', async () => {
const wrapper = await mountView('inventory')
const tabs = wrapper.getComponent({ name: 'ElTabs' })
tabs.vm.$emit('update:modelValue', 'plans')
tabs.vm.$emit('tab-change', 'plans')
await flushPromises()
expect(listReturnPackingPlans).toHaveBeenLastCalledWith({ page: 1, size: 20 })
expect(wrapper.text()).toContain('PLAN-NEW')
wrapper.getComponent(DataTable).vm.$emit('update:page', 2)
await flushPromises()
expect(listReturnPackingPlans).toHaveBeenLastCalledWith({ page: 2, size: 20 })
tabs.vm.$emit('update:modelValue', 'stock')
tabs.vm.$emit('tab-change', 'stock')
await flushPromises()
expect(listReturnStock).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'AVAILABLE' }))
})
it('loads packing sources in pages without per-carton or stock requests and excludes reserved parcel quantities', async () => {
const wrapper = await mountView('inventory')
vi.mocked(listReturnPackingSources)
.mockResolvedValueOnce({ records: [{ cartonMark: 'PARCEL-1', sourceReturnNo: 'RETURN-1', sourceUnitType: 'PARCEL', items: [{ skuCode: 'PACK-SKU', quantity: 9, availableQuantity: 2 }] }], page: 1, size: 200, total: 2, pages: 2 })
.mockResolvedValueOnce({ records: [{ cartonMark: 'BOX-2', sourceReturnNo: 'RETURN-2', sourceUnitType: 'BOX', items: [{ skuCode: 'PACK-SKU', quantity: 100, availableQuantity: 100 }] }], page: 2, size: 200, total: 2, pages: 2 })
await wrapper.findAll('button').find(button => button.text().includes('创建合箱计划'))!.trigger('click')
await flushPromises()
const dialog = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('packing-form'))!
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[0].vm.$emit('update:modelValue', 'customer-1')
selects[0].vm.$emit('change', 'customer-1')
await flushPromises()
expect(listReturnPackingSources).toHaveBeenCalledTimes(2)
expect(listReturnPackingSources).toHaveBeenLastCalledWith({ page: 2, size: 200, customerId: 'customer-1', overseasWarehouseId: 'warehouse-1' })
expect(getReturnCarton).not.toHaveBeenCalled()
expect(listReturnCartons).not.toHaveBeenCalled()
expect(listReturnStock).toHaveBeenCalledTimes(1)
const modes = dialog.findComponent({ name: 'ElRadioGroup' })
modes.vm.$emit('update:modelValue', 'SKU')
modes.vm.$emit('change', 'SKU')
await flushPromises()
expect(dialog.get('.packing-sku-row').text()).toContain('PACK-SKU')
expect(dialog.get('.packing-sku-row').text()).toContain('可用 2')
})
it('stops old pagination and ignores its result after the customer changes', async () => {
const wrapper = await mountView('inventory')
let resolveOld!: (value: Awaited<ReturnType<typeof listReturnPackingSources>>) => void
vi.mocked(listReturnPackingSources)
.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
.mockResolvedValueOnce({ records: [{ cartonMark: 'NEW-BOX', sourceUnitType: 'BOX', items: [] }], page: 1, size: 200, total: 1, pages: 1 })
await wrapper.findAll('button').find(button => button.text().includes('创建合箱计划'))!.trigger('click')
await flushPromises()
const dialog = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('packing-form'))!
const select = dialog.getComponent({ name: 'ElSelect' })
select.vm.$emit('update:modelValue', 'customer-1')
select.vm.$emit('change', 'customer-1')
await flushPromises()
select.vm.$emit('update:modelValue', 'customer-2')
select.vm.$emit('change', 'customer-2')
await flushPromises()
resolveOld({ records: [{ cartonMark: 'OLD-BOX', sourceUnitType: 'BOX', items: [] }], page: 1, size: 200, total: 400, pages: 2 })
await flushPromises()
expect(listReturnPackingSources).toHaveBeenCalledTimes(2)
expect(dialog.text()).toContain('NEW-BOX')
expect(dialog.text()).not.toContain('OLD-BOX')
})
it('keeps query submission and server pagination when using the shared table', async () => {
const wrapper = await mountView('inventory')
expect(wrapper.get('h1').text()).toBe('退货库存管理')
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,8 @@ export const overseasWarehousePermissions = {
inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:fba-appointment',
inboundOrderSmartDispatch: 'platform:overseas-warehouse:inbound-order:smart-dispatch',
discardList: 'platform:overseas-warehouse:discard:list',
discardApprove: 'platform:overseas-warehouse:discard:approve',
dispatchList: 'platform:overseas-warehouse:dispatch:list',
dispatchEdit: 'platform:overseas-warehouse:dispatch:edit',
dispatchCancel: 'platform:overseas-warehouse:dispatch:cancel',
+6
View File
@@ -20,6 +20,12 @@ export const overseasWarehouseRoutes = [
component: () => import('./pages/InboundOrdersView.vue'),
meta: { showPageHeading: false, title: '入库单', description: '管理海外合作仓入库、预约与排车衔接', permission: permission.inboundOrderList },
},
{
path: 'overseas-warehouse/cargo-discards',
name: 'overseas-cargo-discards',
component: () => import('./pages/CargoDiscardsView.vue'),
meta: { showPageHeading: false, title: '废弃单', description: '审核货物废弃申请并跟进 PDA 废弃与客户确认', permission: permission.discardList },
},
{
path: 'overseas-warehouse/inbound-orders/:orderId',
name: 'overseas-inbound-order-detail',