Release/test 合并上生产 #2
@@ -3,7 +3,7 @@ import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
import type { DeliveryOrderRow } from './deliveryOrders'
|
||||
|
||||
export interface OutboundRow { id: string; outboundNo: string; dispatchNo?: string | null; overseasWarehouseId?: string | null; lxCodes?: string | null; status?: string | null; palletCount?: number | null; packageCount?: number | null; createdAt?: string | null }
|
||||
export interface OutboundRow { id: string; outboundNo: string; dispatchNo?: string | null; overseasWarehouseId?: string | null; lxCodes?: string | null; status?: string | null; palletCount?: number | null; packageCount?: number | null; createdAt?: string | null; outboundType?: string | null }
|
||||
export interface OutboundPalletRow {
|
||||
id?: string | null
|
||||
palletNo?: string | null
|
||||
@@ -18,6 +18,7 @@ export interface OutboundPalletRow {
|
||||
}
|
||||
export interface OutboundDetail { outbound: OutboundRow; pallets?: OutboundPalletRow[]; delivery?: DeliveryOrderRow | null }
|
||||
export interface OutboundQuery { page: number; size: number; documentNo?: string; lxCode?: string; warehouseId?: string; status?: string }
|
||||
export interface ReturnOutboundCandidate { returnNo?: string | null; overseasWarehouseId?: string | null; cartonMark?: string | null; fbaCartonMark?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; totalQuantity?: number | null; status?: string | null }
|
||||
export async function listOutbounds(query: OutboundQuery) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<OutboundRow>>, ApiEnvelope<OverseasPagePayload<OutboundRow>>>('/platform/v1/last-leg/outbound-orders', { params: query })
|
||||
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||
@@ -30,6 +31,14 @@ export async function createOneClickDropshipOutbound(customerId: string, payload
|
||||
const response = await overseasHttp.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function listReturnOutboundCandidates(customerId: string, warehouseId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<ReturnOutboundCandidate[]>, ApiEnvelope<ReturnOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function createReturnOutbound(customerId: string, payload: { recipient: string; overseasWarehouseId: string; returnNo: string; cartonMarks: string[]; remark?: string; idempotencyKey: string }) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/return', payload, { params: { customerId } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function cancelOutbound(outboundNo: string, reason: string) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<null>, ApiEnvelope<null>>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}/cancel`, { reason })
|
||||
return unwrapApiResponse(response)
|
||||
|
||||
@@ -64,6 +64,9 @@ export interface RelabelSummaryDto {
|
||||
totalPackageCount?: number | null
|
||||
notifiedPackageCount?: number | null
|
||||
skuCodes?: string | null
|
||||
totalFileId?: string | null
|
||||
totalFileName?: string | null
|
||||
totalFileUrl?: string | null
|
||||
chargeAmount?: number | string | null
|
||||
chargeCurrency?: string | null
|
||||
walletStatus?: string | null
|
||||
@@ -79,6 +82,7 @@ export interface RelabelItemDto {
|
||||
notifiedPackageCount?: number | null
|
||||
newSkuCode?: string | null
|
||||
labelRequirement?: string | null
|
||||
relabelFileId?: string | null
|
||||
relabelFileName?: string | null
|
||||
relabelFileUrl?: string | null
|
||||
shipmentNo?: string | null
|
||||
@@ -144,3 +148,20 @@ export async function changeRelabelOrderStatus(relabelNo: string, status: string
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function uploadRelabelPdf(relabelNo: string, file: File, scope: 'TOTAL' | 'ITEM', itemId?: string) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
const response = await overseasHttp.post<ApiEnvelope<RelabelDetailDto>, ApiEnvelope<RelabelDetailDto>>(
|
||||
`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files`, body,
|
||||
{ params: { scope, itemId }, headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function downloadRelabelPdf(relabelNo: string, fileId: string, scope: 'TOTAL' | 'ITEM', itemId?: string) {
|
||||
return overseasHttp.get<Blob, Blob>(
|
||||
`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files/${encodeURIComponent(fileId)}/download`,
|
||||
{ params: { scope, itemId }, responseType: 'blob' },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { 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; 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; 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 ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string }
|
||||
export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string }
|
||||
|
||||
@@ -14,27 +14,31 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { cancelOutbound, createOneClickDropshipOutbound, listOutbounds, type OutboundRow } from '../api/outbounds'
|
||||
import { cancelOutbound, createOneClickDropshipOutbound, createReturnOutbound, listReturnOutboundCandidates, listOutbounds, type OutboundRow, type ReturnOutboundCandidate } from '../api/outbounds'
|
||||
import { listInventoryBalances, listInventoryPositions, type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
|
||||
const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const rows = ref<OutboundRow[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ documentNo: '', lxCode: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const cartonMarks = ref(''); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const inventoryLoading = ref(false)
|
||||
const filters = ref<QueryModel>({ documentNo: '', lxCode: '', warehouseId: '', status: '' }); const createOpen = ref(false); const saving = ref(false); const recipient = ref(''); const remark = ref(''); const quantity = ref<number | undefined>(); const cartonMarks = ref(''); const selectedReturnMarks = ref<string[]>([]); const selectedCustomerId = ref(''); const selectedPositionId = ref(''); const positionOptions = ref<Array<{ balance: InventoryBalanceDto; position: InventoryPositionDto }>>([]); const returnCandidates = ref<ReturnOutboundCandidate[]>([]); const selectedWarehouseId = ref(''); const selectedReturnNo = ref(''); const outboundType = ref<'ONE_CLICK_DROPSHIP' | 'RETURN_OUTBOUND'>('ONE_CLICK_DROPSHIP'); const inventoryLoading = ref(false)
|
||||
const customerOptions = computed(() => {
|
||||
const customers = new Map<string, string>()
|
||||
positionOptions.value.forEach(({ balance }) => customers.set(balance.customerId, balance.customerName?.trim() || '客户名称待补充'))
|
||||
return [...customers].map(([value, label]) => ({ value, label }))
|
||||
})
|
||||
const availablePositionOptions = computed(() => positionOptions.value.filter(({ balance }) => balance.customerId === selectedCustomerId.value))
|
||||
const warehouseOptions = computed(() => [...new Map(positionOptions.value.filter(({ balance }) => balance.customerId === selectedCustomerId.value).map(({ balance }) => [balance.overseasWarehouseId, balance.overseasWarehouseName || balance.overseasWarehouseId])).entries()].map(([value, label]) => ({ value, label: label || value })))
|
||||
const returnMarksModel = computed({ get: () => selectedReturnMarks.value, set: (value: string[]) => { selectedReturnMarks.value = value } })
|
||||
const oneClickMarks = computed(() => typeof cartonMarks.value === 'string' ? cartonMarks.value.split(/[,\n,]+/).map(value => value.trim()).filter(Boolean) : [])
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
|
||||
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 220 }, { key: 'lxCode', type: 'input', label: 'LX代码', placeholder: '请输入领星入库单代码', width: 220 }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓', width: 220 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }]
|
||||
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, lxCode: String(filters.value.lxCode || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }); rows.value = result.records.map((row) => ({ ...row, createdAt: formatOverseasDateTime(row.createdAt) })); total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
|
||||
async function openCreate() { createOpen.value = true; selectedCustomerId.value = ''; selectedPositionId.value = ''; quantity.value = undefined; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '可用库存加载失败').message) } finally { inventoryLoading.value = false } }
|
||||
function selectCustomer() { selectedPositionId.value = ''; quantity.value = undefined }
|
||||
async function submit() { const selected = availablePositionOptions.value.find(item => item.position.id === selectedPositionId.value); const marks = cartonMarks.value.split(/[,\n,]+/).map(value => value.trim()).filter(Boolean); if (!selectedCustomerId.value || !recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请选择客户,填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; if (marks.length && marks.length !== quantity.value) { ElMessage.warning('指定箱唛数量必须等于出库数量'); return }; saving.value = true; try { await createOneClickDropshipOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined, cartonMarks: marks.length ? marks : undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('出库单已创建并推送PDA拣货任务'); createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '一件代发出库失败').message) } finally { saving.value = false } }
|
||||
async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedPositionId.value = ''; selectedWarehouseId.value = ''; selectedReturnNo.value = ''; returnCandidates.value = []; selectedReturnMarks.value = []; quantity.value = undefined; cartonMarks.value = ''; recipient.value = ''; remark.value = ''; inventoryLoading.value = true; try { const balances = await listInventoryBalances({ page: 1, size: 200, inventoryStatus: 'AVAILABLE' }); const groups = await Promise.all(balances.records.map(async balance => (await listInventoryPositions(balance.id, { inventoryStatus: 'AVAILABLE' })).map(position => ({ balance, position })))); positionOptions.value = groups.flat().filter(item => item.position.availablePackages > 0) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '可用库存加载失败').message) } finally { inventoryLoading.value = false } }
|
||||
function selectCustomer() { selectedPositionId.value = ''; quantity.value = undefined; selectedWarehouseId.value = ''; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = [] }
|
||||
async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { returnCandidates.value = await listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '退货箱唛加载失败').message) } finally { inventoryLoading.value = false } }
|
||||
async function submit() { const marks = String(cartonMarks.value).split(/[,\n,]+/).map(value => value.trim()).filter(Boolean); if (!selectedCustomerId.value || !recipient.value.trim()) { ElMessage.warning('请选择客户并填写收件信息'); return }; saving.value = true; try { if (outboundType.value === 'RETURN_OUTBOUND') { if (!selectedWarehouseId.value || !selectedReturnNo.value || !selectedReturnMarks.value.length) { ElMessage.warning('请选择海外仓、退货单和至少一个箱唛'); return }; await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` }); ElMessage.success('退货出库单已创建并推送PDA箱唛拣货任务') } else { const selected = availablePositionOptions.value.find(item => item.position.id === selectedPositionId.value); if (!selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; if (marks.length && marks.length !== quantity.value) { ElMessage.warning('指定箱唛数量必须等于出库数量'); return }; await createOneClickDropshipOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined, cartonMarks: marks.length ? marks : undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('一件代发出库单已创建并推送PDA拣货任务') } createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '出库单创建失败').message) } finally { saving.value = false } }
|
||||
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
|
||||
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.status === 'PICKED' }
|
||||
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
|
||||
@@ -48,12 +52,12 @@ onMounted(load)
|
||||
<template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
<template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />一件代发出库</el-button></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" type="primary" plain @click="openDetail(row as OutboundRow)"><Eye :size="15" />详情</el-button><el-button v-if="canCancel(row as OutboundRow)" size="small" type="danger" plain @click="cancel(row as OutboundRow)"><XCircle :size="15" />取消</el-button></template>
|
||||
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
<AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form class="create-outbound-form" label-position="top"><el-form-item label="客户" required><el-select v-model="selectedCustomerId" :loading="inventoryLoading" filterable placeholder="请选择库存所属客户" @change="selectCustomer"><el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" :disabled="!selectedCustomerId" filterable placeholder="请先选择客户,再选择有可用库存的托盘"><el-option v-for="item in availablePositionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || '托盘号待补充'} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" placeholder="请输入出库数量" /></el-form-item><el-form-item label="指定箱唛(可选)"><el-input v-model="cartonMarks" type="textarea" :rows="3" placeholder="请输入箱唛,多个箱唛用逗号或换行分隔" /><div class="form-hint">填写后按指定箱唛出库,数量必须与出库数量一致。</div></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" placeholder="请输入备注(选填)" /></el-form-item></el-form></AppDialog>
|
||||
<AppDialog v-model="createOpen" title="新建出库单" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form class="create-outbound-form" label-position="top"><el-form-item label="出库类型" required><el-radio-group v-model="outboundType"><el-radio-button label="ONE_CLICK_DROPSHIP">一件代发出库</el-radio-button><el-radio-button label="RETURN_OUTBOUND">退货出库</el-radio-button></el-radio-group></el-form-item><el-form-item label="客户" required><el-select v-model="selectedCustomerId" :loading="inventoryLoading" filterable placeholder="请选择库存所属客户" @change="selectCustomer"><el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><template v-if="outboundType === 'ONE_CLICK_DROPSHIP'"><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" :disabled="!selectedCustomerId" filterable placeholder="请先选择客户,再选择有可用库存的托盘"><el-option v-for="item in availablePositionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || '托盘号待补充'} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" placeholder="请输入出库数量" /></el-form-item><el-form-item label="指定箱唛(可选)"><el-input v-model="cartonMarks" type="textarea" :rows="3" placeholder="请输入箱唛,多个箱唛用逗号或换行分隔" /><div class="form-hint">填写后按指定箱唛出库,数量必须与出库数量一致。</div></el-form-item><el-alert title="一件代发按托盘维度作业,PDA完成拣货后扣减库存" type="info" :closable="false" /></template><template v-else><el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item><el-form-item label="尾程退货单" required><el-select v-model="selectedReturnNo" :disabled="!selectedWarehouseId" :loading="inventoryLoading" filterable placeholder="请选择有可用箱唛的退货单"><el-option v-for="item in [...new Set(returnCandidates.map(candidate => candidate.returnNo).filter(Boolean))]" :key="String(item ?? null)" :value="String(item ?? null)" :label="String(item ?? null)" /></el-select></el-form-item><el-form-item label="出库箱唛" required><el-select v-model="selectedReturnMarks" multiple :disabled="!selectedReturnNo" :loading="inventoryLoading" filterable collapse-tags placeholder="请选择要出库的箱唛"><el-option v-for="item in returnCandidates.filter(candidate => candidate.returnNo === selectedReturnNo)" :key="item.cartonMark || ''" :value="item.cartonMark || ''" :label="`${item.cartonMark} · ${item.fbaCartonMark || 'FBA箱唛待补充'} · ${item.destinationWarehouseCode || '目的仓待补充'}`" /></el-select></el-form-item><el-alert title="退货出库按尾程退货单的箱唛维度拣货,PDA扫描箱唛后完成出库" type="info" :closable="false" /></template><el-form-item label="备注"><el-input v-model="remark" type="textarea" placeholder="请输入备注(选填)" /></el-form-item></el-form></AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, onBeforeUnmount } from 'vue'
|
||||
import { Eye, FileUp } from '@lucide/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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getRelabelOrder, type RelabelDetailDto } from '../api/relabelOrders'
|
||||
import { downloadRelabelPdf, getRelabelOrder, uploadRelabelPdf, type RelabelDetailDto } from '../api/relabelOrders'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -14,6 +17,10 @@ const orderId = computed(() => String(route.params.orderId || ''))
|
||||
const detail = ref<RelabelDetailDto | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const uploading = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewTitle = ref('')
|
||||
const previewOpen = ref(false)
|
||||
const groups = [
|
||||
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] },
|
||||
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '预算总价'] },
|
||||
@@ -57,19 +64,36 @@ async function load() {
|
||||
}
|
||||
function back() { void router.push({ name: 'overseas-relabel-orders' }) }
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) })
|
||||
async function uploadPdf(event: Event, scope: 'TOTAL' | 'ITEM', itemId?: string) {
|
||||
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
|
||||
if (!file || !detail.value) return
|
||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) { ElMessage.warning('只支持PDF文件'); return }
|
||||
if (file.size > 50 * 1024 * 1024) { ElMessage.warning('PDF文件不能超过50MB'); return }
|
||||
uploading.value = true
|
||||
try { detail.value = await uploadRelabelPdf(orderId.value, file, scope, itemId); ElMessage.success(scope === 'TOTAL' ? '总PDF上传成功' : '明细PDF上传成功') }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, 'PDF上传失败').message) }
|
||||
finally { uploading.value = false }
|
||||
}
|
||||
async function previewPdf(fileId: string | null | undefined, name: string, scope: 'TOTAL' | 'ITEM', itemId?: string) {
|
||||
if (!fileId) return
|
||||
try { const blob = await downloadRelabelPdf(orderId.value, fileId, scope, itemId); if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); previewUrl.value = URL.createObjectURL(blob); previewTitle.value = name; previewOpen.value = true }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, 'PDF预览加载失败').message) }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverseasReadOnlyDetail title="换标单详情" reference-label="换标单号" :reference-value="referenceValue" back-label="返回换标单" domain="换标单详情" :groups="groups" :field-values="fieldValues" :pending="false" :status-text="detail ? status(detail.application.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back">
|
||||
<template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<template #content-after>
|
||||
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button size="small" plain @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
|
||||
<section class="relabel-mark-list" aria-label="箱唛换标信息">
|
||||
<div class="relabel-mark-list__head"><h2>箱唛换标信息</h2><span>共 {{ detail?.items.length || 0 }} 条</span></div>
|
||||
<div class="relabel-mark-list__scroll">
|
||||
<table>
|
||||
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>备注</th></tr></thead>
|
||||
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>明细PDF</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationWarehouseId) }}</td><td>{{ text(item.remark) }}</td></tr>
|
||||
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions"><strong>{{ item.relabelFileName }}</strong><el-button size="small" link type="primary" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)"><Eye :size="14" />预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
|
||||
<tr v-if="!detail?.items.length"><td class="empty-cell" colspan="8">暂无箱唛换标信息</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -77,6 +101,7 @@ onMounted(load)
|
||||
</section>
|
||||
</template>
|
||||
</OverseasReadOnlyDetail>
|
||||
<AppDialog v-if="previewUrl" v-model="previewOpen" :title="previewTitle" width="min(900px, calc(100vw - 28px))" :show-footer="false"><div class="pdf-preview"><iframe :src="previewUrl" :title="`${previewTitle}预览`" /></div></AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -91,5 +116,12 @@ td { min-height: 46px; padding: 12px; border-bottom: 1px solid var(--cc-border);
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover td { background: #fafcff; }
|
||||
.mark-code { color: #245b9e; font-weight: 600; }
|
||||
.relabel-document-panel { grid-column: 1 / -1; padding: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
.document-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px; border:1px solid var(--cc-border); border-radius:6px; }
|
||||
.file-picker, .inline-file-picker { display:inline-flex; align-items:center; gap:5px; color:var(--el-color-primary); cursor:pointer; }
|
||||
.file-picker input, .inline-file-picker input { display:none; }
|
||||
.document-actions { display:inline-flex; align-items:center; gap:8px; }
|
||||
.pdf-preview { overflow:hidden; border:1px solid var(--cc-border); border-radius:6px; background:#f5f7fa; }
|
||||
.pdf-preview iframe { display:block; width:100%; height:min(72vh,760px); border:0; background:#fff; }
|
||||
.empty-cell { height: 96px; color: var(--cc-muted); text-align: center; }
|
||||
</style>
|
||||
|
||||
@@ -48,8 +48,8 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<section class="surface-panel query-panel"><el-select v-model="query.customerId" clearable filterable placeholder="全部客户" :loading="optionsLoading"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="全部海外仓" :loading="optionsLoading"><el-option v-for="item in warehouseOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.cartonMark" clearable placeholder="箱号/箱唛" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" /><el-select v-if="mode==='cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select><el-button type="primary" @click="search"><Search :size="16" />查询</el-button></section>
|
||||
<section class="surface-panel table-panel"><el-table v-if="mode==='inventory' && inventoryTab==='stock'" v-loading="loading" :data="stockRows"><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column prop="totalQuantity" label="总库存" align="right" /><el-table-column prop="availableQuantity" label="可用库存" align="right" /><el-table-column prop="updatedAt" label="更新时间" min-width="180" /></el-table>
|
||||
<el-table v-else-if="mode==='inventory'" v-loading="loading" :data="movementRows"><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column label="业务类型" min-width="120"><template #default="{row}">{{ row.movementType === 'RECEIPT' ? '退货签收' : row.movementType === 'MERGE' ? '退货合箱' : '退货出库' }}</template></el-table-column><el-table-column prop="referenceNo" label="关联单号/箱号" min-width="220" /><el-table-column prop="totalDelta" label="总库存变动" align="right" /><el-table-column prop="availableDelta" label="可用库存变动" align="right" /><el-table-column prop="occurredAt" label="发生时间" min-width="180" /></el-table>
|
||||
<el-table v-else v-loading="loading" :data="cartonRows"><el-table-column label="操作" width="140" fixed="left"><template #default="{row}"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column><el-table-column prop="cartonMark" label="箱号" min-width="220" /><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="sourceReturnNo" label="来源退货单" min-width="220" /><el-table-column label="状态" width="100"><template #default="{row}">{{ statusLabel(row.status) }}</template></el-table-column><el-table-column prop="skuTypes" label="SKU种类" align="right" /><el-table-column prop="totalQuantity" label="总件数" align="right" /><el-table-column label="箱规" min-width="190"><template #default="{row}">{{ row.length ? `${row.length}×${row.width}×${row.height} cm` : '—' }}</template></el-table-column><el-table-column prop="weight" label="重量(kg)" align="right" /><el-table-column prop="createdAt" label="创建时间" min-width="180" /></el-table>
|
||||
<el-table v-else v-loading="loading" :data="cartonRows"><el-table-column label="操作" width="140" fixed="left"><template #default="{row}"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column><el-table-column prop="cartonMark" label="箱号" min-width="220" /><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="sourceReturnNo" label="来源退货单" min-width="220" /><el-table-column prop="fbaCartonMark" label="FBA箱唛" min-width="180" /><el-table-column label="目的仓" min-width="180"><template #default="{row}">{{ row.destinationWarehouseCode || row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column prop="relabelNo" label="换标单号" min-width="200" /><el-table-column label="换标状态" width="110"><template #default="{row}">{{ row.relabelStatus === 'COMPLETED' ? '已完成' : row.relabelStatus === 'BOUND' ? '已绑定' : row.relabelStatus || '未绑定' }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{row}">{{ statusLabel(row.status) }}</template></el-table-column><el-table-column prop="skuTypes" label="SKU种类" align="right" /><el-table-column prop="totalQuantity" label="总件数" align="right" /><el-table-column label="箱规" min-width="190"><template #default="{row}">{{ row.length ? `${row.length}×${row.width}×${row.height} cm` : '—' }}</template></el-table-column><el-table-column prop="weight" label="重量(kg)" align="right" /><el-table-column prop="createdAt" label="创建时间" min-width="180" /></el-table>
|
||||
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, sizes, prev, pager, next" :page-sizes="[20,50,100]" @current-change="load" @size-change="query.page=1;load()" /></section>
|
||||
<AppDialog v-model="detailOpen" title="退货箱号详情" width="min(1080px, calc(100vw - 56px))" :show-footer="false"><template v-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="箱号">{{detail.cartonMark}}</el-descriptions-item><el-descriptions-item label="客户">{{customerName(detail.customerId)}}</el-descriptions-item><el-descriptions-item label="海外仓">{{warehouseName(detail.overseasWarehouseId)}}</el-descriptions-item><el-descriptions-item label="来源退货单">{{detail.sourceReturnNo||'—'}}</el-descriptions-item><el-descriptions-item label="状态">{{statusLabel(detail.status)}}</el-descriptions-item><el-descriptions-item label="箱规">{{detail.length?`${detail.length}×${detail.width}×${detail.height} cm`:'—'}}</el-descriptions-item><el-descriptions-item label="重量">{{detail.weight?`${detail.weight} kg`:'—'}}</el-descriptions-item></el-descriptions><el-table :data="detail.items" class="detail-items"><el-table-column prop="skuCode" label="SKU" /><el-table-column prop="productNameZh" label="中文品名" /><el-table-column prop="quantity" label="数量" align="right" /></el-table></template></AppDialog>
|
||||
<AppDialog v-model="detailOpen" title="退货箱号详情" width="min(1080px, calc(100vw - 56px))" :show-footer="false"><template v-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="箱号">{{detail.cartonMark}}</el-descriptions-item><el-descriptions-item label="客户">{{customerName(detail.customerId)}}</el-descriptions-item><el-descriptions-item label="海外仓">{{warehouseName(detail.overseasWarehouseId)}}</el-descriptions-item><el-descriptions-item label="来源退货单">{{detail.sourceReturnNo||'—'}}</el-descriptions-item><el-descriptions-item label="FBA箱唛">{{detail.fbaCartonMark||'—'}}</el-descriptions-item><el-descriptions-item label="目的仓">{{detail.destinationWarehouseCode||detail.destinationWarehouseName||'—'}}</el-descriptions-item><el-descriptions-item label="换标单号">{{detail.relabelNo||'—'}}</el-descriptions-item><el-descriptions-item label="换标状态">{{detail.relabelStatus === 'COMPLETED' ? '已完成' : detail.relabelStatus === 'BOUND' ? '已绑定' : detail.relabelStatus || '未绑定'}}</el-descriptions-item><el-descriptions-item label="状态">{{statusLabel(detail.status)}}</el-descriptions-item><el-descriptions-item label="箱规">{{detail.length?`${detail.length}×${detail.width}×${detail.height} cm`:'—'}}</el-descriptions-item><el-descriptions-item label="重量">{{detail.weight?`${detail.weight} kg`:'—'}}</el-descriptions-item></el-descriptions><el-table :data="detail.items" class="detail-items"><el-table-column prop="skuCode" label="SKU" /><el-table-column prop="productNameZh" label="中文品名" /><el-table-column prop="quantity" label="数量" align="right" /></el-table></template></AppDialog>
|
||||
</AdminPage></template>
|
||||
<style scoped>.return-inventory-page{display:grid;gap:16px}.query-panel{display:flex;gap:10px;flex-wrap:wrap;padding:16px}.query-panel .el-input,.query-panel .el-select{width:180px}.table-panel{padding:16px}.el-pagination{justify-content:flex-end;margin-top:16px}.detail-items{margin-top:20px}</style>
|
||||
|
||||
Reference in New Issue
Block a user