diff --git a/src/modules/overseas-warehouse/api/warehouseServices.ts b/src/modules/overseas-warehouse/api/warehouseServices.ts index f52dce0..fc23dcd 100644 --- a/src/modules/overseas-warehouse/api/warehouseServices.ts +++ b/src/modules/overseas-warehouse/api/warehouseServices.ts @@ -6,8 +6,8 @@ export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPR export interface PageResult { records: T[]; page: number; size: number; total: number; pages: number } export interface LastlegOrderSummary { id: string; orderNo: string; inboundCode: string; orderType: string; packageCount: number; overseasWarehouseId?: string } export interface HistoricalOrderSummary { id: string | null; orderNo: string; sourceOrderNo?: string; outboundOrderNo?: string; relabelOrderNo?: string; orderType?: string; status?: string; packageCount?: number } -export interface TransferSummary { id: string; transferNo: string; inboundCode: string; lastlegOrderId: string; lastlegOrderNo: string; operatingWarehouseId: string; fbaWarehouseCode?: string; packageCount: number; deliveryMethod: string; needRelabel: boolean; relabelNo?: string; relabelStatus?: string; relabelFulfillmentStatus?: string; status: string; estimatedArrivalAt: string; estimatedShipAt?: string; actualShipAt?: string; createdAt: string } -export interface RelabelSummary { id: string; relabelNo: string; lastlegOrderId: string; lastlegOrderNo: string; customerOrderNo: string; overseasWarehouseId: string; status: string; itemCount: number; totalPackageCount: number; notifiedPackageCount: number; skuCodes: string; createdAt: string; totalFileId?: string; totalFileName?: string; totalFileUrl?: string; cartonTotalFileId?: string; cartonTotalFileName?: string; cartonTotalFileUrl?: string; skuTotalFileId?: string; skuTotalFileName?: string; skuTotalFileUrl?: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean; shippingLabelFileId?: string; shippingLabelFileName?: string; shippingLabelFileUrl?: string; fulfillmentStatus?: string; outboundOrderNo?: string; customerConfirmedAt?: string; reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER'; reviewRemark?: string } +export interface TransferSummary { id: string; transferNo: string; inboundCode: string; lastlegOrderId: string; lastlegOrderNo: string; operatingWarehouseId: string; fbaWarehouseCode?: string; packageCount: number; deliveryMethod: string; needRelabel: boolean; relabelNo?: string; relabelStatus?: string; relabelFulfillmentStatus?: string; status: string; estimatedArrivalAt: string; estimatedShipAt?: string; actualShipAt?: string; reviewedAt?: string; measuredAt?: string; outboundAt?: string; relabelCompletedAt?: string; createdAt: string } +export interface RelabelSummary { id: string; relabelNo: string; lastlegOrderId: string; lastlegOrderNo: string; customerOrderNo: string; overseasWarehouseId: string; status: string; itemCount: number; totalPackageCount: number; notifiedPackageCount: number; skuCodes: string; createdAt: string; reviewedAt?: string; relabelCompletedAt?: string; outboundAt?: string; totalFileId?: string; totalFileName?: string; totalFileUrl?: string; cartonTotalFileId?: string; cartonTotalFileName?: string; cartonTotalFileUrl?: string; skuTotalFileId?: string; skuTotalFileName?: string; skuTotalFileUrl?: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean; shippingLabelFileId?: string; shippingLabelFileName?: string; shippingLabelFileUrl?: string; fulfillmentStatus?: string; outboundOrderNo?: string; customerConfirmedAt?: string; reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER'; reviewRemark?: string } export interface RelabelItemDetail { id: string; skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number shipmentNo: string; trackingCode?: string; newSkuCode?: string; labelRequirement?: string; remark?: string @@ -25,8 +25,8 @@ export interface RelabelDetail { tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }> completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string } } -export interface DiscardSummary { id: string; discardNo: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string } -export interface DiscardCandidate { orderId: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> } +export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string } +export interface DiscardCandidate { orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> } export interface RelabelInventoryCandidate { inventoryBalanceId: string; inboundOrderId: string; inboundOrderNo?: string; inboundCode?: string overseasWarehouseId: string; overseasWarehouseCode?: string; overseasWarehouseName?: string @@ -38,7 +38,7 @@ export interface RelabelInventoryCandidate { } export interface TransferCreate { - inboundCode?: string; sourceLastlegOrderIds?: number[]; operatingWarehouseId: string; fbaWarehouseCode?: string; destinationType?: 'NONE' | 'FBA' | 'PRIVATE' + inboundCode?: string; sourceLastlegOrderIds?: string[]; operatingWarehouseId: string; fbaWarehouseCode?: string; destinationType?: 'NONE' | 'FBA' | 'PRIVATE' destinationWarehouseId?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string; estimatedShipAt?: string @@ -124,7 +124,7 @@ export function getDiscard(discardNo: string) { export function listDiscardCandidates(inboundCode?: string) { return overseasApiRequest({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards/inventory-candidates', params: { inboundCode: inboundCode || undefined } }) } -export function createDiscard(payload: { items: Array<{ orderId: string; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) { +export function createDiscard(payload: { items: Array<{ orderId?: string; returnCartonId?: string; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) { return overseasApiRequest>({ method: 'POST', url: '/merchant/v1/last-leg/cargo-discards', data: payload }) } export function cancelDiscard(discardNo: string) { diff --git a/src/modules/overseas-warehouse/permissions.test.mjs b/src/modules/overseas-warehouse/permissions.test.mjs index e76f422..649278a 100644 --- a/src/modules/overseas-warehouse/permissions.test.mjs +++ b/src/modules/overseas-warehouse/permissions.test.mjs @@ -41,8 +41,8 @@ test('opens warehouse service and return receipt details as dedicated protected }) test('returns to the labeling list after a labeling application is submitted', () => { - assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?router\.push\(\{ name: 'overseas-labeling' \}\)/) - assert.match(services, /uploadReturnRelabelFile\([\s\S]*?router\.push\(\{ name: 'overseas-labeling' \}\)/) + assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) + assert.match(services, /uploadReturnRelabelFile\([\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/) }) test('keeps overseas warehouse writes behind operation-specific button permissions', () => { diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue index 33e42b5..cda9654 100644 --- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue +++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue @@ -187,8 +187,8 @@ async function submit() { creating.value = true try { const payload = { overseasWarehouseId: form.overseasWarehouseId, warehouseContactName: form.warehouseContactName.trim(), warehouseContactPhone: form.warehouseContactPhone.trim(), customerReferenceNo: form.customerReferenceNo || undefined, remark: form.remark || undefined, needRelabel: form.needRelabel, idempotencyKey: createIdempotencyKey(), items: items.map(item => ({ ...item, productNameZh: item.productNameZh || undefined })) } - const detail = editingReturnNo.value ? await updateReturnOrder(editingReturnNo.value, payload) : await createReturnOrder(payload) - ElMessage.success(editingReturnNo.value ? '退货预报已更新' : '退货预报已创建'); showCreate.value = false; editingReturnNo.value = ''; resetForm(); await router.push({ name: 'overseas-return-relabel-detail', params: { returnNo: detail.order.returnNo } }) + await (editingReturnNo.value ? updateReturnOrder(editingReturnNo.value, payload) : createReturnOrder(payload)) + ElMessage.success(editingReturnNo.value ? '退货预报已更新' : '退货预报已创建'); showCreate.value = false; editingReturnNo.value = ''; resetForm(); await router.push({ name: 'overseas-return-relabel' }) } finally { creating.value = false } } function statusType(status: string, needRelabel = false) { diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue index 88f91cf..0b5225e 100644 --- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue +++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue @@ -32,7 +32,7 @@ type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel' type PageMode = 'list' | 'create' | 'edit' | 'detail' type LabelingMode = 'carton' | 'sku' | 'carton_and_sku' type RelabelTemplateType = 'carton' | 'sku' | 'mixed' -interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } +interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; returnCartonId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; customerCartonMarks: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string } interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; destinationWarehouseId: string; remark: string } interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string } interface ReturnRelabelSkuRow { skuCode: string; productNameZh: string; availableQuantity: number; selected: boolean; quantity: number } @@ -55,19 +55,27 @@ const canConfirmRelabel = computed(() => auth.hasPermission('merchant:overseas-w const canConfirmTransfer = computed(() => auth.hasPermission('merchant:overseas-warehouse:transfer:confirm')) const canCancelDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:cancel')) const configs: Record = { - labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '客户单号', '海外操作仓', '贴标要求', 'SKU 数', '总箱数', '状态', '创建时间'] }, - transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '换标单号', '换标状态', '状态', '创建时间'] }, - disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '创建时间'] }, - relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '创建时间'] }, + labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '客户单号', '海外操作仓', '贴标要求', 'SKU 数', '总箱数', '状态', '审核时间', '贴标完成时间', '贴标出库时间', '创建时间'] }, + transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '换标单号', '换标状态', '状态', '审核时间', '测量时间', '出库时间', '创建时间'] }, + disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '废弃出库单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '废弃完成时间', '创建时间'] }, + relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '审核时间', '测量时间', '出库时间', '创建时间'] }, } const config = computed(() => configs[props.service]) const listQueryModel = computed({ - get: () => ({ ...filters, createdDates: [filters.start, filters.end].filter(Boolean) }), + get: () => ({ ...filters, createdDates: [filters.start, filters.end].filter(Boolean), reviewDates: [filters.reviewStart, filters.reviewEnd].filter(Boolean), completedDates: [filters.completedStart, filters.completedEnd].filter(Boolean), measuredDates: [filters.measuredStart, filters.measuredEnd].filter(Boolean), outboundDates: [filters.outboundStart, filters.outboundEnd].filter(Boolean) }), set: (value) => Object.assign(filters, { number: String(value.number || ''), inboundNo: String(value.inboundNo || ''), warehouse: String(value.warehouse || ''), destination: String(value.destination || ''), status: String(value.status || ''), start: Array.isArray(value.createdDates) ? String(value.createdDates[0] || '') : '', end: Array.isArray(value.createdDates) ? String(value.createdDates[1] || '') : '', + reviewStart: Array.isArray(value.reviewDates) ? String(value.reviewDates[0] || '') : '', + reviewEnd: Array.isArray(value.reviewDates) ? String(value.reviewDates[1] || '') : '', + completedStart: Array.isArray(value.completedDates) ? String(value.completedDates[0] || '') : '', + completedEnd: Array.isArray(value.completedDates) ? String(value.completedDates[1] || '') : '', + measuredStart: Array.isArray(value.measuredDates) ? String(value.measuredDates[0] || '') : '', + measuredEnd: Array.isArray(value.measuredDates) ? String(value.measuredDates[1] || '') : '', + outboundStart: Array.isArray(value.outboundDates) ? String(value.outboundDates[0] || '') : '', + outboundEnd: Array.isArray(value.outboundDates) ? String(value.outboundDates[1] || '') : '', }), }) const statusOptions = computed(() => props.service === 'labeling' @@ -84,7 +92,7 @@ const statusOptions = computed(() => props.service === 'labeling' { label: '审核驳回', value: 'REJECTED' }] : [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已通过', value: 'APPROVED' }, { label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '已分配', value: 'ALLOCATED' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '部分收货', value: 'PARTIALLY_RECEIVED' }, { label: '已收货', value: 'RECEIVED' }, { label: '已拒绝', value: 'REJECTED' }]) const mode = ref(props.detailPage ? 'detail' : props.createPage ? 'create' : 'list') -const filters = reactive({ number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' }) +const filters = reactive({ number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) const form = reactive({ customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 }) const transferDestinationType = ref<'NONE' | 'FBA' | 'PRIVATE'>('NONE') const selectedOperationWarehouse = ref(null) @@ -120,6 +128,7 @@ interface ServiceDetail extends Record { remark?: string; needRelabel?: boolean; labelDeadline?: string; relabelContactName?: string relabelContactPhone?: string; relabelDeliveryMethod?: string; relabelNo?: string relabelStatus?: string; relabelFulfillmentStatus?: string; createdAt?: string + inboundCode?: string; sourceOrders?: Array<{ lastlegOrderId?: string | number }> items?: Array<{ skuCode?: string; productNameZh?: string; packageCount?: number; shippingMark?: string }> relabelBindings?: Array<{ operationType?: string; customerCartonMark?: string; originalSku?: string; newSku?: string; quantity?: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }> cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string @@ -152,9 +161,22 @@ const listQueryFields = computed(() => { options: (props.service === 'relabel' ? fbaWarehouses.value : operationWarehouses.value).map((warehouse) => ({ label: warehouseLabel(warehouse), value: String(warehouse.id) })), }) if (props.service === 'transfer' || props.service === 'relabel') fields.push({ key: 'destination', label: '目的 FBA 仓', type: 'input', placeholder: '请输入仓代码' }) - fields.push( - { key: 'status', label: '订单状态', type: 'select', options: statusOptions.value }, - { key: 'createdDates', label: '创建时间', type: 'daterange' }, + fields.push({ key: 'status', label: '订单状态', type: 'select', options: statusOptions.value }) + fields.push({ key: 'createdDates', label: '创建时间', type: 'daterange' }) + if (props.service === 'labeling') fields.push( + { key: 'reviewDates', label: '审核时间', type: 'daterange' }, + { key: 'completedDates', label: '贴标完成时间', type: 'daterange' }, + { key: 'outboundDates', label: '贴标出库时间', type: 'daterange' }, + ) + if (props.service === 'relabel') fields.push( + { key: 'reviewDates', label: '审核时间', type: 'daterange' }, + { key: 'measuredDates', label: '测量时间', type: 'daterange' }, + { key: 'outboundDates', label: '出库时间', type: 'daterange' }, + ) + if (props.service === 'transfer') fields.push( + { key: 'reviewDates', label: '审核时间', type: 'daterange' }, + { key: 'completedDates', label: '测量时间', type: 'daterange' }, + { key: 'outboundDates', label: '出库时间', type: 'daterange' }, ) return fields }) @@ -170,7 +192,9 @@ const selectedRelabelInboundOrderId = ref('') const relabelCandidatesLoading = ref(false) const transferInventoryCandidates = ref([]) const selectedTransferInventory = ref('') -const selectedTransferSourceOrderIds = ref([]) +// Snowflake IDs exceed JavaScript's safe integer range. Keep them as strings +// all the way to JSON so the backend receives the exact source order ID. +const selectedTransferSourceOrderIds = ref([]) const returnRelabelSkuRows = ref([]) const isReturnRelabelSource = computed(() => props.service === 'labeling' && selectedRelabelInboundOrderId.value.startsWith('RETURN_ORDER:')) const selectedReturnOrderNo = computed(() => isReturnRelabelSource.value ? selectedRelabelInboundOrderId.value.slice('RETURN_ORDER:'.length) : '') @@ -267,7 +291,7 @@ onMounted(() => { void nextTick(refreshWarehouseMarkLabels) }) onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos() }) -function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '' }) } +function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) } function resetForm() { Object.assign(form, { customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 }) cargoRows.value = [] @@ -314,19 +338,21 @@ async function openCreate() { function returnToList() { if (props.createPage || props.detailPage) { - const routeName = ({ labeling: 'overseas-labeling', transfer: 'overseas-storage-transfer', disposal: 'overseas-cargo-disposal', relabel: 'overseas-return-relabel' } as Record)[props.service] - void router.push({ name: routeName }) + void router.push({ name: serviceListRouteName(props.service) }) return } mode.value = 'list' } +function serviceListRouteName(service: ServiceType) { + return ({ labeling: 'overseas-labeling', transfer: 'overseas-storage-transfer', disposal: 'overseas-cargo-disposal', relabel: 'overseas-return-relabel' } as Record)[service] +} async function openEdit(transferNo: string) { try { resetForm() await loadWarehouses() const detail = await getTransfer(transferNo) as ServiceDetail - if (detail.status !== 'DRAFT') throw new Error('只有草稿状态的转运单可以编辑') + if (!['DRAFT', 'REJECTED'].includes(detail.status || '')) throw new Error('只有草稿或审核驳回状态的转运单可以编辑') editingTransferNo.value = transferNo Object.assign(form, { warehouse: detail.operatingWarehouseId || '', destination: detail.destinationWarehouseCode || detail.fbaWarehouseCode || '', @@ -362,6 +388,20 @@ async function openEdit(transferNo: string) { if (form.warehouse) { try { selectedOperationWarehouse.value = await getMerchantWarehouseDetail(form.warehouse) } catch { selectedOperationWarehouse.value = null } } + // The warehouse selector loads inventory candidates for a new transfer, but + // edit mode starts with an already populated warehouse and never emits the + // selector change event. Load the same candidates explicitly so the source + // inventory dropdown is usable when editing a draft. + if (props.service === 'transfer' && form.warehouse) { + await loadTransferInventoryCandidates(form.warehouse) + const sourceInboundCode = String(detail.inboundCode || '') + if (transferInventoryOptions.value.some((option) => option.value === sourceInboundCode)) { + selectedTransferInventory.value = sourceInboundCode + form.inboundNo = sourceInboundCode + selectedTransferSourceOrderIds.value = (detail.sourceOrders || []) + .map((item) => String(item.lastlegOrderId || '')).filter(Boolean) + } + } mode.value = 'edit' } catch (error) { ElMessage.error(error instanceof Error ? error.message : '草稿加载失败') } } @@ -375,11 +415,25 @@ async function selectDiscardWarehouse() { discardCandidatesLoading.value = true try { const inventory = await listRelabelInventoryCandidates(form.warehouse) + const orderByInbound = new Map() + const inboundCodes = [...new Set(inventory.map((candidate) => String(candidate.inboundCode || '')).filter(Boolean))] + const historicalGroups = await Promise.all(inboundCodes.map(async (inboundCode) => ({ inboundCode, orders: await listRelabelHistoricalOrders(form.warehouse, inboundCode) }))) + for (const { inboundCode, orders } of historicalGroups) { + for (const order of orders) { + if (order.id) orderByInbound.set(inboundCode, String(order.id)) + } + } const grouped = new Map() for (const candidate of inventory) { - const orderId = String(candidate.inboundOrderId || '') - if (!orderId) continue - const current = grouped.get(orderId) || { orderId, inboundCode: candidate.inboundCode || '', inboundOrderNo: candidate.inboundOrderNo || '', overseasWarehouseId: String(candidate.overseasWarehouseId || form.warehouse), overseasWarehouseName: candidate.overseasWarehouseName || '', availablePackages: 0, skus: [] } + const returnCartonId = String(candidate.returnCartonId || '') + const sourceType = candidate.sourceType === 'RETURN_CARTON' ? 'RETURN_CARTON' : 'DROPSHIP' + const inboundCode = String(candidate.inboundCode || '') + const orderId = sourceType === 'RETURN_CARTON' ? `return:${returnCartonId}` : (orderByInbound.get(inboundCode) || '') + if (!orderId || (sourceType === 'RETURN_CARTON' && !returnCartonId)) continue + const current = grouped.get(orderId) || { orderId, inboundCode, inboundOrderNo: candidate.inboundOrderNo || '', overseasWarehouseId: String(candidate.overseasWarehouseId || form.warehouse), overseasWarehouseName: candidate.overseasWarehouseName || '', availablePackages: 0, skus: [] } + current.sourceType = sourceType + current.returnCartonId = returnCartonId || undefined + current.cartonMark = candidate.cartonMark || undefined const packages = inventoryQuantity(candidate.availablePackages) current.availablePackages += packages const skuCode = String(candidate.skuCode || '').trim() @@ -741,12 +795,13 @@ function importSelectedBindingsToDetails() { function selectDiscardInbound(orderId: string) { const candidate = discardCandidates.value.find((item) => item.orderId === orderId) if (!candidate) { cargoRows.value = []; return } - form.inboundNo = candidate.inboundCode + form.inboundNo = candidate.inboundCode || candidate.inboundOrderNo form.warehouse = candidate.overseasWarehouseId cargoRows.value = candidate.skus.map((sku) => ({ id: ++cargoSequence, sku: sku.skuCode, name: sku.productName, quantity: 0, - availableQuantity: sku.availablePackages, sourceOrderId: candidate.orderId, - fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '', + availableQuantity: sku.availablePackages, sourceOrderId: candidate.orderId, sourceType: candidate.sourceType, + returnCartonId: candidate.returnCartonId, cartonMark: candidate.cartonMark || '', + fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '', })) } function dateTime(value: string, end = false) { return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined } @@ -860,25 +915,25 @@ async function load() { loading.value = true try { if (props.service === 'labeling') { - const result = await listRelabels({ page: page.value, size: pageSize.value, relabelNo: filters.number || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) }) + const result = await listRelabels({ page: page.value, size: pageSize.value, relabelNo: filters.number || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), reviewedFrom: dateTime(filters.reviewStart), reviewedTo: dateTime(filters.reviewEnd, true), completedFrom: dateTime(filters.completedStart), completedTo: dateTime(filters.completedEnd, true), outboundFrom: dateTime(filters.outboundStart), outboundTo: dateTime(filters.outboundEnd, true) }) total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length submittedRows.value = result.records.map((item) => { const destinationWarehouses = warehouseDisplay(item.overseasWarehouseId) - return { ...row([item.relabelNo, item.customerOrderNo, destinationWarehouses, '—', item.itemCount, item.totalPackageCount, statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status, __confirmedAt: item.customerConfirmedAt || '' } + return { ...row([item.relabelNo, item.customerOrderNo, destinationWarehouses, '—', item.itemCount, item.totalPackageCount, statusLabel(item.status), formatDateTime(item.reviewedAt), formatDateTime(item.relabelCompletedAt), formatDateTime(item.outboundAt), formatDateTime(item.createdAt)]), __status: item.status, __confirmedAt: item.customerConfirmedAt || '' } }) } else if (props.service === 'disposal') { - const result = await listDiscards({ page: page.value, size: pageSize.value, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) }) + const result = await listDiscards({ page: page.value, size: pageSize.value, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), completedFrom: dateTime(filters.completedStart), completedTo: dateTime(filters.completedEnd, true) }) total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length - submittedRows.value = result.records.map((item) => row([item.discardNo, item.inboundCodes, item.overseasWarehouseName || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, statusLabel(item.status), formatDateTime(item.createdAt)])) + submittedRows.value = result.records.map((item) => row([item.discardNo, item.outboundNo || '—', item.inboundCodes, item.overseasWarehouseName || warehouseDisplay(item.overseasWarehouseId) || '—', '—', item.totalPackages, `${item.currency} ${item.totalFee}`, statusLabel(item.status), formatDateTime(item.completedAt), formatDateTime(item.createdAt)])) } else { - const result = await listTransfers({ page: page.value, size: pageSize.value, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, ...(props.service === 'relabel' ? { needRelabel: true } : {}), createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true) }) + const result = await listTransfers({ page: page.value, size: pageSize.value, transferNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, operatingWarehouseId: filters.warehouse || undefined, fbaWarehouseCode: filters.destination || undefined, status: filters.status || undefined, ...(props.service === 'relabel' ? { needRelabel: true } : {}), createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), reviewedFrom: dateTime(filters.reviewStart), reviewedTo: dateTime(filters.reviewEnd, true), measuredFrom: props.service === 'relabel' ? dateTime(filters.measuredStart) : dateTime(filters.completedStart), measuredTo: props.service === 'relabel' ? dateTime(filters.measuredEnd, true) : dateTime(filters.completedEnd, true), outboundFrom: dateTime(filters.outboundStart), outboundTo: dateTime(filters.outboundEnd, true) }) total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length submittedRows.value = result.records.map((item) => { const operationWarehouse = warehouseDisplay(item.operatingWarehouseId) const relabelStatus = transferRelabelStatus(item) return props.service === 'transfer' - ? { ...row([item.transferNo, item.packageCount, operationWarehouse, item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是' : '否', item.relabelNo, relabelStatus.label, statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status, __statusLabel: statusLabel(item.status), __relabelStatus: relabelStatus.code, __relabelStatusLabel: relabelStatus.label } - : row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', statusLabel(item.status), formatDateTime(item.createdAt)]) + ? { ...row([item.transferNo, item.packageCount, operationWarehouse, item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是' : '否', item.relabelNo, relabelStatus.label, statusLabel(item.status), formatDateTime(item.reviewedAt), formatDateTime(item.measuredAt), formatDateTime(item.outboundAt), formatDateTime(item.createdAt)]), __status: item.status, __statusLabel: statusLabel(item.status), __relabelStatus: relabelStatus.code, __relabelStatusLabel: relabelStatus.label } + : row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', statusLabel(item.status), formatDateTime(item.reviewedAt), formatDateTime(item.measuredAt), formatDateTime(item.outboundAt), formatDateTime(item.createdAt)]) }) } } catch (error) { submittedRows.value = []; total.value = 0; ElMessage.error(error instanceof Error ? error.message : '列表加载失败') } @@ -889,7 +944,7 @@ function resetListQuery() { resetFilters(); query() } function listRowActions(candidate: object): TableAction[] { const item = candidate as Record const actions: TableAction[] = [{ key: 'detail', label: '详情', run: () => openDetail(item) }] - if (canCreate.value && props.service === 'transfer' && item.__status === 'DRAFT') actions.push({ key: 'edit', label: '编辑', run: () => openEdit(item['0']) }) + if (canCreate.value && props.service === 'transfer' && ['DRAFT', 'REJECTED'].includes(item.__status)) actions.push({ key: 'edit', label: '编辑', run: () => openEdit(item['0']) }) if (canConfirmTransfer.value && props.service === 'transfer' && item.__status === 'WAIT_CUSTOMER_CONFIRMATION') actions.push({ key: 'confirm-measurement', label: '确认测量', run: () => confirmTransferFromList(item['0']) }) if (canConfirmRelabel.value && props.service === 'labeling' && item.__status === 'CUSTOMER_CONFIRM_PENDING' && !item.__confirmedAt) actions.push({ key: 'confirm-relabel', label: '确认换标', run: () => confirmRelabelFromList(item['0']) }) return actions @@ -980,9 +1035,6 @@ async function submit(action: 'draft' | 'submit' = 'submit') { if (props.service === 'transfer' && transferDestinationType.value === 'FBA' && !form.destination) { ElMessage.warning('请选择 FBA 目的仓'); return } - if (props.service === 'transfer' && transferDestinationType.value === 'NONE') { - ElMessage.warning('请选择 FBA 仓或私人仓'); return - } if (props.service === 'transfer' && transferDestinationType.value === 'PRIVATE' && (!form.privateDestinationAddress || !form.privateDestinationPostalCode || !form.privateDestinationContact || !form.privateDestinationPhone)) { ElMessage.warning('请完整填写私人仓地址、邮编、联系人和电话'); return } @@ -1018,20 +1070,22 @@ async function submit(action: 'draft' | 'submit' = 'submit') { if (shippingLabelFile.value) await uploadRelabelShippingLabel(created.application.relabelNo, shippingLabelFile.value) } catch (error) { // Creation already committed: return to the list even when an attachment still needs retrying. - if (props.createPage) await router.push({ name: 'overseas-labeling' }) + if (props.createPage) await router.push({ name: serviceListRouteName(props.service) }) else { mode.value = 'list'; await load(); await openDetail({ '0': created.application.relabelNo }) } await ElMessageBox.alert(`申请 ${created.application.relabelNo} 已创建,部分附件上传失败:${error instanceof Error ? error.message : '上传失败'}。请在原单详情补传失败附件,不要重复下单。`, '申请已创建,附件待补传', { confirmButtonText: '知道了' }) return } ElMessage.success(`${config.value.title}提交成功`) - await router.push({ name: 'overseas-labeling' }) + await router.push({ name: serviceListRouteName(props.service) }) return } else if (props.service === 'disposal') { const items = cargoRows.value.filter((item) => item.quantity > 0).map((item) => { if (!item.sourceOrderId || item.sourceOrderId !== selectedDiscardOrderId.value || item.quantity > Number(item.availableQuantity || 0)) { throw new Error(`SKU ${item.sku} 的可废弃库存不足`) } - return { orderId: item.sourceOrderId, skuCode: item.sku, discardPackages: item.quantity } + return { orderId: item.sourceType === 'RETURN_CARTON' ? undefined : item.sourceOrderId, + returnCartonId: item.sourceType === 'RETURN_CARTON' ? item.returnCartonId : undefined, + skuCode: item.sku, discardPackages: item.quantity } }) await createDiscard({ items, idempotencyKey, remark: [form.reason, form.remark].filter(Boolean).join(';') || undefined }) } else { @@ -1057,7 +1111,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') { if (!relabelNo) { ElMessage.success('转运单已提交审核;PDA 入库确认后将自动生成关联换标单') await ElMessageBox.alert(`转运单 ${transferNo} 已提交。关联换标单会在 PDA 完成入库、客户确认实测库存后自动生成;届时请进入关联换标单详情上传贴标总文件。`, '转运单已提交', { confirmButtonText: '知道了' }) - await openDetail({ '0': transferNo }) + await router.push({ name: serviceListRouteName('transfer') }) return } try { @@ -1070,13 +1124,12 @@ async function submit(action: 'draft' | 'submit' = 'submit') { } } ElMessage.success('转运单已提交审核') - await openDetail({ '0': transferNo }) + await router.push({ name: serviceListRouteName('transfer') }) return } } ElMessage.success(props.service === 'transfer' ? '转运单草稿保存成功' : `${config.value.title}提交成功`) - if (props.createPage) await router.push({ name: props.service === 'relabel' ? 'overseas-return-relabel' : 'overseas-labeling' }) - else { mode.value = 'list'; await load() } + await router.push({ name: serviceListRouteName(props.service) }) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '提交失败') } finally { saving.value = false } } @@ -1220,6 +1273,16 @@ async function loadWarehouses() { } finally { fbaWarehousesLoading.value = false } } function warehouseLabel(value: MerchantWarehouse) { return `${value.warehouseName} · ${value.warehouseCode}` } +async function loadTransferInventoryCandidates(warehouseId: string) { + transferInventoryCandidates.value = [] + if (!warehouseId) return + relabelCandidatesLoading.value = true + try { + transferInventoryCandidates.value = await listRelabelInventoryCandidates(warehouseId) + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : '当前海外仓库存来源加载失败') + } finally { relabelCandidatesLoading.value = false } +} async function selectTransferWarehouse() { selectedOperationWarehouse.value = null form.pickupContact = '' @@ -1235,7 +1298,7 @@ async function selectTransferWarehouse() { form.relabelContact = detail.warehouse.contactName || '' form.relabelPhone = detail.warehouse.contactPhone || '' if (form.deliveryMethod === 'CUSTOMER_DELIVERY') form.address = operationWarehouseAddress.value - transferInventoryCandidates.value = await listRelabelInventoryCandidates(form.warehouse) + await loadTransferInventoryCandidates(form.warehouse) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '中转仓联系信息加载失败') } finally { operationWarehouseDetailLoading.value = false } @@ -1264,6 +1327,7 @@ async function resubmitRejectedRelabel() { idempotencyKey: createIdempotencyKey(), }) ElMessage.success('贴标单已重新提交审核') + await router.push({ name: serviceListRouteName('labeling') }) } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '重新提交失败') } @@ -1302,7 +1366,7 @@ async function selectTransferInventory(value: string) { cargoRows.value = [...grouped.values()] try { const historical = await listRelabelHistoricalOrders(form.warehouse, value) - const sourceIds = historical.map((item) => Number(item.id)).filter((id) => Number.isInteger(id) && id > 0) + const sourceIds = historical.map((item) => String(item.id || '')).filter(Boolean) selectedTransferSourceOrderIds.value = sourceIds } catch (error) { ElMessage.error(error instanceof Error ? error.message : '库存来源订单加载失败') @@ -1405,11 +1469,11 @@ function selectRelabelInventory(inboundOrderId: string) {

{{ props.service === 'transfer' ? '转运信息' : props.service === 'relabel' ? '退货信息' : props.service === 'labeling' ? '订单信息' : '基础信息' }}

- - + + - + @@ -1426,7 +1490,7 @@ function selectRelabelInventory(inboundOrderId: string) {
- + @@ -1437,7 +1501,7 @@ function selectRelabelInventory(inboundOrderId: string) { -
*目的去向暂未确定FBA 仓私人仓
+
目的去向暂未确定FBA 仓私人仓
@@ -1539,7 +1603,7 @@ function selectRelabelInventory(inboundOrderId: string) {
{{ props.service === 'labeling' ? selectedRelabelDetail?.application.relabelNo : selectedDetail?.transferNo || selectedDetail?.['0'] || `${config.title}详情` }}{{ config.title }}详情
-
修改后重新提交确认换标完成取消申请编辑确认测量数据{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}
+
修改后重新提交确认换标完成取消申请编辑确认测量数据{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}
@@ -1628,6 +1692,7 @@ function selectRelabelInventory(inboundOrderId: string) { .warehouse-page-size-select { width: 112px; margin-left: 8px; } .warehouse-page-size-select :deep(.el-input__inner), .warehouse-page-size-select :deep(.el-select__selected-item) { color: #526176 !important; opacity: 1 !important; -webkit-text-fill-color: #526176; } .warehouse-list-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; } +.warehouse-disposal-field { max-width: 520px; } @media (max-width: 720px) { .warehouse-pagination :deep(.el-pagination) { width: 100%; justify-content: flex-end; } .warehouse-transfer-main-card .warehouse-form-grid { grid-template-columns: minmax(0, 1fr); } diff --git a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs index cc3dfb9..94a9f3b 100644 --- a/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs +++ b/src/modules/overseas-warehouse/views/warehouseTransferForm.test.mjs @@ -319,7 +319,9 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提 assert.match(api, /warehouse-transfers\/\$\{encodeURIComponent\(transferNo\)\}\/submit/) assert.match(view, /submit\('draft'\).*保存草稿/s) assert.match(view, /async function openEdit\(transferNo: string\)/) - assert.match(view, /item\.__status === 'DRAFT'.*openEdit\(item\['0'\]\)/s) + assert.match(view, /\['DRAFT', 'REJECTED'\]\.includes\(item\.__status\).*openEdit\(item\['0'\]\)/s) + assert.match(view, /\['DRAFT', 'REJECTED'\]\.includes\(detail\.status \|\| ''\)/) + assert.match(view, /\['DRAFT', 'REJECTED'\]\.includes\(selectedDetail\?\.status \|\| ''\)/) assert.match(view, /v-if="props\.service === 'transfer'"[^>]*type="primary"[^>]*native-type="submit"[^>]*>提交订单/) assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/) assert.match(view, /await submitTransfer\(transferNo\)/) @@ -332,6 +334,22 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提 assert.doesNotMatch(view, /

箱唛明细<\/h3>/) }) +test('编辑仓储转运时加载库存转运来源候选', () => { + assert.match(view, /async function loadTransferInventoryCandidates\(warehouseId: string\)/) + assert.match(view, /if \(props\.service === 'transfer' && form\.warehouse\) \{[\s\S]*await loadTransferInventoryCandidates\(form\.warehouse\)/) + assert.match(view, /transferInventoryCandidates\.value = await listRelabelInventoryCandidates\(warehouseId\)/) + assert.match(view, /transferInventoryOptions\.value\.some\(\(option\) => option\.value === sourceInboundCode\)/) + assert.match(view, /selectedTransferInventory\.value = sourceInboundCode/) + assert.match(view, /detail\.sourceOrders \|\| \[\]/) +}) + +test('仓储转运来源订单雪花 ID 按字符串传递,避免 Number 精度丢失', () => { + assert.match(view, /const selectedTransferSourceOrderIds = ref\(\[\]\)/) + assert.match(view, /historical\.map\(\(item\) => String\(item\.id \|\| ''\)\)/) + assert.match(api, /sourceLastlegOrderIds\?: string\[\]/) + assert.doesNotMatch(view, /sourceLastlegOrderIds[\s\S]{0,160}Number\(/) +}) + test('转运贴标单在PDA确认库存前不误报附件上传失败', () => { assert.match(view, /if \(!relabelNo\) \{[\s\S]*PDA 入库确认后将自动生成关联换标单/) assert.match(view, /届时请进入关联换标单详情上传贴标总文件/)