front/src/views/order/platformStoreOrder/CreateLogisticsModal.vue
zzh a917216c3b
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m50s
feat: 创建运单/代发运单弹窗补齐基本信息与下单协议
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 11:23:15 +08:00

2192 lines
67 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Modal } from '@arco-design/web-vue'
import { IconPlus, IconDelete } from '@arco-design/web-vue/es/icon'
import {
batchCreatePlatformStoreFulfill,
batchCreatePlatformStoreFulfillWms,
batchCreatePlatformStoreLogistics,
getPlatformStoreOrderDetail,
} from '@/api/order/platformStore'
import {
postOrderStockBindInventoryCandidates,
postOrderStockBindParcelCandidates,
} from '@/api/order'
import { getSenderAddressList } from '@/api/order/senderAddress'
import { getExportQualificationList } from '@/api/customer/exportQualification'
import { getDictListAll } from '@/api/system/dict'
import { resolveOmsAssetUrl } from '@/api/oms/common'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import { $baseMessage } from '@/utils'
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute'
import { normalizeListRow, pickField } from '@/utils/warehouseListMapper'
import type { CreateLogisticsMode } from './constants'
type SelectOption = { label: string; value: string }
type QualOption = { id: number; label: string; isDefault: boolean }
const SAMPLE_EXPORT_CUSTOMS_MODE_CODE = '9610Simple'
const EXPORT_CUSTOMS_MODE_9610_SUMMARY = '9610Summary'
const AGREEMENT_TEXT = `《物流下单用户协议》及《物流服务赔偿协议》
一、服务说明
您确认通过本系统创建的运单/代发订单,将按所选运输方式、仓库与件重尺计费并履约。
二、责任与理赔
请以正式协议文本为准;勾选即视为已阅读并同意全部条款。
三、勾选确认
本人已仔细阅读并充分理解全部条款,自愿接受本协议所有约定。`
export type { CreateLogisticsMode }
export type BindDraftItem = {
key: string
bindType: 'inventory' | 'parcelItem'
skuId?: number
inventoryId?: number
parcelId?: number
parcelItemId?: number
bindQty: number
skuCode?: string
skuName?: string
productName?: string
trackingNo?: string
availableQty?: number
imageUrl?: string
}
type OrderDraft = {
storeOrderId: number
customerId?: number
warehouseId?: number
platformOrderNo: string
/** 可编辑客户单号,默认平台订单号 */
clientNo: string
orderCountry: string
recipientName: string
recipientPhone: string
recipientCountry: string
recipientProvince: string
recipientCity: string
recipientZipCode: string
recipientAddress1: string
recipientAddress2: string
lines: Array<{
platformSku: string
productName: string
quantity: number
unitPrice: string
}>
receiverMissing: boolean
linesMissing: boolean
bindItems: BindDraftItem[]
}
const props = defineProps<{
visible: boolean
mode: CreateLogisticsMode
storeOrderIds: number[]
productOptions: Array<{ label: string; value: string }>
warehouseOptions: Array<{ label: string; value: number }>
defaultProductCode?: string
defaultWarehouseId?: number
customerId?: number
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const { t } = useI18n()
const loading = ref(false)
const submitting = ref(false)
const productCode = ref<string>()
const warehouseId = ref<number>()
const drafts = ref<OrderDraft[]>([])
const activeId = ref<number>()
const leftTab = ref<'inventory' | 'parcel'>('inventory')
const pickSourceTabs = computed(() => {
const disabled = !warehouseId.value
return [
{ key: 'inventory', label: t('order.myOrders.stockBind.tabInventory'), disabled },
{ key: 'parcel', label: t('order.myOrders.stockBind.tabParcel'), disabled },
]
})
const keyword = ref('')
const inventoryCandidates = ref<any[]>([])
const parcelCandidates = ref<any[]>([])
const candidatesLoading = ref(false)
const batchMode = ref(true)
const batchMatchDeclaration = ref(false)
const batchQtyPerOrder = ref(1)
let searchTimer: ReturnType<typeof setTimeout> | null = null
/** 运单基本信息(对齐下单页) */
const cargoAttribute = ref('general')
const actualWeight = ref<number | undefined>(undefined)
const quantity = ref<number>(1)
const lengthCm = ref<number | undefined>(10)
const widthCm = ref<number | undefined>(10)
const heightCm = ref<number | undefined>(10)
const exportCustomsMode = ref(SAMPLE_EXPORT_CUSTOMS_MODE_CODE)
const exportQualificationId = ref<number | undefined>()
const platformCode = ref<string>()
const inboundMethod = ref<'selfShip' | 'doorPickup'>('selfShip')
const senderAddressId = ref<number | undefined>()
const expectPickupTime = ref<string>()
const agreementChecked = ref(false)
const agreementModalVisible = ref(false)
const dictLoading = ref(false)
const cargoAttributeOptions = ref<SelectOption[]>([])
const exportCustomsModeOptions = ref<SelectOption[]>([])
const ecommercePlatformOptions = ref<SelectOption[]>([])
const exportQualificationOptions = ref<QualOption[]>([])
const pickupAddressOptions = ref<Array<{ label: string; value: number }>>([])
const modalVisible = computed({
get: () => props.visible,
set: (v: boolean) => emit('update:visible', v),
})
const isFulfill = computed(() => props.mode === 'fulfill' || props.mode === 'fulfillWms')
const isFulfillWms = computed(() => props.mode === 'fulfillWms')
const title = computed(() => {
if (isFulfillWms.value) return t('order.platformStoreOrder.createFulfillWms.title')
if (isFulfill.value) return t('order.platformStoreOrder.createFulfill.title')
return t('order.platformStoreOrder.createLogistics.title')
})
const activeDraft = computed(() => drafts.value.find((d) => d.storeOrderId === activeId.value))
const activeShipLines = computed(() => activeDraft.value?.lines || [])
const orderQtyTotal = computed(() =>
activeShipLines.value.reduce((sum, x) => sum + (Number(x.quantity) || 0), 0),
)
const bindQtyTotal = computed(() =>
(activeDraft.value?.bindItems || []).reduce((sum, x) => sum + (Number(x.bindQty) || 0), 0),
)
/** 数量不一致仅提示校对,不强制拦截(业务允许申报与绑定不对齐) */
const qtyMismatch = computed(() => {
if (!isFulfill.value || !activeDraft.value?.bindItems.length) return false
return orderQtyTotal.value !== bindQtyTotal.value
})
const assignableDrafts = computed(() =>
drafts.value.filter((d) => !d.receiverMissing && !d.linesMissing),
)
const showBatchBar = computed(
() => isFulfill.value && drafts.value.length > 1 && assignableDrafts.value.length > 0,
)
const readyCount = computed(() => {
if (!isFulfill.value) {
return assignableDrafts.value.length
}
return assignableDrafts.value.filter((d) => d.bindItems.length > 0).length
})
function draftOrderQty(d: OrderDraft) {
return d.lines.reduce((s, x) => s + (Number(x.quantity) || 0), 0)
}
function draftBindQty(d: OrderDraft) {
return d.bindItems.reduce((s, x) => s + (Number(x.bindQty) || 0), 0)
}
const productLabel = computed(() => {
const opt = props.productOptions.find((x) => x.value === productCode.value)
return opt?.label || productCode.value || '-'
})
const warehouseLabel = computed(() => {
const opt = props.warehouseOptions.find((x) => x.value === warehouseId.value)
return opt?.label || '-'
})
const needExportQualification = computed(
() =>
String(exportCustomsMode.value ?? '').trim().toLowerCase() ===
EXPORT_CUSTOMS_MODE_9610_SUMMARY.toLowerCase(),
)
const basicInfoReady = computed(() => {
if (!productCode.value) return false
if (!warehouseId.value) return false
if (!String(cargoAttribute.value || '').trim()) return false
if (!(Number(actualWeight.value) > 0)) return false
if (!(Number(quantity.value) > 0)) return false
if (!(Number(lengthCm.value) > 0) || !(Number(widthCm.value) > 0) || !(Number(heightCm.value) > 0)) {
return false
}
if (needExportQualification.value) {
if (!(Number(exportQualificationId.value) > 0)) return false
if (!String(platformCode.value || '').trim()) return false
}
if (inboundMethod.value === 'doorPickup') {
if (!(Number(senderAddressId.value) > 0)) return false
if (!expectPickupTime.value) return false
}
if (drafts.value.some((d) => !String(d.clientNo || '').trim())) return false
return true
})
const canSubmit = computed(() => {
if (!basicInfoReady.value) return false
if (!agreementChecked.value) return false
if (drafts.value.length === 0) return false
if (drafts.value.some((d) => d.receiverMissing || d.linesMissing)) return false
if (isFulfill.value) {
if (drafts.value.some((d) => d.bindItems.length === 0)) return false
}
return true
})
function resolveExportCustomsMode9610Simple() {
const opts = exportCustomsModeOptions.value
const byCode = opts.find(
(o) => o.value === SAMPLE_EXPORT_CUSTOMS_MODE_CODE || o.value.toLowerCase() === '9610simple',
)
if (byCode) return byCode.value
const byLabel = opts.find((o) => /9610.*简易|简易.*9610/i.test(o.label))
if (byLabel) return byLabel.value
return SAMPLE_EXPORT_CUSTOMS_MODE_CODE
}
function mapDictOptions(rows: any[]): SelectOption[] {
return (rows || [])
.map((row) => {
const value = String(row?.dictCode ?? row?.DictCode ?? '').trim()
const label = String(row?.dictName ?? row?.DictName ?? value).trim()
if (!value) return null
return { label: label || value, value }
})
.filter((x): x is SelectOption => !!x)
}
async function loadBasicDicts() {
dictLoading.value = true
try {
const [cargoRows, exportRows, platformRows] = await Promise.all([
getDictListAll({ dictType: 'cargo_attribute' }),
getDictListAll({ dictType: 'export_customs_mode' }),
getDictListAll({ dictType: 'e-commerce_platform' }),
])
cargoAttributeOptions.value = mapDictOptions(cargoRows as any[])
exportCustomsModeOptions.value = mapDictOptions(exportRows as any[])
ecommercePlatformOptions.value = mapDictOptions(platformRows as any[])
if (!cargoAttribute.value && cargoAttributeOptions.value[0]) {
cargoAttribute.value = normalizeCargoAttributeCode(cargoAttributeOptions.value[0].value)
} else {
cargoAttribute.value = normalizeCargoAttributeCode(cargoAttribute.value)
}
exportCustomsMode.value = resolveExportCustomsMode9610Simple()
} catch {
cargoAttributeOptions.value = [
{ label: '普货', value: 'general' },
{ label: '带电', value: 'electronics' },
{ label: '敏感', value: 'sensitive' },
]
exportCustomsModeOptions.value = [
{ label: '9610 简易申报', value: SAMPLE_EXPORT_CUSTOMS_MODE_CODE },
{ label: '9610 汇总申报', value: EXPORT_CUSTOMS_MODE_9610_SUMMARY },
]
} finally {
dictLoading.value = false
}
}
async function loadExportQualifications() {
const customerId = resolveCandidatesCustomerId()
try {
const res = await getExportQualificationList({
page: 1,
pageSize: 100,
customerId,
})
exportQualificationOptions.value = (res.items || [])
.map((item: Record<string, unknown>) => {
const id = Number(pickField(item, 'id', 'Id') ?? 0)
if (!id) return null
const companyName = String(pickField(item, 'companyName', 'CompanyName') ?? '').trim()
const creditCode = String(pickField(item, 'socialCreditCode', 'SocialCreditCode') ?? '').trim()
const isDefault = Boolean(pickField(item, 'isDefault', 'IsDefault'))
const base = companyName || creditCode || `#${id}`
return { id, label: isDefault ? `${base}(默认)` : base, isDefault }
})
.filter((x): x is QualOption => !!x)
if (!exportQualificationId.value) {
const def =
exportQualificationOptions.value.find((x) => x.isDefault) || exportQualificationOptions.value[0]
exportQualificationId.value = def?.id
}
} catch {
exportQualificationOptions.value = []
}
}
async function loadPickupAddresses() {
const customerId = resolveCandidatesCustomerId()
try {
const res = await getSenderAddressList({
page: 1,
pageSize: 200,
addressType: 'pickup',
customerId,
})
pickupAddressOptions.value = (res.items || [])
.map((item: Record<string, unknown>) => {
const id = Number(pickField(item, 'id', 'Id') ?? 0)
if (!id) return null
const name = String(pickField(item, 'contactName', 'ContactName', 'name', 'Name') ?? '').trim()
const phone = String(pickField(item, 'phone', 'Phone') ?? '').trim()
const label = [name, phone].filter(Boolean).join(' · ') || `#${id}`
return { label, value: id }
})
.filter((x): x is { label: string; value: number } => !!x)
} catch {
pickupAddressOptions.value = []
}
}
function openAgreementModal() {
agreementModalVisible.value = true
}
function buildParcelBasicPayload() {
return {
cargoAttribute: normalizeCargoAttributeCode(cargoAttribute.value),
actualWeight: Number(actualWeight.value),
quantity: Number(quantity.value),
length: Number(lengthCm.value),
width: Number(widthCm.value),
height: Number(heightCm.value),
exportCustomsMode: String(exportCustomsMode.value || '').trim() || resolveExportCustomsMode9610Simple(),
exportQualificationId: needExportQualification.value ? exportQualificationId.value : undefined,
platformCode: needExportQualification.value ? platformCode.value : undefined,
inboundMethod: inboundMethod.value,
senderAddressId: inboundMethod.value === 'doorPickup' ? senderAddressId.value : undefined,
expectPickupTime: inboundMethod.value === 'doorPickup' ? expectPickupTime.value : undefined,
}
}
function close() {
emit('update:visible', false)
}
function imgSrc(url?: string | null) {
if (!url) return ''
return resolveOmsAssetUrl(url) || url
}
function mapDetail(raw: Record<string, unknown>): OrderDraft {
const row = normalizeListRow(raw)
const recipientName = String(pickField(row, 'recipientName', 'RecipientName') ?? '')
const recipientCountry = String(
pickField(row, 'recipientCountry', 'RecipientCountry') ?? pickField(row, 'orderCountry', 'OrderCountry') ?? '',
)
const recipientZipCode = String(pickField(row, 'recipientZipCode', 'RecipientZipCode') ?? '')
const recipientAddress1 = String(pickField(row, 'recipientAddress1', 'RecipientAddress1') ?? '')
const linesRaw = (pickField(row, 'lines', 'Lines') as any[]) || []
const lines = linesRaw.map((line) => {
const r = normalizeListRow(line as Record<string, unknown>)
return {
platformSku: String(pickField(r, 'platformSku', 'PlatformSku') ?? '-'),
productName: String(pickField(r, 'productName', 'ProductName') ?? '-'),
quantity: Number(pickField(r, 'quantity', 'Quantity') ?? 0),
unitPrice: String(pickField(r, 'unitPrice', 'UnitPrice') ?? '-'),
}
})
const shipLines = lines
const customerIdRaw = Number(pickField(row, 'customerId', 'CustomerId') ?? 0)
const warehouseIdRaw = Number(pickField(row, 'warehouseId', 'WarehouseId') ?? 0)
const platformOrderNo = String(pickField(row, 'platformOrderNo', 'PlatformOrderNo') ?? '')
return {
storeOrderId: Number(pickField(row, 'id', 'Id')),
customerId: Number.isFinite(customerIdRaw) && customerIdRaw > 0 ? customerIdRaw : undefined,
warehouseId: Number.isFinite(warehouseIdRaw) && warehouseIdRaw > 0 ? warehouseIdRaw : undefined,
platformOrderNo,
clientNo: platformOrderNo,
orderCountry: String(pickField(row, 'orderCountry', 'OrderCountry') ?? ''),
recipientName,
recipientPhone: String(pickField(row, 'recipientPhone', 'RecipientPhone') ?? ''),
recipientCountry,
recipientProvince: String(pickField(row, 'recipientProvince', 'RecipientProvince') ?? ''),
recipientCity: String(pickField(row, 'recipientCity', 'RecipientCity') ?? ''),
recipientZipCode,
recipientAddress1,
recipientAddress2: String(pickField(row, 'recipientAddress2', 'RecipientAddress2') ?? ''),
lines,
receiverMissing: !recipientName || !recipientCountry || !recipientZipCode || !recipientAddress1,
linesMissing: shipLines.length === 0,
bindItems: [],
}
}
/** 草稿模式 orderId=0,必须带上店铺订单所属客户,才能查到该客户库存(对齐运单绑定用订单客户)。 */
function resolveCandidatesCustomerId(): number | undefined {
const fromActive = activeDraft.value?.customerId
if (fromActive && fromActive > 0) return fromActive
const fromDrafts = drafts.value.find((d) => d.customerId && d.customerId > 0)?.customerId
if (fromDrafts && fromDrafts > 0) return fromDrafts
const fromProp = Number(props.customerId ?? 0)
return Number.isFinite(fromProp) && fromProp > 0 ? fromProp : undefined
}
async function loadDrafts() {
loading.value = true
drafts.value = []
activeId.value = undefined
try {
const list: OrderDraft[] = []
for (const id of props.storeOrderIds) {
const detail = await getPlatformStoreOrderDetail(id, props.customerId)
list.push(mapDetail(detail as Record<string, unknown>))
}
drafts.value = list
activeId.value = list[0]?.storeOrderId
// 优先用店铺订单已绑定仓库,避免默认仓与库存仓不一致导致空列表
const orderWarehouseId = list.find((d) => d.warehouseId)?.warehouseId
if (orderWarehouseId && props.warehouseOptions.some((w) => w.value === orderWarehouseId)) {
warehouseId.value = orderWarehouseId
}
} catch (e: any) {
$baseMessage(e?.message || t('order.platformStoreOrder.message.detailFailed'), 'error')
close()
} finally {
loading.value = false
}
}
async function loadCandidates() {
if (!isFulfill.value || !warehouseId.value) {
inventoryCandidates.value = []
parcelCandidates.value = []
return
}
const customerId = resolveCandidatesCustomerId()
candidatesLoading.value = true
try {
const body = {
orderId: 0,
warehouseId: warehouseId.value,
customerId,
keyword: keyword.value || undefined,
page: 1,
pageSize: 50,
}
if (leftTab.value === 'inventory') {
const res = await postOrderStockBindInventoryCandidates(body)
inventoryCandidates.value = res.items || []
} else {
const res = await postOrderStockBindParcelCandidates({ ...body, lifecycleStatus: 'all' })
parcelCandidates.value = res.items || []
}
} catch (e: any) {
inventoryCandidates.value = []
parcelCandidates.value = []
$baseMessage(e?.message || t('order.myOrders.stockBind.loadFailed'), 'error')
} finally {
candidatesLoading.value = false
}
}
function scheduleSearch() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => void loadCandidates(), 300)
}
function selectOrder(id: number) {
activeId.value = id
if (isFulfill.value) void loadCandidates()
}
function bindKey(item: {
bindType: string
skuId?: number
inventoryId?: number
parcelItemId?: number
}) {
if (item.bindType === 'parcelItem') return `parcelItem:${item.parcelItemId}`
return `inventory:${item.skuId}:${item.inventoryId ?? 0}`
}
function resolveBatchQty(draft: OrderDraft) {
if (batchMatchDeclaration.value) {
const qty = draftOrderQty(draft)
return qty > 0 ? qty : 1
}
return Math.max(1, Number(batchQtyPerOrder.value) || 1)
}
function cloneBindItems(items: BindDraftItem[]): BindDraftItem[] {
return items.map((item) => ({ ...item }))
}
function setInventoryOnDraft(draft: OrderDraft, row: any, qty: number) {
const skuId = Number(row.skuId ?? row.SkuId)
const key = bindKey({ bindType: 'inventory', skuId })
const available = Number(row.availableQty ?? row.AvailableQty ?? 0)
const exists = draft.bindItems.find((x) => x.key === key)
const line: BindDraftItem = {
key,
bindType: 'inventory',
skuId,
bindQty: qty,
skuCode: String(row.skuCode ?? row.SkuCode ?? ''),
skuName: String(row.skuName ?? row.SkuName ?? ''),
availableQty: available,
imageUrl: row.skuImageUrl ?? row.SkuImageUrl,
}
if (exists) {
Object.assign(exists, line)
} else {
draft.bindItems.push(line)
}
}
function setParcelItemOnDraft(draft: OrderDraft, parcel: any, item: any, qty: number) {
const parcelItemId = Number(item.parcelItemId ?? item.ParcelItemId)
const key = bindKey({ bindType: 'parcelItem', parcelItemId })
const available = Number(item.availableQty ?? item.AvailableQty ?? 0)
draft.bindItems = draft.bindItems.filter((x) => x.bindType !== 'parcelItem')
draft.bindItems.push({
key,
bindType: 'parcelItem',
parcelId: Number(parcel.parcelId ?? parcel.ParcelId ?? parcel.id ?? parcel.Id),
parcelItemId,
skuId: item.skuId ?? item.SkuId,
bindQty: qty,
skuCode: String(item.skuCode ?? item.SkuCode ?? ''),
productName: String(item.productName ?? item.ProductName ?? ''),
trackingNo: String(parcel.trackingNo ?? parcel.TrackingNo ?? ''),
availableQty: available,
imageUrl: item.imageUrl ?? item.ImageUrl,
})
}
function copyActiveToAll() {
const source = activeDraft.value
if (!source || source.receiverMissing || source.linesMissing) return
if (!source.bindItems.length) {
$baseMessage(t('order.myOrders.stockBind.copyEmpty'), 'warning')
return
}
const targets = assignableDrafts.value.filter((d) => d.storeOrderId !== source.storeOrderId)
if (!targets.length) {
$baseMessage(t('order.myOrders.stockBind.copyNoTarget'), 'warning')
return
}
Modal.confirm({
title: t('order.myOrders.stockBind.copyToAllTitle'),
content: t('order.myOrders.stockBind.copyConfirm', { count: targets.length }),
onOk: () => {
for (const draft of targets) {
draft.bindItems = cloneBindItems(source.bindItems)
}
$baseMessage(t('order.myOrders.stockBind.copySuccess', { count: targets.length }), 'success')
},
})
}
function distributeParcelToOrders(parcel: any) {
const editable = assignableDrafts.value
const items = (parcel.items || parcel.Items || []).filter(
(x: any) => Number(x.availableQty ?? x.AvailableQty ?? 0) > 0,
)
if (!editable.length) return
if (!items.length) {
$baseMessage(t('order.myOrders.stockBind.parcelDistributeEmpty'), 'warning')
return
}
const count = Math.min(editable.length, items.length)
if (items.length < editable.length) {
$baseMessage(
t('order.myOrders.stockBind.parcelDistributePartial', {
orders: editable.length,
items: items.length,
}),
'warning',
)
}
for (let i = 0; i < count; i += 1) {
setParcelItemOnDraft(editable[i], parcel, items[i], 1)
}
$baseMessage(t('order.myOrders.stockBind.parcelDistributeSuccess', { count }), 'success')
}
function addInventory(row: any) {
const editable = assignableDrafts.value
if (batchMode.value && isFulfill.value && editable.length > 1) {
const totalQty = editable.reduce((sum, draft) => sum + resolveBatchQty(draft), 0)
const available = Number(row.availableQty ?? row.AvailableQty ?? 0)
if (totalQty > available) {
$baseMessage(
t('order.myOrders.stockBind.batchInsufficient', { need: totalQty, available }),
'warning',
)
return
}
for (const draft of editable) {
setInventoryOnDraft(draft, row, resolveBatchQty(draft))
}
$baseMessage(t('order.myOrders.stockBind.batchAssignSuccess', { count: editable.length }), 'success')
return
}
const draft = activeDraft.value
if (!draft) return
const skuId = Number(row.skuId ?? row.SkuId)
const key = bindKey({ bindType: 'inventory', skuId })
const exist = draft.bindItems.find((x) => x.key === key)
const available = Number(row.availableQty ?? row.AvailableQty ?? 0)
if (exist) {
if (exist.bindQty < available) exist.bindQty += 1
return
}
draft.bindItems.push({
key,
bindType: 'inventory',
skuId,
bindQty: 1,
skuCode: String(row.skuCode ?? row.SkuCode ?? ''),
skuName: String(row.skuName ?? row.SkuName ?? ''),
availableQty: available,
imageUrl: row.skuImageUrl ?? row.SkuImageUrl,
})
}
function addParcelItem(parcel: any, item: any) {
if (batchMode.value && isFulfill.value && assignableDrafts.value.length > 1) {
$baseMessage(t('order.myOrders.stockBind.parcelBatchHint'), 'info')
return
}
const draft = activeDraft.value
if (!draft) return
const parcelItemId = Number(item.parcelItemId ?? item.ParcelItemId)
const key = bindKey({ bindType: 'parcelItem', parcelItemId })
const exist = draft.bindItems.find((x) => x.key === key)
const available = Number(item.availableQty ?? item.AvailableQty ?? 0)
if (exist) {
if (exist.bindQty < available) exist.bindQty += 1
return
}
draft.bindItems.push({
key,
bindType: 'parcelItem',
parcelId: Number(parcel.parcelId ?? parcel.ParcelId ?? parcel.id ?? parcel.Id),
parcelItemId,
skuId: item.skuId ?? item.SkuId,
bindQty: 1,
skuCode: String(item.skuCode ?? item.SkuCode ?? ''),
productName: String(item.productName ?? item.ProductName ?? ''),
trackingNo: String(parcel.trackingNo ?? parcel.TrackingNo ?? ''),
availableQty: available,
imageUrl: item.imageUrl ?? item.ImageUrl,
})
}
function changeQty(item: BindDraftItem, delta: number) {
const next = item.bindQty + delta
if (next < 1) return
if (item.availableQty != null && next > item.availableQty) return
item.bindQty = next
}
function removeBind(key: string) {
const draft = activeDraft.value
if (!draft) return
draft.bindItems = draft.bindItems.filter((x) => x.key !== key)
}
function clearBindsOnWarehouseChange() {
drafts.value.forEach((d) => {
d.bindItems = []
})
}
function buildBindPayload(items: BindDraftItem[]) {
return items.map((x) => ({
bindType: x.bindType,
skuId: x.skuId,
inventoryId: x.inventoryId,
parcelId: x.parcelId,
parcelItemId: x.parcelItemId,
bindQty: x.bindQty,
}))
}
async function handleSubmit(): Promise<boolean> {
if (!canSubmit.value) {
if (!agreementChecked.value) {
$baseMessage(t('order.placeOrder.agreement.required'), 'warning')
return false
}
if (!productCode.value) {
$baseMessage(
isFulfillWms.value
? t('order.platformStoreOrder.createFulfillWms.productRequired')
: t('order.platformStoreOrder.createLogistics.productRequired'),
'warning',
)
return false
}
if (!warehouseId.value) {
$baseMessage(t('order.platformStoreOrder.message.warehouseRequired'), 'warning')
return false
}
if (!basicInfoReady.value) {
$baseMessage(t('order.platformStoreOrder.createLogistics.basicRequired'), 'warning')
return false
}
const bad = drafts.value.find(
(d) => d.receiverMissing || d.linesMissing || (isFulfill.value && d.bindItems.length === 0),
)
if (bad) {
activeId.value = bad.storeOrderId
$baseMessage(t('order.platformStoreOrder.createLogistics.fixIncomplete'), 'warning')
}
return false
}
const confirmContent = isFulfillWms.value
? t('order.platformStoreOrder.createFulfillWms.confirm', {
count: drafts.value.length,
product: productLabel.value,
warehouse: warehouseLabel.value,
})
: isFulfill.value
? t('order.platformStoreOrder.createFulfill.confirm', {
count: drafts.value.length,
product: productLabel.value,
warehouse: warehouseLabel.value,
})
: t('order.platformStoreOrder.createLogistics.confirm', {
count: drafts.value.length,
product: productLabel.value,
})
const confirmed = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: title.value,
content: confirmContent,
onOk: () => resolve(true),
onCancel: () => resolve(false),
})
})
if (!confirmed) return false
submitting.value = true
try {
const items = drafts.value.map((d) => ({
storeOrderId: d.storeOrderId,
clientNo: String(d.clientNo || '').trim() || undefined,
bindItems: isFulfill.value ? buildBindPayload(d.bindItems) : undefined,
}))
const body = {
productCode: productCode.value,
warehouseId: warehouseId.value,
customerId: props.customerId,
parcelBasic: buildParcelBasicPayload(),
items,
}
const result = isFulfillWms.value
? await batchCreatePlatformStoreFulfillWms(body)
: isFulfill.value
? await batchCreatePlatformStoreFulfill(body)
: await batchCreatePlatformStoreLogistics(body)
const success = Number(result?.successCount ?? result?.SuccessCount ?? 0)
const fail = Number(result?.failCount ?? result?.FailCount ?? 0)
if (fail > 0) {
const failItems = (result?.items ?? result?.Items ?? []).filter(
(x: any) => !(x.success ?? x.Success),
)
const msg = failItems
.slice(0, 3)
.map((x: any) => `${x.platformOrderNo || x.PlatformOrderNo || x.id}: ${x.message || x.Message}`)
.join('')
$baseMessage(
t('order.platformStoreOrder.createLogistics.partial', { success, fail, detail: msg }),
'warning',
)
} else {
$baseMessage(
isFulfillWms.value
? t('order.platformStoreOrder.createFulfillWms.success', { count: success })
: t('order.platformStoreOrder.createLogistics.success', { count: success }),
'success',
)
}
emit('success')
return true
} catch (e: any) {
$baseMessage(e?.message || t('order.platformStoreOrder.createLogistics.failed'), 'error')
return false
} finally {
submitting.value = false
}
}
/** v-if 挂载时 visible 已是 true,必须 immediate,否则不会拉草稿/库存(对齐 PullOrdersModal / StockBindModal) */
watch(
() => props.visible,
async (open) => {
if (!open) return
productCode.value = props.defaultProductCode
warehouseId.value = props.defaultWarehouseId
leftTab.value = 'inventory'
keyword.value = ''
batchMode.value = props.storeOrderIds.length > 1
batchMatchDeclaration.value = false
batchQtyPerOrder.value = 1
cargoAttribute.value = 'general'
actualWeight.value = undefined
quantity.value = 1
lengthCm.value = 10
widthCm.value = 10
heightCm.value = 10
exportCustomsMode.value = SAMPLE_EXPORT_CUSTOMS_MODE_CODE
exportQualificationId.value = undefined
platformCode.value = undefined
inboundMethod.value = 'selfShip'
senderAddressId.value = undefined
expectPickupTime.value = undefined
agreementChecked.value = false
await loadBasicDicts()
await loadDrafts()
// 实重默认按订单申报行估算(取首单)
const first = drafts.value[0]
if (first && !(Number(actualWeight.value) > 0)) {
const est = first.lines.reduce((s, x) => s + Math.max(1, Number(x.quantity) || 0) * 0.01, 0)
actualWeight.value = Number(est.toFixed(3)) || 0.01
}
await loadExportQualifications()
await loadPickupAddresses()
if (isFulfill.value) await loadCandidates()
},
{ immediate: true },
)
watch(warehouseId, () => {
if (!props.visible || !isFulfill.value) return
clearBindsOnWarehouseChange()
void loadCandidates()
})
watch(leftTab, () => {
if (!props.visible || !isFulfill.value) return
void loadCandidates()
})
watch(needExportQualification, (need) => {
if (!props.visible || !need) return
void loadExportQualifications()
})
watch(inboundMethod, (method) => {
if (!props.visible || method !== 'doorPickup') return
void loadPickupAddresses()
})
</script>
<template>
<a-modal
v-model:visible="modalVisible"
:title="title"
:width="isFulfill ? '1360px' : '1080px'"
:top="isFulfill ? '20px' : '6vh'"
:ok-loading="submitting"
:ok-text="
isFulfillWms
? t('order.platformStoreOrder.createFulfillWms.submit')
: isFulfill
? t('order.platformStoreOrder.createFulfill.submit')
: t('order.platformStoreOrder.createLogistics.submit')
"
:ok-button-props="{ disabled: !canSubmit || loading }"
unmount-on-close
:body-style="{
padding: '12px 16px',
maxHeight: isFulfill ? 'calc(100vh - 140px)' : 'calc(100vh - 160px)',
overflow: isFulfill ? 'hidden' : 'auto',
}"
:on-before-ok="handleSubmit"
@cancel="close"
>
<div v-if="loading" class="psc-loading">{{ t('common.loading') }}</div>
<div v-else class="psc-root" :class="{ 'is-fulfill': isFulfill }">
<p class="psc-tip">
{{
isFulfillWms
? t('order.platformStoreOrder.createFulfillWms.tip')
: isFulfill
? t('order.platformStoreOrder.createFulfill.tip')
: t('order.platformStoreOrder.createLogistics.tip')
}}
</p>
<section class="psc-basic">
<header class="psc-basic__head">
<strong>{{ t('order.placeOrder.single.stepBasic') }}</strong>
<span class="psc-config__meta">
{{
t('order.platformStoreOrder.createLogistics.readyMeta', {
total: drafts.length,
ready: readyCount,
})
}}
</span>
</header>
<div class="psc-basic__grid">
<div class="psc-basic__item">
<span class="psc-basic__label">
{{
isFulfillWms
? t('order.platformStoreOrder.createFulfillWms.product')
: t('order.platformStoreOrder.createLogistics.product')
}}
<em>*</em>
</span>
<a-select
v-model="productCode"
allow-search
:options="productOptions"
:loading="dictLoading"
:placeholder="t('order.platformStoreOrder.form.product')"
/>
</div>
<div class="psc-basic__item">
<span class="psc-basic__label">客户单号 <em>*</em></span>
<a-input
v-if="activeDraft"
v-model="activeDraft.clientNo"
:placeholder="activeDraft.platformOrderNo || '客户单号'"
/>
<a-input v-else disabled placeholder="—" />
</div>
<div class="psc-basic__item">
<span class="psc-basic__label">货物属性 <em>*</em></span>
<a-select
v-model="cargoAttribute"
allow-search
:options="cargoAttributeOptions"
:loading="dictLoading"
placeholder="请选择"
/>
</div>
<div class="psc-basic__item">
<span class="psc-basic__label">仓库 <em>*</em></span>
<a-select
v-model="warehouseId"
allow-search
:options="warehouseOptions"
:placeholder="t('warehouse.common.warehouse')"
/>
</div>
<div class="psc-basic__item">
<span class="psc-basic__label">实重(kg) <em>*</em></span>
<a-input-number v-model="actualWeight" :min="0.01" :precision="3" hide-button style="width: 100%" />
</div>
<div class="psc-basic__item">
<span class="psc-basic__label">包裹数量 <em>*</em></span>
<a-input-number v-model="quantity" :min="1" :precision="0" hide-button style="width: 100%" />
</div>
<div class="psc-basic__item">
<span class="psc-basic__label">出口报关方式</span>
<a-select
v-model="exportCustomsMode"
allow-search
:options="exportCustomsModeOptions"
:loading="dictLoading"
placeholder="请选择"
/>
</div>
<div v-if="needExportQualification" class="psc-basic__item">
<span class="psc-basic__label">出口资质备案 <em>*</em></span>
<a-select
v-model="exportQualificationId"
allow-search
:options="exportQualificationOptions.map((x) => ({ label: x.label, value: x.id }))"
placeholder="请选择"
/>
</div>
<div v-if="needExportQualification" class="psc-basic__item">
<span class="psc-basic__label">电商平台 <em>*</em></span>
<a-select
v-model="platformCode"
allow-search
:options="ecommercePlatformOptions"
placeholder="请选择"
/>
</div>
<div class="psc-basic__item psc-basic__item--dims">
<span class="psc-basic__label">长宽高(cm) <em>*</em></span>
<div class="psc-dims">
<a-input-number v-model="lengthCm" :min="0.1" :precision="1" hide-button placeholder="长" />
<span>×</span>
<a-input-number v-model="widthCm" :min="0.1" :precision="1" hide-button placeholder="宽" />
<span>×</span>
<a-input-number v-model="heightCm" :min="0.1" :precision="1" hide-button placeholder="高" />
</div>
</div>
<div class="psc-basic__item psc-basic__item--inbound">
<span class="psc-basic__label">到仓方式 <em>*</em></span>
<a-radio-group v-model="inboundMethod" type="button" size="small">
<a-radio value="selfShip">自寄到仓</a-radio>
<a-radio value="doorPickup">上门揽收</a-radio>
</a-radio-group>
</div>
<template v-if="inboundMethod === 'doorPickup'">
<div class="psc-basic__item">
<span class="psc-basic__label">揽收地址 <em>*</em></span>
<a-select
v-model="senderAddressId"
allow-search
:options="pickupAddressOptions"
placeholder="请选择揽收地址"
/>
</div>
<div class="psc-basic__item">
<span class="psc-basic__label">期望揽收时间 <em>*</em></span>
<a-date-picker
v-model="expectPickupTime"
show-time
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</div>
</template>
</div>
<div class="psc-agreement">
<a-checkbox v-model="agreementChecked" />
<span class="psc-agreement__link" @click="openAgreementModal">
{{ t('order.placeOrder.agreement.label') }}
</span>
</div>
</section>
<div v-if="showBatchBar" class="psc-batch-bar">
<div class="psc-batch-bar__left">
<a-switch v-model="batchMode" size="small" />
<span class="psc-batch-bar__label">{{ t('order.myOrders.stockBind.batchMode') }}</span>
<span class="psc-batch-bar__hint">{{ t('order.myOrders.stockBind.batchModeHint') }}</span>
</div>
<div class="psc-batch-bar__opts">
<span class="psc-batch-bar__opt-label">{{ t('order.myOrders.stockBind.batchQtyPerOrder') }}</span>
<a-input-number
v-model="batchQtyPerOrder"
:min="1"
:max="9999"
size="small"
:disabled="batchMatchDeclaration"
style="width: 88px"
/>
<a-checkbox v-model="batchMatchDeclaration">
{{ t('order.platformStoreOrder.createFulfill.batchMatchOrderQty') }}
</a-checkbox>
</div>
<a-button
size="small"
type="outline"
:disabled="!activeDraft?.bindItems.length"
@click="copyActiveToAll"
>
{{
t('order.myOrders.stockBind.copyToAll', {
count: Math.max(assignableDrafts.length - 1, 0),
})
}}
</a-button>
</div>
<div class="psc-layout" :class="{ 'is-fulfill': isFulfill }">
<aside class="psc-list">
<div class="psc-list__head">{{ t('order.platformStoreOrder.createLogistics.orderList') }}</div>
<button
v-for="d in drafts"
:key="d.storeOrderId"
type="button"
class="psc-list__item"
:class="{
active: d.storeOrderId === activeId,
danger: d.receiverMissing || d.linesMissing || (isFulfill && !d.bindItems.length),
}"
@click="selectOrder(d.storeOrderId)"
>
<div class="psc-list__no">{{ d.platformOrderNo }}</div>
<div class="psc-list__sub">
<span>{{ d.recipientCountry || d.orderCountry || '-' }}</span>
<span>{{ d.recipientName || '-' }}</span>
</div>
<div v-if="isFulfill" class="psc-list__qty">
<span>{{ t('order.platformStoreOrder.createFulfill.orderQtyShort', { n: draftOrderQty(d) }) }}</span>
<span class="psc-list__qty-sep">/</span>
<span :class="{ warn: d.bindItems.length > 0 && draftOrderQty(d) !== draftBindQty(d) }">
{{ t('order.platformStoreOrder.createFulfill.bindQtyShort', { n: draftBindQty(d) }) }}
</span>
</div>
<div class="psc-list__tags">
<a-tag v-if="d.receiverMissing" size="small" color="red">
{{ t('order.platformStoreOrder.createLogistics.tagReceiver') }}
</a-tag>
<a-tag v-else-if="d.linesMissing" size="small" color="red">
{{ t('order.platformStoreOrder.createLogistics.tagLines') }}
</a-tag>
<a-tag v-else-if="isFulfill && !d.bindItems.length" size="small" color="orangered">
{{ t('order.platformStoreOrder.createLogistics.tagUnbound') }}
</a-tag>
<a-tag
v-else-if="isFulfill && draftOrderQty(d) !== draftBindQty(d)"
size="small"
color="orangered"
>
{{ t('order.platformStoreOrder.createFulfill.tagQtyDiff') }}
</a-tag>
<a-tag v-else-if="isFulfill" size="small" color="green">
{{ t('order.platformStoreOrder.createFulfill.tagQtyOk') }}
</a-tag>
<a-tag v-else size="small" color="green">
{{ t('order.platformStoreOrder.createLogistics.tagOk') }}
</a-tag>
</div>
</button>
</aside>
<section v-if="activeDraft" class="psc-detail">
<!-- 仅物流:收件?+ 商品纵向校对 -->
<template v-if="!isFulfill">
<div class="psc-section">
<h4>{{ t('order.platformStoreOrder.createLogistics.receiverTitle') }}</h4>
<div class="psc-receiver" :class="{ missing: activeDraft.receiverMissing }">
<div><label>{{ t('order.platformStoreOrder.recipient.name') }}</label>{{ activeDraft.recipientName || '-' }}</div>
<div><label>{{ t('order.platformStoreOrder.recipient.phone') }}</label>{{ activeDraft.recipientPhone || '-' }}</div>
<div><label>{{ t('order.platformStoreOrder.recipient.destCountry') }}</label>{{ activeDraft.recipientCountry || '-' }}</div>
<div><label>{{ t('order.platformStoreOrder.recipient.zipCode') }}</label>{{ activeDraft.recipientZipCode || '-' }}</div>
<div class="full">
<label>{{ t('order.platformStoreOrder.recipient.address1') }}</label>
{{
[activeDraft.recipientProvince, activeDraft.recipientCity, activeDraft.recipientAddress1, activeDraft.recipientAddress2]
.filter(Boolean)
.join(' ') || '-'
}}
</div>
</div>
</div>
<div class="psc-section">
<h4>{{ t('order.platformStoreOrder.createLogistics.linesTitle') }}</h4>
<a-table
:data="activeShipLines"
:pagination="false"
size="small"
row-key="platformSku"
>
<template #columns>
<a-table-column :title="t('order.platformStoreOrder.line.sku')" data-index="platformSku" :width="120" />
<a-table-column :title="t('order.platformStoreOrder.line.name')" data-index="productName" />
<a-table-column :title="t('order.platformStoreOrder.line.qty')" data-index="quantity" :width="70" />
<a-table-column :title="t('order.platformStoreOrder.line.price')" data-index="unitPrice" :width="90" />
</template>
</a-table>
</div>
</template>
<!-- 物流&代发:订单商已绑资源 对照校对 -->
<template v-else>
<div class="psc-ship-strip" :class="{ missing: activeDraft.receiverMissing }">
<strong>{{ activeDraft.platformOrderNo }}</strong>
<span>{{ activeDraft.recipientName || '-' }}</span>
<span>{{ activeDraft.recipientPhone || '-' }}</span>
<span class="psc-ship-strip__addr">
{{
[activeDraft.recipientCountry, activeDraft.recipientProvince, activeDraft.recipientCity, activeDraft.recipientAddress1]
.filter(Boolean)
.join(' ') || '-'
}}
</span>
</div>
<div class="psc-compare-bar" :class="{ mismatch: qtyMismatch, empty: !activeDraft.bindItems.length }">
<div class="psc-compare-bar__side">
<span class="psc-compare-bar__label">{{ t('order.platformStoreOrder.createFulfill.orderSide') }}</span>
<strong class="psc-compare-bar__num">{{ orderQtyTotal }}</strong>
<span class="psc-compare-bar__unit">{{ t('order.platformStoreOrder.createFulfill.pcs') }}</span>
<em>{{ t('order.platformStoreOrder.createFulfill.lineCount', { n: activeShipLines.length }) }}</em>
</div>
<div class="psc-compare-bar__vs">vs</div>
<div class="psc-compare-bar__side">
<span class="psc-compare-bar__label">{{ t('order.platformStoreOrder.createFulfill.bindSide') }}</span>
<strong class="psc-compare-bar__num" :class="{ warn: qtyMismatch }">{{ bindQtyTotal }}</strong>
<span class="psc-compare-bar__unit">{{ t('order.platformStoreOrder.createFulfill.pcs') }}</span>
<em>{{ t('order.platformStoreOrder.createFulfill.bindCount', { n: activeDraft.bindItems.length }) }}</em>
</div>
<div v-if="!activeDraft.bindItems.length" class="psc-compare-bar__msg">
{{ t('order.platformStoreOrder.createFulfill.compareNeedBind') }}
</div>
<div v-else-if="qtyMismatch" class="psc-compare-bar__msg warn">
{{ t('order.platformStoreOrder.createFulfill.compareQtyDiff', { order: orderQtyTotal, bind: bindQtyTotal }) }}
</div>
<div v-else class="psc-compare-bar__msg ok">
{{ t('order.platformStoreOrder.createFulfill.compareQtyOk') }}
</div>
</div>
<div class="psc-compare">
<div class="psc-compare__col psc-compare__col--order">
<div class="psc-compare__head">
<h4>{{ t('order.platformStoreOrder.createFulfill.orderGoodsTitle') }}</h4>
<span>{{ t('order.platformStoreOrder.createFulfill.orderGoodsHint') }}</span>
</div>
<div class="psc-compare__scroll">
<div
v-for="(line, idx) in activeShipLines"
:key="`${line.platformSku}-${idx}`"
class="psc-goods-row"
>
<div class="psc-goods-row__main">
<strong>{{ line.productName || '-' }}</strong>
<span class="psc-goods-row__sku">{{ line.platformSku || '-' }}</span>
</div>
<div class="psc-goods-row__qty">
<em>{{ t('order.platformStoreOrder.createFulfill.needQty') }}</em>
<b>{{ line.quantity }}</b>
</div>
</div>
<div v-if="!activeShipLines.length" class="psc-bind__empty">
{{ t('order.platformStoreOrder.createLogistics.tagLines') }}
</div>
</div>
</div>
<div class="psc-compare__col psc-compare__col--bound">
<div class="psc-compare__head">
<h4>{{ t('order.platformStoreOrder.createFulfill.boundTitle') }}</h4>
<span>{{ t('order.platformStoreOrder.createFulfill.boundHint') }}</span>
</div>
<div class="psc-compare__scroll">
<div
v-for="item in activeDraft.bindItems"
:key="item.key"
class="psc-bound-row"
>
<a-tag size="small" :color="item.bindType === 'inventory' ? 'arcoblue' : 'purple'">
{{
item.bindType === 'inventory'
? t('order.myOrders.stockBind.tabInventory')
: t('order.myOrders.stockBind.tabParcel')
}}
</a-tag>
<div class="psc-bound-row__main">
<strong>{{ item.skuCode || item.productName || item.trackingNo || '-' }}</strong>
<span>{{ item.skuName || item.productName || item.trackingNo }}</span>
</div>
<div class="psc-bound-row__ops">
<div class="psc-stepper">
<button type="button" class="psc-stepper__btn" @click="changeQty(item, -1)">-</button>
<span class="psc-stepper__val">{{ item.bindQty }}</span>
<button type="button" class="psc-stepper__btn" @click="changeQty(item, 1)">+</button>
</div>
<button type="button" class="psc-cart-row__remove" @click="removeBind(item.key)">
<IconDelete />
</button>
</div>
</div>
<div v-if="!activeDraft.bindItems.length" class="psc-bind__empty">
{{ t('order.platformStoreOrder.createFulfill.bindEmpty') }}
</div>
</div>
</div>
</div>
<div class="psc-pick">
<div class="psc-pick__head">
<h4>{{ t('order.platformStoreOrder.createFulfill.pickTitle') }}</h4>
<SlidingTabs
v-model="leftTab"
size="mini"
class="psc-pick__tabs"
:items="pickSourceTabs"
:aria-label="t('order.platformStoreOrder.createFulfill.pickTitle')"
/>
<a-input
v-model="keyword"
allow-clear
size="small"
class="psc-pick__search"
:disabled="!warehouseId"
:placeholder="t('order.myOrders.stockBind.searchPlaceholder')"
@input="scheduleSearch"
@press-enter="loadCandidates"
@clear="loadCandidates"
/>
</div>
<div v-if="!warehouseId" class="psc-bind__empty">
{{ t('order.platformStoreOrder.message.warehouseRequired') }}
</div>
<div v-else class="psc-pick__scroll">
<div v-if="candidatesLoading" class="psc-bind__empty">{{ t('common.loading') }}</div>
<template v-else-if="leftTab === 'inventory'">
<button
v-for="row in inventoryCandidates"
:key="row.skuId || row.SkuId"
type="button"
class="psc-cand"
@click="addInventory(row)"
>
<img v-if="imgSrc(row.skuImageUrl || row.SkuImageUrl)" :src="imgSrc(row.skuImageUrl || row.SkuImageUrl)" alt="" />
<div class="psc-cand__main">
<strong>{{ row.skuCode || row.SkuCode }}</strong>
<span>{{ row.skuName || row.SkuName }}</span>
</div>
<div class="psc-cand__meta">
<em>{{ t('order.myOrders.stockBind.available') }} {{ row.availableQty ?? row.AvailableQty ?? 0 }}</em>
<IconPlus />
</div>
</button>
<div v-if="!inventoryCandidates.length" class="psc-bind__empty">
{{ t('order.myOrders.stockBind.emptyInventory') }}
</div>
</template>
<template v-else>
<div
v-for="parcel in parcelCandidates"
:key="parcel.parcelId || parcel.ParcelId || parcel.id"
class="psc-parcel"
>
<div class="psc-parcel__head">
<span>{{ parcel.trackingNo || parcel.TrackingNo || `#${parcel.parcelId || parcel.id}` }}</span>
<a-button
v-if="batchMode && assignableDrafts.length > 1"
size="mini"
type="outline"
class="psc-parcel__batch"
@click.stop="distributeParcelToOrders(parcel)"
>
{{
t('order.myOrders.stockBind.parcelDistributeBtn', {
count: assignableDrafts.length,
})
}}
</a-button>
</div>
<button
v-for="item in (parcel.items || parcel.Items || [])"
:key="item.parcelItemId || item.ParcelItemId"
type="button"
class="psc-cand"
@click="addParcelItem(parcel, item)"
>
<div class="psc-cand__main">
<strong>{{ item.productName || item.ProductName || item.skuCode }}</strong>
<span>{{ item.skuCode || item.SkuCode || item.attributeText || '' }}</span>
</div>
<div class="psc-cand__meta">
<em>{{ t('order.myOrders.stockBind.available') }} {{ item.availableQty ?? item.AvailableQty ?? 0 }}</em>
<IconPlus />
</div>
</button>
</div>
<div v-if="!parcelCandidates.length" class="psc-bind__empty">
{{ t('order.myOrders.stockBind.emptyParcel') }}
</div>
</template>
</div>
</div>
</template>
</section>
</div>
<div class="psc-footer-summary">
{{ productLabel }}
<template v-if="warehouseId"> · {{ warehouseLabel }}</template>
· {{ drafts.length }} {{ t('order.platformStoreOrder.createLogistics.unitOrders') }}
</div>
</div>
</a-modal>
<a-modal
v-model:visible="agreementModalVisible"
:title="t('order.placeOrder.agreement.label')"
:footer="false"
:width="560"
unmount-on-close
>
<pre class="psc-agreement-body">{{ AGREEMENT_TEXT }}</pre>
</a-modal>
</template>
<style scoped lang="less">
@primary: var(--app-primary-color);
.psc-loading {
padding: 48px;
text-align: center;
color: var(--color-text-3);
}
.psc-root.is-fulfill {
display: flex;
flex-direction: column;
height: calc(100vh - 160px);
min-height: 620px;
max-height: calc(100vh - 160px);
}
.psc-tip {
margin: 0 0 10px;
color: var(--color-text-2);
font-size: 13px;
line-height: 1.5;
flex-shrink: 0;
}
.psc-basic {
margin-bottom: 10px;
padding: 12px;
background: var(--color-fill-2);
border-radius: 4px;
flex-shrink: 0;
}
.psc-basic__head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
strong {
font-size: 14px;
color: var(--color-text-1);
}
}
.psc-basic__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px 12px;
}
.psc-basic__item {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.psc-basic__label {
font-size: 12px;
color: var(--color-text-2);
em {
color: #f53f3f;
font-style: normal;
margin-left: 2px;
}
}
.psc-basic__item--dims,
.psc-basic__item--inbound {
grid-column: span 1;
}
.psc-dims {
display: flex;
align-items: center;
gap: 6px;
.arco-input-number {
flex: 1;
min-width: 0;
}
}
.psc-agreement {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--color-border-2);
}
.psc-agreement__link {
color: var(--app-primary-color);
cursor: pointer;
font-size: 13px;
user-select: none;
&:hover {
text-decoration: underline;
}
}
.psc-agreement-body {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 13px;
line-height: 1.7;
color: var(--color-text-2);
max-height: 50vh;
overflow: auto;
}
.psc-config__meta {
margin-left: auto;
font-size: 12px;
color: var(--color-text-3);
}
.psc-batch-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
padding: 10px 12px;
margin-bottom: 10px;
border-radius: 4px;
border: 1px solid color-mix(in srgb, @primary 22%, var(--color-border-2));
background: color-mix(in srgb, @primary 6%, var(--app-card-bg));
flex-shrink: 0;
}
.psc-batch-bar__left {
display: flex;
align-items: center;
gap: 8px;
min-width: 220px;
}
.psc-batch-bar__label {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.psc-batch-bar__hint {
font-size: 12px;
color: var(--color-text-3);
}
.psc-batch-bar__opts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
flex: 1;
}
.psc-batch-bar__opt-label {
font-size: 12px;
color: var(--color-text-2);
white-space: nowrap;
}
.psc-layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 12px;
min-height: 420px;
max-height: 62vh;
}
.psc-layout.is-fulfill {
flex: 1;
min-height: 0;
max-height: none;
height: auto;
grid-template-columns: 220px 1fr;
}
.psc-list {
border: 1px solid var(--color-border-2);
border-radius: 4px;
overflow: auto;
background: var(--app-card-bg);
min-height: 0;
}
.psc-list__head {
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
border-bottom: 1px solid var(--color-border-2);
background: var(--color-fill-1);
position: sticky;
top: 0;
z-index: 1;
}
.psc-list__item {
display: block;
width: 100%;
text-align: left;
border: none;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--color-border-2);
background: var(--app-card-bg);
padding: 10px 12px;
cursor: pointer;
&:hover {
background: var(--color-fill-2);
}
&.active {
border-left-color: @primary;
background: color-mix(in srgb, @primary 8%, var(--app-card-bg));
}
&.danger:not(.active) {
background: #fff7f7;
}
}
.psc-list__no {
font-weight: 600;
font-size: 13px;
color: var(--color-text-1);
}
.psc-list__sub {
display: flex;
gap: 8px;
margin-top: 4px;
font-size: 12px;
color: var(--color-text-3);
}
.psc-list__tags {
margin-top: 6px;
}
.psc-list__qty {
margin-top: 6px;
font-size: 12px;
color: var(--color-text-2);
font-variant-numeric: tabular-nums;
.warn {
color: #f77234;
font-weight: 600;
}
}
.psc-list__qty-sep {
margin: 0 4px;
color: #c9cdd4;
}
.psc-detail {
border: 1px solid var(--color-border-2);
border-radius: 4px;
padding: 12px;
overflow: auto;
background: var(--app-card-bg);
min-height: 0;
}
.psc-layout.is-fulfill .psc-detail {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.psc-section {
& + & {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--color-border-2);
}
h4 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
}
.psc-receiver {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
font-size: 13px;
color: var(--color-text-1);
&.missing {
background: #fff7f7;
border: 1px solid #ffece8;
border-radius: 4px;
padding: 8px;
}
label {
display: inline-block;
min-width: 48px;
color: var(--color-text-3);
margin-right: 6px;
}
.full {
grid-column: 1 / -1;
}
}
/* ── 代发:收件摘?── */
.psc-ship-strip {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 14px;
padding: 8px 12px;
background: var(--color-fill-1);
border-bottom: 1px solid var(--color-border-2);
font-size: 12px;
color: var(--color-text-2);
strong {
color: var(--color-text-1);
font-size: 13px;
}
&.missing {
background: #fff7f7;
}
&__addr {
flex: 1;
min-width: 180px;
color: var(--color-text-3);
}
}
/* ── 代发:数量对照条 ── */
.psc-compare-bar {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
padding: 10px 14px;
border-bottom: 1px solid var(--color-border-2);
background: #f2f9f9;
&.mismatch {
background: #fff7e8;
}
&.empty {
background: #fff7f7;
}
&__side {
display: flex;
align-items: baseline;
gap: 6px;
}
&__label {
font-size: 12px;
color: var(--color-text-3);
}
&__num {
font-size: 22px;
font-weight: 700;
color: var(--color-text-1);
font-variant-numeric: tabular-nums;
line-height: 1;
&.warn {
color: #f77234;
}
}
&__unit {
font-size: 12px;
color: var(--color-text-2);
}
em {
font-style: normal;
font-size: 12px;
color: var(--color-text-3);
}
&__vs {
font-size: 12px;
font-weight: 600;
color: #c9cdd4;
}
&__msg {
margin-left: auto;
font-size: 12px;
color: #00b42a;
&.warn {
color: #f77234;
}
&.ok {
color: #00b42a;
}
}
}
/* ── 代发:订单商已绑 对照 ── */
.psc-compare {
flex: 1.2;
min-height: 220px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
border-bottom: 1px solid var(--color-border-2);
overflow: hidden;
}
.psc-compare__col {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
&--order {
border-right: 1px solid var(--color-border-2);
background: var(--app-card-bg);
}
&--bound {
background: #fcfcfd;
}
}
.psc-compare__head {
flex-shrink: 0;
padding: 10px 12px 8px;
border-bottom: 1px solid var(--color-border-2);
h4 {
margin: 0 0 2px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
span {
font-size: 12px;
color: var(--color-text-3);
}
}
.psc-compare__scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 6px 8px;
}
.psc-goods-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px;
border-radius: 4px;
border: 1px solid var(--color-border-2);
margin-bottom: 6px;
background: var(--app-card-bg);
}
.psc-goods-row__main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
font-size: 13px;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.psc-goods-row__sku {
font-size: 12px;
color: var(--color-text-3);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.psc-goods-row__qty {
flex-shrink: 0;
text-align: right;
em {
display: block;
font-style: normal;
font-size: 11px;
color: var(--color-text-3);
}
b {
font-size: 20px;
font-weight: 700;
color: @primary;
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
}
.psc-bound-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
border-radius: 4px;
border: 1px solid var(--color-border-2);
margin-bottom: 6px;
background: var(--app-card-bg);
}
.psc-bound-row__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
font-size: 13px;
color: var(--color-text-1);
}
span {
font-size: 12px;
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.psc-bound-row__ops {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* ── 代发:底部挑选资?── */
.psc-pick {
flex: 0.9;
min-height: 180px;
display: flex;
flex-direction: column;
background: var(--color-fill-2);
overflow: hidden;
}
.psc-pick__head {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border-2);
background: var(--app-card-bg);
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
}
}
.psc-pick__tabs {
width: fit-content;
max-width: 100%;
}
.psc-pick__search {
width: 220px;
margin-left: auto;
}
.psc-pick__scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 8px;
align-content: start;
}
.psc-bind__empty {
padding: 28px 12px;
text-align: center;
color: var(--color-text-3);
font-size: 13px;
grid-column: 1 / -1;
}
.psc-cand {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: 4px;
border: 1px solid var(--color-border-2);
background: var(--app-card-bg);
cursor: pointer;
text-align: left;
width: 100%;
&:hover {
border-color: color-mix(in srgb, @primary 45%, var(--color-border-2));
background: color-mix(in srgb, @primary 6%, var(--app-card-bg));
}
img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
background: var(--color-fill-2);
flex-shrink: 0;
}
}
.psc-cand__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
strong {
font-size: 13px;
color: var(--color-text-1);
}
span {
font-size: 12px;
color: var(--color-text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.psc-cand__meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
color: @primary;
em {
font-style: normal;
font-size: 11px;
color: var(--color-text-3);
}
}
.psc-parcel {
grid-column: 1 / -1;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--app-card-bg);
padding: 8px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 6px;
.psc-cand {
border-color: var(--color-border-2);
}
}
.psc-parcel__head {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: var(--color-text-2);
padding: 2px 4px 6px;
}
.psc-parcel__batch {
margin-left: auto;
}
.psc-stepper {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-border-2);
border-radius: 4px;
overflow: hidden;
}
.psc-stepper__btn {
width: 28px;
height: 28px;
border: none;
background: var(--color-fill-2);
cursor: pointer;
&:hover {
background: color-mix(in srgb, @primary 10%, var(--color-fill-2));
color: @primary;
}
}
.psc-stepper__val {
min-width: 32px;
text-align: center;
font-weight: 700;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.psc-cart-row__remove {
border: none;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
padding: 4px;
&:hover {
color: #f53f3f;
}
}
.psc-footer-summary {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--color-border-2);
font-size: 13px;
color: var(--color-text-2);
flex-shrink: 0;
}
</style>