Merge branch 'dev' of http://git.buggoglobal.com/html/admin-web into release/test
This commit is contained in:
@@ -219,6 +219,7 @@ export interface TransferDeliveryPayload {
|
||||
paidAt?: string; paymentMethod?: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'; paymentReferenceNo?: string
|
||||
paymentVoucherFileIds?: string[]; chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
|
||||
periodStart?: string; periodEnd?: string; remark?: string }>
|
||||
palletCartonMarks?: Record<string, string[]>
|
||||
}
|
||||
export async function transferDispatchToDelivery(dispatchNo: string, payload: TransferDeliveryPayload) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<TransferDeliveryResult>, ApiEnvelope<TransferDeliveryResult>>(
|
||||
|
||||
@@ -3,17 +3,23 @@ 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
|
||||
cartonMarks?: string | null
|
||||
inboundCode?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
destinationWarehouseName?: string | null
|
||||
skuCode?: string | null
|
||||
packageCount?: number | null
|
||||
pickedPackageCount?: number | null
|
||||
status?: string | null
|
||||
}
|
||||
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 OutboundDetail { outbound: OutboundRow; pallets?: OutboundPalletRow[]; delivery?: DeliveryOrderRow | null }
|
||||
export interface OutboundQuery { page: number; size: number; documentNo?: string; lxCode?: string; warehouseId?: string; status?: string; outboundType?: 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 interface OneClickOutboundCandidate { inventoryPositionId: string; orderNo?: string | null; customerId?: string | null; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; palletNo?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackages: number }
|
||||
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))
|
||||
@@ -22,10 +28,22 @@ export async function getOutbound(outboundNo: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OutboundDetail>, ApiEnvelope<OutboundDetail>>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function createOneClickDropshipOutbound(customerId: string, payload: { recipient: string; items: Array<{ inventoryPositionId: string; packages: number; sku?: string }>; remark?: string; idempotencyKey: string }) {
|
||||
export async function createOneClickDropshipOutbound(customerId: string, payload: { recipient: string; items: Array<{ inventoryPositionId: string; packages: number; sku?: string; cartonMarks?: string[] }>; remark?: string; idempotencyKey: string }) {
|
||||
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 listOneClickOutboundCandidates(customerId?: string, query: { warehouseId?: string } = {}) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OneClickOutboundCandidate[]>, ApiEnvelope<OneClickOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates', { params: { customerId, ...query } })
|
||||
return unwrapApiResponse(response).map((item) => ({ ...item, inventoryPositionId: String(item.inventoryPositionId), availablePackages: Number(item.availablePackages || 0) }))
|
||||
}
|
||||
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,10 +64,20 @@ 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
|
||||
walletErrorMessage?: string | null
|
||||
deliveryMethod?: string | null
|
||||
printShippingLabel?: boolean | null
|
||||
shippingLabelFileId?: string | null
|
||||
shippingLabelFileName?: string | null
|
||||
fulfillmentStatus?: string | null
|
||||
outboundOrderNo?: string | null
|
||||
customerConfirmedAt?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
@@ -79,6 +89,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
|
||||
@@ -102,6 +113,7 @@ export interface RelabelDetailDto {
|
||||
remark?: string | null
|
||||
items: RelabelItemDto[]
|
||||
history: RelabelHistoryDto[]
|
||||
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string | null; reviewerName?: string | null; reviewRemark?: string | null; completedAt?: string | null } | null
|
||||
}
|
||||
|
||||
export async function listRelabelOrders(query: RelabelOrderQuery) {
|
||||
@@ -144,3 +156,27 @@ 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' },
|
||||
)
|
||||
}
|
||||
|
||||
export async function downloadRelabelCompletionPhoto(relabelNo: string, fileId: string) {
|
||||
return overseasHttp.get<Blob, Blob>(
|
||||
`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/completion-photos/${encodeURIComponent(fileId)}/download`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
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; 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 }
|
||||
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 overseasHttp.get<ApiEnvelope<OverseasPagePayload<ReturnStockRow>>, ApiEnvelope<OverseasPagePayload<ReturnStockRow>>>('/platform/v1/last-leg/return-inventory', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) }
|
||||
export async function listReturnCartons(params: ReturnInventoryQuery) { const response = await overseasHttp.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 overseasHttp.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 overseasHttp.get<ApiEnvelope<ReturnCartonDetail>, ApiEnvelope<ReturnCartonDetail>>(`/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}`); return unwrapApiResponse(response) }
|
||||
export async function getReturnCartonLabel(cartonMark: string) { const response = await overseasHttp.get<ApiEnvelope<ReturnCartonLabel>, ApiEnvelope<ReturnCartonLabel>>(`/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}/label`); return unwrapApiResponse(response) }
|
||||
@@ -33,7 +33,7 @@ const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const editOpen = ref(false); const editRow = ref<DispatchListItem | null>(null); const editSaving = ref(false)
|
||||
const dispatchSnapshots = new Map<string, DispatchSummaryDto>()
|
||||
const editForm = reactive({ vehiclePlate: '', driverName: '', driverPhone: '', carrierName: '', route: '', departAt: '', fbaAppointmentNo: '', fbaAppointmentStatus: '', vehicleAppointmentStatus: '', estimatedArrivalAt: '' })
|
||||
const transferForm = reactive({ plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: '', pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: '', driverPhone: '', vehiclePlate: '', appointmentAccount: '', sellerName: '', deliveryFee: undefined as number | undefined, loadingFee: undefined as number | undefined, appointmentFee: undefined as number | undefined, remark: '' })
|
||||
const transferForm = reactive({ plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: '', pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: '', driverPhone: '', vehiclePlate: '', appointmentAccount: '', sellerName: '', deliveryFee: undefined as number | undefined, loadingFee: undefined as number | undefined, appointmentFee: undefined as number | undefined, cartonMarksJson: '', remark: '' })
|
||||
|
||||
const deliveryMethodOptions = [
|
||||
{ label: 'FBA自提', value: 'FBA_PICKUP' }, { label: '客户自提', value: 'CUSTOMER_PICKUP' },
|
||||
@@ -190,7 +190,7 @@ function canEdit(row: DispatchListItem) { return access.can(permission.dispatchE
|
||||
function canCancel(row: DispatchListItem) { return access.can(permission.dispatchCancel) && row.statusCode === 'PENDING_DEPARTURE' }
|
||||
function canTransfer(row: DispatchListItem) { return access.can(permission.dispatchTransferDelivery) && ['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'].includes(row.statusCode) }
|
||||
function openEdit(row: DispatchListItem) { if (!canEdit(row)) return; const source = dispatchSnapshots.get(row.dispatchNo); editRow.value = row; Object.assign(editForm, { vehiclePlate: source?.vehiclePlate || '', driverName: source?.driverName || '', driverPhone: source?.driverPhone || '', carrierName: source?.carrierName || '', route: source?.route || '', departAt: source?.departAt || '', fbaAppointmentNo: source?.fbaAppointmentNo || '', fbaAppointmentStatus: source?.fbaAppointmentStatus || '', vehicleAppointmentStatus: source?.vehicleAppointmentStatus || '', estimatedArrivalAt: source?.estimatedArrivalAt || '' }); mutationError.value = null; editOpen.value = true }
|
||||
function openTransfer(row: DispatchListItem) { if (!canTransfer(row)) return; transferRow.value = row; Object.assign(transferForm, { plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: row.carrierName === '—' ? '' : row.carrierName, pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: row.driverName === '—' ? '' : row.driverName, driverPhone: row.driverPhone === '—' ? '' : row.driverPhone, vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate, appointmentAccount: '', sellerName: '', deliveryFee: undefined, loadingFee: undefined, appointmentFee: undefined, remark: '' }); mutationError.value = null; transferOpen.value = true }
|
||||
function openTransfer(row: DispatchListItem) { if (!canTransfer(row)) return; transferRow.value = row; Object.assign(transferForm, { plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: row.carrierName === '—' ? '' : row.carrierName, pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: row.driverName === '—' ? '' : row.driverName, driverPhone: row.driverPhone === '—' ? '' : row.driverPhone, vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate, appointmentAccount: '', sellerName: '', deliveryFee: undefined, loadingFee: undefined, appointmentFee: undefined, cartonMarksJson: '', remark: '' }); mutationError.value = null; transferOpen.value = true }
|
||||
async function submitEdit() { if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return; editSaving.value = true; mutationError.value = null; try { await updateDispatch(editRow.value.dispatchNo, { vehiclePlate: editForm.vehiclePlate.trim() || null, driverName: editForm.driverName.trim() || null, driverPhone: editForm.driverPhone.trim() || null, carrierName: editForm.carrierName.trim() || null, route: editForm.route.trim() || null, departAt: editForm.departAt || null, fbaAppointmentNo: editForm.fbaAppointmentNo.trim() || null, fbaAppointmentStatus: editForm.fbaAppointmentStatus || null, vehicleAppointmentStatus: editForm.vehicleAppointmentStatus || null, estimatedArrivalAt: editForm.estimatedArrivalAt || null }); editOpen.value = false; ElMessage.success('排车信息已保存'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '排车信息保存失败,请重试') } finally { editSaving.value = false } }
|
||||
async function cancel(row: DispatchListItem) { if (!canCancel(row)) return; try { await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', { confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }) } catch { return } mutationError.value = null; try { await cancelDispatch(row.dispatchNo); ElMessage.success('排车单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试') } }
|
||||
function handleMore(command: string, row: DispatchListItem) { if (command === 'edit') openEdit(row); else if (command === 'cancel') void cancel(row) }
|
||||
@@ -202,8 +202,12 @@ async function submitTransfer() {
|
||||
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || transferForm.appointmentFee == null)) { ElMessage.warning('买约时请填写卖家姓名和买约费用'); return }
|
||||
const feeInputs = [['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'], ['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'], ['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用']] as const
|
||||
const fees = feeInputs.filter(([, amount]) => amount != null).map(([feeType, amount, feeName]) => ({ feeType, feeName, direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: Number(amount), remark: feeName }))
|
||||
let palletCartonMarks: Record<string, string[]> | undefined
|
||||
if (transferForm.cartonMarksJson.trim()) {
|
||||
try { const parsed = JSON.parse(transferForm.cartonMarksJson); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(); palletCartonMarks = Object.fromEntries(Object.entries(parsed).map(([pallet, marks]) => [pallet, Array.isArray(marks) ? marks.map((mark) => String(mark).trim()).filter(Boolean) : []])) } catch { ElMessage.warning('托盘箱唛分配格式不正确,请使用 JSON:{"托盘号":["箱唛1","箱唛2"]}'); return }
|
||||
}
|
||||
transferSaving.value = true
|
||||
try { await transferDispatchToDelivery(transferRow.value.dispatchNo, { idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`, plannedDeliveryAt: transferForm.plannedDeliveryAt, deliveryMethod: transferForm.deliveryMethod, waybillNo: transferForm.waybillNo.trim() || undefined, carrierName: transferForm.carrierName.trim() || undefined, pickupMethod: transferForm.pickupMethod, loadingType: transferForm.loadingType, vehicleType: transferForm.vehicleType || undefined, vehicleSize: transferForm.vehicleSize || undefined, driverName: transferForm.driverName.trim() || undefined, driverPhone: transferForm.driverPhone.trim() || undefined, vehiclePlate: transferForm.vehiclePlate.trim() || undefined, appointmentAccount: transferForm.appointmentAccount || undefined, sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined, remark: transferForm.remark.trim() || undefined, fees }); transferOpen.value = false; ElMessage.success('已生成出库单和派送单'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试') } finally { transferSaving.value = false }
|
||||
try { await transferDispatchToDelivery(transferRow.value.dispatchNo, { idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`, plannedDeliveryAt: transferForm.plannedDeliveryAt, deliveryMethod: transferForm.deliveryMethod, waybillNo: transferForm.waybillNo.trim() || undefined, carrierName: transferForm.carrierName.trim() || undefined, pickupMethod: transferForm.pickupMethod, loadingType: transferForm.loadingType, vehicleType: transferForm.vehicleType || undefined, vehicleSize: transferForm.vehicleSize || undefined, driverName: transferForm.driverName.trim() || undefined, driverPhone: transferForm.driverPhone.trim() || undefined, vehiclePlate: transferForm.vehiclePlate.trim() || undefined, appointmentAccount: transferForm.appointmentAccount || undefined, sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined, remark: transferForm.remark.trim() || undefined, fees, palletCartonMarks }); transferOpen.value = false; ElMessage.success('已生成出库单和派送单'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试') } finally { transferSaving.value = false }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
@@ -219,7 +223,7 @@ onMounted(load)
|
||||
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
|
||||
<template #pagination-left>共 {{ total }} 条排车记录</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
|
||||
</ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><el-form-item label="托盘箱唛确认"><el-input v-model="transferForm.cartonMarksJson" type="textarea" :rows="3" placeholder="按实际托盘填写 JSON,例如 {"PALLET-001":["箱唛1","箱唛2"]}" /><div class="form-hint">入库未逐箱绑定时,在出库前确认箱唛属于哪个实际托盘。</div></el-form-item><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
|
||||
<AppDialog v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态。`" type="info" :closable="false" /><el-form label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" placeholder="请输入运输路线" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form></AppDialog>
|
||||
</template>
|
||||
<style scoped>.transfer-grid,.edit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.transfer-grid :deep(.el-select),.transfer-grid :deep(.el-date-editor),.transfer-grid :deep(.el-input-number),.edit-grid :deep(.el-select),.edit-grid :deep(.el-date-editor){width:100%}h3{margin:4px 0 12px}@media(max-width:900px){.transfer-grid,.edit-grid{grid-template-columns:1fr}}</style>
|
||||
|
||||
@@ -183,13 +183,13 @@ onMounted(load)
|
||||
</section>
|
||||
<AppDialog v-model="feeDialogOpen" title="新增费用" width="min(720px, calc(100vw - 28px))" confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
|
||||
<el-form class="fee-form" label-position="top"><div class="fee-form-grid">
|
||||
<el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable placeholder="请选择费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || ''"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item>
|
||||
<el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable allow-create clearable default-first-option placeholder="请选择或输入费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || String(feeForm.itemCode).trim()"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item>
|
||||
<el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item>
|
||||
<el-form-item v-if="feeForm.direction === 'PAYABLE'" label="供应商" required><el-select v-model="feeForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="业务日期" required><el-date-picker v-model="feeForm.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
|
||||
<el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" placeholder="请输入费用金额" /></el-form-item>
|
||||
<el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item>
|
||||
<el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入计费数量" /></el-form-item><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item>
|
||||
<el-form-item label="计费开始日期"><el-date-picker v-model="feeForm.periodStart" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item><el-form-item label="计费结束日期"><el-date-picker v-model="feeForm.periodEnd" type="date" value-format="YYYY-MM-DD" placeholder="仓租等周期费用填写" /></el-form-item>
|
||||
</div></el-form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -27,10 +27,14 @@ const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'destinationWarehouseCode', label: '目的仓编码', width: 150 },
|
||||
{ key: 'destinationWarehouseName', label: '目的仓名称', minWidth: 180 },
|
||||
{ key: 'skuCode', label: '商品 SKU', width: 170 },
|
||||
{ key: 'cartonMarks', label: '箱唛', minWidth: 220 },
|
||||
{ key: 'packageCount', label: '箱数', width: 100, align: 'right' },
|
||||
{ key: 'pickedPackageCount', label: '已拣箱数', width: 110, align: 'right' },
|
||||
{ key: 'status', label: '状态', width: 110 },
|
||||
]
|
||||
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
|
||||
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
|
||||
function outboundTypeLabel(value?: string | null) { return value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
|
||||
const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.status === 'PICKED')
|
||||
async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } }
|
||||
async function cancel() { if (!canCancel.value || cancelling.value) return; let reason = ''; try { const result = await ElMessageBox.prompt(`确认取消出库单 ${outboundNo.value}?`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return } cancelling.value = true; mutationError.value = null; try { await cancelOutbound(outboundNo.value, reason); ElMessage.success('出库单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败,请重试') } finally { cancelling.value = false } }
|
||||
@@ -43,7 +47,7 @@ watch(outboundNo, () => { void load() }, { immediate: true })
|
||||
<template #header><div class="detail-heading"><el-button aria-label="返回出库单列表" @click="back"><ArrowLeft :size="16" />返回</el-button><div class="detail-title"><div><h1>出库单详情</h1><p>出库单号 {{ outboundNo }}</p></div><StatusBadge v-if="detail" :status="statusLabel(detail.outbound.status)" /></div><el-button v-if="canCancel" type="danger" plain :loading="cancelling" @click="cancel"><XCircle :size="16" />取消出库单</el-button></div></template>
|
||||
<OperationErrorToast :details="mutationError" />
|
||||
<LoadErrorState v-if="loadError" title="出库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ show(detail.outbound.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>出库托盘</h2><DataTable class="outbound-pallet-table" aria-label="出库托盘" :data="detail.pallets" :columns="palletColumns" stripe column-settings-mode="hide" empty-text="暂无出库托盘" /></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.delivery" description="暂无关联派送单" :image-size="56" /><el-descriptions v-else :column="4" border><el-descriptions-item label="派送单号">{{ show(detail.delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ show(detail.delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(detail.delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(detail.delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(detail.delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.delivery.remark) }}</el-descriptions-item></el-descriptions></section></template>
|
||||
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="出库类型">{{ outboundTypeLabel(detail.outbound.outboundType) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ show(detail.outbound.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>出库托盘</h2><DataTable class="outbound-pallet-table" aria-label="出库托盘" :data="detail.pallets || []" :columns="palletColumns" stripe column-settings-mode="hide" empty-text="暂无出库托盘" /></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.delivery" description="暂无关联派送单" :image-size="56" /><el-descriptions v-else :column="4" border><el-descriptions-item label="派送单号">{{ show(detail.delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ show(detail.delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(detail.delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(detail.delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(detail.delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.delivery.remark) }}</el-descriptions-item></el-descriptions></section></template>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -29,25 +29,37 @@ describe('OutboundsView permissions and navigation', () => {
|
||||
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('详情')
|
||||
expect(wrapper.text()).not.toContain('一件代发出库')
|
||||
expect(wrapper.text()).not.toContain('新建出库单')
|
||||
expect(wrapper.text()).not.toContain('取消')
|
||||
expect(OutboundsView.__file).toContain('OutboundsView.vue')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('shows one-click creation and cancellation only with their exact permissions', async () => {
|
||||
it('shows outbound creation and cancellation only with their exact permissions', async () => {
|
||||
canPermission.mockImplementation((code: string) => ['platform:overseas-warehouse:outbound:create', 'platform:overseas-warehouse:outbound:cancel'].includes(code))
|
||||
const wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('一件代发出库')
|
||||
expect(wrapper.text()).toContain('新建出库单')
|
||||
expect(wrapper.text()).toContain('取消')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('requires an explicit customer selection before choosing customer inventory', async () => {
|
||||
it('loads one-click candidates across customers and loads return inventory after customer selection', async () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8')
|
||||
expect(source).toContain('v-model="selectedCustomerId"')
|
||||
expect(source).toContain('availablePositionOptions')
|
||||
expect(source).toContain('createOneClickDropshipOutbound(selectedCustomerId.value')
|
||||
expect(source).toContain('listOneClickOutboundCandidates(selectedCustomerId.value || undefined')
|
||||
expect(source).toContain('if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates()')
|
||||
expect(source).toContain("createOneClickDropshipOutbound([...customerIds][0] || ''")
|
||||
})
|
||||
|
||||
it('applies batched return filters only through explicit search and exposes page sizes', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/OutboundsView.vue'), 'utf8')
|
||||
expect(source).toContain('returnSkuQueryInput')
|
||||
expect(source).toContain('returnCartonQueryInput')
|
||||
expect(source).toContain("item.replace(/\\s+/g, '')")
|
||||
expect(source).toContain('@click="searchReturnSkus"')
|
||||
expect(source).toContain('@click="searchReturnCartons"')
|
||||
expect(source).toContain(':page-sizes="[5, 10, 20, 50]"')
|
||||
expect(source).toContain('width="min(1320px, calc(100vw - 32px))"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,27 +14,95 @@ 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 { listInventoryBalances, listInventoryPositions, type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory'
|
||||
import { cancelOutbound, createOneClickDropshipOutbound, createReturnOutbound, listOneClickOutboundCandidates, listReturnOutboundCandidates, listOutbounds, type OneClickOutboundCandidate, type OutboundRow, type ReturnOutboundCandidate } from '../api/outbounds'
|
||||
import { type InventoryBalanceDto, type InventoryPositionDto } from '../api/inventory'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam/api/platformIam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { getReturnCarton, listReturnCartons, listReturnStock, type ReturnCartonDetail, type ReturnCartonRow, type ReturnStockRow } from '../api/returnInventory'
|
||||
|
||||
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 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: '', outboundType: '' }); 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 dropshipCandidates = ref<OneClickOutboundCandidate[]>([]); const selectedDropshipIds = ref<string[]>([]); 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 dropshipQueryInput = ref(''); const dropshipQuery = ref(''); const dropshipPage = ref(1); const dropshipPageSize = ref(5)
|
||||
const tenantOptions = ref<PlatformTenantResource[]>([])
|
||||
const warehouseOptionsSource = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
const returnStockRows = ref<ReturnStockRow[]>([])
|
||||
const returnCartonRows = ref<ReturnCartonRow[]>([])
|
||||
const selectedReturnSkus = ref<string[]>([])
|
||||
const returnSkuQueryInput = ref(''); const returnSkuQuery = ref('')
|
||||
const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('')
|
||||
const returnSkuPage = ref(1); const returnCartonPage = ref(1)
|
||||
const returnSkuPageSize = ref(5); const returnCartonPageSize = ref(5)
|
||||
const returnCartonDetails = ref<Record<string, ReturnCartonDetail>>({})
|
||||
const customerOptions = computed(() => {
|
||||
const customers = new Map<string, string>()
|
||||
tenantOptions.value.forEach((item) => {
|
||||
if (item.status !== 'ACTIVE') return
|
||||
const name = item.tenantName?.trim() || '客户名称待补充'
|
||||
const phone = item.contactPhone?.trim()
|
||||
customers.set(String(item.id), phone ? `${name}-${phone}` : name)
|
||||
})
|
||||
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 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' }] }]
|
||||
const warehouseOptions = computed(() => warehouseOptionsSource.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS').map((item) => ({ value: String(item.id), label: item.warehouseName || String(item.id) })))
|
||||
const selectedReturnStock = computed(() => returnStockRows.value.filter((item) => Number(item.availableQuantity || 0) > 0))
|
||||
const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE'))
|
||||
const queryTokens = (value: string) => value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '').toLowerCase()).filter(Boolean)
|
||||
const matchesAnyToken = (value: string, query: string) => { const tokens = queryTokens(query); const normalized = value.replace(/\s+/g, '').toLowerCase(); return !tokens.length || tokens.some((token) => normalized.includes(token)) }
|
||||
const filteredReturnStocks = computed(() => selectedReturnStock.value.filter((item) => matchesAnyToken(`${item.skuCode},${item.productNameZh || ''}`, returnSkuQuery.value)))
|
||||
const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter((item) => {
|
||||
const detailSkus = returnCartonDetails.value[item.cartonMark]?.items?.map((detail) => detail.skuCode).join(',') || ''
|
||||
return matchesAnyToken(`${item.cartonMark},${item.fbaCartonMark || ''},${detailSkus}`, returnCartonQuery.value)
|
||||
}))
|
||||
const pagedReturnStocks = computed(() => filteredReturnStocks.value.slice((returnSkuPage.value - 1) * returnSkuPageSize.value, returnSkuPage.value * returnSkuPageSize.value))
|
||||
const pagedReturnCartons = computed(() => filteredReturnCartons.value.slice((returnCartonPage.value - 1) * returnCartonPageSize.value, returnCartonPage.value * returnCartonPageSize.value))
|
||||
const effectiveReturnMarks = computed(() => [...new Set(selectedReturnMarks.value)].filter(Boolean) as string[])
|
||||
const returnMarksModel = computed({ get: () => selectedReturnMarks.value, set: (value: string[]) => { selectedReturnMarks.value = value } })
|
||||
const filteredDropshipCandidates = computed(() => dropshipCandidates.value.filter(item => !dropshipQuery.value.trim() || [item.orderNo, item.inboundCode, item.customerName, item.palletNo, item.skuCode, item.productName].join(' ').toLowerCase().includes(dropshipQuery.value.trim().toLowerCase())))
|
||||
const pagedDropshipCandidates = computed(() => filteredDropshipCandidates.value.slice((dropshipPage.value - 1) * dropshipPageSize.value, dropshipPage.value * dropshipPageSize.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: 'outboundType', prop: 'outboundType', label: '出库类型', width: 150, slotName: 'outboundType' }, { 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' }] }, { key: 'outboundType', type: 'select', label: '出库类型', placeholder: '全部出库类型', width: 170, options: [{ label: '全部', value: '' }, { label: '一件代发出库', value: 'ONE_CLICK_DROPSHIP' }, { label: '退货出库', value: 'RETURN_OUTBOUND' }, { label: '派送出库', value: 'DISPATCH_DELIVERY' }] }]
|
||||
|
||||
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); if (!selectedCustomerId.value || !recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请选择客户,填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { 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 }], 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 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, outboundType: String(filters.value.outboundType || '').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 } }
|
||||
function outboundTypeLabel(value?: string | null) { return value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : value || '—' }
|
||||
async function loadInventoryOptions() {
|
||||
inventoryLoading.value = true
|
||||
try {
|
||||
const [tenants, warehouses] = await Promise.allSettled([
|
||||
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
|
||||
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' }),
|
||||
])
|
||||
if (tenants.status === 'fulfilled') tenantOptions.value = tenants.value.records
|
||||
else ElMessage.error(getApiErrorDetails(tenants.reason, '客户列表加载失败').message)
|
||||
if (warehouses.status === 'fulfilled') {
|
||||
warehouseOptionsSource.value = warehouses.value.records
|
||||
if (!selectedWarehouseId.value) {
|
||||
const lax = warehouses.value.records.find((item) => item.warehouseCode === 'US-LAX-01' || item.warehouseName?.includes('洛杉矶'))
|
||||
selectedWarehouseId.value = String(lax?.id || warehouses.value.records[0]?.id || '')
|
||||
}
|
||||
} else ElMessage.error(getApiErrorDetails(warehouses.reason, '海外仓列表加载失败').message)
|
||||
} finally { inventoryLoading.value = false }
|
||||
}
|
||||
async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; dropshipCandidates.value = []; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
|
||||
async function loadDropshipCandidates() { if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '一件代发订单加载失败').message) } finally { inventoryLoading.value = false } }
|
||||
async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() }
|
||||
async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
|
||||
async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; dropshipCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates() }
|
||||
async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnSkus.value = []; selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } }
|
||||
function dropshipCandidateKey(item: OneClickOutboundCandidate) { return `${item.inventoryPositionId}:${item.skuCode || ''}` }
|
||||
function isDropshipSelected(item: OneClickOutboundCandidate) { return selectedDropshipIds.value.includes(dropshipCandidateKey(item)) }
|
||||
function toggleDropshipCandidate(item: OneClickOutboundCandidate, checked: boolean | string | number) { const key = dropshipCandidateKey(item); selectedDropshipIds.value = checked ? [...new Set([...selectedDropshipIds.value, key])] : selectedDropshipIds.value.filter(value => value !== key) }
|
||||
function searchDropshipCandidates() { dropshipQuery.value = dropshipQueryInput.value.trim(); dropshipPage.value = 1 }
|
||||
function resetDropshipCandidates() { dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1 }
|
||||
function normalizeBatchQuery(value: string) { return value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '')).filter(Boolean).join(',') }
|
||||
function searchReturnSkus() { returnSkuQueryInput.value = normalizeBatchQuery(returnSkuQueryInput.value); returnSkuQuery.value = returnSkuQueryInput.value; returnSkuPage.value = 1 }
|
||||
function searchReturnCartons() { returnCartonQueryInput.value = normalizeBatchQuery(returnCartonQueryInput.value); returnCartonQuery.value = returnCartonQueryInput.value; returnCartonPage.value = 1 }
|
||||
function toggleReturnSku(sku: string, checked: boolean | string | number) { selectedReturnSkus.value = checked ? [...new Set([...selectedReturnSkus.value, sku])] : selectedReturnSkus.value.filter((value) => value !== sku) }
|
||||
async function submit() { const marks = String(cartonMarks.value).split(/[,\n,]+/).map(value => value.trim()).filter(Boolean); if (!selectedWarehouseId.value) { ElMessage.warning('请选择海外仓'); return }; if (outboundType.value === 'RETURN_OUTBOUND' && !selectedCustomerId.value) { ElMessage.warning('请选择客户'); return }; saving.value = true; try { if (outboundType.value === 'RETURN_OUTBOUND') { if (!recipient.value.trim() || !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 = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item))); if (!selected.length) { ElMessage.warning('请至少选择一个已完成入库的一件代发订单'); return }; const customerIds = new Set(selected.map(item => item.customerId).filter(Boolean)); if (customerIds.size !== 1) { ElMessage.warning('一张出库单只能选择同一个客户的订单'); return }; const warehouses = new Set(selected.map(item => item.overseasWarehouseId).filter(Boolean)); if (warehouses.size > 1 || (warehouses.size === 1 && !warehouses.has(selectedWarehouseId.value))) { ElMessage.warning('所选订单必须属于当前海外仓'); return }; const items = selected.map(item => ({ inventoryPositionId: item.inventoryPositionId, packages: item.availablePackages, sku: item.skuCode || '', cartonMarks: [] as string[] })); await createOneClickDropshipOutbound([...customerIds][0] || '', { recipient: '一件代发订单直出', items, remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success(`已按订单生成 ${selected.length} 个托盘出库明细并推送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,14 +116,101 @@ 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 #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</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-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="新建出库单" width="min(1320px, calc(100vw - 32px))" 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" @change="changeOutboundType">
|
||||
<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 v-if="outboundType === 'RETURN_OUTBOUND'" label="客户" required>
|
||||
<el-select v-model="selectedCustomerId" :loading="inventoryLoading" clearable 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>
|
||||
<template v-if="outboundType !== 'RETURN_OUTBOUND'">
|
||||
<el-form-item label="海外仓" required>
|
||||
<el-select v-model="selectedWarehouseId" filterable placeholder="请选择海外仓" @change="changeDropshipWarehouse">
|
||||
<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="已完成入库的一件代发订单">
|
||||
<div class="candidate-query">
|
||||
<el-input v-model="dropshipQueryInput" clearable placeholder="按订单号、入库单号、客户、托盘号、SKU或品名筛选" @keyup.enter="searchDropshipCandidates" />
|
||||
<el-button type="primary" :loading="inventoryLoading" @click="searchDropshipCandidates">查询</el-button>
|
||||
<el-button @click="resetDropshipCandidates">重置</el-button>
|
||||
</div>
|
||||
<el-table v-loading="inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table">
|
||||
<el-table-column label="选择" width="68" align="center">
|
||||
<template #default="{ row }"><el-checkbox :model-value="isDropshipSelected(row as OneClickOutboundCandidate)" @change="toggleDropshipCandidate(row as OneClickOutboundCandidate, $event)" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="customerName" label="客户" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="orderNo" label="尾程订单号" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="inboundCode" label="入库单号" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="palletNo" label="托盘号" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="skuCode" label="SKU" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="productName" label="品名" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="availablePackages" label="可出箱数" width="95" align="right" />
|
||||
</el-table>
|
||||
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="form-hint">暂无可直接出库的已完成入库订单</div>
|
||||
<div v-if="filteredDropshipCandidates.length" class="candidate-pagination">
|
||||
<span>已选择 {{ selectedDropshipIds.length }} 条</span>
|
||||
<el-pagination v-model:current-page="dropshipPage" v-model:page-size="dropshipPageSize" :page-sizes="[5, 10, 20]" :total="filteredDropshipCandidates.length" layout="total, sizes, prev, pager, next" />
|
||||
</div>
|
||||
<el-alert title="一件代发按托盘与SKU选择;数量默认使用所选行全部可用箱数,同一张出库单只能选择同一海外仓。" type="info" :closable="false" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item>
|
||||
<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>
|
||||
<div class="return-lists">
|
||||
<section class="return-list-card">
|
||||
<div class="list-title"><span>SKU库存</span><small>支持多个 SKU,用英文逗号分隔</small></div>
|
||||
<div class="candidate-query"><el-input v-model="returnSkuQueryInput" clearable placeholder="如:SKU001,SKU002" /><el-button type="primary" @click="searchReturnSkus">查询</el-button></div>
|
||||
<el-table :data="pagedReturnStocks" border stripe class="return-result-table" max-height="400"><el-table-column label="选择" width="60" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedReturnSkus.includes(row.skuCode)" @change="toggleReturnSku(row.skuCode, $event)" /></template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column prop="availableQuantity" label="可用数量" width="100" align="right" /></el-table>
|
||||
<div class="return-pagination"><span>已选择 {{ selectedReturnSkus.length }} 个 SKU</span><el-pagination v-model:current-page="returnSkuPage" v-model:page-size="returnSkuPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnStocks.length" layout="total, sizes, prev, pager, next" @size-change="returnSkuPage = 1" /></div>
|
||||
</section>
|
||||
<section class="return-list-card">
|
||||
<div class="list-title"><span>箱号列表</span><small>支持多个箱号或 SKU,用英文逗号分隔</small></div>
|
||||
<div class="candidate-query"><el-input v-model="returnCartonQueryInput" clearable placeholder="如:RC001,RC002 或 SKU001,SKU002" /><el-button type="primary" @click="searchReturnCartons">查询</el-button></div>
|
||||
<el-table :data="pagedReturnCartons" border stripe class="return-result-table" max-height="400"><el-table-column label="选择" width="60" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="(v) => selectedReturnMarks = v ? [...new Set([...selectedReturnMarks, row.cartonMark])] : selectedReturnMarks.filter((m) => m !== row.cartonMark)" /></template></el-table-column><el-table-column prop="cartonMark" label="箱号" min-width="190" show-overflow-tooltip /><el-table-column label="箱内SKU及数量" min-width="230"><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column></el-table>
|
||||
<div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个箱号</span><el-pagination v-model:current-page="returnCartonPage" v-model:page-size="returnCartonPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnCartons.length" layout="total, sizes, prev, pager, next" @size-change="returnCartonPage = 1" /></div>
|
||||
</section>
|
||||
</div>
|
||||
<el-alert title="退货出库支持按客户选择单个或多个箱子;SKU数量直接从所选箱唛明细扣减。" 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>
|
||||
.create-outbound-form { padding: 0 4px; }
|
||||
.create-outbound-form :deep(.el-input), .create-outbound-form :deep(.el-select), .create-outbound-form :deep(.el-input-number) { width: 100%; }
|
||||
.form-hint { margin-top: 6px; color: var(--cc-muted); font-size: 12px; }
|
||||
.candidate-list { display: grid; gap: 8px; max-height: 300px; overflow: auto; padding: 8px 0; }
|
||||
.candidate-list :deep(.el-checkbox) { margin-right: 0; }
|
||||
.candidate-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(150px, 1fr) auto; gap: 12px; align-items: center; width: min(700px, 100%); }
|
||||
.candidate-row span, .candidate-row em { color: var(--cc-muted); font-style: normal; font-size: 12px; }
|
||||
.candidate-query { display: flex; width: 100%; gap: 10px; }
|
||||
.candidate-query :deep(.el-input) { flex: 1; }
|
||||
.candidate-query :deep(.el-button) { flex: 0 0 auto; min-width: 78px; margin-left: 0; }
|
||||
.dropship-candidate-table { margin-top: 12px; width: 100%; }
|
||||
.candidate-pagination { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.return-lists { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; width: 100%; margin: 4px 0 16px; }
|
||||
.return-list-card { min-width: 0; padding: 16px; border: 1px solid var(--el-border-color-light); border-radius: 10px; background: var(--el-fill-color-extra-light); }
|
||||
.list-title { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.list-title span { color: var(--el-text-color-primary); font-size: 16px; font-weight: 650; }
|
||||
.list-title small { color: var(--cc-muted); font-size: 12px; font-weight: 400; }
|
||||
.return-result-table { width: 100%; margin-top: 12px; background: var(--el-bg-color); }
|
||||
.return-pagination { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: 12px; padding-top: 12px; color: var(--cc-muted); font-size: 12px; }
|
||||
.return-pagination :deep(.el-pagination) { flex-wrap: wrap; justify-content: flex-end; }
|
||||
@media (max-width: 980px) { .return-lists { grid-template-columns: 1fr; }.return-pagination { align-items: flex-start; flex-direction: column; } }
|
||||
</style>
|
||||
|
||||
@@ -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 { downloadRelabelCompletionPhoto, 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', '中文品名', '箱数', '预算总价'] },
|
||||
@@ -22,7 +29,7 @@ const groups = [
|
||||
]
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function status(value: unknown) {
|
||||
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认', READY_FOR_OUTBOUND: '待出库', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
return labels[String(value || '')] || text(value)
|
||||
}
|
||||
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || text(value) }
|
||||
@@ -57,19 +64,41 @@ 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) }
|
||||
}
|
||||
async function previewCompletionPhoto(fileId: string) {
|
||||
try { const blob = await downloadRelabelCompletionPhoto(orderId.value, fileId); const url = URL.createObjectURL(blob); window.open(url, '_blank', 'noopener,noreferrer'); window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '完工照片加载失败').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="PDA换标完工证据"><div class="relabel-mark-list__head"><h2>PDA 换标完工证据</h2><span>{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}</span></div><div v-if="detail?.completionEvidence?.photoFileIds?.length" class="document-row"><strong>共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片</strong><span class="document-actions"><el-button v-for="(fileId, index) in detail.completionEvidence.photoFileIds" :key="fileId" size="small" plain @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></span></div><div v-else class="attachment-empty">暂未上传完工照片</div></section>
|
||||
<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 +106,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 +121,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>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Printer, Search } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { getReturnCarton, getReturnCartonLabel, listReturnCartons, listReturnInventoryMovements, listReturnStock, type ReturnCartonDetail, type ReturnCartonRow, type ReturnInventoryMovement, type ReturnStockRow } from '../api/returnInventory'
|
||||
const props = defineProps<{ mode: 'inventory' | 'cartons' }>()
|
||||
const loading = ref(false), total = ref(0), detailOpen = ref(false)
|
||||
const stockRows = ref<ReturnStockRow[]>([]), cartonRows = ref<ReturnCartonRow[]>([]), detail = ref<ReturnCartonDetail>()
|
||||
const inventoryTab = ref<'stock' | 'movements'>('stock')
|
||||
const movementRows = ref<ReturnInventoryMovement[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
const optionsLoading = ref(false)
|
||||
const query = reactive({ page: 1, size: 20, customerId: '', overseasWarehouseId: '', skuCode: '', cartonMark: '', returnNo: '', status: 'AVAILABLE' })
|
||||
function params() { return { ...query, customerId: query.customerId || undefined, overseasWarehouseId: query.overseasWarehouseId || undefined, skuCode: query.skuCode || undefined, cartonMark: query.cartonMark || undefined, returnNo: query.returnNo || undefined, status: query.status || undefined } }
|
||||
async function load() { loading.value = true; try { if (props.mode === 'inventory') { if (inventoryTab.value === 'stock') { const page = await listReturnStock(params()); stockRows.value = page.records; total.value = page.total } else { const page = await listReturnInventoryMovements(params()); movementRows.value = page.records; total.value = page.total } } else { const page = await listReturnCartons(params()); cartonRows.value = page.records; total.value = page.total } } finally { loading.value = false } }
|
||||
function search() { query.page = 1; void load() }
|
||||
function changeInventoryTab() { query.page = 1; void load() }
|
||||
const customerOptions = computed(() => customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })))
|
||||
const warehouseOptions = computed(() => warehouses.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS').map((item) => ({ label: item.warehouseName, value: String(item.id) })))
|
||||
function customerName(customerId?: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户名称待补充' }
|
||||
function warehouseName(warehouseId?: string) { return warehouses.value.find((item) => String(item.id) === String(warehouseId))?.warehouseName || '海外仓名称待补充' }
|
||||
function statusLabel(status?: string) { return status === 'AVAILABLE' ? '可用' : status === 'MERGED' ? '已合箱' : '—' }
|
||||
async function loadOptions() {
|
||||
optionsLoading.value = true
|
||||
try {
|
||||
const [customerPage, warehousePage] = await Promise.all([
|
||||
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
|
||||
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' }),
|
||||
])
|
||||
customers.value = customerPage.records
|
||||
warehouses.value = warehousePage.records
|
||||
} catch (error) {
|
||||
ElMessage.warning(error instanceof Error ? `筛选名称加载失败:${error.message}` : '筛选名称加载失败')
|
||||
} finally { optionsLoading.value = false }
|
||||
}
|
||||
async function openCarton(mark: string) { try { detail.value = await getReturnCarton(mark); detailOpen.value = true } catch (e) { ElMessage.error(e instanceof Error ? e.message : '箱号详情加载失败') } }
|
||||
async function printLabel(mark: string) { try { const label = await getReturnCartonLabel(mark); const popup = window.open('', '_blank'); if (!popup) return ElMessage.warning('请允许浏览器打开打印窗口'); popup.opener = null; popup.document.open(); popup.document.write(label.printableHtml); popup.document.close(); popup.focus(); popup.print() } catch (e) { ElMessage.error(e instanceof Error ? e.message : '箱唛加载失败') } }
|
||||
onMounted(() => { void load(); void loadOptions() })
|
||||
</script>
|
||||
|
||||
<template><AdminPage as="div" class="business-page return-inventory-page">
|
||||
<el-tabs v-if="mode==='inventory'" v-model="inventoryTab" @tab-change="changeInventoryTab"><el-tab-pane label="退货库存" name="stock" /><el-tab-pane label="退货进销存明细" name="movements" /></el-tabs>
|
||||
<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 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="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>
|
||||
@@ -84,9 +84,12 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
const value = source(fileName)
|
||||
expect(value).toContain('<OverseasReadOnlyDetail')
|
||||
expect(value).toContain('@back="back"')
|
||||
expect(value).not.toContain('AppDialog')
|
||||
expect(value).not.toContain('AppDrawer')
|
||||
if (fileName !== 'RelabelOrderDetailView.vue') expect(value).not.toContain('AppDialog')
|
||||
}
|
||||
const relabel = source('RelabelOrderDetailView.vue')
|
||||
expect(relabel).toContain('<AppDialog v-if="previewUrl"')
|
||||
expect(relabel).toContain('<iframe :src="previewUrl"')
|
||||
})
|
||||
|
||||
it('keeps second-batch forms non-submitting until overseas write contracts exist', () => {
|
||||
|
||||
@@ -26,6 +26,8 @@ export const overseasWarehousePermissions = {
|
||||
inventoryExport: 'platform:overseas-warehouse:inventory:export',
|
||||
inventoryEdit: 'platform:overseas-warehouse:inventory:edit',
|
||||
inventoryAdjust: 'platform:overseas-warehouse:inventory:adjust',
|
||||
returnInventoryList: 'platform:overseas-warehouse:return-inventory:list',
|
||||
returnCartonsList: 'platform:overseas-warehouse:return-cartons:list',
|
||||
relabelOrderList: 'platform:overseas-warehouse:relabel-order:list',
|
||||
relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create',
|
||||
relabelOrderStatus: 'platform:overseas-warehouse:relabel-order:status',
|
||||
|
||||
@@ -18,6 +18,8 @@ describe('overseas warehouse routes', () => {
|
||||
['overseas-warehouse/exception-orders', permission.exceptionOrderList],
|
||||
['overseas-warehouse/customer-visits', permission.customerVisitList],
|
||||
['overseas-warehouse/inventory', permission.inventoryList],
|
||||
['overseas-warehouse/return-inventory', permission.returnInventoryList],
|
||||
['overseas-warehouse/return-cartons', permission.returnCartonsList],
|
||||
['overseas-warehouse/inventory-transactions', permission.inventoryList],
|
||||
['overseas-warehouse/inventory/:inventoryId', permission.inventoryList],
|
||||
['overseas-warehouse/relabel-orders', permission.relabelOrderList],
|
||||
|
||||
@@ -80,6 +80,20 @@ export const overseasWarehouseRoutes = [
|
||||
component: () => import('./pages/InventoryView.vue'),
|
||||
meta: { title: '库存管理', description: '查看海外仓库存汇总、可用量和库内分布', permission: permission.inventoryList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/return-inventory',
|
||||
name: 'overseas-return-inventory',
|
||||
component: () => import('./pages/ReturnInventoryManagementView.vue'),
|
||||
props: { mode: 'inventory' },
|
||||
meta: { title: '退货库存管理', description: '按客户和 SKU 查询退货签收后的可用库存', permission: permission.returnInventoryList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/return-cartons',
|
||||
name: 'overseas-return-cartons',
|
||||
component: () => import('./pages/ReturnInventoryManagementView.vue'),
|
||||
props: { mode: 'cartons' },
|
||||
meta: { title: '退货箱号管理', description: '查询合箱后的箱号、箱规和箱内 SKU', permission: permission.returnCartonsList },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/inventory-transactions',
|
||||
name: 'overseas-inventory-transactions',
|
||||
|
||||
Reference in New Issue
Block a user