This commit is contained in:
2026-10-20 11:54:44 +08:00
parent 5036593195
commit 8abc058a65
29 changed files with 445 additions and 78 deletions
@@ -163,3 +163,10 @@ test('replay rejection cannot discard an earlier unknown creation outcome', asyn
assert.deepEqual(calls[1], calls[0])
} finally { harness.dispose() }
})
test('purchase category is optional while its length remains bounded', () => {
const payload = presentation.buildProcurementCreate({ ...draft(), purchaseCategory: ' ' }, 'Company', 'key')
assert.equal(payload.purchaseCategory, '')
assert.equal(submission.restoreProcurementSubmission(JSON.stringify(payload)).purchaseCategory, '')
assert.throws(() => presentation.buildProcurementCreate({ ...draft(), purchaseCategory: 'x'.repeat(129) }, 'Company', 'key'), /品类/)
})
@@ -101,11 +101,12 @@ export function draftBudgetTotal(draft: ProcurementDraft) {
}
export function buildProcurementCreate(draft: ProcurementDraft, customerName: string, key: string): ProcurementCreate {
for (const [field, label, max] of [
['purchaseCategory', '采购品类', 128], ['productName', '商品名称', 255], ['receivingAddress', '完整国内收货地址', 1000],
['productName', '商品名称', 255], ['receivingAddress', '完整国内收货地址', 1000],
['contactName', '最终收件人', 128], ['contactPhone', '联系电话', 64],
] as const) {
if (!draft[field].trim() || draft[field].trim().length > max) throw new Error(`请填写有效的${label}`)
}
if (draft.purchaseCategory.trim().length > 128) throw new Error('采购品类超过长度限制')
if (!customerName.trim()) throw new Error('当前企业信息尚未加载,请刷新登录信息后重试')
if (!/^\d+$/.test(draft.purchaseQuantity) || !Number.isSafeInteger(Number(draft.purchaseQuantity)) || Number(draft.purchaseQuantity) <= 0) throw new Error('采购数量必须为正整数')
const price = cents(draft.budgetUnitPrice)
@@ -147,7 +147,7 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<el-alert v-if="submitError" class="submit-error" :title="submitError" type="error" :closable="false" />
<el-form :model="form" label-position="top" :disabled="submitting || Boolean(pending) || Boolean(recoveryError)" @submit.prevent="submit">
<h3>商品与预算</h3><div class="procurement-form-grid">
<el-form-item label="采购品类" required><el-input v-model.trim="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item>
<el-form-item label="采购品类"><el-input v-model.trim="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类(选填)" /></el-form-item>
<el-form-item label="商品名称" required><el-input v-model.trim="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item>
<el-form-item label="商品规格"><el-input v-model.trim="form.productSpecification" maxlength="500" placeholder="规格、型号" /></el-form-item>
<el-form-item label="采购数量(件)" required><el-input v-model.trim="form.purchaseQuantity" inputmode="numeric" maxlength="18" placeholder="正整数" /></el-form-item>
@@ -158,12 +158,11 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
<h3>最终国内收件资料</h3><div class="procurement-form-grid">
<el-form-item label="收件人" required><el-input v-model.trim="form.contactName" maxlength="128" placeholder="实际收件人姓名" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model.trim="form.contactPhone" maxlength="64" type="tel" placeholder="快递可联系的电话" /></el-form-item>
<el-form-item label="下单企业"><el-input :model-value="companyName" disabled /></el-form-item>
<el-form-item class="full" label="完整国内地址" required><el-input v-model.trim="form.receivingAddress" maxlength="1000" placeholder="省、市、区县、街道及详细门牌" /></el-form-item>
<el-form-item class="full" label="采购要求"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="其他采购要求(选填)" /></el-form-item>
</div>
</el-form>
<footer class="procurement-form-actions"><el-button v-if="canList" :disabled="submitting" @click="returnToList">返回列表</el-button><el-button type="primary" :loading="submitting" :disabled="Boolean(recoveryError) || (!pending && !companyName) || !identity" @click="submit">{{ pending ? '确认上次提交结果' : '提交采购订单' }}</el-button></footer>
<footer class="procurement-form-actions"><el-button type="primary" :loading="submitting" :disabled="Boolean(recoveryError) || (!pending && !companyName) || !identity" @click="submit">{{ pending ? '确认上次提交结果' : '提交采购订单' }}</el-button></footer>
</template>
</section>
</section>
@@ -13,8 +13,8 @@ const primaryActions = computed(() => [
{ title: '美本土散板', description: '拼车拼载 · 打盘服务 · 灵活派送', icon: Forklift, route: '/cross-border/pallet-shipping/create', permission: 'merchant:cross-border:pallet-shipping:create', tone: 'cyan', featured: false },
{ title: '海外采购', description: '采购代购 · 验货质检 · 集中发货', icon: ShoppingCart, route: '/cross-border/procurement/create', permission: 'merchant:cross-border:procurement:create', tone: 'orange', featured: false },
{ title: '贴标申请', description: '产品建档 · 标签上传 · 批量贴标', icon: Tags, route: '/overseas-warehouse/labeling/create', permission: 'merchant:overseas-warehouse:relabel:create', tone: 'rose', featured: false },
{ title: '仓储转运', description: '仓储管理 · 货物转运', icon: Warehouse, route: '/overseas-warehouse/storage-transfer', permission: 'merchant:overseas-warehouse:transfer:list', tone: 'teal', featured: false, actionLabel: '开始办理' },
{ title: '货物废弃', description: '废弃申请 · 处理进度', icon: PackageOpen, route: '/overseas-warehouse/cargo-disposal', permission: 'merchant:overseas-warehouse:discard:list', tone: 'brown', featured: false, actionLabel: '开始办理' },
{ title: '仓储转运', description: '仓储管理 · 货物转运', icon: Warehouse, route: '/overseas-warehouse/storage-transfer/create', permission: 'merchant:overseas-warehouse:transfer:create', tone: 'teal', featured: false, actionLabel: '开始办理' },
{ title: '货物废弃', description: '废弃申请 · 处理进度', icon: PackageOpen, route: '/overseas-warehouse/cargo-disposal/create', permission: 'merchant:overseas-warehouse:discard:create', tone: 'brown', featured: false, actionLabel: '开始办理' },
].filter((action) => auth.hasPermission(action.permission)))
</script>
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
test('计费重确认保留大整数来源和原幂等键,只提交预期重量版本', async () => {
const calls = []
const source = readFileSync(new URL('./chargeableWeights.ts', import.meta.url), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
const exports = {}
new Function('require', 'exports', code)(() => ({ apiRequest: async request => { calls.push(request); return [] } }), exports)
const id = '9223372036854775806'
await exports.getChargeableWeights(id)
await exports.confirmChargeableWeight({ inboundId: id, fingerprint: 'abc' }, 'original-command')
assert.equal(calls[0].url, `/merchant/v1/first-leg/orders/${id}/chargeable-weights`)
assert.equal(calls[1].url, `/merchant/v1/first-leg/chargeable-weights/${id}/confirm`)
assert.deepEqual(calls[1].headers, { 'Idempotency-Key': 'original-command' })
assert.deepEqual(calls[1].data, { expectedFingerprint: 'abc' })
assert.equal(calls.some(call => call.url.includes('inbound-billing')), false)
})
@@ -0,0 +1,16 @@
import { apiRequest } from '@/api/http'
export interface ChargeableWeight {
inboundId: string
orderId: string
chargeableWeightKg: number
fingerprint: string
confirmed: boolean
confirmedAt: string | null
}
export function getChargeableWeights(orderId: string) {
return apiRequest<ChargeableWeight[]>({ method: 'GET', url: `/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/chargeable-weights` })
}
export function confirmChargeableWeight(weight: ChargeableWeight, key: string) {
return apiRequest<ChargeableWeight>({ method: 'POST', url: `/merchant/v1/first-leg/chargeable-weights/${encodeURIComponent(weight.inboundId)}/confirm`,
headers: { 'Idempotency-Key': key }, data: { expectedFingerprint: weight.fingerprint } })
}
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import ts from 'typescript'
import { ref } from 'vue'
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() }
function harness(api, props = { orderId: 'one', canConfirm: true }) {
let changed, disposed
const events = []
const source = readFileSync(new URL('./ChargeableWeightDialog.vue', import.meta.url), 'utf8').split('<script setup lang="ts">')[1].split('</script>')[0]
const code = ts.transpileModule(source + '\nexports.state = { rows, loading, saving, error, confirm, load };', {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText
const exports = {}
new Function('require', 'exports', 'defineProps', 'defineEmits', code)(name => {
if (name === 'vue') return { ref, watch: (_, callback) => { changed = callback; callback() }, onBeforeUnmount: callback => { disposed = callback } }
if (name === 'element-plus') return { ElMessage: { success() {} } }
return api
}, exports, () => props, () => name => events.push(name))
return { ...exports.state, props, change: () => changed(), dispose: () => disposed(), events }
}
test('确认结果未知时重试原命令,成功后回查并通知工作台刷新', async () => {
const row = { inboundId: '100', fingerprint: 'weight-v1', chargeableWeightKg: 50, confirmed: false }
const commands = []
let fail = true
const h = harness({ getChargeableWeights: async () => [row], confirmChargeableWeight: async (_, key) => {
commands.push(key); if (fail) throw new Error('响应丢失'); row.confirmed = true
} })
await flush(); await h.confirm(row)
assert.equal(h.error.value, '响应丢失'); assert.equal(h.saving.value, '')
fail = false; await h.confirm(row)
assert.equal(commands[0], commands[1]); assert.deepEqual(h.events, ['confirmed'])
assert.equal(h.rows.value[0].confirmed, true); assert.equal(h.saving.value, '')
})
test('切换订单或关闭后丢弃迟到的读取结果,无权限不能确认', async () => {
let resolveOld
const h = harness({ getChargeableWeights: order => order === 'one' ? new Promise(resolve => { resolveOld = resolve }) : Promise.resolve([]),
confirmChargeableWeight: () => { throw new Error('无权限不得写入') } }, { orderId: 'one', canConfirm: false })
h.props.orderId = 'two'; h.change(); await flush()
resolveOld([{ inboundId: 'old' }]); await flush()
assert.deepEqual(h.rows.value, [])
await h.confirm({ inboundId: 'old', fingerprint: 'old' })
assert.deepEqual(h.events, [])
h.dispose()
})
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getChargeableWeights, confirmChargeableWeight, type ChargeableWeight } from '../api/chargeableWeights'
const props = defineProps<{ orderId: string; canConfirm: boolean }>()
const emit = defineEmits<{ close: []; confirmed: [] }>()
const rows = ref<ChargeableWeight[]>([])
const loading = ref(false)
const saving = ref('')
const error = ref('')
const commands = new Map<string, string>()
let sequence = 0
let confirmationSequence = 0
async function load() {
const ticket = ++sequence
loading.value = true; error.value = ''; rows.value = []
try { const data = await getChargeableWeights(props.orderId); if (ticket === sequence) rows.value = data }
catch (e) { if (ticket === sequence) error.value = e instanceof Error ? e.message : '计费重加载失败' }
finally { if (ticket === sequence) loading.value = false }
}
async function confirm(row: ChargeableWeight) {
if (saving.value || !props.canConfirm) return
const ticket = sequence
const attempt = ++confirmationSequence
const fingerprint = `${row.inboundId}:${row.fingerprint}`
const key = commands.get(fingerprint) ?? `weight-${crypto.randomUUID()}`
commands.set(fingerprint, key); saving.value = row.inboundId; error.value = ''
try {
await confirmChargeableWeight(row, key)
if (ticket !== sequence) return
ElMessage.success('计费重已确认'); emit('confirmed'); await load()
} catch (e) { if (ticket === sequence) error.value = e instanceof Error ? e.message : '确认失败,请刷新后重试' }
finally { if (attempt === confirmationSequence) saving.value = '' }
}
watch(() => props.orderId, () => { confirmationSequence++; saving.value = ''; commands.clear(); void load() }, { immediate: true })
onBeforeUnmount(() => { sequence++; confirmationSequence++ })
</script>
<template>
<el-dialog :model-value="true" title="确认计费重" width="560px" :close-on-click-modal="false" @close="emit('close')">
<el-alert v-if="error" :title="error" type="error" :closable="false" />
<el-button v-if="error" :disabled="!!saving" @click="load">重新加载</el-button>
<div v-loading="loading">
<el-empty v-if="!loading && !error && !rows.length" description="暂无已审核的计费重" />
<div v-for="row in rows" :key="row.inboundId" class="weight-row">
<span>来源入仓单:{{ row.inboundId }}<br>计费重:{{ row.chargeableWeightKg }} kg</span>
<el-tag v-if="row.confirmed" type="success">已确认</el-tag>
<el-button v-else-if="canConfirm" type="primary" :disabled="!!saving" :loading="saving === row.inboundId" @click="confirm(row)">确认计费重</el-button>
<span v-else>待确认</span>
</div>
</div>
</el-dialog>
</template>
<style scoped>
.weight-row { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 12px 0; }
</style>
@@ -20,7 +20,7 @@ test('supports a standalone dialog and keeps the unpushed billing-weight state v
assert.match(source, /watch\(\(\) => props\.autoOpen/)
assert.match(source, /if \(autoOpen\) feeDialogOpen\.value = true/)
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
assert.match(source, /平台推送计费重后,可在这里核对并确认/)
assert.match(source, /平台推送费用后,可在这里核对并确认/)
assert.match(source, /@closed="emit\('closed'\)"/)
})
@@ -198,19 +198,19 @@ async function confirmFee() {
</section>
<el-dialog v-model="feeDialogOpen" class="billing-confirm-dialog" title="确认测量与计费数据" width="min(1180px, calc(100vw - 40px))" append-to-body @closed="emit('closed')">
<el-dialog v-model="feeDialogOpen" class="billing-confirm-dialog" title="确认费用" width="min(1180px, calc(100vw - 40px))" append-to-body @closed="emit('closed')">
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
<el-skeleton :rows="4" animated />
<span class="sr-only">正在加载计费重</span>
<span class="sr-only">正在加载费用</span>
</div>
<div v-else-if="props.error" class="inbound-billing-state is-error" role="alert">
<TriangleAlert :size="22" />
<div><strong>计费重加载失败</strong><span>{{ props.error }}</span></div>
<div><strong>费用加载失败</strong><span>{{ props.error }}</span></div>
<el-button @click="$emit('retry')"><RefreshCw :size="15" />重新加载</el-button>
</div>
<div v-else-if="props.unavailable || !props.data" class="inbound-billing-state is-pending" role="status">
<Clock3 :size="22" />
<div><strong>{{ props.unavailableMessage }}</strong><span>平台推送计费重后,可在这里核对并确认。</span></div>
<div><strong>{{ props.unavailableMessage }}</strong><span>平台推送费用后,可在这里核对并确认。</span></div>
</div>
<template v-else>
<dl class="fee-dialog-summary" aria-label="计费汇总">
@@ -623,7 +623,7 @@ test('loads merchant inbound billing as an isolated read-only detail section', (
assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/)
assert.match(source, /error\.message \|\| unavailableMessage/)
assert.match(source, /order\.status === 'DRAFT'/)
assert.match(source, /feeConfirmationOpen\.value[\s\S]*\? '未推送计费重'[\s\S]*: '订单提交后可查看入库结果'/)
assert.match(source, /feeConfirmationOpen\.value[\s\S]*\? '未推送费用'[\s\S]*: '订单提交后可查看入库结果'/)
assert.match(source, /订单提交后可查看入库结果/)
assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/)
})
@@ -636,10 +636,10 @@ test('keeps platform enterprise scope and tenant terminology out of the customer
test('opens billing-weight confirmation independently from order details', () => {
const handler = source.slice(source.indexOf('function openFeeConfirmation'), source.indexOf('async function retryDetail'))
assert.match(handler, /feeConfirmationOpen\.value = true/)
assert.match(handler, /loadInboundBilling\(order, '未推送计费重'\)/)
assert.match(handler, /loadInboundBilling\(order, '未推送费用'\)/)
assert.doesNotMatch(handler, /openDetail\(/)
assert.match(source, /v-if="feeConfirmationOpen"[\s\S]*dialog-only[\s\S]*auto-open/)
assert.match(source, /unavailable-message="未推送计费重"/)
assert.match(source, /unavailable-message="未推送费用"/)
})
test('shows billing-weight confirmation only when the backend marks it pending', () => {
@@ -9,6 +9,7 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
import type { TableAction } from '@/components/common/tableActions'
import { ApiError } from '@/api/http'
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue'
import ChargeableWeightDialog from '@/modules/first-leg/components/ChargeableWeightDialog.vue'
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue'
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
@@ -267,6 +268,7 @@ const inboundBillingUnavailableMessage = ref('还未测量推送')
const inboundBillingError = ref('')
const inboundBillingOrderId = ref('')
const feeConfirmationOpen = ref(false)
const weightConfirmationOrderId = ref('')
const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({})
const customsEligibilityPending = new Set<string>()
const customsDialogOpen = ref(false)
@@ -1361,8 +1363,11 @@ function getOrderRowActions(row: object): TableAction[] {
if (canCreateOrder.value) {
actions.push({ key: 'copy', label: '复制下单', loading: copyingOrderId.value === order.id, run: () => copyOrder(order) })
}
if (canConfirmInboundFee.value && !['DRAFT', 'CANCELLED', 'REJECTED', 'SUBMITTED'].includes(order.status)) {
actions.push({ key: 'weight', label: '确认计费重', run: () => { weightConfirmationOrderId.value = order.id } })
}
if (canConfirmInboundFee.value && order.businessSummary.customerFeeConfirmationRequired) {
actions.push({ key: 'fee', label: '确认计费重', run: () => openFeeConfirmation(order) })
actions.push({ key: 'fee', label: '确认费用', run: () => openFeeConfirmation(order) })
}
if (canEditOrder.value && canClientEditFirstLegOrder(order.status)) {
actions.push({ key: 'edit', label: '编辑', loading: openingEditor.value, run: () => openEdit(order) })
@@ -1471,9 +1476,9 @@ async function loadDetailPage(orderId: string) {
function openFeeConfirmation(order: FirstLegOrder) {
resetInboundBillingState()
inboundBillingUnavailableMessage.value = '未推送计费重'
inboundBillingUnavailableMessage.value = '未推送费用'
feeConfirmationOpen.value = true
void loadInboundBilling(order, '未推送计费重')
void loadInboundBilling(order, '未推送费用')
}
async function retryDetail() {
@@ -1508,7 +1513,7 @@ async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '
if (order.status === 'DRAFT') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
? '未推送计费重'
? '未推送费用'
: '订单提交后可查看入库结果'
return
}
@@ -1523,7 +1528,7 @@ async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '
if (error instanceof ApiError && error.code === 'A0404') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
? '未推送计费重'
? '未推送费用'
: error.message || unavailableMessage
} else {
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
@@ -1535,7 +1540,7 @@ async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '
async function retryInboundBilling() {
const order = inboundBillingOrderId.value ? store.getById(inboundBillingOrderId.value) : undefined
if (order) await loadInboundBilling(order, feeConfirmationOpen.value ? '未推送计费重' : '还未测量推送')
if (order) await loadInboundBilling(order, feeConfirmationOpen.value ? '未推送费用' : '还未测量推送')
}
function handleInboundFeeConfirmed() {
@@ -2548,13 +2553,14 @@ async function searchOrders(model: QueryModel) {
:data="inboundBilling"
:loading="inboundBillingLoading"
:unavailable="inboundBillingUnavailable"
unavailable-message="未推送计费重"
unavailable-message="未推送费用"
:error="inboundBillingError"
:can-confirm="canConfirmInboundFee"
@retry="retryInboundBilling"
@confirmed="handleInboundFeeConfirmed"
@closed="feeConfirmationOpen = false"
/>
<ChargeableWeightDialog v-if="weightConfirmationOrderId" :order-id="weightConfirmationOrderId" :can-confirm="canConfirmInboundFee" @close="weightConfirmationOrderId = ''" @confirmed="handleInboundFeeConfirmed" />
<CustomsDocumentsDialog
v-model="customsDialogOpen"
:order-id="customsDialogOrderId"
@@ -76,7 +76,7 @@ watch(originWarehouseId, (value, previous) => { if (!value || previous === '') r
<template>
<div class="pricing-page"><section class="pricing-inquiry"><div class="inquiry-copy"><span>SAILING PRICE INQUIRY</span><h2>船期价格查询</h2><p>重量或体积填写一项即可,系统会按计价规则自动换算</p></div><el-form class="pricing-form" label-position="top"><el-form-item label="起运仓"><el-select v-model="originWarehouseId" filterable :loading="loading" placeholder="选择起运仓"><el-option v-for="item in warehouses" :key="String(item.id)" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="item.id" /></el-select></el-form-item><el-form-item label="货物重量"><div class="unit-input"><el-input v-model.number="weight" type="number" min="0" placeholder="可选,输入重量" /><span>KG</span></div></el-form-item><el-form-item label="货物立方数"><div class="unit-input"><el-input v-model.number="volume" type="number" min="0" placeholder="可选,输入体积" /><span>CBM</span></div></el-form-item><el-form-item label="目的 FBA 仓"><el-select v-model="destinationWarehouseId" filterable :loading="loading" placeholder="输入或选择仓库代码"><el-option v-for="item in destinationWarehouses" :key="String(item.id)" :label="item.warehouseCode" :value="item.id" /></el-select></el-form-item><el-form-item label="报价方式"><el-radio-group v-model="taxMode"><el-radio-button value="TAX_INCLUDED" label="包税" /><el-radio-button value="TAX_EXCLUDED" label="不包税" /></el-radio-group></el-form-item><el-form-item class="query-item"><el-button type="primary" :loading="querying" @click="query"><Search :size="16" />立即查询</el-button></el-form-item></el-form>
<el-dialog v-model="quoteDialogOpen" class="quote-dialog" :show-close="false" :lock-scroll="false" width="min(1080px, calc(100vw - 48px))" top="6vh"><template #header><div class="quote-dialog-head"><div><h3>查询结果</h3><span>{{ resultSummary }}</span></div><button type="button" class="quote-close" aria-label="关闭" @click="quoteDialogOpen = false">×</button></div></template><div class="quote-grid"><article v-for="item in quoteCards" :key="item.quote.channelCode" class="quote-card" :class="recommendation(item)?.className"><div v-if="recommendation(item)" class="quote-ribbon">{{ recommendation(item)?.label }}</div><div class="quote-card-title"><strong>{{ item.channel.channelName }}</strong><span>{{ item.quote.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税' }} / {{ item.quote.chargeUnit || 'CBM' }}</span></div><div class="quote-price"><b>{{ formatMoney(item.quote) }}</b><small>预估运费</small></div><div class="quote-meta"><div><span>单价</span><strong>{{ formatUnitPrice(item.quote) }}</strong></div><div><span>参考时效</span><strong>{{ item.channel.referenceTransit || '待更新' }}</strong></div><div><span>最晚进仓</span><strong>{{ item.channel.latestWarehouseEntry || '—' }}</strong></div><div><span>船期</span><strong>{{ item.channel.estimatedDepartureText || item.channel.estimatedDepartureDate || '待更新' }}</strong></div></div><el-button type="primary" plain class="quote-order" @click="placeOrder(item.quote.channelCode, item.channel.channelName)">下单</el-button></article></div></el-dialog></section>
<section class="pricing-results"><div class="section-title"><div><h2>匹配渠道</h2><span>{{ channels.length }} 个渠道 · 参考价格以服务端估价为准</span></div><Calculator :size="20" /></div><el-skeleton v-if="loading || querying" :rows="4" animated /><el-alert v-else-if="errorMessage" :title="errorMessage" type="error" show-icon /><el-empty v-else-if="!channels.length" description="暂无可用船期价格" /><el-table v-else :data="channels" stripe><el-table-column label="渠道" min-width="160"><template #default="{ row }"><strong>{{ row.channelName }}</strong><small>{{ row.channelCode }}</small></template></el-table-column><el-table-column label="参考船期" min-width="150"><template #default="{ row }">{{ row.estimatedDepartureText || row.estimatedDepartureDate || '待更新' }}</template></el-table-column><el-table-column label="最晚进仓" min-width="130"><template #default="{ row }">{{ row.latestWarehouseEntry || '—' }}</template></el-table-column><el-table-column label="参考时效" width="120"><template #default="{ row }">{{ row.referenceTransit || '—' }}</template></el-table-column><el-table-column label="预估运费" width="150"><template #default="{ row }"><span v-if="quotes.find((quote) => quote.channelCode === row.channelCode)">{{ formatMoney(quotes.find((quote) => quote.channelCode === row.channelCode)!) }}</span><span v-else class="muted">查询后显示</span></template></el-table-column><el-table-column label="附加费说明" min-width="180" prop="surchargeDescription" /><el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button link type="primary" :disabled="!quotes.some((quote) => quote.channelCode === row.channelCode)" @click="placeOrder(row.channelCode, row.channelName)">下单</el-button></template></el-table-column></el-table></section>
<section class="pricing-results"><div class="section-title"><div><h2>匹配渠道</h2><span>{{ channels.length }} 个渠道 · 参考价格以服务端估价为准</span></div><Calculator :size="20" /></div><el-skeleton v-if="loading || querying" :rows="4" animated /><el-alert v-else-if="errorMessage" :title="errorMessage" type="error" show-icon /><el-empty v-else-if="!channels.length" description="暂无可用船期价格" /><el-table v-else :data="channels" stripe><el-table-column label="渠道" min-width="160"><template #default="{ row }"><strong>{{ row.channelName }}</strong><small>{{ row.channelCode }}</small></template></el-table-column><el-table-column label="参考船期" min-width="150"><template #default="{ row }">{{ row.estimatedDepartureText || row.estimatedDepartureDate || '待更新' }}</template></el-table-column><el-table-column label="最晚进仓" min-width="130"><template #default="{ row }">{{ row.latestWarehouseEntry || '—' }}</template></el-table-column><el-table-column label="参考时效" width="120"><template #default="{ row }">{{ row.referenceTransit || '—' }}</template></el-table-column><el-table-column label="预估运费" width="150"><template #default="{ row }"><span v-if="quotes.find((quote) => quote.channelCode === row.channelCode)">{{ formatMoney(quotes.find((quote) => quote.channelCode === row.channelCode)!) }}</span><span v-else class="muted">查询后显示</span></template></el-table-column><el-table-column label="附加费说明" min-width="180" prop="surchargeDescription" /></el-table></section>
</div>
</template>
@@ -0,0 +1,21 @@
import { apiRequest } from '@/api/http'
export interface RelabelQuantity {
labelPieceCount: number | string | null
cartonCount: number | string | null
skuCount: number | string
duplicateCarton: boolean
}
export interface RelabelQuantityTotals {
orderCount: number | string
skuCount: number | string
knownLabelPieceCount: number | string
unknownPieceOrders: number | string
knownCartonCount: number | string
unknownCartonOrders: number | string
orders: Record<string, RelabelQuantity>
}
/** 客户范围来自会话,数量汇总覆盖全部筛选结果。 */
export function getRelabelQuantities(params: Record<string, unknown>) {
return apiRequest<RelabelQuantityTotals>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications/quantity-summary', params })
}
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { apiRequest } from '@/api/http'
interface Statistics { orderCount: number; skuCount: number; requestedPackages: number; actualPackages: number | null; requestedPieces: number | null; actualPieces: number | null; palletCount: number | null }
const props = defineProps<{ query: Record<string, unknown>; revision: number }>()
const statistics = ref<Statistics | null>(null)
const error = ref(false)
let generation = 0
/** 仅消费与列表已提交查询一致的全筛选统计,旧响应不得覆盖新查询。 */
watch(() => [props.query, props.revision], async () => {
const request = ++generation
statistics.value = null; error.value = false
try {
const result = await apiRequest<Statistics>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards/statistics', params: props.query })
if (request === generation) statistics.value = result
} catch { if (request === generation) error.value = true }
}, { immediate: true })
/** 缺少商品或托盘事实时保留空值含义。 */
function quantity(value: number | null, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` }
</script>
<template>
<p aria-live="polite" class="discard-statistics" v-if="statistics">全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuCount }} 种 SKU · 申请 {{ statistics.requestedPackages }} 箱 · 已确认实际 {{ quantity(statistics.actualPackages, '箱') }} · 商品 {{ quantity(statistics.requestedPieces, '个') }} · 托盘 {{ quantity(statistics.palletCount, '托盘') }}</p>
<p v-else class="discard-statistics">{{ error ? '数量汇总加载失败,请重新查询' : '数量汇总加载中…' }}</p>
</template>
<style scoped>.discard-statistics{margin:10px 0;color:var(--muted);font-size:13px;line-height:1.6}</style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { getRelabelQuantities, type RelabelQuantityTotals } from '../api/relabelQuantities'
const props = defineProps<{ query?: Record<string, unknown>; relabelNo?: string }>()
const data = ref<RelabelQuantityTotals | null>(null)
const error = ref('')
let generation = 0
function dimension(known: number, unknown: number, unit: string) {
if (unknown > 0 && known === 0) return `未提供(${unknown} 票)`
return `已知 ${known} ${unit}${unknown > 0 ? `(${unknown} 票未提供)` : ''}`
}
watch(() => [props.query, props.relabelNo], async () => {
const current = ++generation
data.value = null; error.value = ''
try {
const result = await getRelabelQuantities({ ...props.query, ...(props.relabelNo ? { relabelNo: props.relabelNo } : {}) })
if (current === generation) data.value = result
} catch { if (current === generation) error.value = '数量汇总加载失败,请刷新重试' }
}, { immediate: true, deep: true })
</script>
<template>
<section class="relabel-quantity-panel" aria-label="贴标数量维度">
<p v-if="error" role="alert">{{ error }}</p>
<template v-else-if="data">
<strong>{{ relabelNo ? '本单数量' : '全部筛选结果' }}</strong>
<span>{{ data.orderCount }} 票</span><span>{{ data.skuCount }} 种 SKU</span>
<span>贴标件数:{{ dimension(Number(data.knownLabelPieceCount), Number(data.unknownPieceOrders), '件') }}</span>
<span>箱数:{{ dimension(Number(data.knownCartonCount), Number(data.unknownCartonOrders), '箱') }}</span>
<p v-if="relabelNo && data.orders[relabelNo]?.duplicateCarton" role="status">存在重复箱唛关联,箱数已去重,贴标件数待核查。</p>
</template>
<span v-else>数量加载中…</span>
</section>
</template>
<style scoped>
.relabel-quantity-panel { display: flex; gap: 12px; flex-wrap: wrap; padding: 12px 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface, white); margin-bottom: 12px; }
.relabel-quantity-panel p { margin: 0; }
</style>
@@ -0,0 +1,52 @@
<script setup lang="ts">
const props = defineProps<{ modelValue: number }>()
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
let drag: { pointerId: number; startX: number; startValue: number; width: number } | null = null
function resize(value: number) {
emit('update:modelValue', Math.min(65, Math.max(35, value)))
}
function startDrag(event: PointerEvent) {
if (event.button !== 0 || !event.isPrimary) return
const handle = event.currentTarget as HTMLElement
const width = (handle.parentElement?.getBoundingClientRect().width ?? 0) - 16
if (width <= 0) return
event.preventDefault()
handle.focus()
drag = { pointerId: event.pointerId, startX: event.clientX, startValue: props.modelValue, width }
handle.setPointerCapture(event.pointerId)
}
function moveDrag(event: PointerEvent) {
if (!drag || drag.pointerId !== event.pointerId) return
resize(drag.startValue + (event.clientX - drag.startX) / drag.width * 100)
}
function endDrag(event: PointerEvent) {
if (drag?.pointerId !== event.pointerId) return
drag = null
const handle = event.currentTarget as HTMLElement
if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId)
}
function keyResize(event: KeyboardEvent) {
const value = { ArrowLeft: props.modelValue - 2, ArrowRight: props.modelValue + 2, Home: 35, End: 65, Enter: 43 }[event.key]
if (value === undefined) return
event.preventDefault()
resize(value)
}
</script>
<template>
<div class="transfer-pane-divider" role="separator" tabindex="0" aria-label="调整转运信息和换标申请宽度"
aria-orientation="vertical" :aria-valuenow="Math.round(modelValue)" :aria-valuemin="35" :aria-valuemax="65"
aria-controls="transfer-main-pane transfer-relabel-pane" title="左右拖动调整宽度,双击恢复"
@pointerdown="startDrag" @pointermove="moveDrag" @pointerup="endDrag" @pointercancel="endDrag"
@lostpointercapture="drag = null" @keydown="keyResize" @dblclick="resize(43)">
<span aria-hidden="true" />
</div>
</template>
<style scoped>
.transfer-pane-divider { display:flex; width:16px; align-items:center; justify-content:center; align-self:stretch; cursor:col-resize; touch-action:none; user-select:none; outline:none; }
.transfer-pane-divider span { width:4px; height:64px; border-radius:4px; background:#c5d1e1; transition:background .15s; }
.transfer-pane-divider:hover, .transfer-pane-divider:focus-visible { background:#e9f1ff; border-radius:6px; }
.transfer-pane-divider:hover span, .transfer-pane-divider:focus-visible span { background:#3478c8; }
</style>
+11
View File
@@ -5,6 +5,17 @@ export const overseasWarehouseModule = {
key: 'overseas-warehouse',
navigationGroup: { title: '海外仓', icon: Warehouse },
routes: [
{
path: 'overseas-warehouse/storage-transfer/create', name: 'overseas-storage-transfer-create',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'transfer', createPage: true }, release: 'active',
meta: { title: '新增仓储转运单', workspaceTitle: '仓储转运 · 新建', description: '填写并提交仓储转运申请', permission: 'merchant:overseas-warehouse:transfer:create', keepAlive: false },
},
{
path: 'overseas-warehouse/cargo-disposal/create', name: 'overseas-cargo-disposal-create',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'disposal', createPage: true }, release: 'active',
meta: { title: '新增货物废弃申请', workspaceTitle: '货物废弃 · 新建', description: '填写并提交货物废弃申请', permission: 'merchant:overseas-warehouse:discard:create', keepAlive: false },
},
{
path: 'overseas-warehouse/labeling/create', name: 'overseas-labeling-create',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'labeling', createPage: true }, release: 'active',
@@ -36,3 +36,16 @@ test('缺少箱级记录时合并仓位可用箱数并保持全部废弃明细',
assert.equal(result.cartonSelectionSupported, false)
assert.equal(result.sources.length, 2)
})
test('接口 Long 字符串按数值相加,不串联数字', () => {
const source = { ...returned('1'), sourceType: 'INVENTORY', cartonSelectionSupported: false }
const sources = ['50', '100', '97'].map((availablePackages, index) => ({ ...source, orderId: `inventory:${index}`, returnCartonId: undefined, availablePackages }))
assert.equal(groupDiscardDocuments(sources)[0].availablePackages, 247)
assert.equal(groupDiscardDocuments([{ ...source, orderId: 'i:1', returnCartonId: undefined, availablePackages: '125' }, { ...source, orderId: 'i:2', returnCartonId: undefined, availablePackages: '125' }])[0].availablePackages, 250)
})
test('相同仓位重复关联只计一次,零值与未知可用量不进入可选来源', () => {
const source = { ...returned('1'), returnCartonId: undefined, sourceType: 'INVENTORY', inventoryPositionId: 'position:1', cartonSelectionSupported: false, availablePackages: 2 }
assert.equal(groupDiscardDocuments([source, source])[0].availablePackages, 2)
for (const availablePackages of [0, null, undefined, 'invalid']) assert.equal(groupDiscardDocuments([{ ...source, availablePackages }]).length, 0)
})
@@ -7,7 +7,15 @@ export interface DiscardDocument extends DiscardCandidate {
/** 下拉按来源单据和操作仓汇总;原始库存身份保留给选箱与提交。 */
export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDocument[] {
const groups = new Map<string, DiscardDocument>()
const identities = new Set<string>()
for (const candidate of candidates) {
const available = Number(candidate.availablePackages)
if (candidate.availablePackages == null || !Number.isFinite(available) || available <= 0) continue
// 按真实来源身份去重;缺身份的旧接口行仍独立保留,不合并不同库存。
const identity = candidate.sourceType === 'RETURN_CARTON' ? candidate.returnCartonId : candidate.inventoryPositionId || candidate.orderId
const sourceKey = identity ? JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, identity]) : null
if (sourceKey && identities.has(sourceKey)) continue
if (sourceKey) identities.add(sourceKey)
const documentNo = candidate.inboundOrderNo || candidate.inboundCode
const key = JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, documentNo || candidate.orderId])
let document = groups.get(key)
@@ -27,7 +35,9 @@ export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDo
} else if (source.cartonSelectionSupported && source.cartons?.length) {
for (const box of source.cartons) boxes.add(`${source.sourceType}:${box.cartonId}`)
} else {
untrackedPackages += source.availablePackages
// Long 序列化可能返回字符串,必须先转数值再汇总,避免拼成 005010097。
const packages = Number(source.availablePackages)
untrackedPackages += Number.isFinite(packages) && packages > 0 ? packages : 0
}
}
document.availablePackages = boxes.size + untrackedPackages
@@ -20,6 +20,14 @@ test('签收箱号只包含箱子和托盘签收,包裹实物记录不重复
assert.deepEqual(exports.receiptBoxDetails(receipts.slice(0, 1), cartons), [])
})
test('零箱、未记录箱数和包裹不补造一箱,缺少箱数时可使用真实标签', () => {
assert.equal(exports.receiptPrintableBoxCount([{ unitType: 'BOX', boxCount: 0, labels: [] }]), 0)
assert.equal(exports.receiptPrintableBoxCount([{ unitType: 'PALLET', boxCount: null, labels: [] }]), 0)
assert.equal(exports.receiptPrintableBoxCount([{ unitType: 'PARCEL', boxCount: 1, labels: [{}] }]), 0)
assert.equal(exports.receiptPrintableBoxCount([{ unitType: 'PALLET', boxCount: null, labels: [{}, {}] }]), 2)
assert.equal(exports.receiptPrintableBoxCount([{ boxCount: -1 }, { boxCount: 'bad' }]), 0)
})
test('volume rounds upward to exactly three decimal places without increasing exact thousandths', () => {
for (const [input, expected] of [[0.186624, '0.187'], [0.15, '0.150'], [1.001, '1.001'], [1.00100001, '1.002'], [0.0000001, '0.001'], [0, '0.000'], [null, '—'], [NaN, '—']]) {
assert.equal(exports.formatReturnVolume(input), expected)
@@ -1,7 +1,12 @@
import type { ReturnPallet, ReturnReceipt, ReturnReceiptCarton } from '../api/returnOrders'
export function receiptPrintableBoxCount(receipts: ReturnReceipt[]) {
return receipts.reduce((total, receipt) => total + Number(receipt.labels?.length || receipt.boxCount || 1), 0)
// 只累计真实箱数;缺少箱数时使用已生成标签,不凭空补一箱。
return receipts.reduce((total, receipt) => {
if (receipt.unitType === 'PARCEL') return total
const count = Number(receipt.boxCount ?? receipt.labels?.length ?? 0)
return total + (Number.isSafeInteger(count) && count >= 0 ? count : 0)
}, 0)
}
export function receiptPhysicalDetails(pallets: ReturnPallet[], boxes: ReturnReceiptCarton[]) {
@@ -77,11 +77,10 @@ test('新建退货预报使用独立路由页面,编辑仍保留加宽弹框',
assert.match(view, /class="return-create-actions"[\s\S]*?<el-button type="primary" :loading="creating" @click="submit">提交预报<\/el-button>/)
})
test('新建预报铺满工作区,联系字段与数量输入保持紧凑', () => {
test('新建预报铺满工作区,基础字段等宽对齐且数量输入保持紧凑', () => {
assert.match(view, /return-create-surface \{ width:100%; min-width:0;[^}]*margin:0;/)
assert.match(view, /<el-form label-width="130px" class="return-create-form">/)
assert.match(view, /return-create-form :deep\(\.el-input\) \{ max-width:320px;/)
assert.match(view, /return-create-form :deep\(\.el-select\) \{ max-width:520px;/)
assert.match(view, /return-create-form :deep\(\.el-input\), \.return-create-body \.return-create-form :deep\(\.el-select\) \{ width:100%; max-width:none;/)
assert.match(view, /:deep\(\.el-table \.el-input-number\) \{ width:120px;/)
assert.match(view, /<el-table class="return-forecast-table"[^>]*>/)
assert.match(view, /return-forecast-table \{[^}]*border-radius:8px;/)
@@ -484,16 +484,15 @@ onBeforeUnmount(clearPhotoUrls)
.relabel-suborder { display: flex; align-items: center; gap: 8px; min-height: 30px; white-space: nowrap; }
.return-orders-page { display:grid; gap:12px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
.return-create-surface { width:100%; min-width:0; min-height:calc(100dvh - 198px); margin:0; box-sizing:border-box; overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }
.return-create-header, .return-create-body, .return-create-actions { width:calc(100% - 24px); max-width:1118px; margin-left:24px; box-sizing:border-box; }
.return-create-header, .return-create-body, .return-create-actions { width:100%; min-width:0; box-sizing:border-box; }
.return-create-header { display:flex; min-height:68px; align-items:center; justify-content:space-between; gap:20px; padding:12px 20px; border-bottom:1px solid var(--client-border); }
.return-create-header h2 { margin:0; color:#1f2d45; font-size:20px; }
.return-create-body { padding:18px 20px 14px; }
.return-create-body :deep(.el-select), .return-create-body :deep(.el-input-number) { width:100%; }
.return-create-body .return-create-form { max-width:900px; column-gap:24px; }
.return-create-body .return-create-form { width:100%; column-gap:24px; }
.return-create-body .return-create-form :deep(.el-form-item) { margin-bottom:16px; }
.return-create-body .return-create-form :deep(.el-form-item__content) { min-width:0; }
.return-create-body .return-create-form :deep(.el-input) { max-width:320px; }
.return-create-body .return-create-form :deep(.el-select) { max-width:520px; }
.return-create-body .return-create-form :deep(.el-input), .return-create-body .return-create-form :deep(.el-select) { width:100%; max-width:none; }
.return-create-body .return-create-form :deep(.el-textarea) { max-width:100%; }
.return-create-body :deep(.el-table .el-input-number) { width:120px; }
.return-forecast-table { overflow:hidden; border:1px solid var(--client-border); border-radius:8px; --el-table-header-bg-color:#f5f7fb; --el-table-header-text-color:#526176; --el-table-border-color:#edf1f5; --el-table-row-hover-bg-color:#f8faff; }
@@ -1,5 +1,9 @@
<script setup lang="ts">
import RelabelQuantityPanel from '../components/RelabelQuantityPanel.vue'
import { getRelabelQuantities, type RelabelQuantity } from '../api/relabelQuantities'
import TransferPickupProgressPanel from '../components/TransferPickupProgressPanel.vue'
import DiscardStatistics from '../components/DiscardStatistics.vue'
import TransferPaneDivider from '../components/TransferPaneDivider.vue'
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -49,6 +53,7 @@ const props = withDefaults(defineProps<{ service: ServiceType; createPage?: bool
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const transferPaneShare = ref(43)
// 缓存列表不订阅其他详情页的路由参数,避免离开时清空筛选并重复请求。
const detailNumber = computed(() => props.detailPage ? props.detailNo || String(route.params.detailNo || '') : '')
const canCreate = computed(() => auth.hasPermission(({
@@ -74,9 +79,9 @@ async function refreshAfterCompletion(no: string) {
}
const canCancelDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:cancel'))
const configs: Record<ServiceType, ServiceConfig> = {
labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '关联业务单号', '海外操作仓', 'SKU 数(种)', '总箱数/包裹', '换标类型', '状态', '创建时间'] },
labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '关联业务单号', '海外操作仓', 'SKU 数(种)', '箱数', '贴标件数', '换标类型', '状态', '创建时间'] },
transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '预计箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '换标单号', '换标状态', '状态', '创建时间'] },
disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '废弃出库单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '废弃完成时间', '创建时间'] },
disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '废弃出库单号', '进仓编号', '操作仓', 'SKU 汇总', '申请箱数', '预估费用', '状态', '废弃完成时间', '创建时间'] },
relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '审核时间', '测量时间', '出库时间', '创建时间'] },
}
const config = computed(() => configs[props.service])
@@ -131,9 +136,12 @@ const skuRelabelRows = ref<SkuRelabelRow[]>([])
const importedRelabelBindings = ref<ImportedRelabelBinding[]>([])
const cartonDetailRows = computed(() => cargoRows.value.filter((row) => row.included))
const submittedRows = ref<Record<string, string>[]>([])
const discardStatisticsQuery = ref<Record<string, unknown>>({ page: 1, size: 15 })
const discardStatisticsRevision = ref(0)
const page = ref(1)
const pageSize = ref(15)
const total = ref(0)
const relabelQuantityQuery = ref<Record<string, unknown>>({ page: 1, size: 15 })
const editingTransferNo = ref('')
interface TransferPallet { id?: string | number; sequenceNo?: number; palletNo?: string; warehouseLocationCode?: string; cartonCount?: number; lengthCm?: number; widthCm?: number; heightCm?: number; grossWeightKg?: number; volumeCbm?: number; status?: string; completedAt?: string }
interface TransferMeasurementPhoto { fileId: string | number; fileName?: string; contentType?: string; sizeBytes?: number; uploadedAt?: string }
@@ -150,7 +158,7 @@ interface ServiceDetail extends Record<string, unknown> {
relabelContactPhone?: string; relabelDeliveryMethod?: string; relabelNo?: string
relabelStatus?: string; relabelFulfillmentStatus?: string; createdAt?: string
inboundCode?: string; sourceOrders?: Array<{ lastlegOrderId?: string | number }>
items?: Array<{ id?: string | number; cartonMarks?: string[]; inboundCode?: string; productName?: string; discardPackages?: number; skuCode?: string; productNameZh?: string; quantity?: number | null; packageCount?: number; shippingMark?: string }>
items?: Array<{ id?: string | number; cartonMarks?: string[]; inboundCode?: string; productName?: string; discardPackages?: number; quantityUnit?: string; actualDiscardPackages?: number | null; skuCode?: string; productNameZh?: string; quantity?: number | null; 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
}
@@ -198,7 +206,7 @@ function transferCartonLines(carton: TransferCarton) {
return [{ transferItemId: carton.transferItemId, skuCode: item?.skuCode || '—', productNameZh: item?.productNameZh || '—', quantity: Number(carton.quantity || 0) }]
}
function palletCartons(pallet: TransferPallet) {
return (selectedDetail.value?.cartons || []).filter((carton) => carton.status === 'COUNTED' && pallet.id != null && carton.transferPalletId != null && String(carton.transferPalletId) === String(pallet.id))
return countedTransferCartons().filter((carton) => pallet.id != null && carton.transferPalletId != null && String(carton.transferPalletId) === String(pallet.id))
}
function palletSkuRows(pallet: TransferPallet) {
const summary = new Map<string, number>()
@@ -208,7 +216,8 @@ function palletSkuRows(pallet: TransferPallet) {
return [...summary.entries()].map(([sku, quantity]) => ({ sku, quantity }))
}
function palletQuantity(pallet: TransferPallet) { return palletCartons(pallet).reduce((sum, carton) => sum + Number(carton.quantity || 0), 0) }
function countedTransferCartons() { return (selectedDetail.value?.cartons || []).filter((carton) => carton.status === 'COUNTED') }
/** 保留历史实收,后续占用、废弃与出库不回退签收事实。 */
function countedTransferCartons() { return (selectedDetail.value?.cartons || []).filter((carton) => ['COUNTED', 'DISCARD_PENDING', 'DISCARDED', 'SHIPPED'].includes(carton.status)) }
function receivedItemCartons(itemId: string | number | undefined) {
return countedTransferCartons().filter(carton => itemId != null && transferCartonLines(carton).some(line => String(line.transferItemId) === String(itemId)))
}
@@ -218,6 +227,7 @@ function receivedItemQuantity(itemId: string | number | undefined) {
const detailError = ref('')
const selectedRelabelDetail = ref<RelabelDetail | null>(null)
const selectedRelabelQuantity = ref<RelabelQuantity | null>(null)
const completionPhotoUrls = reactive<Record<string, string>>({})
const completionPhotoLoading = reactive<Record<string, boolean>>({})
const completionPhotoErrors = reactive<Record<string, boolean>>({})
@@ -490,7 +500,10 @@ onMounted(() => {
})
let activatedOnce = false
onActivated(() => {
if (activatedOnce && !props.createPage && !props.detailPage && mode.value === 'list') void loadPage()
if (activatedOnce && !props.createPage && !props.detailPage) {
mode.value = 'list'
void loadPage()
}
activatedOnce = true
})
onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos(); clearDiscardPhotos() })
@@ -523,8 +536,12 @@ function resetForm() {
editingTransferNo.value = ''
}
async function openCreate() {
if (!props.createPage && props.service !== 'relabel') {
await router.push({ name: `${serviceListRouteName(props.service)}-create` })
return
}
resetForm(); mode.value = 'create'
if (props.service === 'labeling' || props.service === 'relabel' || props.service === 'transfer') await loadWarehouses()
await loadWarehouses()
if (props.service === 'labeling') {
const requestedReturnNo = String(route.query.returnNo || '').trim()
const requestedWarehouseId = String(route.query.warehouseId || '').trim()
@@ -1115,14 +1132,18 @@ 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), 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) })
const query = { 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) }
relabelQuantityQuery.value = query
const [result, dimensionResult] = await Promise.all([listRelabels(query), getRelabelQuantities(query).catch(() => null)])
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.relatedBusinessOrderNos || []).join('、') || '—', destinationWarehouses, new Set(item.skuCodes.split(',').map(code => code.trim()).filter(Boolean)).size, item.totalPackageCount, (item.operationTypes || []).map(type => ({ CARTON_LABEL: '只换箱唛', SKU_LABEL: '只换 SKU', RETURN_BULK_SKU_LABEL: '只换 SKU', CARTON_AND_SKU: 'SKU 和箱唛都换' }[type] || type)).join('、') || '—', statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status, __confirmedAt: item.customerConfirmedAt || '' }
return { ...row([item.relabelNo, (item.relatedBusinessOrderNos || []).join('、') || '—', destinationWarehouses, dimensionResult?.orders[item.relabelNo]?.skuCount ?? '未提供', dimensionResult?.orders[item.relabelNo]?.cartonCount ?? '未提供', dimensionResult?.orders[item.relabelNo]?.labelPieceCount ?? '未提供', (item.operationTypes || []).map(type => ({ CARTON_LABEL: '只换箱唛', SKU_LABEL: '只换 SKU', RETURN_BULK_SKU_LABEL: '只换 SKU', CARTON_AND_SKU: 'SKU 和箱唛都换' }[type] || type)).join('、') || '—', statusLabel(item.status), 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), completedFrom: dateTime(filters.completedStart), completedTo: dateTime(filters.completedEnd, true) })
const discardQuery = { 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) }
const result = await listDiscards(discardQuery)
discardStatisticsQuery.value = discardQuery; discardStatisticsRevision.value++
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
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.customerConfirmedAt), formatDateTime(item.createdAt)]), __status: item.status }))
} else {
@@ -1339,12 +1360,17 @@ async function openDetail(value: Record<string, string>) {
return
}
detailError.value = ''
if (props.service === 'labeling') selectedRelabelQuantity.value = null
try {
const detail = props.service === 'labeling' ? await getRelabel(value['0']) : props.service === 'disposal' ? await getDiscard(value['0']) : await getTransfer(value['0'])
const discardSummary = props.service === 'disposal' ? (detail as { summary?: ServiceDetail }).summary : undefined
showTransferCartons.value = false
selectedDetail.value = { ...value, ...(detail as ServiceDetail), ...(discardSummary || {}) }
selectedRelabelDetail.value = props.service === 'labeling' ? detail as RelabelDetail : null
if (props.service === 'labeling') {
const quantities = await getRelabelQuantities({ relabelNo: value['0'] }).catch(() => null)
if (selectedRelabelDetail.value?.application.relabelNo === value['0']) selectedRelabelQuantity.value = quantities?.orders[value['0']] || null
}
mode.value = 'detail'
if (props.service === 'labeling') void loadCompletionPhotos()
if (props.service === 'transfer') void loadMeasurementPhotos()
@@ -1675,14 +1701,16 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
</script>
<template>
<section class="warehouse-service-page" :class="{ 'warehouse-service-page--detail': mode === 'detail', 'warehouse-service-page--transfer': props.service === 'transfer', 'warehouse-service-page--return-relabel': isReturnRelabelSource }">
<section class="warehouse-service-page" :class="{ 'warehouse-service-page--detail': mode === 'detail', 'warehouse-service-page--transfer': props.service === 'transfer', 'warehouse-service-page--disposal-form': props.service === 'disposal' && (mode === 'create' || mode === 'edit'), 'warehouse-service-page--return-relabel': isReturnRelabelSource }">
<template v-if="mode === 'list'">
<RelabelQuantityPanel v-if="props.service === 'labeling'" :query="relabelQuantityQuery" />
<QueryToolbar v-model="listQueryModel" :class="{ 'labeling-query': props.service === 'labeling', 'transfer-query': props.service === 'transfer' || props.service === 'disposal', 'disposal-query': props.service === 'disposal' }" layout="grid" :ariaLabel="`${config.title}筛选`" :fields="listQueryFields" :search-on-reset="false" @search="query" @reset="resetListQuery" />
<DiscardStatistics v-if="props.service === 'disposal'" :query="discardStatisticsQuery" :revision="discardStatisticsRevision" />
<div class="warehouse-card warehouse-table-card">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="submittedRows" :columns="listColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="listRowActions" :empty-text="config.emptyText">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.service === 'relabel' ? '新建退货换标单' : props.service === 'transfer' ? '新增订单' : `新增${config.title}` }}</el-button></template>
<template #transferRelabelStatus="{ row }"><el-tag :type="transferRelabelStatusTagType(row.__relabelStatus)" effect="light">{{ row.__relabelStatusLabel }}</el-tag></template>
<template #labelingType="{ row }"><span v-if="row['5'] === '—'">—</span><span v-else class="labeling-type-tags"><el-tag v-for="label in labelingTypeLabels(row['5'])" :key="label" :type="labelingTypeTagType(label)" size="small" effect="light">{{ label }}</el-tag></span></template>
<template #labelingType="{ row }"><span v-if="row['6'] === '—'">—</span><span v-else class="labeling-type-tags"><el-tag v-for="label in labelingTypeLabels(row['6'])" :key="label" :type="labelingTypeTagType(label)" size="small" effect="light">{{ label }}</el-tag></span></template>
<template #labelingStatus="{ row }"><el-tag :type="labelingStatusTagType(row.__status)" size="small" effect="light">{{ statusLabel(row.__status) }}</el-tag></template>
<template #transferStatus="{ row }"><el-tag :type="transferStatusTagType(row.__status)" effect="light">{{ row.__statusLabel }}</el-tag></template>
</DataTable>
@@ -1691,8 +1719,8 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<template v-else-if="mode === 'create' || mode === 'edit'">
<div class="warehouse-page-heading"><h2>{{ mode === 'edit' ? `编辑转运草稿 ${editingTransferNo}` : config.createTitle }}</h2><el-button @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button></div>
<form :class="{ 'warehouse-transfer-form': props.service === 'transfer', 'warehouse-transfer-form--relabel': props.service === 'transfer' && form.relabel }" @submit.prevent="submit('submit')">
<div class="warehouse-card warehouse-form-section warehouse-transfer-main-card">
<form :class="{ 'warehouse-transfer-form': props.service === 'transfer', 'warehouse-transfer-form--relabel': props.service === 'transfer' && form.relabel }" :style="props.service === 'transfer' ? { '--transfer-left': `${transferPaneShare}fr`, '--transfer-right': `${100 - transferPaneShare}fr` } : undefined" @submit.prevent="submit('submit')">
<div id="transfer-main-pane" class="warehouse-card warehouse-form-section warehouse-transfer-main-card">
<div v-if="props.service === 'labeling'" class="warehouse-order-info-heading">
<h3>订单信息</h3>
<el-button text type="primary" native-type="button" :aria-expanded="!orderInfoCollapsed" @click="orderInfoCollapsed = !orderInfoCollapsed">{{ orderInfoCollapsed ? '展开信息' : '收起信息' }}</el-button>
@@ -1707,7 +1735,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<label v-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable clearable :loading="discardCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可废弃库存" placeholder="" @change="selectDiscardInbound"><el-option v-for="candidate in discardDocuments" :key="candidate.orderId" :label="`${candidate.sourceType === 'RETURN_CARTON' ? '退货|' : ''}${candidate.inboundOrderNo || candidate.inboundCode || '—'}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>进仓编号</span><el-input v-model="form.inboundNo" placeholder="请输入已有入仓编号" /></label>
<label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
<label v-else-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>废弃原因</span><el-select v-model="form.reason" placeholder=""><el-option label="客户退货" value="客户退货" /><el-option label="亚马逊移除" value="亚马逊移除" /><el-option label="残损不可售" value="残损不可售" /></el-select></label>
<label v-else-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>废弃原因</span><el-select v-model="form.reason" placeholder=""><el-option label="残损不可售" value="残损不可售" /><el-option label="其他" value="其他" /></el-select></label>
<label v-else><span><i>*</i>{{ props.service === 'labeling' ? '海外操作仓(中转仓)' : props.service === 'transfer' ? '入库海外仓' : '操作仓' }}</span><el-select v-model="form.warehouse" :loading="fbaWarehousesLoading || operationWarehouseDetailLoading" :disabled="Boolean(operationWarehousesError)" clearable placeholder="" @change="props.service === 'labeling' ? selectOperationWarehouse() : props.service === 'transfer' && selectTransferWarehouse()"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="operationWarehousesError" class="warehouse-hint">{{ operationWarehousesError }}</small></label>
<label v-if="props.service === 'labeling'"><span><i>*</i>选择入仓单或退货单</span><el-select v-model="selectedRelabelInboundOrderId" filterable clearable :filter-method="(value: string) => relabelInventorySearch = value" :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="暂无匹配的可选库存" placeholder="" @change="selectRelabelInventory"><el-option v-for="option in visibleRelabelInboundOptions.filter(item => !relabelInventorySearch.trim() || item.inboundOrderNo.toUpperCase().includes(relabelInventorySearch.trim().toUpperCase()) || item.inboundCode.toUpperCase().includes(relabelInventorySearch.trim().toUpperCase()))" :key="option.value" :label="relabelOptionLabel(option)" :value="option.value" :disabled="option.availablePackages <= 0" /></el-select></label>
<label v-if="props.service === 'transfer'"><span>库存转运来源(可选)</span><el-select v-model="selectedTransferInventory" filterable clearable :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可用库存" placeholder="" @change="selectTransferInventory"><el-option v-for="option in transferInventoryOptions" :key="option.value" :label="`${option.label}|可用 ${option.packages} 箱`" :value="option.value" /></el-select></label>
@@ -1825,7 +1853,8 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<div v-if="!inventoryTransfer && (props.service !== 'disposal' || !discardSelectedCandidate?.cartonSelectionSupported)" class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU{{ props.service === 'transfer' ? '' : ' *' }}</th><th>中文品名{{ props.service === 'transfer' ? '' : ' *' }}</th><th v-if="props.service === 'disposal'">可用箱数</th><th v-if="props.service === 'relabel'">FNSKU / 箱唛</th><th v-if="props.service === 'transfer'">SKU 数量(件)*</th><th>{{ props.service === 'disposal' ? '本次废弃箱数' : '预计箱数' }} *</th><th v-if="props.service !== 'disposal'">操作</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td><el-input v-model="row.sku" :readonly="props.service === 'disposal'" :placeholder="props.service === 'relabel' ? '请输入商品 SKU' : ''" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" :placeholder="props.service === 'relabel' ? '请输入中文品名' : ''" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-if="props.service === 'relabel'"><el-input v-model="row.shippingMark" placeholder="请输入FNSKU或箱唛" /></td><td v-if="props.service === 'transfer'"><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="" /></td><td v-if="props.service === 'transfer'"><el-input-number v-model="row.expectedPackageCount" :min="1" :precision="0" controls-position="right" placeholder="" /></td><td v-else><el-input-number v-model="row.quantity" :disabled="props.service === 'disposal'" :min="props.service === 'disposal' ? 0 : 1" :max="row.availableQuantity" controls-position="right" :placeholder="props.service === 'relabel' ? '请输入箱数' : ''" /></td><td v-if="props.service !== 'disposal'"><button type="button" class="warehouse-delete" title="删除" @click="removeCargoRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="cargoRows.length === 0"><td :colspan="props.service === 'disposal' ? 4 : props.service === 'relabel' || props.service === 'transfer' ? 5 : 4"><div class="warehouse-empty">暂无货物明细</div></td></tr></tbody></table></div>
</template>
</div>
<div v-if="props.service === 'transfer' && form.relabel" class="warehouse-card warehouse-form-section warehouse-transfer-relabel">
<TransferPaneDivider v-if="props.service === 'transfer' && form.relabel" v-model="transferPaneShare" class="warehouse-transfer-divider" />
<div id="transfer-relabel-pane" v-if="props.service === 'transfer' && form.relabel" class="warehouse-card warehouse-form-section warehouse-transfer-relabel">
<div class="warehouse-section-heading warehouse-labeling-heading warehouse-step-heading"><div><h3><span class="warehouse-step-number">2</span>换标申请</h3></div></div>
<div class="warehouse-form-grid warehouse-transfer-relabel-fields"><label><span><i>*</i>换标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="" /></label><label><span><i>*</i>换标联系人</span><el-input v-model="form.relabelContact" placeholder="" maxlength="128" /></label><label><span><i>*</i>换标联系人电话</span><el-input v-model="form.relabelPhone" placeholder="" maxlength="64" /></label><label v-if="labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.relabelDeliveryMethod" placeholder=""><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></label><label class="warehouse-span-2"><span><i>*</i>整票换标货物目的 FBA 仓</span><el-select v-model="transferRelabelDefaultDestination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder=""><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label><label><span>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="" /></label><label class="warehouse-span-2"><span>贴标要求</span><el-input v-model="form.requirement" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="" /></label></div>
<div class="warehouse-labeling-mode"><span>换标类型</span><el-radio-group v-model="labelingMode" aria-label="换标类型"><el-radio-button value="carton">只换箱唛</el-radio-button><el-radio-button value="sku">只换 SKU 标签</el-radio-button><el-radio-button value="carton_and_sku">SKU 和箱唛都换</el-radio-button></el-radio-group></div>
@@ -1862,10 +1891,10 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
</header>
<div v-if="detailError" class="warehouse-detail-error" role="alert"><strong>{{ detailError }}</strong><el-button @click="loadDetailPage">重新加载</el-button></div>
<ReviewRejectionNotice v-if="!detailError" :status="props.service === 'labeling' ? selectedRelabelDetail?.application.status : selectedDetail?.status" :reason="props.service === 'labeling' ? selectedRelabelDetail?.application.reviewRemark : selectedDetail?.reviewRemark" />
<div v-if="!detailError" class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'labeling'" class="warehouse-detail-grid"><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>关联单号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数(种)</span><strong>{{ selectedRelabelDetail ? relabelSkuCount(selectedRelabelDetail.items) : '—' }}</strong></div><div><span>总箱数</span><strong>{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else-if="props.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"><div><span>状态</span><el-tag :type="transferStatusTagType(selectedDetail?.status || '')" effect="light">{{ statusLabel(selectedDetail?.status || '') }}</el-tag></div><div><span>目的去向</span><strong>{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 目的仓</span><strong>{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 货件号</span><strong>{{ selectedDetail?.fbaShipmentNo || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'" class="warehouse-overview-wide"><span>私人仓地址</span><strong>{{ selectedDetail?.privateDestinationAddress || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>邮编</span><strong>{{ selectedDetail?.privateDestinationPostalCode || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>私人仓联系人</span><strong>{{ selectedDetail?.privateDestinationContactName || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>联系电话</span><strong>{{ selectedDetail?.privateDestinationContactPhone || '—' }}</strong></div><div><span>海外仓</span><strong>{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}</strong></div><div><span>预报 SKU 种数</span><strong>{{ new Set((selectedDetail?.items || []).map(item => item.skuCode).filter(Boolean)).size }}</strong></div><div><span>预报 SKU 总件数</span><strong>{{ transferForecastQuantity }}</strong></div><div><span>预计箱数</span><strong>{{ selectedDetail?.packageCount ?? '—' }}</strong></div><div><span>实际签收箱数</span><strong>{{ selectedDetail?.cartons?.filter((carton) => carton.status === 'COUNTED').length ?? '—' }}</strong></div><div><span>入仓方式</span><strong>{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}</strong></div><div><span>期望发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div><div><span>是否换标</span><strong>{{ selectedDetail?.needRelabel ? '是' : '否' }}</strong></div><div><span>换标状态</span><el-tag :type="transferRelabelStatusTagType(transferRelabelStatus(selectedDetail || {}).code)" effect="light">{{ transferRelabelStatus(selectedDetail || {}).label }}</el-tag></div><div><span>换标单号</span><strong>{{ selectedDetail?.relabelNo || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedDetail?.createdAt) }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
<div v-if="!detailError" class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'labeling'" class="warehouse-detail-grid"><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>关联单号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数(种)</span><strong>{{ selectedRelabelDetail ? relabelSkuCount(selectedRelabelDetail.items) : '—' }}</strong></div><div><span>箱数</span><strong>{{ selectedRelabelQuantity?.cartonCount ?? '未提供' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else-if="props.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"><div><span>状态</span><el-tag :type="transferStatusTagType(selectedDetail?.status || '')" effect="light">{{ statusLabel(selectedDetail?.status || '') }}</el-tag></div><div><span>目的去向</span><strong>{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 目的仓</span><strong>{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 货件号</span><strong>{{ selectedDetail?.fbaShipmentNo || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'" class="warehouse-overview-wide"><span>私人仓地址</span><strong>{{ selectedDetail?.privateDestinationAddress || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>邮编</span><strong>{{ selectedDetail?.privateDestinationPostalCode || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>私人仓联系人</span><strong>{{ selectedDetail?.privateDestinationContactName || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>联系电话</span><strong>{{ selectedDetail?.privateDestinationContactPhone || '—' }}</strong></div><div><span>海外仓</span><strong>{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}</strong></div><div><span>预报 SKU 种数</span><strong>{{ new Set((selectedDetail?.items || []).map(item => item.skuCode).filter(Boolean)).size }}</strong></div><div><span>预报 SKU 总件数</span><strong>{{ transferForecastQuantity }}</strong></div><div><span>预计箱数</span><strong>{{ selectedDetail?.packageCount ?? '—' }}</strong></div><div><span>实际签收箱数</span><strong>{{ countedTransferCartons().length ?? '—' }}</strong></div><div><span>入仓方式</span><strong>{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}</strong></div><div><span>期望发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div><div><span>是否换标</span><strong>{{ selectedDetail?.needRelabel ? '是' : '否' }}</strong></div><div><span>换标状态</span><el-tag :type="transferRelabelStatusTagType(transferRelabelStatus(selectedDetail || {}).code)" effect="light">{{ transferRelabelStatus(selectedDetail || {}).label }}</el-tag></div><div><span>换标单号</span><strong>{{ selectedDetail?.relabelNo || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedDetail?.createdAt) }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
<TransferPickupProgressPanel v-if="!detailError && props.service === 'transfer' && selectedDetail?.deliveryMethod === 'WAREHOUSE_PICKUP'" :transfer-no="String(selectedDetail.transferNo || '')" />
<div v-if="props.service === 'disposal'" class="warehouse-card warehouse-form-section">
<h3>本次废弃箱子</h3><div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>入仓码</th><th>SKU</th><th>品名</th><th>申请数量</th><th>所选箱唛</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="index"><td>{{ item.inboundCode || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.productName || '—' }}</td><td>{{ item.discardPackages ?? '—' }}</td><td>{{ item.cartonMarks?.join('、') || '全部库存(历史记录无箱唛快照)' }}</td></tr></tbody></table></div>
<h3>本次废弃箱子</h3><div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>入仓码</th><th>SKU</th><th>品名</th><th>申请数量</th><th>所选箱唛</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="index"><td>{{ item.inboundCode || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.productName || '—' }}</td><td>{{ item.discardPackages ?? '—' }} {{ item.quantityUnit || '单位未记录' }}<br>实际:{{ item.actualDiscardPackages == null ? '未记录' : `${item.actualDiscardPackages} ${item.quantityUnit || '单位未记录'}` }}</td><td>{{ item.cartonMarks?.join('、') || '全部库存(历史记录无箱唛快照)' }}</td></tr></tbody></table></div>
<h3>废弃现场照片</h3><div v-if="selectedDetail?.photos?.length" class="warehouse-photo-grid"><div v-for="photo in selectedDetail.photos" :key="photo.fileId" class="warehouse-photo-item"><el-image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" :preview-src-list="discardPhotoPreviewUrls" :initial-index="discardPhotoPreviewUrls.indexOf(discardPhotoUrls[photo.fileId])" fit="cover" preview-teleported /><div v-else-if="discardPhotoLoading[photo.fileId]" class="warehouse-photo-placeholder">照片加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button><small>{{ formatDateTime(photo.submittedAt) }}</small></div></div><div v-else class="warehouse-empty">暂无已提交的废弃现场照片</div>
</div>
<div v-if="props.service === 'transfer' && selectedDetail?.status !== 'DRAFT'" class="warehouse-card warehouse-form-section">
@@ -1939,14 +1968,14 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<div class="warehouse-section-heading"><h3>贴标明细资料</h3><input ref="relabelFileInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="uploadSelectedRelabelFile"></div>
<div class="warehouse-hint">需要补充或替换资料时,可在对应明细行上传 SKU 或箱唛文件。</div>
<div class="warehouse-table-scroll"><table class="warehouse-relabel-detail-table"><thead><tr><th>换标模式</th><th>仓库唛头</th><th>SKU</th><th>明细文件</th><th>操作</th></tr></thead><tbody>
<tr v-for="item in selectedRelabelDetail?.items || []" :key="item.id"><td>{{ item.operationType === 'CARTON_AND_SKU' ? '箱唛 + SKU 换标' : item.operationType === 'SKU_LABEL' ? 'SKU 换标' : '箱唛换标' }}</td><td>{{ item.cartonMark || '—' }}</td><td>{{ relabelSkuCodes(item).join('、') || '—' }}</td><td>{{ item.relabelFileName || '未上传' }}</td><td><el-button size="small" @click="chooseRelabelFile('ITEM', String(item.id))"><Upload :size="14" />上传明细文件</el-button></td></tr>
<tr v-for="item in selectedRelabelDetail?.items || []" :key="item.id"><td>{{ item.operationType === 'CARTON_AND_SKU' ? '箱唛 + SKU 换标' : ['SKU_LABEL', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType || '') ? 'SKU 换标' : '箱唛换标' }}</td><td>{{ item.cartonMark || '—' }}</td><td>{{ relabelSkuCodes(item).join('、') || '—' }}</td><td>{{ item.relabelFileName || '未上传' }}</td><td><el-button size="small" @click="chooseRelabelFile('ITEM', String(item.id))"><Upload :size="14" />上传明细文件</el-button></td></tr>
<tr v-if="!selectedRelabelDetail?.items?.length"><td colspan="5"><div class="warehouse-empty">暂无贴标明细</div></td></tr>
</tbody></table></div>
</div>
<div v-if="props.service === 'labeling'" class="warehouse-card warehouse-form-section">
<h3>换标任务明细</h3>
<div class="warehouse-table-scroll"><table class="warehouse-relabel-detail-table"><thead><tr><th>换标模式</th><th>我方箱唛</th><th>新 FBA 箱唛</th><th>FBA 目的仓</th><th>任务箱数</th><th>商品 SKU</th><th>SKU 换标明细</th><th>FBA 货件编号</th><th>贴标要求</th><th>备注</th></tr></thead><tbody>
<tr v-for="item in selectedRelabelDetail?.items || []" :key="item.id"><td>{{ item.operationType === 'CARTON_AND_SKU' ? '箱唛 + SKU 换标' : item.operationType === 'SKU_LABEL' ? 'SKU 换标' : '箱唛换标' }}</td><td><strong>{{ item.cartonMark || '—' }}</strong></td><td><strong>{{ item.fbaCartonMark || '—' }}</strong></td><td>{{ warehouseDisplay(item.destinationFbaWarehouseCode) }}</td><td>{{ item.totalPackageCount }} 箱</td><td>{{ relabelSkuCodes(item).join('、') || '—' }}</td><td><template v-if="item.skuItems?.length"><div v-for="skuItem in item.skuItems" :key="`${skuItem.skuCode}-${skuItem.newSkuCode}-${skuItem.quantity}`">{{ skuItem.skuCode }} → {{ skuItem.newSkuCode }} × {{ skuItem.quantity }} 件</div></template><span v-else>{{ item.newSkuCode || '—' }}</span></td><td>{{ item.shipmentNo || '—' }}</td><td>{{ item.labelRequirement || '—' }}</td><td>{{ item.remark || '—' }}</td></tr>
<h3>换标任务明细</h3><RelabelQuantityPanel :relabel-no="detailNumber" />
<div class="warehouse-table-scroll"><table class="warehouse-relabel-detail-table"><thead><tr><th>换标模式</th><th>我方箱唛</th><th>新 FBA 箱唛</th><th>FBA 目的仓</th><th>任务数量/单位</th><th>商品 SKU</th><th>SKU 换标明细</th><th>FBA 货件编号</th><th>贴标要求</th><th>备注</th></tr></thead><tbody>
<tr v-for="item in selectedRelabelDetail?.items || []" :key="item.id"><td>{{ item.operationType === 'CARTON_AND_SKU' ? '箱唛 + SKU 换标' : ['SKU_LABEL', 'RETURN_BULK_SKU_LABEL'].includes(item.operationType || '') ? 'SKU 换标' : '箱唛换标' }}</td><td><strong>{{ item.cartonMark || '—' }}</strong></td><td><strong>{{ item.fbaCartonMark || '—' }}</strong></td><td>{{ warehouseDisplay(item.destinationFbaWarehouseCode) }}</td><td>{{ item.totalPackageCount ?? '未提供' }} {{ String(item.operationType) === 'RETURN_BULK_SKU_LABEL' ? '件' : '箱任务' }}</td><td>{{ relabelSkuCodes(item).join('、') || '—' }}</td><td><template v-if="item.skuItems?.length"><div v-for="skuItem in item.skuItems" :key="`${skuItem.skuCode}-${skuItem.newSkuCode}-${skuItem.quantity}`">{{ skuItem.skuCode }} → {{ skuItem.newSkuCode }} × {{ skuItem.quantity }} 件</div></template><span v-else>{{ item.newSkuCode || '—' }}</span></td><td>{{ item.shipmentNo || '—' }}</td><td>{{ item.labelRequirement || '—' }}</td><td>{{ item.remark || '—' }}</td></tr>
<tr v-if="!selectedRelabelDetail?.items?.length"><td colspan="10"><div class="warehouse-empty">暂无换标任务明细</div></td></tr>
</tbody></table></div>
</div>
@@ -2099,9 +2128,9 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
.warehouse-measurement-photo-item{width:100%;height:120px}
.warehouse-template-actions{flex-wrap:wrap;justify-content:flex-end}.warehouse-import-preview{margin:0 0 16px;padding:14px;border:1px solid #cfe0f5;border-radius:5px;background:#f8fbff}.warehouse-import-preview .warehouse-section-heading{margin-bottom:10px}.warehouse-import-preview .warehouse-section-heading>div{display:flex;align-items:center;gap:10px}.warehouse-import-preview p{margin:0;color:#7d8797;font-size:12px}.warehouse-binding-preview-table{min-width:980px;background:#fff}
.warehouse-transfer-relabel .warehouse-labeling-heading>div:first-child{min-width:0}.warehouse-transfer-relabel .warehouse-labeling-heading h3{margin-bottom:6px}.warehouse-transfer-relabel .warehouse-labeling-heading p{margin:0 0 16px;color:#7d8797;font-size:12px}.warehouse-transfer-relabel-fields{margin-bottom:18px}.warehouse-transfer-file-groups{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);align-items:stretch;gap:14px;margin-bottom:14px}.warehouse-transfer-total-files,.warehouse-transfer-template-group{height:100%;margin:0}.warehouse-transfer-mark-table{min-width:1420px}.warehouse-transfer-mark-table td{vertical-align:top}.warehouse-transfer-mark-table :deep(.el-textarea){min-width:220px}.warehouse-transfer-mark-table :deep(.el-input-number){width:150px}.warehouse-count-error{color:#d64545}
.warehouse-transfer-form{display:grid;grid-template-columns:minmax(0,1fr);gap:12px}.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);align-items:start}.warehouse-transfer-form--relabel .warehouse-transfer-main-card{grid-column:1;grid-row:1}.warehouse-transfer-form--relabel .warehouse-transfer-cargo-card{grid-column:1;grid-row:2}.warehouse-transfer-form--relabel .warehouse-transfer-relabel{grid-column:2;grid-row:1 / span 2;margin-bottom:0}.warehouse-transfer-form--relabel .warehouse-transfer-submit-bar{grid-column:1 / -1}.warehouse-transfer-form :deep(.el-input__wrapper),.warehouse-transfer-form :deep(.el-textarea__inner),.warehouse-transfer-form :deep(.el-select__wrapper),.warehouse-transfer-form :deep(.el-date-editor.el-input__wrapper){min-height:32px}.warehouse-transfer-form .warehouse-form-section{padding:16px}.warehouse-transfer-form .warehouse-form-section h3{margin-bottom:12px;padding-bottom:9px;font-size:14px}.warehouse-transfer-form .warehouse-form-grid{gap:12px 16px}.warehouse-transfer-form .warehouse-choice-field{min-height:34px;padding:8px 10px;grid-template-columns:118px minmax(0,1fr);gap:10px}.warehouse-transfer-form .warehouse-choice-field>span{font-size:12px}.warehouse-transfer-form .warehouse-receiving-card{gap:12px;padding:10px 12px}.warehouse-transfer-form .warehouse-section-heading{margin-bottom:8px}.warehouse-transfer-form .warehouse-edit-table{font-size:11px}.warehouse-transfer-form .warehouse-edit-table th{height:36px;padding:0 9px}.warehouse-transfer-form .warehouse-edit-table td{height:42px;padding:6px 7px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-textarea){min-width:150px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-input-number){width:112px}.warehouse-transfer-form .warehouse-hint{margin-top:5px;font-size:11px}.warehouse-transfer-form .warehouse-labeling-mode{gap:9px;margin-bottom:10px;padding:9px 10px}.warehouse-transfer-form .warehouse-labeling-mode>span{font-size:12px}.warehouse-transfer-form .warehouse-labeling-mode :deep(.el-radio-button__inner){padding:6px 9px}.warehouse-transfer-form .warehouse-template-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:5px;padding-bottom:8px}.warehouse-transfer-form .warehouse-template-actions :deep(.el-button){padding:6px 8px;font-size:11px}.warehouse-transfer-form .warehouse-submit-bar{padding:4px 0 18px}.warehouse-transfer-form .warehouse-submit-bar :deep(.el-button){min-height:32px;padding:7px 14px}.warehouse-transfer-form .warehouse-transfer-relabel{overflow:hidden}.warehouse-transfer-form .warehouse-transfer-relabel-fields{margin-bottom:10px}.warehouse-step-heading{align-items:flex-start}.warehouse-step-heading h3{display:flex;align-items:center;gap:9px}.warehouse-step-number{display:inline-grid;flex:0 0 auto;width:24px;height:24px;border-radius:50%;background:#3478c8;color:#fff;font-size:12px;place-items:center}.warehouse-heading-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.warehouse-transfer-template-group{margin:0 0 14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#fbfcfe}.warehouse-binding-heading{margin-top:14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#f8faff}.warehouse-binding-heading strong{display:flex;align-items:center;gap:8px;color:#34445c;font-size:13px}
.warehouse-transfer-form{display:grid;grid-template-columns:minmax(0,1fr);gap:12px}.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,var(--transfer-left,43fr)) 16px minmax(0,var(--transfer-right,57fr));column-gap:0;align-items:start}.warehouse-transfer-form--relabel .warehouse-transfer-main-card{grid-column:1;grid-row:1}.warehouse-transfer-form--relabel .warehouse-transfer-cargo-card{grid-column:1;grid-row:2}.warehouse-transfer-form--relabel .warehouse-transfer-relabel{grid-column:3;grid-row:1 / span 2;margin-bottom:0}.warehouse-transfer-form--relabel .warehouse-transfer-submit-bar{grid-column:1 / -1}.warehouse-transfer-form :deep(.el-input__wrapper),.warehouse-transfer-form :deep(.el-textarea__inner),.warehouse-transfer-form :deep(.el-select__wrapper),.warehouse-transfer-form :deep(.el-date-editor.el-input__wrapper){min-height:32px}.warehouse-transfer-form .warehouse-form-section{padding:16px}.warehouse-transfer-form .warehouse-form-section h3{margin-bottom:12px;padding-bottom:9px;font-size:14px}.warehouse-transfer-form .warehouse-form-grid{gap:12px 16px}.warehouse-transfer-form .warehouse-choice-field{min-height:34px;padding:8px 10px;grid-template-columns:118px minmax(0,1fr);gap:10px}.warehouse-transfer-form .warehouse-choice-field>span{font-size:12px}.warehouse-transfer-form .warehouse-receiving-card{gap:12px;padding:10px 12px}.warehouse-transfer-form .warehouse-section-heading{margin-bottom:8px}.warehouse-transfer-form .warehouse-edit-table{font-size:11px}.warehouse-transfer-form .warehouse-edit-table th{height:36px;padding:0 9px}.warehouse-transfer-form .warehouse-edit-table td{height:42px;padding:6px 7px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-textarea){min-width:150px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-input-number){width:112px}.warehouse-transfer-form .warehouse-hint{margin-top:5px;font-size:11px}.warehouse-transfer-form .warehouse-labeling-mode{gap:9px;margin-bottom:10px;padding:9px 10px}.warehouse-transfer-form .warehouse-labeling-mode>span{font-size:12px}.warehouse-transfer-form .warehouse-labeling-mode :deep(.el-radio-button__inner){padding:6px 9px}.warehouse-transfer-form .warehouse-template-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:5px;padding-bottom:8px}.warehouse-transfer-form .warehouse-template-actions :deep(.el-button){padding:6px 8px;font-size:11px}.warehouse-transfer-form .warehouse-submit-bar{padding:4px 0 18px}.warehouse-transfer-form .warehouse-submit-bar :deep(.el-button){min-height:32px;padding:7px 14px}.warehouse-transfer-form .warehouse-transfer-relabel{overflow:hidden}.warehouse-transfer-form .warehouse-transfer-relabel-fields{margin-bottom:10px}.warehouse-step-heading{align-items:flex-start}.warehouse-step-heading h3{display:flex;align-items:center;gap:9px}.warehouse-step-number{display:inline-grid;flex:0 0 auto;width:24px;height:24px;border-radius:50%;background:#3478c8;color:#fff;font-size:12px;place-items:center}.warehouse-heading-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.warehouse-transfer-template-group{margin:0 0 14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#fbfcfe}.warehouse-binding-heading{margin-top:14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#f8faff}.warehouse-binding-heading strong{display:flex;align-items:center;gap:8px;color:#34445c;font-size:13px}
.warehouse-transfer-form .warehouse-choice-field--relabel{grid-template-columns:150px auto minmax(240px,1fr)}.warehouse-choice-field--relabel :deep(.el-radio-group){display:flex;flex-wrap:nowrap}.warehouse-choice-field--relabel>small{min-width:0;margin:0;line-height:1.5}
@media(max-width:1250px){.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.warehouse-transfer-form .warehouse-template-actions{justify-content:flex-start}.warehouse-transfer-form .warehouse-labeling-heading{align-items:flex-start;flex-direction:column}}
@media(max-width:1250px){.warehouse-transfer-form .warehouse-template-actions{justify-content:flex-start}.warehouse-transfer-form .warehouse-labeling-heading{align-items:flex-start;flex-direction:column}}
@media(max-width:1180px){.warehouse-transfer-file-groups{grid-template-columns:minmax(0,1fr)}}
.warehouse-transfer-form .pickup-address-field{grid-column:span 1}
.warehouse-transfer-main-card .warehouse-form-grid{grid-template-columns:repeat(2,minmax(280px,640px))}.warehouse-transfer-cargo-card .warehouse-section-heading,.warehouse-transfer-cargo-card .warehouse-table-scroll{width:100%;max-width:1296px}
@@ -2119,6 +2148,11 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
.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; }
.warehouse-service-page--disposal-form .warehouse-form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
.warehouse-service-page--disposal-form .warehouse-disposal-field,
.warehouse-service-page--disposal-form .warehouse-order-info-heading,
.warehouse-service-page--disposal-form .warehouse-section-heading,
.warehouse-service-page--disposal-form .warehouse-table-scroll { width:100%; max-width:none; }
@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); }
@@ -2126,6 +2160,13 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
.warehouse-transfer-form .warehouse-choice-field--relabel { grid-template-columns: 1fr; align-items: start; }
}
.warehouse-transfer-form .warehouse-form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
.warehouse-transfer-form .warehouse-form-section { min-width:0; }
.warehouse-transfer-form .warehouse-section-heading,
.warehouse-transfer-form .warehouse-table-scroll,
.warehouse-transfer-form .warehouse-order-info-heading,
.warehouse-transfer-form .warehouse-file-groups--compact { width:100%; max-width:none; }
.warehouse-transfer-form--relabel .warehouse-transfer-divider { grid-column:2; grid-row:1 / span 2; }
</style>
<style scoped>
@@ -19,3 +19,11 @@ test('mixed carton detail and pallet summaries preserve every SKU without duplic
assert.equal(display.receivedItemQuantity('2'), 7)
assert.equal(display.receivedItemCartons('2').length, 1)
})
test('discard and shipment lifecycle keeps historical receipt quantities', () => {
const detail = { value: { items: [{ id: '1', skuCode: 'A' }], cartons: ['COUNTED', 'DISCARD_PENDING', 'DISCARDED', 'SHIPPED'].map((status, i) => ({ status, transferItemId: '1', transferPalletId: '9', quantity: i + 1 })) } }
const display = load(detail)
assert.equal(display.receivedItemCartons('1').length, 4)
assert.equal(display.receivedItemQuantity('1'), 10)
assert.equal(display.palletQuantity({ id: '9' }), 10)
})
@@ -371,13 +371,13 @@ test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强
assert.match(view, /v-model="row.expectedPackageCount"/)
assert.match(view, /Number.isSafeInteger\(row.quantity\)/)
assert.match(view, /Number.isSafeInteger\(row.expectedPackageCount\)/)
assert.match(view, /carton.status === 'COUNTED'/)
assert.match(view, /\['COUNTED', 'DISCARD_PENDING', 'DISCARDED', 'SHIPPED'\]\.includes\(carton.status\)/)
assert.match(view, /实际签收箱数/)
})
test('转运客户详情保留预报并显示实际签收箱内明细和托盘汇总', () => {
for (const label of ['预报数量(件)', '预报 SKU 种数', '签收箱号明细', '查看箱号明细', 'SKU 汇总', '毛重(kg)', '体积(m³)']) assert.ok(view.includes(label), label)
assert.match(view, /carton.status === 'COUNTED' && pallet.id != null/)
assert.match(view, /countedTransferCartons\(\)\.filter\(\(carton\) => pallet.id != null/)
assert.match(view, /String\(carton.transferPalletId\) === String\(pallet.id\)/)
assert.match(view, /carton.transferItemId != null && String\(item.id\) === String\(carton.transferItemId\)/)
assert.match(view, /@click="showTransferCartons = true"/)
+7 -19
View File
@@ -52,7 +52,7 @@ test('keeps standalone business pages at the client sidebar root', () => {
{ title: '首页', children: undefined },
{
title: '订单中心',
children: ['头程订单', '入库订单', '派送订单', '海外采购', '美国本土卡', '美本土散板'],
children: ['头程订单', '海外采购', '美国本土卡', '美本土散板'],
},
{ title: '报关资料', children: undefined },
{ title: '船期价格', children: undefined },
@@ -88,8 +88,6 @@ test('flattens visible leaves with unique paths', () => {
assert.deepEqual(paths, [
'/',
'/orders/first-leg',
'/orders/inbound',
'/orders/delivery',
'/cross-border/procurement',
'/cross-border/domestic-trucking',
'/cross-border/pallet-shipping',
@@ -121,22 +119,10 @@ test('filters inaccessible leaves and empty navigation groups', () => {
assert.equal(getFirstAccessiblePath([]), '/forbidden')
})
test('exposes active inbound and delivery queries only with their existing read permissions', () => {
const deliveryOnly = ['merchant:warehouse:delivery:list']
const inboundOnly = ['merchant:warehouse:inbound:list']
assert.deepEqual(getNavigationLeaves(filterNavigationByPermissions(clientNavigation, deliveryOnly))
.map(({ title, path }) => ({ title, path })), [{ title: '派送订单', path: '/orders/delivery' }])
assert.deepEqual(getNavigationLeaves(filterNavigationByPermissions(clientNavigation, inboundOnly))
.map(({ title, path }) => ({ title, path })), [{ title: '入库订单', path: '/orders/inbound' }])
assert.equal(getFirstAccessiblePath(deliveryOnly), '/orders/delivery')
assert.equal(getFirstAccessiblePath(inboundOnly), '/orders/inbound')
assert.deepEqual(getNavigationLeaves(filterNavigationByPermissions(clientNavigation,
['merchant:warehouse:delivery:create'])), [])
assert.deepEqual(getRouteBreadcrumbs('/orders/delivery/DL-1', '派送单详情'), [
{ title: '首页', path: '/' }, { title: '订单中心' },
{ title: '派送订单', path: '/orders/delivery' }, { title: '派送单详情' },
])
assert.deepEqual(getNavigationParentKeys(clientNavigation, '/orders/delivery'), ['order-center'])
test('omits inbound and delivery entries from the customer sidebar', () => {
const permissions = ['merchant:warehouse:delivery:list', 'merchant:warehouse:inbound:list']
assert.deepEqual(getNavigationLeaves(filterNavigationByPermissions(clientNavigation, permissions)), [])
assert.equal(getFirstAccessiblePath(permissions), '/forbidden')
})
test('excludes entries marked for manual hiding without affecting other visible items', () => {
@@ -383,6 +369,8 @@ test('exposes customer business lists and keeps utility routes outside navigatio
]
const hiddenActivePaths = new Set([
'orders/inbound',
'orders/delivery',
'first-leg/quick-order',
'overseas-warehouse/return-inventory',
'overseas-warehouse/return-cartons',
-2
View File
@@ -52,8 +52,6 @@ export const clientNavigation: ClientNavigationNode[] = [
icon: ClipboardList,
children: [
requireLeaf('first-leg-orders', '头程订单'),
requireLeaf('orders-inbound', '入库订单'),
requireLeaf('orders-delivery', '派送订单'),
requireLeaf('cross-border-procurement'),
requireLeaf('cross-border-domestic-trucking'),
requireLeaf('cross-border-pallet-shipping'),