2026-08-03 11:44:50 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-08-13 09:48:51 +08:00
|
|
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
|
|
|
|
import { useRouter } from 'vue-router'
|
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
|
import { MapPin, Plus, Send, Trash2 } from '@lucide/vue'
|
|
|
|
|
|
import { useAuthStore } from '@/modules/auth/store/auth'
|
2026-08-18 11:32:58 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getMerchantInboundCodePolicy,
|
2026-09-04 10:51:28 +08:00
|
|
|
|
getMerchantOrderAddresses,
|
2026-08-18 11:32:58 +08:00
|
|
|
|
getMerchantPricingChannels,
|
2026-09-04 10:51:28 +08:00
|
|
|
|
type CustomerOrderAddress,
|
2026-08-18 11:32:58 +08:00
|
|
|
|
type InboundCodePolicy,
|
|
|
|
|
|
type MerchantPricingChannel,
|
|
|
|
|
|
} from '@/modules/first-leg/api/firstLeg'
|
2026-08-13 09:48:51 +08:00
|
|
|
|
import { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders'
|
2026-08-25 16:11:00 +08:00
|
|
|
|
import { useWorkspaceTabsStore } from '@/navigation/workspace-tabs/workspaceTabs'
|
2026-08-13 09:48:51 +08:00
|
|
|
|
import type { FirstLegOrderDraft } from '@/modules/first-leg/types/firstLegOrder'
|
|
|
|
|
|
import { createEmptyCargoItem } from '@/modules/first-leg/utils/firstLegOrder'
|
|
|
|
|
|
|
|
|
|
|
|
type DestinationType = 'FBA' | 'OVERSEAS' | 'PRIVATE'
|
|
|
|
|
|
|
|
|
|
|
|
interface QuickOrderProduct {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
sku: string
|
|
|
|
|
|
packageCount: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface QuickOrderRow {
|
|
|
|
|
|
id: string
|
2026-08-18 11:32:58 +08:00
|
|
|
|
inboundCode: string
|
2026-08-13 09:48:51 +08:00
|
|
|
|
channelKey: string
|
|
|
|
|
|
destinationType: DestinationType
|
|
|
|
|
|
destinationWarehouseId: string
|
|
|
|
|
|
destinationWarehouseName: string
|
|
|
|
|
|
destinationCountryCode: string
|
|
|
|
|
|
amazonWarehouseCode: string
|
2026-09-04 10:51:28 +08:00
|
|
|
|
privateDestinationAddressId: string
|
2026-08-13 09:48:51 +08:00
|
|
|
|
consigneeContactName: string
|
|
|
|
|
|
consigneePhone: string
|
|
|
|
|
|
consigneeCity: string
|
|
|
|
|
|
consigneeAddress: string
|
|
|
|
|
|
consigneePostalCode: string
|
|
|
|
|
|
products: QuickOrderProduct[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface QuickChannelOption extends MerchantPricingChannel {
|
|
|
|
|
|
warehouseId: string
|
|
|
|
|
|
warehouseName: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface QuickDraftResult {
|
|
|
|
|
|
rowId: string
|
|
|
|
|
|
sku: string
|
|
|
|
|
|
draftId: string
|
|
|
|
|
|
inboundCode: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
|
const store = useFirstLegOrdersStore()
|
2026-08-25 16:11:00 +08:00
|
|
|
|
const workspace = useWorkspaceTabsStore()
|
2026-08-13 09:48:51 +08:00
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const submitting = ref(false)
|
|
|
|
|
|
const loadError = ref('')
|
2026-08-18 11:32:58 +08:00
|
|
|
|
const inboundCodePolicy = ref<InboundCodePolicy | null>(null)
|
2026-08-13 09:48:51 +08:00
|
|
|
|
const channelOptions = ref<QuickChannelOption[]>([])
|
|
|
|
|
|
const destinationDialogOpen = ref(false)
|
|
|
|
|
|
const destinationLoading = ref(false)
|
2026-09-04 10:51:28 +08:00
|
|
|
|
const privateAddresses = ref<CustomerOrderAddress[]>([])
|
|
|
|
|
|
const privateAddressesError = ref('')
|
2026-08-13 09:48:51 +08:00
|
|
|
|
const editingDestinationRow = ref<QuickOrderRow>()
|
|
|
|
|
|
const draftResults = reactive<QuickDraftResult[]>([])
|
|
|
|
|
|
const resultDialogOpen = ref(false)
|
|
|
|
|
|
const activeBatchKey = ref('')
|
|
|
|
|
|
const destinationForm = reactive({
|
|
|
|
|
|
type: 'FBA' as DestinationType,
|
|
|
|
|
|
warehouseId: '',
|
2026-09-04 10:51:28 +08:00
|
|
|
|
privateAddressId: '',
|
2026-08-13 09:48:51 +08:00
|
|
|
|
})
|
|
|
|
|
|
let rowSequence = 0
|
|
|
|
|
|
let productSequence = 0
|
|
|
|
|
|
|
|
|
|
|
|
function createBatchKey() {
|
|
|
|
|
|
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
|
|
|
|
return `first-leg-quick-batch-${randomPart}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createProduct(): QuickOrderProduct {
|
|
|
|
|
|
return reactive({ id: `quick-product-${++productSequence}`, sku: '', packageCount: 1 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function createRow(): QuickOrderRow {
|
|
|
|
|
|
return reactive({
|
|
|
|
|
|
id: `quick-order-${++rowSequence}`,
|
2026-08-18 11:32:58 +08:00
|
|
|
|
inboundCode: '',
|
2026-08-13 09:48:51 +08:00
|
|
|
|
channelKey: '',
|
|
|
|
|
|
destinationType: 'FBA',
|
|
|
|
|
|
destinationWarehouseId: '',
|
|
|
|
|
|
destinationWarehouseName: '',
|
|
|
|
|
|
destinationCountryCode: '',
|
|
|
|
|
|
amazonWarehouseCode: '',
|
2026-09-04 10:51:28 +08:00
|
|
|
|
privateDestinationAddressId: '',
|
2026-08-13 09:48:51 +08:00
|
|
|
|
consigneeContactName: '',
|
|
|
|
|
|
consigneePhone: '',
|
|
|
|
|
|
consigneeCity: '',
|
|
|
|
|
|
consigneeAddress: '',
|
|
|
|
|
|
consigneePostalCode: '',
|
|
|
|
|
|
products: [createProduct()],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const rows = reactive<QuickOrderRow[]>([createRow()])
|
|
|
|
|
|
const hasChannels = computed(() => channelOptions.value.length > 0)
|
2026-08-18 11:32:58 +08:00
|
|
|
|
const manualInboundCodeRequired = computed(() => inboundCodePolicy.value?.mode === 'LEGACY_MANUAL')
|
2026-08-13 09:48:51 +08:00
|
|
|
|
const availableDestinationWarehouses = computed(() => destinationForm.type === 'PRIVATE'
|
|
|
|
|
|
? []
|
|
|
|
|
|
: store.warehouses.filter((warehouse) => warehouse.warehouseType === destinationForm.type))
|
|
|
|
|
|
|
|
|
|
|
|
function addOrderRow() {
|
|
|
|
|
|
rows.push(createRow())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function addProduct(row: QuickOrderRow) {
|
|
|
|
|
|
row.products.push(createProduct())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function removeProduct(row: QuickOrderRow, productIndex: number) {
|
|
|
|
|
|
if (row.products.length === 1) {
|
|
|
|
|
|
row.products[0] = createProduct()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
row.products.splice(productIndex, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function removeRow(rowIndex: number) {
|
|
|
|
|
|
if (rows.length === 1) {
|
|
|
|
|
|
rows[0] = createRow()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
rows.splice(rowIndex, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function channelKey(warehouseId: string, channelCode: string) {
|
|
|
|
|
|
return `${warehouseId}:${channelCode}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectedChannel(row: QuickOrderRow) {
|
|
|
|
|
|
return channelOptions.value.find((channel) => channelKey(channel.warehouseId, channel.channelCode) === row.channelKey)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-04 10:51:28 +08:00
|
|
|
|
function addressLabel(address: CustomerOrderAddress) {
|
|
|
|
|
|
return `${address.addressName} · ${address.contactName} · ${address.countryCode} ${address.city} ${address.addressLine}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 09:48:51 +08:00
|
|
|
|
function createConsigneeSnapshot(row: QuickOrderRow) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: '',
|
|
|
|
|
|
code: '',
|
|
|
|
|
|
name: row.destinationWarehouseName,
|
|
|
|
|
|
role: 'CONSIGNEE' as const,
|
|
|
|
|
|
contactName: row.consigneeContactName,
|
|
|
|
|
|
phone: row.consigneePhone,
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
countryRegion: row.destinationCountryCode,
|
|
|
|
|
|
province: '',
|
|
|
|
|
|
city: row.consigneeCity,
|
|
|
|
|
|
postalCode: row.consigneePostalCode,
|
|
|
|
|
|
address: row.consigneeAddress,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasCompletePrivateContact(row: QuickOrderRow) {
|
|
|
|
|
|
return [row.consigneeContactName, row.consigneePhone, row.consigneeCity, row.consigneeAddress]
|
|
|
|
|
|
.every((value) => value.trim())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildOrderDraft(row: QuickOrderRow, channel: QuickChannelOption): FirstLegOrderDraft {
|
|
|
|
|
|
const pricingTaxMode = channel.pricingTaxModes[0] ?? 'TAX_INCLUDED'
|
|
|
|
|
|
return {
|
2026-08-18 11:32:58 +08:00
|
|
|
|
inboundCode: row.inboundCode.trim() || undefined,
|
2026-08-13 09:48:51 +08:00
|
|
|
|
referenceNo: '',
|
|
|
|
|
|
transportMode: 'FCL',
|
|
|
|
|
|
businessType: 'EXPORT',
|
|
|
|
|
|
origin: '',
|
|
|
|
|
|
destination: row.destinationWarehouseName,
|
|
|
|
|
|
receivingWarehouseId: channel.warehouseId,
|
|
|
|
|
|
expectedDepartureDate: '',
|
|
|
|
|
|
shipperPartyId: '',
|
2026-09-01 19:34:37 +08:00
|
|
|
|
senderAddressId: '',
|
2026-08-13 09:48:51 +08:00
|
|
|
|
shipperPartySnapshot: null,
|
|
|
|
|
|
shipper: '',
|
|
|
|
|
|
consigneePartyId: '',
|
2026-09-04 10:51:28 +08:00
|
|
|
|
privateDestinationAddressId: row.destinationType === 'PRIVATE' ? row.privateDestinationAddressId : '',
|
2026-08-13 09:48:51 +08:00
|
|
|
|
consigneePartySnapshot: row.destinationType === 'PRIVATE' && hasCompletePrivateContact(row)
|
|
|
|
|
|
? createConsigneeSnapshot(row) : null,
|
|
|
|
|
|
consignee: row.destinationType === 'PRIVATE' && hasCompletePrivateContact(row)
|
|
|
|
|
|
? row.destinationWarehouseName : '',
|
|
|
|
|
|
priority: 'NORMAL',
|
|
|
|
|
|
customerRemark: '',
|
|
|
|
|
|
tradeTerm: 'FOB',
|
|
|
|
|
|
destinationCountryCode: row.destinationCountryCode,
|
|
|
|
|
|
destinationWarehouseId: row.destinationWarehouseId,
|
|
|
|
|
|
destinationWarehouseName: row.destinationWarehouseName,
|
|
|
|
|
|
pickupMethod: 'SELF_EXPRESS',
|
|
|
|
|
|
channelCode: channel.channelCode,
|
|
|
|
|
|
channelName: channel.channelName,
|
|
|
|
|
|
pricingTaxMode,
|
|
|
|
|
|
taxMode: 'BUY_DECLARATION',
|
2026-09-01 19:34:37 +08:00
|
|
|
|
customsInspection: null,
|
2026-08-13 09:48:51 +08:00
|
|
|
|
destinationType: row.destinationType,
|
|
|
|
|
|
amazonWarehouseCode: row.amazonWarehouseCode,
|
|
|
|
|
|
shipmentId: '',
|
|
|
|
|
|
items: row.products.map((product) => {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...createEmptyCargoItem(`quick-cargo-${product.id}`),
|
|
|
|
|
|
sku: product.sku.trim(),
|
|
|
|
|
|
packageCount: product.packageCount,
|
|
|
|
|
|
pieces: product.packageCount,
|
|
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function destinationLabel(row: QuickOrderRow) {
|
|
|
|
|
|
if (!row.destinationWarehouseName) return '尚未绑定目的仓'
|
|
|
|
|
|
const code = row.amazonWarehouseCode || row.destinationCountryCode
|
|
|
|
|
|
return [row.destinationWarehouseName, code].filter(Boolean).join(' · ')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openDestinationDialog(row: QuickOrderRow, typeValue: string | number | boolean | undefined) {
|
|
|
|
|
|
const type = String(typeValue) as DestinationType
|
|
|
|
|
|
if (!['FBA', 'OVERSEAS', 'PRIVATE'].includes(type)) return
|
|
|
|
|
|
editingDestinationRow.value = row
|
|
|
|
|
|
Object.assign(destinationForm, {
|
|
|
|
|
|
type,
|
|
|
|
|
|
warehouseId: row.destinationType === type ? row.destinationWarehouseId : '',
|
2026-09-04 10:51:28 +08:00
|
|
|
|
privateAddressId: row.destinationType === type ? row.privateDestinationAddressId : '',
|
2026-08-13 09:48:51 +08:00
|
|
|
|
})
|
|
|
|
|
|
destinationDialogOpen.value = true
|
|
|
|
|
|
destinationLoading.value = true
|
|
|
|
|
|
try {
|
2026-09-04 10:51:28 +08:00
|
|
|
|
if (type === 'PRIVATE') {
|
|
|
|
|
|
privateAddressesError.value = ''
|
|
|
|
|
|
privateAddresses.value = await getMerchantOrderAddresses('PRIVATE_DESTINATION')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await store.loadWarehouseType(type)
|
|
|
|
|
|
}
|
2026-08-13 09:48:51 +08:00
|
|
|
|
} catch (error) {
|
2026-09-04 10:51:28 +08:00
|
|
|
|
const message = error instanceof Error ? error.message : '目的仓加载失败,请稍后重试'
|
|
|
|
|
|
if (type === 'PRIVATE') {
|
|
|
|
|
|
privateAddresses.value = []
|
|
|
|
|
|
privateAddressesError.value = message
|
|
|
|
|
|
}
|
|
|
|
|
|
ElMessage.error(message)
|
2026-08-13 09:48:51 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
destinationLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function bindDestination() {
|
|
|
|
|
|
const row = editingDestinationRow.value
|
|
|
|
|
|
if (!row) return
|
|
|
|
|
|
if (destinationForm.type !== 'PRIVATE') {
|
|
|
|
|
|
const warehouse = availableDestinationWarehouses.value.find((item) => item.id === destinationForm.warehouseId)
|
|
|
|
|
|
if (!warehouse) {
|
|
|
|
|
|
ElMessage.warning('请选择一个目的仓')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
Object.assign(row, {
|
|
|
|
|
|
destinationType: destinationForm.type,
|
|
|
|
|
|
destinationWarehouseId: warehouse.id,
|
|
|
|
|
|
destinationWarehouseName: warehouse.name,
|
|
|
|
|
|
destinationCountryCode: warehouse.countryCode,
|
|
|
|
|
|
amazonWarehouseCode: destinationForm.type === 'FBA' ? warehouse.code : '',
|
2026-09-04 10:51:28 +08:00
|
|
|
|
privateDestinationAddressId: '',
|
2026-08-13 09:48:51 +08:00
|
|
|
|
consigneeContactName: '',
|
|
|
|
|
|
consigneePhone: '',
|
|
|
|
|
|
consigneeCity: '',
|
|
|
|
|
|
consigneeAddress: '',
|
|
|
|
|
|
consigneePostalCode: '',
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
2026-09-04 10:51:28 +08:00
|
|
|
|
const address = privateAddresses.value.find((item) => String(item.id) === destinationForm.privateAddressId)
|
|
|
|
|
|
if (!address) {
|
|
|
|
|
|
ElMessage.warning('请选择企业已保存的私人仓地址')
|
2026-08-13 09:48:51 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
Object.assign(row, {
|
|
|
|
|
|
destinationType: 'PRIVATE',
|
|
|
|
|
|
destinationWarehouseId: '',
|
2026-09-04 10:51:28 +08:00
|
|
|
|
destinationWarehouseName: address.addressName,
|
|
|
|
|
|
destinationCountryCode: address.countryCode,
|
2026-08-13 09:48:51 +08:00
|
|
|
|
amazonWarehouseCode: '',
|
2026-09-04 10:51:28 +08:00
|
|
|
|
privateDestinationAddressId: String(address.id),
|
|
|
|
|
|
consigneeContactName: address.contactName,
|
|
|
|
|
|
consigneePhone: address.phone,
|
|
|
|
|
|
consigneeCity: address.city,
|
|
|
|
|
|
consigneeAddress: address.addressLine,
|
|
|
|
|
|
consigneePostalCode: address.postalCode ?? '',
|
2026-08-13 09:48:51 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
destinationDialogOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadChannels() {
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
loadError.value = ''
|
|
|
|
|
|
try {
|
2026-08-18 11:32:58 +08:00
|
|
|
|
const [, policy] = await Promise.all([
|
|
|
|
|
|
store.loadWarehouseType('DOMESTIC', true),
|
|
|
|
|
|
getMerchantInboundCodePolicy(),
|
|
|
|
|
|
])
|
|
|
|
|
|
inboundCodePolicy.value = policy
|
2026-08-13 09:48:51 +08:00
|
|
|
|
const results = await Promise.allSettled(store.warehouses
|
|
|
|
|
|
.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
|
|
|
|
|
|
.map(async (warehouse) => {
|
|
|
|
|
|
const channels = await getMerchantPricingChannels(warehouse.id)
|
|
|
|
|
|
return channels.map((channel) => ({ ...channel, warehouseId: warehouse.id, warehouseName: warehouse.name }))
|
|
|
|
|
|
}))
|
|
|
|
|
|
channelOptions.value = results.flatMap((result) => result.status === 'fulfilled' ? result.value : [])
|
|
|
|
|
|
if (!channelOptions.value.length) loadError.value = '当前国内收货仓暂无可用渠道,请联系平台管理员维护渠道。'
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
loadError.value = error instanceof Error ? error.message : '渠道加载失败,请稍后重试。'
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 09:14:38 +08:00
|
|
|
|
async function refreshInboundCodePolicyForWrite() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const policy = await getMerchantInboundCodePolicy()
|
|
|
|
|
|
inboundCodePolicy.value = policy
|
|
|
|
|
|
if (policy.mode === 'AUTO') {
|
|
|
|
|
|
rows.forEach((row) => { row.inboundCode = '' })
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
inboundCodePolicy.value = null
|
|
|
|
|
|
ElMessage.warning(error instanceof Error ? error.message : '入仓码规则加载失败,请稍后重试')
|
|
|
|
|
|
return false
|
2026-08-18 11:32:58 +08:00
|
|
|
|
}
|
2026-08-19 09:14:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitQuickOrder() {
|
|
|
|
|
|
if (!await refreshInboundCodePolicyForWrite()) return
|
2026-08-18 11:32:58 +08:00
|
|
|
|
const missingInboundCodeIndex = manualInboundCodeRequired.value
|
|
|
|
|
|
? rows.findIndex((row) => !row.inboundCode.trim())
|
|
|
|
|
|
: -1
|
|
|
|
|
|
if (missingInboundCodeIndex >= 0) {
|
|
|
|
|
|
ElMessage.warning(`请填写第 ${missingInboundCodeIndex + 1} 行的旧系统入仓码`)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-13 09:48:51 +08:00
|
|
|
|
const incompleteIndex = rows.findIndex((row) => !row.channelKey || !row.destinationWarehouseName
|
2026-09-04 10:51:28 +08:00
|
|
|
|
|| (row.destinationType === 'PRIVATE' && !row.privateDestinationAddressId)
|
2026-08-13 09:48:51 +08:00
|
|
|
|
|| row.products.some((product) => !product.sku.trim() || product.packageCount < 1))
|
|
|
|
|
|
if (incompleteIndex >= 0) {
|
|
|
|
|
|
ElMessage.warning(`请完善第 ${incompleteIndex + 1} 行的渠道、目的仓、产品 SKU 和箱数`)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const unavailableIndex = rows.findIndex((row) => !selectedChannel(row))
|
|
|
|
|
|
if (unavailableIndex >= 0) {
|
|
|
|
|
|
ElMessage.warning(`第 ${unavailableIndex + 1} 行所选渠道已不可用,请重新选择`)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const drafts = rows.flatMap((row) => {
|
|
|
|
|
|
const channel = selectedChannel(row)
|
|
|
|
|
|
return channel ? [{ clientRowId: row.id, draft: buildOrderDraft(row, channel) }] : []
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
submitting.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const customerName = auth.user?.tenantName?.trim() || auth.profile.tenantName.trim()
|
|
|
|
|
|
if (!customerName) throw new Error('当前登录会话缺少企业名称,请重新登录后重试')
|
|
|
|
|
|
if (!activeBatchKey.value) activeBatchKey.value = createBatchKey()
|
|
|
|
|
|
const response = await store.saveDraftBatch(drafts, customerName, activeBatchKey.value)
|
|
|
|
|
|
const successes = response.items.flatMap((item): QuickDraftResult[] => {
|
2026-08-18 11:32:58 +08:00
|
|
|
|
if (!item.success || item.orderId == null) return []
|
2026-08-13 09:48:51 +08:00
|
|
|
|
const row = rows.find((candidate) => candidate.id === item.clientRowId)
|
|
|
|
|
|
if (!row) return []
|
|
|
|
|
|
return [{
|
|
|
|
|
|
rowId: row.id,
|
|
|
|
|
|
sku: row.products.map((product) => product.sku.trim()).join('、'),
|
|
|
|
|
|
draftId: String(item.orderId),
|
2026-08-18 11:32:58 +08:00
|
|
|
|
inboundCode: item.inboundCode?.trim() ?? '',
|
2026-08-13 09:48:51 +08:00
|
|
|
|
}]
|
|
|
|
|
|
})
|
2026-08-18 11:32:58 +08:00
|
|
|
|
const failures = response.items.filter((item) => !item.success || item.orderId == null)
|
2026-08-13 09:48:51 +08:00
|
|
|
|
draftResults.splice(0, draftResults.length, ...successes)
|
|
|
|
|
|
const successfulIds = new Set(successes.map((item) => item.rowId))
|
|
|
|
|
|
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
|
|
|
|
if (successfulIds.has(rows[index].id)) rows.splice(index, 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (rows.length === 0) rows.push(createRow())
|
2026-08-25 16:15:38 +08:00
|
|
|
|
resultDialogOpen.value = successes.length > 0 && failures.length > 0
|
2026-08-13 09:48:51 +08:00
|
|
|
|
if (failures.length) ElMessage.error(`${failures.length} 张草稿生成失败:${failures[0].errorMessage || '草稿单生成失败'}`)
|
|
|
|
|
|
else {
|
|
|
|
|
|
activeBatchKey.value = ''
|
2026-08-25 16:15:38 +08:00
|
|
|
|
await Promise.all(successes.map((item) => store.loadDraftDetail(item.draftId)))
|
2026-09-19 17:02:11 +08:00
|
|
|
|
workspace.close(['first-leg-quick-order', 'first-leg-order-create'])
|
2026-08-25 16:11:00 +08:00
|
|
|
|
await router.push({ name: 'first-leg-orders' })
|
2026-09-18 16:48:35 +08:00
|
|
|
|
ElMessage.success(`已生成 ${successes.length} 张草稿单,提交后将生成入仓码`)
|
2026-08-13 09:48:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
ElMessage.error(error instanceof Error ? error.message : '批量草稿生成失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
submitting.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function editDraft(result: QuickDraftResult) {
|
|
|
|
|
|
resultDialogOpen.value = false
|
|
|
|
|
|
await router.push({ name: 'first-leg-order-edit', params: { draftId: result.draftId } })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(loadChannels)
|
2026-08-03 11:44:50 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
2026-08-13 09:48:51 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<section class="quick-order-page" aria-labelledby="quick-order-heading">
|
|
|
|
|
|
<header class="quick-order-intro">
|
|
|
|
|
|
<h2 id="quick-order-heading">客户下单</h2>
|
|
|
|
|
|
<p>只需输入渠道、目的仓、产品 SKU 和箱数,即可快速填写订单。</p>
|
2026-08-18 11:32:58 +08:00
|
|
|
|
<small v-if="manualInboundCodeRequired">当前全局使用旧系统入仓码,每张草稿都需要填写旧系统生成的入仓码。</small>
|
2026-08-13 09:48:51 +08:00
|
|
|
|
<small>每一行会直接生成一张独立草稿单,其他明细可进入草稿后继续编辑并提交。</small>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<el-alert v-if="loadError" :title="loadError" type="warning" show-icon :closable="false">
|
|
|
|
|
|
<template #default><el-button text type="primary" @click="loadChannels">重新加载</el-button></template>
|
|
|
|
|
|
</el-alert>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-loading="loading" class="quick-order-table-wrap">
|
|
|
|
|
|
<table class="quick-order-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
2026-08-18 11:32:58 +08:00
|
|
|
|
<th v-if="manualInboundCodeRequired" class="inbound-code-heading">旧系统入仓码</th>
|
|
|
|
|
|
<th class="channel-heading">选择渠道</th>
|
|
|
|
|
|
<th class="destination-heading">目的仓</th>
|
|
|
|
|
|
<th class="operation-heading">操作</th>
|
2026-08-13 09:48:51 +08:00
|
|
|
|
<th>产品信息:产品 SKU / 箱数</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr v-for="(row, rowIndex) in rows" :key="row.id">
|
2026-08-18 11:32:58 +08:00
|
|
|
|
<td v-if="manualInboundCodeRequired" class="inbound-code-cell">
|
|
|
|
|
|
<el-input
|
|
|
|
|
|
v-model="row.inboundCode"
|
|
|
|
|
|
maxlength="40"
|
|
|
|
|
|
placeholder="请输入旧系统入仓码"
|
|
|
|
|
|
@input="row.inboundCode = String($event).trim().toUpperCase()"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</td>
|
2026-08-13 09:48:51 +08:00
|
|
|
|
<td class="channel-cell">
|
|
|
|
|
|
<el-select v-model="row.channelKey" :disabled="!hasChannels" placeholder="请选择">
|
|
|
|
|
|
<el-option
|
|
|
|
|
|
v-for="channel in channelOptions"
|
|
|
|
|
|
:key="channelKey(channel.warehouseId, channel.channelCode)"
|
|
|
|
|
|
:label="channel.channelName"
|
|
|
|
|
|
:value="channelKey(channel.warehouseId, channel.channelCode)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{{ channel.channelName }}</span>
|
|
|
|
|
|
<small>{{ channel.warehouseName }}</small>
|
|
|
|
|
|
</el-option>
|
|
|
|
|
|
</el-select>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<el-radio-group :model-value="row.destinationType" class="destination-options" @change="(value) => openDestinationDialog(row, value)">
|
|
|
|
|
|
<el-radio value="FBA">FBA</el-radio>
|
|
|
|
|
|
<el-radio value="OVERSEAS">海外仓</el-radio>
|
|
|
|
|
|
<el-radio value="PRIVATE">私人仓</el-radio>
|
|
|
|
|
|
</el-radio-group>
|
|
|
|
|
|
<button type="button" class="destination-binding" @click="openDestinationDialog(row, row.destinationType)">
|
|
|
|
|
|
<MapPin :size="14" />
|
|
|
|
|
|
<span>{{ destinationLabel(row) }}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td class="operation-cell">
|
|
|
|
|
|
<el-button text type="primary" @click="addProduct(row)"><Plus :size="15" />添加产品行</el-button>
|
|
|
|
|
|
<el-button text type="danger" @click="removeRow(rowIndex)"><Trash2 :size="15" />删除本行</el-button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td class="products-cell">
|
|
|
|
|
|
<div v-for="(product, productIndex) in row.products" :key="product.id" class="product-fields">
|
|
|
|
|
|
<el-input v-model="product.sku" maxlength="64" placeholder="产品 SKU" />
|
2026-09-03 19:51:29 +08:00
|
|
|
|
<el-input-number v-model="product.packageCount" :min="1" :max="99999" :precision="0" controls-position="right" aria-label="箱数" placeholder="请输入箱数" />
|
2026-08-13 09:48:51 +08:00
|
|
|
|
<el-button v-if="row.products.length > 1" text type="danger" aria-label="删除产品" @click="removeProduct(row, productIndex)"><Trash2 :size="15" /></el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="quick-order-actions">
|
|
|
|
|
|
<el-button plain @click="addOrderRow"><Plus :size="16" />新增一行</el-button>
|
|
|
|
|
|
<el-button type="primary" :loading="submitting" :disabled="loading || !hasChannels" @click="submitQuickOrder">
|
|
|
|
|
|
<Send :size="16" />生成草稿单
|
|
|
|
|
|
</el-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<el-dialog v-model="destinationDialogOpen" class="destination-dialog" :title="`绑定${destinationForm.type === 'FBA' ? ' FBA 仓' : destinationForm.type === 'OVERSEAS' ? '海外仓' : '私人仓'}`" width="min(560px, 92vw)">
|
|
|
|
|
|
<el-form label-position="top" class="destination-dialog-form">
|
|
|
|
|
|
<el-form-item label="目的仓类型" required>
|
|
|
|
|
|
<el-radio-group v-model="destinationForm.type" class="destination-type-switch" @change="(value) => openDestinationDialog(editingDestinationRow!, value)">
|
|
|
|
|
|
<el-radio-button value="FBA">FBA 仓</el-radio-button>
|
|
|
|
|
|
<el-radio-button value="OVERSEAS">海外仓</el-radio-button>
|
|
|
|
|
|
<el-radio-button value="PRIVATE">私人仓</el-radio-button>
|
|
|
|
|
|
</el-radio-group>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<el-form-item v-if="destinationForm.type !== 'PRIVATE'" :label="destinationForm.type === 'FBA' ? 'FBA 仓' : '海外仓'" required>
|
|
|
|
|
|
<el-select v-model="destinationForm.warehouseId" filterable :loading="destinationLoading" placeholder="请选择当前可送达的目的仓">
|
2026-09-27 00:09:50 +08:00
|
|
|
|
<el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="destinationForm.type === 'FBA' ? warehouse.code : `${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" />
|
2026-08-13 09:48:51 +08:00
|
|
|
|
</el-select>
|
|
|
|
|
|
<small v-if="!destinationLoading && availableDestinationWarehouses.length === 0">平台暂未维护可用目的仓,请联系平台管理员。</small>
|
|
|
|
|
|
</el-form-item>
|
|
|
|
|
|
<template v-else>
|
2026-09-04 10:51:28 +08:00
|
|
|
|
<el-form-item label="私人仓地址" required>
|
|
|
|
|
|
<el-select v-model="destinationForm.privateAddressId" filterable :loading="destinationLoading" placeholder="请选择企业已保存的私人仓地址">
|
|
|
|
|
|
<el-option v-for="address in privateAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" />
|
|
|
|
|
|
</el-select>
|
|
|
|
|
|
<small v-if="privateAddressesError">私人仓地址加载失败,请关闭后重试。</small>
|
|
|
|
|
|
<small v-else-if="!destinationLoading && privateAddresses.length === 0">暂无已保存的私人仓地址,请先在普通下单页面维护。</small>
|
|
|
|
|
|
</el-form-item>
|
2026-08-13 09:48:51 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</el-form>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<el-button @click="destinationDialogOpen = false">取消</el-button>
|
|
|
|
|
|
<el-button type="primary" @click="bindDestination">确认绑定</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
|
|
|
|
|
|
<el-dialog v-model="resultDialogOpen" class="quick-result-dialog" title="草稿单生成结果" width="min(700px, 94vw)">
|
|
|
|
|
|
<div class="quick-draft-results">
|
|
|
|
|
|
<article v-for="result in draftResults" :key="result.rowId">
|
2026-09-18 16:48:35 +08:00
|
|
|
|
<div><strong>{{ result.inboundCode || '入仓码提交后生成' }}</strong><span>SKU {{ result.sku }}</span></div>
|
2026-08-13 09:48:51 +08:00
|
|
|
|
<el-button type="primary" plain @click="editDraft(result)">编辑明细</el-button>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<template #footer><el-button type="primary" @click="resultDialogOpen = false">完成</el-button></template>
|
|
|
|
|
|
</el-dialog>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.quick-order-page { min-width: 0; min-height: 330px; padding: 18px 16px 24px; border: 1px solid #e8edf4; border-radius: 8px; background: #fff; box-shadow: 0 10px 28px rgba(15, 31, 58, .05); }
|
|
|
|
|
|
.quick-order-intro { margin-bottom: 16px; }
|
|
|
|
|
|
.quick-order-intro h2 { margin: 0 0 6px; color: #1d2939; font-size: 16px; }
|
|
|
|
|
|
.quick-order-intro p, .quick-order-intro small { display: block; margin: 0; color: #8a94a6; font-size: 12px; line-height: 1.65; }
|
|
|
|
|
|
.quick-order-page > :deep(.el-alert) { margin-bottom: 14px; }
|
|
|
|
|
|
.quick-order-table-wrap { min-height: 126px; overflow-x: auto; border: 1px solid #e5eaf2; border-radius: 8px; }
|
|
|
|
|
|
.quick-order-table { width: 100%; min-width: 900px; border-collapse: collapse; table-layout: fixed; }
|
|
|
|
|
|
.quick-order-table th { height: 42px; padding: 0 14px; background: #f4f5fb; color: #637fec; font-size: 12px; font-weight: 650; text-align: left; }
|
2026-08-18 11:32:58 +08:00
|
|
|
|
.quick-order-table .inbound-code-heading, .quick-order-table .channel-heading { width: 220px; }
|
|
|
|
|
|
.quick-order-table .destination-heading { width: 145px; }
|
|
|
|
|
|
.quick-order-table .operation-heading { width: 210px; }
|
2026-08-13 09:48:51 +08:00
|
|
|
|
.quick-order-table td { padding: 12px 14px; border-top: 1px solid #edf0f5; vertical-align: middle; }
|
|
|
|
|
|
.channel-cell :deep(.el-select) { width: 100%; }
|
2026-08-18 11:32:58 +08:00
|
|
|
|
.inbound-code-cell :deep(.el-input) { width: 100%; }
|
2026-08-13 09:48:51 +08:00
|
|
|
|
.destination-options { display: flex; align-items: flex-start; flex-direction: column; gap: 4px; }
|
|
|
|
|
|
.destination-options :deep(.el-radio) { height: 20px; margin-right: 0; }
|
|
|
|
|
|
.destination-binding { display: flex; width: 100%; min-width: 0; align-items: center; gap: 5px; margin-top: 7px; padding: 0; border: 0; background: transparent; color: #637fec; cursor: pointer; font-size: 11px; text-align: left; }
|
|
|
|
|
|
.destination-binding span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
|
|
.destination-binding:focus-visible { outline: 2px solid #637fec; outline-offset: 3px; }
|
|
|
|
|
|
.operation-cell { white-space: normal; }
|
|
|
|
|
|
.operation-cell :deep(.el-button) { margin: 0 8px 4px 0; padding: 4px 0; }
|
|
|
|
|
|
.products-cell { padding-top: 7px !important; padding-bottom: 7px !important; }
|
|
|
|
|
|
.product-fields { display: grid; grid-template-columns: minmax(160px, 260px) 112px 32px; align-items: center; gap: 10px; min-height: 44px; }
|
|
|
|
|
|
.product-fields + .product-fields { padding-top: 7px; border-top: 1px dashed #e5eaf2; }
|
|
|
|
|
|
.product-fields :deep(.el-input-number) { width: 112px; }
|
|
|
|
|
|
.product-fields :deep(.el-button) { width: 30px; height: 30px; margin: 0; padding: 0; }
|
|
|
|
|
|
.quick-order-actions { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 12px; padding-bottom: 44px; }
|
|
|
|
|
|
.quick-order-actions > :deep(.el-button:last-child) { position: absolute; left: 50%; bottom: 0; min-width: 112px; transform: translateX(-50%); }
|
|
|
|
|
|
.quick-order-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
|
|
|
|
|
:deep(.el-select-dropdown__item small) { float: right; margin-left: 12px; color: #98a2b3; }
|
|
|
|
|
|
:deep(.destination-dialog .el-dialog__body) { padding-top: 12px; }
|
|
|
|
|
|
.destination-dialog-form :deep(.el-select) { width: 100%; }
|
|
|
|
|
|
.destination-dialog-form :deep(.el-form-item) { margin-bottom: 16px; }
|
|
|
|
|
|
.destination-dialog-form small { display: block; margin-top: 6px; color: #8a94a6; font-size: 11px; }
|
|
|
|
|
|
.destination-type-switch { display: flex; width: 100%; }
|
|
|
|
|
|
.destination-type-switch :deep(.el-radio-button) { flex: 1; }
|
|
|
|
|
|
.destination-type-switch :deep(.el-radio-button__inner) { width: 100%; }
|
|
|
|
|
|
.destination-private-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
|
|
|
|
|
|
.quick-draft-results { display: grid; gap: 10px; }
|
|
|
|
|
|
.quick-draft-results article { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 14px; border: 1px solid #e5eaf2; border-radius: 6px; }
|
|
|
|
|
|
.quick-draft-results article div { display: grid; min-width: 0; gap: 3px; }
|
|
|
|
|
|
.quick-draft-results article strong { color: #1d2939; font-size: 14px; }
|
|
|
|
|
|
.quick-draft-results article span { color: #667085; font-size: 11px; }
|
|
|
|
|
|
@media (max-width: 720px) {
|
|
|
|
|
|
.quick-order-page { padding: 14px 12px 20px; }
|
|
|
|
|
|
.quick-order-table { min-width: 760px; }
|
|
|
|
|
|
.quick-order-actions { align-items: stretch; flex-direction: column; padding-bottom: 0; }
|
|
|
|
|
|
.quick-order-actions > :deep(.el-button), .quick-order-actions > :deep(.el-button:last-child) { position: static; width: 100%; min-height: 42px; transform: none; }
|
|
|
|
|
|
.destination-private-grid { grid-template-columns: 1fr; }
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|