优化
This commit is contained in:
@@ -48,6 +48,7 @@ describe('adminNavigation', () => {
|
|||||||
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
|
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
|
||||||
'尾程订单',
|
'尾程订单',
|
||||||
'入库单',
|
'入库单',
|
||||||
|
'废弃单',
|
||||||
'库位管理',
|
'库位管理',
|
||||||
'排车列表',
|
'排车列表',
|
||||||
'出库单',
|
'出库单',
|
||||||
@@ -334,3 +335,5 @@ describe('adminNavigation', () => {
|
|||||||
expect(getFirstNavigationLeaf(nodes)?.path).toBe('/tracking')
|
expect(getFirstNavigationLeaf(nodes)?.path).toBe('/tracking')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
|
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
|
||||||
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
|
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
|
||||||
|
{ key: 'fba-cargo-discard', title: '废弃单', icon: ClipboardList, path: '/overseas-warehouse/cargo-discards', permission: 'platform:overseas-warehouse:discard:list' },
|
||||||
{ key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' },
|
{ key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' },
|
||||||
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
|
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
|
||||||
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
|
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
|
||||||
|
|||||||
@@ -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
|
relabelFileUrl?: string | null
|
||||||
shipmentNo?: string | null
|
shipmentNo?: string | null
|
||||||
trackingCode?: string | null
|
trackingCode?: string | null
|
||||||
remark?: string | null
|
remark?: string
|
||||||
cartonMark?: string | null
|
cartonMark?: string | null
|
||||||
fbaCartonMark?: string | null
|
fbaCartonMark?: string | null
|
||||||
destinationWarehouseId?: string | null
|
destinationWarehouseId?: string | null
|
||||||
@@ -109,7 +109,7 @@ export interface RelabelHistoryDto {
|
|||||||
fromStatus?: string | null
|
fromStatus?: string | null
|
||||||
toStatus?: string | null
|
toStatus?: string | null
|
||||||
operatorId?: string | null
|
operatorId?: string | null
|
||||||
remark?: string | null
|
remark?: string
|
||||||
createdAt?: string | null
|
createdAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,16 +137,24 @@ export interface ReturnPackingPlanBox {
|
|||||||
|
|
||||||
export interface ReturnPackingPlanPayload {
|
export interface ReturnPackingPlanPayload {
|
||||||
returnNos: string[]
|
returnNos: string[]
|
||||||
mode: 'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'
|
mode: 'BOX' | 'SKU' | 'CUSTOMER_INSTRUCTION' | 'WAREHOUSE_RECOMMENDATION'
|
||||||
boxes: ReturnPackingPlanBox[]
|
boxes: ReturnPackingPlanBox[]
|
||||||
remark?: string
|
remark?: string
|
||||||
idempotencyKey: string
|
idempotencyKey: string
|
||||||
|
sourceCartonMarks?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReturnPackingPlanDto extends ReturnPackingPlanPayload {
|
export interface ReturnPackingPlanDto extends Omit<ReturnPackingPlanPayload, 'remark'> {
|
||||||
planNo: string
|
planNo: string
|
||||||
|
customerId?: string
|
||||||
|
overseasWarehouseId?: string
|
||||||
status: string
|
status: string
|
||||||
|
boxes: ReturnPackingPlanBox[]
|
||||||
|
remark?: string | null
|
||||||
|
createdAt?: string | null
|
||||||
pushedAt?: string | null
|
pushedAt?: string | null
|
||||||
|
completedAt?: string | null
|
||||||
|
resultCartons?: import('./returnInventory').ReturnCartonDetail[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) {
|
export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) {
|
||||||
@@ -156,6 +164,21 @@ export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload)
|
|||||||
return unwrapApiResponse(response) as ReturnPackingPlanDto
|
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) {
|
export async function listRelabelOrders(query: RelabelOrderQuery) {
|
||||||
const response = await http.get<ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>, ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>>(
|
const response = await http.get<ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>, ApiEnvelope<OverseasPagePayload<RelabelSummaryDto>>>(
|
||||||
'/platform/v1/last-leg/relabel-applications', { params: query },
|
'/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'
|
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 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; 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 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 ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }> }
|
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 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 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 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 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 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 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) }
|
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 ElementPlus from 'element-plus'
|
||||||
import DataTable from '@/shared/components/DataTable.vue'
|
import DataTable from '@/shared/components/DataTable.vue'
|
||||||
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
|
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'
|
import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
|
||||||
|
|
||||||
vi.mock('@/modules/iam', () => ({
|
vi.mock('@/modules/iam', () => ({
|
||||||
@@ -14,6 +15,11 @@ vi.mock('@/modules/iam', () => ({
|
|||||||
vi.mock('../api/returnInventory', () => ({
|
vi.mock('../api/returnInventory', () => ({
|
||||||
listReturnStock: vi.fn(), listReturnCartons: vi.fn(), listReturnInventoryMovements: vi.fn(),
|
listReturnStock: vi.fn(), listReturnCartons: vi.fn(), listReturnInventoryMovements: vi.fn(),
|
||||||
getReturnCarton: vi.fn(), getReturnCartonLabel: 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 wrappers: VueWrapper[] = []
|
||||||
const stock = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', skuCode: 'SKU-TEST', totalQuantity: 8, availableQuantity: 6, cartonQuantity: 1, updatedAt: '2026-09-30' }
|
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(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(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(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(() => {
|
afterEach(() => {
|
||||||
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
|
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
|
||||||
@@ -41,6 +52,127 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('ReturnInventoryManagementView shared list', () => {
|
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 () => {
|
it('keeps query submission and server pagination when using the shared table', async () => {
|
||||||
const wrapper = await mountView('inventory')
|
const wrapper = await mountView('inventory')
|
||||||
expect(wrapper.get('h1').text()).toBe('退货库存管理')
|
expect(wrapper.get('h1').text()).toBe('退货库存管理')
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ export const overseasWarehousePermissions = {
|
|||||||
inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
|
inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
|
||||||
inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:fba-appointment',
|
inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:fba-appointment',
|
||||||
inboundOrderSmartDispatch: 'platform:overseas-warehouse:inbound-order:smart-dispatch',
|
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',
|
dispatchList: 'platform:overseas-warehouse:dispatch:list',
|
||||||
dispatchEdit: 'platform:overseas-warehouse:dispatch:edit',
|
dispatchEdit: 'platform:overseas-warehouse:dispatch:edit',
|
||||||
dispatchCancel: 'platform:overseas-warehouse:dispatch:cancel',
|
dispatchCancel: 'platform:overseas-warehouse:dispatch:cancel',
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export const overseasWarehouseRoutes = [
|
|||||||
component: () => import('./pages/InboundOrdersView.vue'),
|
component: () => import('./pages/InboundOrdersView.vue'),
|
||||||
meta: { showPageHeading: false, title: '入库单', description: '管理海外合作仓入库、预约与排车衔接', permission: permission.inboundOrderList },
|
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',
|
path: 'overseas-warehouse/inbound-orders/:orderId',
|
||||||
name: 'overseas-inbound-order-detail',
|
name: 'overseas-inbound-order-detail',
|
||||||
|
|||||||
Reference in New Issue
Block a user