feat: add overseas warehouse location management
This commit is contained in:
@@ -112,6 +112,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
children: [
|
||||
{ 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: '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-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
|
||||
{ key: 'fba-delivery', title: '派送单列表', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface InboundPlanDetailDto {
|
||||
customerName?: string | null
|
||||
orderType?: string | null
|
||||
containerNo?: string | null
|
||||
overseasWarehouseId?: string | null
|
||||
overseasWarehouseCode?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
status?: string | null
|
||||
@@ -67,7 +68,7 @@ export interface InboundPlanDetailDto {
|
||||
updatedAt?: string | null
|
||||
remark?: string | null
|
||||
lines: Array<{ id: string; orderId: string; inboundCode?: string | null; fbaWarehouseCode?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; grossWeightKg?: number | null; netWeightKg?: number | null; volumeCbm?: number | null; requiredPackageCount: number; plannedPalletCount: number }>
|
||||
pallets: Array<{ id: string; palletNo?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; capacityCbm?: number | null; allocatedVolumeCbm?: number | null; sequenceNo: number; warehousePalletId?: string | null; warehouseLocationId?: string | null; warehouseResourceLockId?: string | null; assignmentStatus?: string | null; lockedAt?: string | null; actualVolumeCbm?: number | null; actualPackageCount: number }>
|
||||
pallets: Array<{ id: string; palletNo?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; capacityCbm?: number | null; allocatedVolumeCbm?: number | null; sequenceNo: number; warehousePalletId?: string | null; warehouseLocationId?: string | null; warehouseLocationCode?: string | null; warehouseLocationType?: string | null; warehouseLocationStatus?: string | null; sharedOverflow?: boolean | null; assignmentStatus?: string | null; lockedAt?: string | null; actualVolumeCbm?: number | null; actualPackageCount: number; version?: number }>
|
||||
allocations: Array<{ lineId: string; palletId: string; palletNo: string; palletOwnerPlanNo: string; palletOwnedByCurrentPlan: boolean; allocatedVolumeCbm?: number | null; plannedPackageCount: number; countedPackageCount: number; pickedPackageCount: number; actualVolumeCbm?: number | null; status?: string | null; version: number }>
|
||||
}
|
||||
|
||||
@@ -79,3 +80,27 @@ export async function listInboundPlans(query: InboundPlanQuery) {
|
||||
export async function getInboundPlan(planNo: string, customerId: string) {
|
||||
return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } }))
|
||||
}
|
||||
|
||||
export interface PlatformInventoryMoveRequest {
|
||||
overseasWarehouseId: string
|
||||
sourcePalletCode: string
|
||||
targetLocationCode: string
|
||||
expectedPalletVersion: number
|
||||
reason: string
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
export interface PlatformInventoryMoveResult {
|
||||
commandId: string
|
||||
status: string
|
||||
canMove?: boolean
|
||||
blockReason?: string | null
|
||||
sourceLocationCode?: string | null
|
||||
targetLocationCode?: string | null
|
||||
sharedOverflow?: boolean | null
|
||||
}
|
||||
|
||||
export async function moveInboundPallet(payload: PlatformInventoryMoveRequest) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<PlatformInventoryMoveResult>, ApiEnvelope<PlatformInventoryMoveResult>>('/platform/v1/last-leg/inventory-moves', payload, { headers: { 'Idempotency-Key': payload.idempotencyKey } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
|
||||
export type OverseasLocationType = 'VIRTUAL' | 'NORMAL' | 'SHARED_OVERFLOW'
|
||||
export type OverseasLocationStatus = 'ACTIVE' | 'IDLE' | 'OCCUPIED' | 'INACTIVE' | 'DISABLED'
|
||||
|
||||
export interface OverseasLocationDto {
|
||||
id: string
|
||||
warehouseId: string
|
||||
warehouseCode?: string | null
|
||||
warehouseName?: string | null
|
||||
zoneCode: string
|
||||
zoneName?: string | null
|
||||
locationCode: string
|
||||
locationName?: string | null
|
||||
locationType: OverseasLocationType
|
||||
sharedOverflow: boolean
|
||||
status: OverseasLocationStatus
|
||||
maxLoadKg?: number | string | null
|
||||
occupiedPalletCount?: number | null
|
||||
version: number
|
||||
remark?: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface SaveOverseasLocationPayload {
|
||||
zoneCode: string
|
||||
locationCode: string
|
||||
locationType: 'NORMAL'
|
||||
maxLoadKg: number
|
||||
status: 'ACTIVE' | 'INACTIVE'
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface OverseasLocationQuery { page?: number; size?: number; zoneCode?: string; keyword?: string; locationType?: OverseasLocationType | ''; status?: OverseasLocationStatus | '' }
|
||||
|
||||
function base(warehouseId: string) { return `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations` }
|
||||
|
||||
export async function listOverseasLocations(warehouseId: string, query: OverseasLocationQuery = {}) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>, ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>>(base(warehouseId), { params: { page: query.page ?? 1, size: query.size ?? 20, ...query } })
|
||||
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function createOverseasLocation(warehouseId: string, payload: SaveOverseasLocationPayload) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<string>, ApiEnvelope<string>>(base(warehouseId), payload)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updateOverseasLocation(warehouseId: string, locationId: string, payload: SaveOverseasLocationPayload & { version: number }) {
|
||||
const response = await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, payload)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function changeOverseasLocationStatus(warehouseId: string, locationId: string, payload: SaveOverseasLocationPayload & { version: number }) {
|
||||
const response = await overseasHttp.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, payload)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function deleteOverseasLocation(warehouseId: string, locationId: string, version: number) {
|
||||
const response = await overseasHttp.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, { params: { version } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getInboundPlan, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import { getInboundPlan, moveInboundPallet, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import { listOverseasLocations, type OverseasLocationDto } from '../api/locations'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
|
||||
|
||||
@@ -14,6 +17,7 @@ const route = useRoute(); const router = useRouter()
|
||||
const planNo = computed(() => String(route.params.orderId || ''))
|
||||
const customerId = computed(() => String(route.query.customerId || ''))
|
||||
const detail = ref<InboundPlanDetailDto | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const moveOpen = ref(false); const moveSaving = ref(false); const moveRow = ref<{ palletNo: string; palletVersion: number } | null>(null); const targetLocationCode = ref(''); const moveReason = ref('入库托盘上架'); const locationOptions = ref<OverseasLocationDto[]>([])
|
||||
const referenceValue = computed(() => detail.value?.planNo || (/^\d+$/.test(planNo.value) ? '—' : planNo.value))
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function receivingWarehouse(code: unknown, name: unknown) {
|
||||
@@ -38,21 +42,41 @@ const summaryGroups = [
|
||||
const appointmentFields = ['预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注']
|
||||
const feeColumns: DataTableColumn[] = [{ key: 'index', label: '序号', width: 70, align: 'center' }, { key: 'name', label: '费用项目', minWidth: 180 }, { key: 'currency', label: '币种', width: 100 }, { key: 'amount', label: '金额', width: 140, align: 'right' }, { key: 'remark', label: '备注', minWidth: 200 }]
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 150 },
|
||||
{ key: 'palletOwner', prop: 'palletOwner', label: '托盘所属计划', minWidth: 190 },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '对应箱数', width: 110, align: 'right' },
|
||||
{ key: 'location', label: '当前库位', minWidth: 150, slotName: 'location' },
|
||||
{ key: 'locationType', label: '库位类型', width: 120, prop: 'locationType' },
|
||||
{ key: 'putawayStatus', label: '上架状态', width: 110, prop: 'putawayStatus' },
|
||||
]
|
||||
const palletRows = computed(() => (detail.value?.allocations || []).map((allocation, index) => {
|
||||
const line = detail.value?.lines.find(item => item.id === allocation.lineId)
|
||||
const pallet = detail.value?.pallets.find(item => item.id === allocation.palletId || item.warehousePalletId === allocation.palletId)
|
||||
const locationCode = pallet?.warehouseLocationCode || null
|
||||
const locationType = pallet?.sharedOverflow ? 'SHARED_OVERFLOW' : (pallet?.warehouseLocationType || null)
|
||||
return {
|
||||
id: `${allocation.lineId}-${allocation.palletId}-${index}`,
|
||||
inboundCode: text(line?.inboundCode),
|
||||
palletNo: text(allocation.palletNo),
|
||||
palletOwner: allocation.palletOwnedByCurrentPlan ? `${allocation.palletOwnerPlanNo}(本计划)` : `${allocation.palletOwnerPlanNo}(跨计划合托)`,
|
||||
packageCount: Number(allocation.pickedPackageCount ?? allocation.countedPackageCount ?? allocation.plannedPackageCount ?? 0),
|
||||
palletId: String(allocation.palletId), palletVersion: pallet?.version ?? 0, sourceLocationId: pallet?.warehouseLocationId || null, sourceLocationCode: locationCode,
|
||||
sourceLocationVersion: null, location: locationCode || (pallet?.warehouseLocationId ? '—' : '虚拟收货位'), locationType: locationType === 'SHARED_OVERFLOW' ? 'A0共享溢出位' : locationType === 'NORMAL' ? '普通库位' : '虚拟收货位', putawayStatus: locationType === 'NORMAL' || locationType === 'SHARED_OVERFLOW' ? '已上架' : '待上架',
|
||||
}
|
||||
}))
|
||||
const availableLocations = computed(() => locationOptions.value.filter(item => item.status !== 'DISABLED'))
|
||||
async function openMove(row: typeof palletRows.value[number]) {
|
||||
if (!detail.value?.overseasWarehouseCode) { ElMessage.warning('入库单未返回海外仓范围,暂不能移库'); return }
|
||||
moveRow.value = { palletNo: row.palletNo, palletVersion: row.palletVersion }; targetLocationCode.value = ''; moveReason.value = '入库托盘上架'; moveOpen.value = true
|
||||
try { const result = await listOverseasLocations(String((detail.value as InboundPlanDetailDto & { overseasWarehouseId?: string }).overseasWarehouseId || ''), { page: 1, size: 200 }); locationOptions.value = result.records.filter(item => item.locationType !== 'VIRTUAL' && item.status !== 'DISABLED') } catch { locationOptions.value = []; ElMessage.error('可用库位加载失败,请重试') }
|
||||
}
|
||||
async function submitMove() {
|
||||
if (!moveRow.value || !targetLocationCode.value || !moveReason.value.trim()) { ElMessage.warning('请选择目标库位并填写操作原因'); return }
|
||||
moveSaving.value = true
|
||||
try { const warehouseId = String(detail.value?.overseasWarehouseId || ''); if (!warehouseId) { ElMessage.error('入库单未返回海外仓 ID,暂不能移库'); return }; const result = await moveInboundPallet({ overseasWarehouseId: warehouseId, sourcePalletCode: moveRow.value.palletNo, targetLocationCode: targetLocationCode.value, expectedPalletVersion: moveRow.value.palletVersion, reason: moveReason.value.trim(), idempotencyKey: `admin-inbound-move-${moveRow.value.palletNo}-${Date.now()}` }); if (result.canMove === false) { ElMessage.error(result.blockReason || '当前托盘不可移库'); return }; ElMessage.success('移库完成'); moveOpen.value = false; await load() } catch (error) { ElMessage.error(getApiErrorDetails(error, '移库失败').message) } finally { moveSaving.value = false }
|
||||
}
|
||||
async function load() { if (!planNo.value || !customerId.value) { loadError.value = { message: '详情缺少客户范围,请从入库单列表重新进入' }; return }; loading.value = true; loadError.value = null; try { detail.value = await getInboundPlan(planNo.value, customerId.value) } catch (error) { loadError.value = getApiErrorDetails(error, '入库单详情加载失败,请重试') } finally { loading.value = false } }
|
||||
function back() { void router.push({ name: 'overseas-inbound-orders' }) }
|
||||
onMounted(load)
|
||||
@@ -61,10 +85,10 @@ onMounted(load)
|
||||
<template><OverseasDetailShell class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
|
||||
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in summaryGroups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
|
||||
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数。</p></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细" /></div></section>
|
||||
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!(row as typeof palletRows[number]).palletNo || !(row as typeof palletRows[number]).palletVersion" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></section>
|
||||
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
|
||||
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><div class="compact-section-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><span>费用结果以服务端为准</span></div><div class="detail-table-wrap"><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /></div><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
|
||||
</OverseasDetailShell></template>
|
||||
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog" title="托盘移库" width="min(560px, calc(100vw - 28px))" confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="!targetLocationCode" @confirm="submitMove"><el-form class="density-form" label-position="top"><el-form-item label="托盘号"><el-input :model-value="moveRow?.palletNo" readonly /></el-form-item><el-form-item label="目标库位" required><el-select v-model="targetLocationCode" filterable placeholder="请选择普通库位或 A0"><el-option v-for="item in availableLocations" :key="item.id" :value="item.locationCode" :label="`${item.locationCode}${item.sharedOverflow || item.locationType === 'SHARED_OVERFLOW' ? ' · A0共享溢出位' : ` · ${item.zoneCode}`}`" /></el-select></el-form-item><el-form-item label="操作原因" required><el-input v-model="moveReason" maxlength="200" /></el-form-item></el-form></AppDialog></template>
|
||||
|
||||
<style scoped>
|
||||
.detail-section { min-width: 0; padding: 0; border: 0; background: #fff; }
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Save } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { changeOverseasLocationStatus, createOverseasLocation, deleteOverseasLocation, listOverseasLocations, updateOverseasLocation, type OverseasLocationDto, type OverseasLocationStatus, type OverseasLocationType } from '../api/locations'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
|
||||
const access = useAccessPreviewStore(); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([]); const rows = ref<OverseasLocationDto[]>([])
|
||||
const loading = ref(false); const contextLoading = ref(false); const loadError = ref<{ message: string } | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const filters = ref<QueryModel>({ warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' }); const editorOpen = ref(false); const saving = ref(false); const editing = ref<OverseasLocationDto | null>(null)
|
||||
const form = reactive({ warehouseId: '', zoneCode: '', zoneName: '', locationCode: '', locationName: '', locationType: 'NORMAL' as const, maxWeightKg: null as number | null, status: 'ACTIVE' as 'ACTIVE' | 'INACTIVE', remark: '' })
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'warehouseId', type: 'select', label: '海外仓', width: 260, options: warehouses.value.map(item => ({ value: String(item.id), label: `${item.warehouseName}(${item.warehouseCode})` })), props: { filterable: true, clearable: true, disabled: contextLoading.value } },
|
||||
{ key: 'zoneCode', type: 'input', label: '库区编码', width: 180 }, { key: 'locationCode', type: 'input', label: '库位编码', width: 180 },
|
||||
{ key: 'locationType', type: 'select', label: '类型', width: 150, options: [{ label: '普通库位', value: 'NORMAL' }, { label: 'A0共享溢出位', value: 'SHARED_OVERFLOW' }, { label: '虚拟收货位', value: 'VIRTUAL' }] },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 140, options: [{ label: '可用', value: 'ACTIVE' }, { label: '空闲', value: 'IDLE' }, { label: '已占用', value: 'OCCUPIED' }, { label: '已停用', value: 'DISABLED' }] },
|
||||
])
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' }, { key: 'locationCode', prop: 'locationCode', label: '库位编码', minWidth: 180 }, { key: 'warehouse', label: '海外仓', minWidth: 190, slotName: 'warehouse' }, { key: 'zone', label: '库区', minWidth: 150, slotName: 'zone' }, { key: 'locationType', label: '类型', width: 130, slotName: 'locationType' }, { key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'occupiedPalletCount', prop: 'occupiedPalletCount', label: '托盘数', width: 100, align: 'right' }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 }]
|
||||
const typeLabels: Record<OverseasLocationType, string> = { VIRTUAL: '虚拟收货位', NORMAL: '普通库位', SHARED_OVERFLOW: 'A0共享溢出位' }; const statusLabels: Record<OverseasLocationStatus, string> = { ACTIVE: '可用', IDLE: '空闲', OCCUPIED: '已占用', INACTIVE: '已停用', DISABLED: '已停用' }
|
||||
const selectedWarehouse = computed(() => warehouses.value.find(item => String(item.id) === String(form.warehouseId)))
|
||||
function isA0(row: OverseasLocationDto) { return row.sharedOverflow || row.locationType === 'SHARED_OVERFLOW' || row.locationCode.toUpperCase() === 'A0' }
|
||||
function canEdit(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
|
||||
function canStatus(row: OverseasLocationDto) { return access.can(permission.locationEdit) && !isA0(row) && row.locationType === 'NORMAL' && (row.status === 'ACTIVE' || row.status === 'INACTIVE' || row.status === 'DISABLED') }
|
||||
function canDelete(row: OverseasLocationDto) { return access.can(permission.locationDelete) && !isA0(row) && row.status === 'DISABLED' && Number(row.occupiedPalletCount || 0) === 0 }
|
||||
function actionError(error: unknown, fallback: string) { ElMessage.error(getApiErrorDetails(error, fallback).message) }
|
||||
async function loadWarehouses() { contextLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records } catch (error) { actionError(error, '海外仓列表加载失败') } finally { contextLoading.value = false } }
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); const warehouseId = String(filters.value.warehouseId || ''); if (!warehouseId) { rows.value = []; total.value = 0; return }; loading.value = true; loadError.value = null; try { const result = await listOverseasLocations(warehouseId, { page: page.value, size: pageSize.value, zoneCode: String(filters.value.zoneCode || '').trim() || undefined, keyword: String(filters.value.locationCode || '').trim() || undefined, locationType: String(filters.value.locationType || '') as OverseasLocationType | '', status: (String(filters.value.status || '') === 'DISABLED' ? 'INACTIVE' : String(filters.value.status || '')) as OverseasLocationStatus | '' }); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外库位加载失败') } finally { loading.value = false } }
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function resetForm() { Object.assign(form, { warehouseId: String(filters.value.warehouseId || ''), zoneCode: '', zoneName: '', locationCode: '', locationName: '', locationType: 'NORMAL', maxWeightKg: null, status: 'ACTIVE', remark: '' }); editing.value = null }
|
||||
function openCreate() { resetForm(); editorOpen.value = true }
|
||||
function openEdit(row: OverseasLocationDto) { if (!canEdit(row)) return; Object.assign(form, { warehouseId: row.warehouseId, zoneCode: row.zoneCode, zoneName: row.zoneName || '', locationCode: row.locationCode, locationName: row.locationName || '', locationType: 'NORMAL', maxWeightKg: row.maxLoadKg == null ? null : Number(row.maxLoadKg), status: row.status === 'DISABLED' ? 'INACTIVE' : 'ACTIVE', remark: row.remark || '' }); editing.value = row; editorOpen.value = true }
|
||||
async function save() { if (!form.warehouseId || !form.zoneCode.trim() || !form.locationCode.trim() || form.maxWeightKg == null) { ElMessage.warning('请填写海外仓、库区编码、库位编码和最大承重'); return }; saving.value = true; try { const payload = { zoneCode: form.zoneCode.trim(), locationCode: form.locationCode.trim(), locationType: 'NORMAL' as const, maxLoadKg: form.maxWeightKg, status: form.status, remark: form.remark.trim() || null }; if (editing.value) await updateOverseasLocation(form.warehouseId, editing.value.id, { ...payload, version: editing.value.version }); else await createOverseasLocation(form.warehouseId, payload); editorOpen.value = false; await load() } catch (error) { actionError(error, '保存库位失败') } finally { saving.value = false } }
|
||||
async function toggleStatus(row: OverseasLocationDto) { if (!canStatus(row)) return; const next = row.status === 'DISABLED' ? 'ACTIVE' : 'INACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'}库位“${row.locationCode}”?`, '请确认'); await changeOverseasLocationStatus(row.warehouseId, row.id, { zoneCode: row.zoneCode, locationCode: row.locationCode, locationType: 'NORMAL', maxLoadKg: Number(row.maxLoadKg || 0), status: next, remark: row.remark || null, version: row.version }); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '更新库位状态失败') } }
|
||||
async function remove(row: OverseasLocationDto) { if (!canDelete(row)) return; try { await ElMessageBox.confirm(`确认删除库位“${row.locationCode}”?`, '请确认', { type: 'warning' }); await deleteOverseasLocation(row.warehouseId, row.id, row.version); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '删除库位失败') } }
|
||||
onMounted(() => { void loadWarehouses() })
|
||||
</script>
|
||||
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="海外仓库位管理"><template #toolbar><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外库位筛选" preference-key="overseas-locations-query" @search="search" @reset="page = 1; load()" /></template><template #state><LoadErrorState v-if="loadError" title="海外库位加载失败" :message="loadError.message" :loading="loading" @retry="load" /><el-alert v-else-if="!filters.warehouseId" title="请选择海外仓后查询库位" type="info" :closable="false" /></template><section v-if="!loadError && filters.warehouseId" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无库位" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.locationCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template><template #actions="{ row }"><div class="density-actions"><el-button v-if="canEdit(row as OverseasLocationDto)" size="small" class="density-action" @click="openEdit(row as OverseasLocationDto)">编辑</el-button><el-button v-if="canStatus(row as OverseasLocationDto)" size="small" class="density-action" :data-tone="(row as OverseasLocationDto).status === 'DISABLED' ? 'success' : 'danger'" @click="toggleStatus(row as OverseasLocationDto)">{{ (row as OverseasLocationDto).status === 'DISABLED' ? '启用' : '停用' }}</el-button><el-button v-if="canDelete(row as OverseasLocationDto)" size="small" class="density-action" data-tone="danger" @click="remove(row as OverseasLocationDto)">删除</el-button></div></template><template #warehouse="{ row }">{{ (row as OverseasLocationDto).warehouseName || selectedWarehouse?.warehouseName || '—' }}</template><template #zone="{ row }">{{ (row as OverseasLocationDto).zoneName || '—' }}({{ (row as OverseasLocationDto).zoneCode }})</template><template #locationType="{ row }"><el-tag :type="isA0(row as OverseasLocationDto) ? 'warning' : 'info'">{{ typeLabels[(row as OverseasLocationDto).locationType] }}</el-tag></template><template #status="{ row }"><el-tag>{{ statusLabels[(row as OverseasLocationDto).status] }}</el-tag></template><template #pagination-left>共 {{ total }} 个库位</template></DataTable></section></ListPageLayout><AppDialog v-model="editorOpen" :title="editing ? '编辑海外库位' : '新增海外库位'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" :show-confirm="false"><el-form label-position="top" :disabled="saving"><div class="editor-grid"><el-form-item label="海外仓"><el-select v-model="form.warehouseId" disabled><el-option v-if="selectedWarehouse" :value="selectedWarehouse.id" :label="`${selectedWarehouse.warehouseName}(${selectedWarehouse.warehouseCode})`" /></el-select></el-form-item><el-form-item label="库位编码"><el-input v-model="form.locationCode" maxlength="50" /></el-form-item><el-form-item label="库区编码"><el-input v-model="form.zoneCode" maxlength="50" /></el-form-item><el-form-item label="库区名称"><el-input v-model="form.zoneName" maxlength="100" /></el-form-item><el-form-item label="库位名称"><el-input v-model="form.locationName" maxlength="100" /></el-form-item><el-form-item label="最大承重(kg)"><el-input-number v-model="form.maxWeightKg" :min="0" :precision="3" controls-position="right" /></el-form-item></div><el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" /></el-form-item></el-form><template #footer><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存库位</el-button></template></AppDialog></AdminPage></template>
|
||||
|
||||
<style scoped>.editor-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px}.editor-grid :deep(.el-select),.editor-grid :deep(.el-input-number){width:100%}@media(max-width:720px){.editor-grid{grid-template-columns:1fr}}</style>
|
||||
@@ -37,6 +37,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
['PalletsView.vue', 'overseas-pallets-query'],
|
||||
['CartonsView.vue', 'overseas-cartons-query'],
|
||||
['ProductsView.vue', 'overseas-products-query'],
|
||||
['LocationsView.vue', 'overseas-locations-query'],
|
||||
])
|
||||
const keys = [...expected.entries()].map(([fileName, preferenceKey]) => {
|
||||
const value = source(fileName)
|
||||
@@ -127,6 +128,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(value).toContain('@back="back"')
|
||||
expect(value).not.toContain('AppDrawer')
|
||||
if (fileName === 'LastMileOrderDetailView.vue') expect(value).toContain('title="新增费用"')
|
||||
else if (fileName === 'InboundOrderDetailView.vue') expect(value).toContain('移库')
|
||||
else expect(value).not.toContain('AppDialog')
|
||||
}
|
||||
})
|
||||
@@ -342,10 +344,17 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(value).toContain('<RowActions :actions="rowActions(row as DispatchListItem)"')
|
||||
expect(value).toContain("{ key: 'detail', label: '查看', priority: 300 }")
|
||||
expect(value).toContain("if (command === 'detail') openDetail(row)")
|
||||
} else if (fileName === 'LastMileOrdersView.vue') {
|
||||
expect(value).toContain('<RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)"')
|
||||
expect(value).toContain("{ key: 'detail', label: '查看', priority: 500 }")
|
||||
expect(value).toContain("if (command === 'detail') openDetail(row)")
|
||||
} else {
|
||||
expect(value).toMatch(/@click="openDetail\(row(?: as [^"]+)?\)">(?:<Eye :size="15" \/>)?查看<\/el-button>/)
|
||||
}
|
||||
}
|
||||
const locations = source('LocationsView.vue')
|
||||
expect(locations).toContain('海外仓库位管理')
|
||||
expect(locations).toContain('库位编码')
|
||||
})
|
||||
|
||||
it('connects the dispatch prototype detail to the real overseas API', () => {
|
||||
@@ -387,12 +396,12 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(columnKeys('LastMileOrdersView.vue')).toEqual([
|
||||
'actions', 'selection', 'orderNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse',
|
||||
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
|
||||
'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
|
||||
'cartons', 'cartonDifference', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
|
||||
'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
|
||||
'createdAt', 'internalRemark', 'customerRemark', 'warehouseRemark',
|
||||
])
|
||||
expect(columnKeys('InboundOrdersView.vue')).toEqual([
|
||||
'actions', 'selection', 'orderNo', 'inboundCode', 'lxCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'volume',
|
||||
'actions', 'selection', 'orderNo', 'inboundCode', 'lxCode', 'type', 'warehouse', 'fbaWarehouse', 'owner', 'cartons', 'pickedCartons', 'grossWeight', 'netWeight', 'volume',
|
||||
'pallets', 'assigned', 'mergeAllowed', 'urgent', 'status', 'appointmentStatus', 'dispatchStatus', 'createdAt',
|
||||
])
|
||||
expect(columnKeys('InventoryView.vue')).toEqual([
|
||||
|
||||
@@ -130,6 +130,12 @@ export const overseasWarehouseRoutes = [
|
||||
component: () => import('./pages/PalletDetailView.vue'),
|
||||
meta: { title: '托盘详情', description: '查看托盘基础信息与使用信息', permission: permission.palletList, showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/locations',
|
||||
name: 'overseas-locations',
|
||||
component: () => import('./pages/LocationsView.vue'),
|
||||
meta: { title: '库位管理', description: '维护海外仓普通库位与 A0 共享溢出位', permission: permission.locationList },
|
||||
},
|
||||
{
|
||||
path: 'general/mojia-sku-sync',
|
||||
name: 'mojia-sku-sync',
|
||||
|
||||
Reference in New Issue
Block a user