统一海外仓新增页样式与字段校验

This commit is contained in:
2026-10-29 16:14:44 +08:00
parent 3abcdcbd89
commit bda33648f3
7 changed files with 599 additions and 41 deletions
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import ts from 'typescript' import ts from 'typescript'
import { createRequire } from 'node:module'
const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8') const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8') const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8')
@@ -118,7 +119,7 @@ test('新建退货预报使用独立路由页面,编辑仍保留加宽弹框',
test('新建预报铺满工作区,基础字段等宽对齐且数量输入保持紧凑', () => { test('新建预报铺满工作区,基础字段等宽对齐且数量输入保持紧凑', () => {
assert.match(view, /return-create-surface \{ width:100%; min-width:0;[^}]*margin:0;/) 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, /<el-form ref="createFormRef"[^>]*:model="form"[^>]*:rules="createFormRules"[^>]*label-position="top"/)
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, /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, /:deep\(\.el-table \.el-input-number\) \{ width:120px;/)
assert.match(view, /<el-table class="return-forecast-table"[^>]*>/) assert.match(view, /<el-table class="return-forecast-table"[^>]*>/)
@@ -314,3 +315,37 @@ test('贴标入口保留完成入库门禁与退货来源,箱号只作为目
{ name: 'overseas-labeling-create', query: { returnNo: 'RT-TEST', warehouseId: 'WH-TEST', cartonMark: 'RC-TEST' } }, { name: 'overseas-labeling-create', query: { returnNo: 'RT-TEST', warehouseId: 'WH-TEST', cartonMark: 'RC-TEST' } },
]) ])
}) })
test('新增退货校验海外仓与联系人,空白联系人无效,客户参考号可选', async () => {
const { default: Schema } = createRequire(import.meta.resolve('element-plus'))('async-validator')
const source = view.slice(view.indexOf('const createFormRules:'), view.indexOf('const selectedOperationWarehouse ='))
const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const rules = new Function(code + '\nreturn createFormRules')()
const validator = new Schema(rules)
await assert.rejects(validator.validate({ overseasWarehouseId: '', warehouseContactName: ' ', warehouseContactPhone: '' }), error => {
assert.deepEqual(Object.keys(error.fields).sort(), ['overseasWarehouseId', 'warehouseContactName', 'warehouseContactPhone'])
return true
})
await validator.validate({ overseasWarehouseId: 'WH', warehouseContactName: 'Contact', warehouseContactPhone: '123456', customerReferenceNo: '' })
})
test('新增校验失败或正在提交时不继续创建,编辑保持既有校验路径', async () => {
const start = view.indexOf('async function submit()')
const end = view.indexOf(' const items =', start)
const code = view.slice(start, end) + '\nreturn "continue"\n}'
const creating = { value: false }, props = { createPage: true }
let validations = 0
const formRef = { value: { validate: async () => { validations++; throw new Error('required') } } }
const submit = new Function('creating', 'props', 'createFormRef', code + '\nreturn submit')(creating, props, formRef)
assert.equal(await submit(), undefined)
assert.equal(validations, 1)
creating.value = true
assert.equal(await submit(), undefined)
assert.equal(validations, 1)
creating.value = false
props.createPage = false
assert.equal(await submit(), 'continue')
props.createPage = true
formRef.value.validate = async () => true
assert.equal(await submit(), 'continue')
})
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowLeft, ChevronDown, Plus } from '@lucide/vue' import { ArrowLeft, ChevronDown, Plus } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
@@ -97,6 +97,12 @@ interface SkuDraft { id: number; skuCode: string; productNameZh: string; expecte
let skuSequence = 0 let skuSequence = 0
function emptySku(): SkuDraft { return { id: ++skuSequence, skuCode: '', productNameZh: '', expectedQuantity: 1 } } function emptySku(): SkuDraft { return { id: ++skuSequence, skuCode: '', productNameZh: '', expectedQuantity: 1 } }
const form = reactive({ overseasWarehouseId: '', warehouseContactName: '', warehouseContactPhone: '', customerReferenceNo: '', remark: '', needRelabel: false, items: [emptySku()] }) const form = reactive({ overseasWarehouseId: '', warehouseContactName: '', warehouseContactPhone: '', customerReferenceNo: '', remark: '', needRelabel: false, items: [emptySku()] })
const createFormRef = ref<FormInstance>()
const createFormRules: FormRules = {
overseasWarehouseId: [{ required: true, message: '请选择海外仓', trigger: 'change' }],
warehouseContactName: [{ required: true, whitespace: true, message: '请输入联系人', trigger: 'blur' }],
warehouseContactPhone: [{ required: true, whitespace: true, message: '请输入联系电话', trigger: 'blur' }],
}
const selectedOperationWarehouse = computed(() => operationWarehouses.value.find(item => String(item.id) === form.overseasWarehouseId)) const selectedOperationWarehouse = computed(() => operationWarehouses.value.find(item => String(item.id) === form.overseasWarehouseId))
function normalizedSku(value: string) { return value.trim().toUpperCase() } function normalizedSku(value: string) { return value.trim().toUpperCase() }
const duplicateSkuIds = computed(() => { const duplicateSkuIds = computed(() => {
@@ -107,7 +113,7 @@ const duplicateSkuIds = computed(() => {
} }
return new Set([...grouped.values()].filter(ids => ids.length > 1).flat()) return new Set([...grouped.values()].filter(ids => ids.length > 1).flat())
}) })
function resetForm() { Object.assign(form, { overseasWarehouseId: '', warehouseContactName: '', warehouseContactPhone: '', customerReferenceNo: '', remark: '', needRelabel: false, items: [emptySku()] }) } function resetForm() { createFormRef.value?.clearValidate(); Object.assign(form, { overseasWarehouseId: '', warehouseContactName: '', warehouseContactPhone: '', customerReferenceNo: '', remark: '', needRelabel: false, items: [emptySku()] }) }
function warehouseLabel(warehouse: MerchantWarehouse) { return `${warehouse.warehouseName} · ${warehouse.warehouseCode}` } function warehouseLabel(warehouse: MerchantWarehouse) { return `${warehouse.warehouseName} · ${warehouse.warehouseCode}` }
function operationWarehouseLabel(warehouseId: string) { function operationWarehouseLabel(warehouseId: string) {
const warehouse = operationWarehouses.value.find(item => String(item.id) === String(warehouseId)) const warehouse = operationWarehouses.value.find(item => String(item.id) === String(warehouseId))
@@ -117,6 +123,7 @@ function selectOperationWarehouse() {
const warehouse = operationWarehouses.value.find(item => String(item.id) === form.overseasWarehouseId) const warehouse = operationWarehouses.value.find(item => String(item.id) === form.overseasWarehouseId)
form.warehouseContactName = warehouse?.contactName || '' form.warehouseContactName = warehouse?.contactName || ''
form.warehouseContactPhone = warehouse?.contactPhone || '' form.warehouseContactPhone = warehouse?.contactPhone || ''
if (props.createPage) createFormRef.value?.clearValidate(['warehouseContactName', 'warehouseContactPhone'])
} }
async function loadOperationWarehouses() { async function loadOperationWarehouses() {
operationWarehousesLoading.value = true operationWarehousesLoading.value = true
@@ -251,6 +258,8 @@ function openRelabel(order: Pick<ReturnOrderSummary, 'returnNo' | 'overseasWareh
void router.push({ name: 'overseas-labeling-create', query: { returnNo: order.returnNo, warehouseId: String(order.overseasWarehouseId), ...(cartonMark ? { cartonMark } : {}) } }) void router.push({ name: 'overseas-labeling-create', query: { returnNo: order.returnNo, warehouseId: String(order.overseasWarehouseId), ...(cartonMark ? { cartonMark } : {}) } })
} }
async function submit() { async function submit() {
if (creating.value) return
if (props.createPage && !await createFormRef.value?.validate().catch(() => false)) return
const items = form.items.map(({ skuCode, productNameZh, expectedQuantity }) => ({ skuCode: skuCode.trim(), productNameZh: productNameZh.trim(), expectedQuantity })) const items = form.items.map(({ skuCode, productNameZh, expectedQuantity }) => ({ skuCode: skuCode.trim(), productNameZh: productNameZh.trim(), expectedQuantity }))
if (!form.overseasWarehouseId || items.some(item => !item.skuCode || item.expectedQuantity <= 0)) { ElMessage.warning('请填写操作仓、SKU 和预报数量'); return } if (!form.overseasWarehouseId || items.some(item => !item.skuCode || item.expectedQuantity <= 0)) { ElMessage.warning('请填写操作仓、SKU 和预报数量'); return }
if (!form.warehouseContactName.trim() || !form.warehouseContactPhone.trim()) { ElMessage.warning('请填写海外仓联系人和联系电话'); return } if (!form.warehouseContactName.trim() || !form.warehouseContactPhone.trim()) { ElMessage.warning('请填写海外仓联系人和联系电话'); return }
@@ -314,30 +323,38 @@ onBeforeUnmount(clearPhotoUrls)
</script> </script>
<template> <template>
<section class="return-orders-page"> <section class="return-orders-page" :class="{ 'return-orders-create client-ui': props.createPage }">
<template v-if="props.createPage"> <template v-if="props.createPage">
<article class="return-create-surface"> <article class="return-create-surface">
<header class="return-create-header"> <header class="return-create-header">
<div><h2>新建退货预报</h2></div> <div><h2>新建退货预报</h2></div>
<el-button @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button> <el-button @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button>
</header> </header>
<div class="return-create-body"> <el-form ref="createFormRef" class="return-create-body" :model="form" :rules="createFormRules" label-position="top" :disabled="creating" scroll-to-error @submit.prevent="submit">
<el-form label-width="130px" class="return-create-form"> <section class="return-create-section">
<el-form-item label="海外仓" required><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item> <h3>基础信息</h3>
<el-form-item label="客户参考号"><el-input v-model="form.customerReferenceNo" placeholder="" /></el-form-item> <div class="return-create-form">
<el-form-item label="海外仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" placeholder="" /></el-form-item> <el-form-item label="海外仓" prop="overseasWarehouseId"><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" popper-class="return-create-popper" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
<el-form-item label="海外仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" placeholder="" /></el-form-item> <el-form-item label="海外仓联系人" prop="warehouseContactName"><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" placeholder="请输入海外仓联系人" /></el-form-item>
<el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="" /></el-form-item> <el-form-item label="海外仓联系电话" prop="warehouseContactPhone"><el-input v-model="form.warehouseContactPhone" type="tel" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" placeholder="请输入海外仓联系电话" /></el-form-item>
</el-form> <el-form-item label="客户参考号"><el-input v-model="form.customerReferenceNo" placeholder="请输入客户参考号(选填)" /></el-form-item>
<div class="items-title"><span>SKU 预报明细</span><el-button text type="primary" @click="addItem"><Plus :size="15" />添加 SKU</el-button></div> <el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" :rows="3" resize="vertical" placeholder="请输入退货要求或其他补充信息(选填)" /></el-form-item>
<div class="return-relabel-option"><span>是否换标</span><el-radio-group v-model="form.needRelabel" aria-label="是否换标"><el-radio-button :value="false">不换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group></div>␍ <el-table class="return-forecast-table" :data="form.items" row-key="id" size="small"> </div>
<el-table-column label="操作" width="80" fixed="left"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column> </section>
<el-table-column label="SKU" width="300"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column> <section class="return-create-section">
<el-table-column label="中文品名" min-width="320"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="" /></template></el-table-column> <div class="items-title"><h3>SKU 预报明细</h3><el-button type="primary" @click="addItem"><Plus :size="15" />添加 SKU</el-button></div>
<el-table-column label="预报数量" width="150"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="" /></template></el-table-column> <div class="return-create-card">
</el-table> <div class="return-relabel-option"><span>是否换标</span><el-radio-group v-model="form.needRelabel" aria-label="是否换标"><el-radio-button :value="false">不换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group></div>
</div> <el-table class="return-forecast-table" :data="form.items" row-key="id" size="small">
<footer class="return-create-actions"><el-button @click="cancelCreate">取消</el-button><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></footer> <el-table-column label="操作" width="80" fixed="left"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
<el-table-column label="SKU" width="300"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="请输入 SKU" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
<el-table-column label="中文品名" min-width="320"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="请输入中文品名(选填)" /></template></el-table-column>
<el-table-column label="预报数量" width="150"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="请输入数量" /></template></el-table-column>
</el-table>
</div>
</section>
</el-form>
<footer class="return-create-actions"><el-button :disabled="creating" @click="cancelCreate">返回列表</el-button><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></footer>
</article> </article>
</template> </template>
@@ -600,3 +617,50 @@ onBeforeUnmount(clearPhotoUrls)
@media (max-width: 900px) { .return-photo-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } } @media (max-width: 900px) { .return-photo-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } }
@media (max-width: 720px) { .return-detail-header { grid-template-columns:1fr auto; } .return-back { grid-column:1/-1; } .return-pagination { padding:12px; } .return-photo-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } } @media (max-width: 720px) { .return-detail-header { grid-template-columns:1fr auto; } .return-back { grid-column:1/-1; } .return-pagination { padding:12px; } .return-photo-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
</style> </style>
<style scoped>
.return-orders-create { --el-color-primary: #2f7bff; --el-text-color-regular: #1f2937; --el-text-color-placeholder: #b0b7c3; width: 100%; min-width: 0; background: #f4f7fc; color: #1f2937; font: 14px/1.5 "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif; }
.return-orders-create .return-create-surface { min-height: 0; overflow: visible; border: 0; border-radius: 0; background: transparent; }
.return-orders-create .return-create-header { min-height: 44px; margin-bottom: 12px; padding: 0; border: 0; }
.return-orders-create .return-create-header h2 { color: #1f2937; font-size: 26px; font-weight: 700; }
.return-orders-create .return-create-body { padding: 0; }
.return-orders-create .return-create-section { margin-bottom: 12px; }
.return-orders-create .return-create-section h3 { display: flex; align-items: center; gap: 10px; margin: 0 0 10px; color: #1f2937; font-size: 18px; font-weight: 700; }
.return-orders-create .return-create-section h3::before { content: ''; width: 9px; height: 9px; flex: 0 0 9px; border-radius: 50%; background: #2f7bff; }
.return-orders-create .return-create-section:nth-of-type(2) h3::before { background: #0ea5a4; }
.return-orders-create .return-create-form,
.return-orders-create .return-create-card { min-width: 0; padding: 16px; box-sizing: border-box; border: 1px solid #e3e8f0; border-radius: 10px; background: #fff; }
.return-orders-create .return-create-form { --el-component-size: 44px; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 20px; }
.return-orders-create .return-create-form :deep(.el-form-item) { min-width: 0; margin: 0; }
.return-orders-create .return-create-form :deep(.el-form-item__label) { height: auto; margin: 0 0 6px; padding: 0; color: #374151; font-size: 14px; font-weight: 600; line-height: 20px; }
.return-orders-create .return-create-form :deep(.el-form-item.is-required .el-form-item__label::before) { color: #e5484d; }
.return-orders-create .return-create-form :deep(.el-form-item__content) { min-width: 0; line-height: 20px; }
.return-orders-create .return-create-form :deep(.el-form-item__error) { position: absolute; top: auto; bottom: calc(100% + 6px); right: 0; left: auto; width: auto; max-width: calc(100% - 140px); padding: 0; font-size: 12px; line-height: 20px; text-align: right; }
.return-orders-create :deep(.el-input__wrapper),
.return-orders-create :deep(.el-select__wrapper),
.return-orders-create :deep(.el-textarea__inner) { border: 1px solid #d5dce8; border-radius: 6px; box-shadow: none; }
.return-orders-create .return-create-form :deep(.el-input__wrapper),
.return-orders-create .return-create-form :deep(.el-select__wrapper) { min-height: 44px; box-sizing: border-box; padding: 10px 14px; }
.return-orders-create .return-create-form :deep(.el-input__inner) { height: 22px; color: #1f2937; font-size: 14px; line-height: 22px; }
.return-orders-create .return-create-form :deep(.el-textarea__inner) { min-height: 110px !important; padding: 11px 14px; color: #1f2937; font-size: 14px; line-height: 20px; }
.return-orders-create :deep(.el-input__inner::placeholder),
.return-orders-create :deep(.el-textarea__inner::placeholder),
.return-orders-create :deep(.el-select__placeholder.is-transparent) { color: #b0b7c3; opacity: 1; }
.return-orders-create :deep(.el-input__wrapper.is-focus),
.return-orders-create :deep(.el-select__wrapper.is-focused),
.return-orders-create :deep(.el-textarea__inner:focus) { outline: 2px solid #2f7bff; outline-offset: 0; }
.return-orders-create :deep(.is-error .el-input__wrapper),
.return-orders-create :deep(.is-error .el-select__wrapper),
.return-orders-create :deep(.duplicate-sku-input .el-input__wrapper) { border-color: #e5484d; box-shadow: none; }
.return-orders-create .items-title { margin: 0 0 10px; }
.return-orders-create .items-title h3 { margin: 0; }
.return-orders-create .return-relabel-option { margin: 0 0 12px; color: #374151; }
.return-orders-create .return-relabel-option :deep(.el-radio-button__inner) { box-shadow: none; }
.return-orders-create .return-create-actions { gap: 12px; padding: 0; border: 0; background: transparent; }
.return-orders-create :deep(.el-button) { box-shadow: none; }
.return-orders-create .return-create-header :deep(.el-button),
.return-orders-create .return-create-actions :deep(.el-button) { min-height: 44px; margin-left: 0; padding: 10px 18px; border: 1px solid #d5dce8; border-radius: 6px; background: #fff; color: #1d5fe0; font-size: 14px; font-weight: 600; }
.return-orders-create .return-create-actions :deep(.el-button--primary) { padding: 12px 28px; border-color: #2f7bff; background: #2f7bff; color: #fff; }
.return-orders-create .return-create-actions :deep(.el-button.is-disabled) { opacity: .55; }
:global(.return-create-popper.el-popper) { border: 1px solid #e3e8f0; border-radius: 6px; box-shadow: none; }
</style>
@@ -4,7 +4,7 @@ import TransferPickupProgressPanel from '../components/TransferPickupProgressPan
import TransferPaneDivider from '../components/TransferPaneDivider.vue' import TransferPaneDivider from '../components/TransferPaneDivider.vue'
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElForm, ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { ArrowLeft, ChevronDown, Download, Plus, Printer, Trash2, Upload, X } from '@lucide/vue' import { ArrowLeft, ChevronDown, Download, Plus, Printer, Trash2, Upload, X } from '@lucide/vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue' import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
@@ -115,7 +115,7 @@ const statusOptions = computed(() => props.service === 'labeling'
: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已通过', value: 'APPROVED' }, { label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '已分配', value: 'ALLOCATED' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '部分收货', value: 'PARTIALLY_RECEIVED' }, { label: '已收货', value: 'RECEIVED' }, { label: '已拒绝', value: 'REJECTED' }]) : [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已通过', value: 'APPROVED' }, { label: '待分配', value: 'PENDING_ASSIGNMENT' }, { label: '已分配', value: 'ALLOCATED' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '部分收货', value: 'PARTIALLY_RECEIVED' }, { label: '已收货', value: 'RECEIVED' }, { label: '已拒绝', value: 'REJECTED' }])
const mode = ref<PageMode>(props.detailPage ? 'detail' : props.createPage ? 'create' : 'list') const mode = ref<PageMode>(props.detailPage ? 'detail' : props.createPage ? 'create' : 'list')
const filters = reactive({ number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) const filters = reactive({ number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' })
const form = reactive({ customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 }) const form = reactive({ customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: props.service === 'transfer' ? 'WAREHOUSE_PICKUP' : '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 })
const transferDestinationType = ref<'NONE' | 'FBA' | 'PRIVATE'>('NONE') const transferDestinationType = ref<'NONE' | 'FBA' | 'PRIVATE'>('NONE')
const selectedOperationWarehouse = ref<MerchantWarehouseDetail | null>(null) const selectedOperationWarehouse = ref<MerchantWarehouseDetail | null>(null)
const operationWarehouseDetailLoading = ref(false) const operationWarehouseDetailLoading = ref(false)
@@ -322,7 +322,33 @@ const selectedDiscardOrderId = ref('')
const discardCandidatesLoading = ref(false) const discardCandidatesLoading = ref(false)
const relabelCandidates = ref<RelabelInventoryCandidate[]>([]) const relabelCandidates = ref<RelabelInventoryCandidate[]>([])
const selectedRelabelInboundOrderId = ref('') const selectedRelabelInboundOrderId = ref('')
const isLabelingCreate = computed(() => props.service === 'labeling' && mode.value === 'create')
const isTransferCreate = computed(() => props.service === 'transfer' && mode.value === 'create')
const isDisposalCreate = computed(() => props.service === 'disposal' && mode.value === 'create')
const isServiceCreate = computed(() => isLabelingCreate.value || isTransferCreate.value || isDisposalCreate.value)
const disposalFormModel = computed(() => ({ ...form, sourceOrder: selectedDiscardOrderId.value, requestedPackages: discardRequestedPackages.value }))
const disposalFormRules: FormRules = {
warehouse: [{ required: true, message: '请选择操作仓', trigger: 'change' }],
sourceOrder: [{ required: true, message: '请选择入库单', trigger: 'change' }],
reason: [{ required: true, whitespace: true, message: '请选择废弃原因', trigger: 'change' }],
}
const labelingFormRef = ref<FormInstance>()
const labelingFormModel = computed(() => ({ ...form, sourceOrder: selectedRelabelInboundOrderId.value, shippingLabelFile: shippingLabelFile.value }))
const labelingFormRules = computed<FormRules>(() => ({
warehouse: [{ required: true, message: '请选择操作仓', trigger: 'change' }],
sourceOrder: [{ required: true, message: '请选择入仓单或退货单', trigger: 'change' }],
contact: [{ required: true, whitespace: true, message: '请输入签收联系人', trigger: 'blur' }],
phone: [{ required: true, whitespace: true, message: '请输入联系电话', trigger: 'blur' }],
labelDeadline: [{ required: true, message: '请选择贴标截止时间', trigger: 'change' }],
destination: labelingMode.value === 'sku' ? [] : [{ required: true, message: '请选择 FBA 目的仓', trigger: 'change' }],
deliveryMethod: labelingMode.value === 'sku' ? [] : [{ required: true, message: '请选择派送方式', trigger: 'change' }],
shippingLabelFile: labelingMode.value !== 'sku' && form.deliveryMethod === 'EXPRESS' && form.relabel
? [{ required: true, type: 'object', message: '请上传面单文件', trigger: 'change' }] : [],
}))
const orderInfoCollapsed = ref(false) const orderInfoCollapsed = ref(false)
watch([labelingMode, () => form.deliveryMethod, () => form.relabel], () => {
if (isLabelingCreate.value) labelingFormRef.value?.clearValidate(['destination', 'deliveryMethod', 'shippingLabelFile'])
})
const availableBoxesCollapsed = ref(false) const availableBoxesCollapsed = ref(false)
const availableSkusCollapsed = ref(false) const availableSkusCollapsed = ref(false)
const relabelCandidatesLoading = ref(false) const relabelCandidatesLoading = ref(false)
@@ -337,6 +363,34 @@ const transferSourceCartons = ref<TransferSourceCarton[]>([])
const transferSourceLoading = ref(false) const transferSourceLoading = ref(false)
const transferSourceError = ref('') const transferSourceError = ref('')
let transferSourceRequest = 0 let transferSourceRequest = 0
const transferFormModel = computed(() => ({ ...form, relabelDestination: transferRelabelDefaultDestination.value }))
const transferFormRules = computed<FormRules>(() => {
const required = (message: string, enabled = true, trigger = 'change') => enabled ? [{ required: true, whitespace: true, message, trigger }] : []
const incoming = !inventoryTransfer.value
const pickup = incoming && form.deliveryMethod === 'WAREHOUSE_PICKUP'
const privateDestination = transferDestinationType.value === 'PRIVATE'
return {
warehouse: required('请选择入库海外仓'),
deliveryMethod: required('请选择入仓方式', incoming),
address: required(form.deliveryMethod === 'WAREHOUSE_PICKUP' ? '请输入提货地址' : '请输入送货地址', incoming, 'blur'),
pickupContact: required('请输入提货联系人', pickup, 'blur'),
pickupPhone: required('请输入联系电话', pickup, 'blur'),
estimatedArrivalAt: required('请选择预计入仓时间', incoming),
destination: required('请选择 FBA 目的仓', transferDestinationType.value === 'FBA'),
privateDestinationAddress: required('请输入私人仓地址', privateDestination, 'blur'),
privateDestinationPostalCode: required('请输入邮编', privateDestination, 'blur'),
privateDestinationContact: required('请输入联系人', privateDestination, 'blur'),
privateDestinationPhone: required('请输入联系电话', privateDestination, 'blur'),
labelDeadline: required('请选择换标截止时间', form.relabel),
relabelContact: required('请输入换标联系人', form.relabel, 'blur'),
relabelPhone: required('请输入联系电话', form.relabel, 'blur'),
relabelDeliveryMethod: required('请选择派送方式', form.relabel && labelingMode.value !== 'sku'),
relabelDestination: required('请选择 FBA 目的仓', form.relabel),
}
})
watch([inventoryTransfer, transferDestinationType, labelingMode, () => form.deliveryMethod, () => form.relabel], () => {
if (isTransferCreate.value) labelingFormRef.value?.clearValidate()
})
async function loadTransferSourceCartons() { async function loadTransferSourceCartons() {
const request = ++transferSourceRequest const request = ++transferSourceRequest
transferSourceCartons.value = []; transferSourceError.value = '' transferSourceCartons.value = []; transferSourceError.value = ''
@@ -505,10 +559,11 @@ onActivated(() => {
onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos(); clearDiscardPhotos() }) onBeforeUnmount(() => { clearCompletionPhotos(); clearMeasurementPhotos(); clearDiscardPhotos() })
function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) } function resetFilters() { Object.assign(filters, { number: '', inboundNo: '', warehouse: '', destination: '', status: '', start: '', end: '', reviewStart: '', reviewEnd: '', completedStart: '', completedEnd: '', measuredStart: '', measuredEnd: '', outboundStart: '', outboundEnd: '' }) }
function resetForm() { function resetForm() {
labelingFormRef.value?.clearValidate?.()
orderInfoCollapsed.value = false orderInfoCollapsed.value = false
availableBoxesCollapsed.value = false availableBoxesCollapsed.value = false
availableSkusCollapsed.value = false availableSkusCollapsed.value = false
Object.assign(form, { customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 }) Object.assign(form, { customerNo: '', inboundNo: '', sourceWarehouse: '', warehouse: '', destination: '', privateDestinationAddress: '', privateDestinationPostalCode: '', privateDestinationContact: '', privateDestinationPhone: '', address: '', contact: '', phone: '', pickupContact: '', pickupPhone: '', relabelContact: '', relabelPhone: '', deliveryMethod: props.service === 'transfer' ? 'WAREHOUSE_PICKUP' : '', relabelDeliveryMethod: '', trackingNo: '', reason: '', requirement: '', relabel: false, remark: '', labelDeadline: '', estimatedArrivalAt: '', estimatedShipAt: '', fbaShipmentNo: '', volumeCbm: 0.01 })
cargoRows.value = [] cargoRows.value = []
transferRelabelRows.value = [] transferRelabelRows.value = []
transferRelabelDefaultDestination.value = '' transferRelabelDefaultDestination.value = ''
@@ -690,7 +745,10 @@ function applyExistingCartonBinding(row: SkuRelabelRow) {
if (!row.shipmentNo.trim()) row.shipmentNo = mapping.shipmentNo if (!row.shipmentNo.trim()) row.shipmentNo = mapping.shipmentNo
if (!row.destinationWarehouseId) row.destinationWarehouseId = mapping.destinationWarehouseId if (!row.destinationWarehouseId) row.destinationWarehouseId = mapping.destinationWarehouseId
} }
function selectShippingLabel(event: Event) { shippingLabelFile.value = (event.target as HTMLInputElement).files?.[0] || null } function selectShippingLabel(event: Event) {
shippingLabelFile.value = (event.target as HTMLInputElement).files?.[0] || null
if (isLabelingCreate.value) void nextTick(() => labelingFormRef.value?.validateField('shippingLabelFile').catch(() => undefined))
}
function selectCartonTotalFile(event: Event) { function selectCartonTotalFile(event: Event) {
cartonTotalFile.value = (event.target as HTMLInputElement).files?.[0] || null cartonTotalFile.value = (event.target as HTMLInputElement).files?.[0] || null
if (cartonTotalFile.value && !requiresBothTotalFiles.value) clearCreateTotalFile('SKU_TOTAL') if (cartonTotalFile.value && !requiresBothTotalFiles.value) clearCreateTotalFile('SKU_TOTAL')
@@ -1139,11 +1197,21 @@ function printForecastCartons() {
} }
async function submit(action: 'draft' | 'submit' = 'submit') { async function submit(action: 'draft' | 'submit' = 'submit') {
if (saving.value) return if (saving.value) return
if (isLabelingCreate.value) {
orderInfoCollapsed.value = false
await nextTick()
if (!await labelingFormRef.value?.validate().catch(() => false)) return
}
if (props.service === 'transfer' && inventoryTransfer.value && action === 'draft') { if (props.service === 'transfer' && inventoryTransfer.value && action === 'draft') {
ElMessage.warning('选择库存来源后不能保存草稿,请直接提交订单') ElMessage.warning('选择库存来源后不能保存草稿,请直接提交订单')
return return
} }
if (isDisposalCreate.value && !await labelingFormRef.value?.validate().catch(() => false)) return
if (transferRelabelRequired.value) form.relabel = true if (transferRelabelRequired.value) form.relabel = true
if (isTransferCreate.value) {
await nextTick()
if (!await labelingFormRef.value?.validate().catch(() => false)) return
}
const primaryWarehouse = props.service === 'relabel' ? form.sourceWarehouse : form.warehouse const primaryWarehouse = props.service === 'relabel' ? form.sourceWarehouse : form.warehouse
const relabelRows = labelingMode.value === 'carton' ? cartonDetailRows.value : skuRelabelRows.value const relabelRows = labelingMode.value === 'carton' ? cartonDetailRows.value : skuRelabelRows.value
const invalidCargo = props.service === 'disposal' const invalidCargo = props.service === 'disposal'
@@ -1659,7 +1727,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
</script> </script>
<template> <template>
<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 }"> <section class="warehouse-service-page" :class="{ 'warehouse-create client-ui': isServiceCreate, 'warehouse-labeling-create': isLabelingCreate, 'warehouse-transfer-create': isTransferCreate, 'warehouse-disposal-create': isDisposalCreate, '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'"> <template v-if="mode === 'list'">
<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" /> <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" />
<div class="warehouse-card warehouse-table-card"> <div class="warehouse-card warehouse-table-card">
@@ -1676,18 +1744,75 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<template v-else-if="mode === 'create' || mode === 'edit'"> <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> <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 }" :style="props.service === 'transfer' ? { '--transfer-left': `${transferPaneShare}fr`, '--transfer-right': `${100 - transferPaneShare}fr` } : undefined" @submit.prevent="submit('submit')"> <component :is="isServiceCreate ? ElForm : 'form'" ref="labelingFormRef" :model="isLabelingCreate ? labelingFormModel : isTransferCreate ? transferFormModel : isDisposalCreate ? disposalFormModel : undefined" :rules="isLabelingCreate ? labelingFormRules : isTransferCreate ? transferFormRules : isDisposalCreate ? disposalFormRules : undefined" :disabled="isServiceCreate ? saving : undefined" label-position="top" :validate-on-rule-change="false" :scroll-to-error="isServiceCreate" :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 id="transfer-main-pane" class="warehouse-card warehouse-form-section warehouse-transfer-main-card">
<div v-if="isTransferCreate || isDisposalCreate" class="warehouse-order-info-heading"><h3>{{ isTransferCreate ? '转运信息' : '基础信息' }}</h3></div>
<div v-if="props.service === 'labeling'" class="warehouse-order-info-heading"> <div v-if="props.service === 'labeling'" class="warehouse-order-info-heading">
<h3>订单信息</h3> <h3>订单信息</h3>
<el-button text type="primary" native-type="button" :aria-expanded="!orderInfoCollapsed" @click="orderInfoCollapsed = !orderInfoCollapsed">{{ orderInfoCollapsed ? '展开信息' : '收起信息' }}</el-button> <el-button text type="primary" native-type="button" :aria-expanded="!orderInfoCollapsed" @click="orderInfoCollapsed = !orderInfoCollapsed">{{ orderInfoCollapsed ? '展开信息' : '收起信息' }}</el-button>
</div> </div>
<h3 v-else>{{ props.service === 'transfer' ? '转运信息' : props.service === 'relabel' ? '退货信息' : '基础信息' }}</h3> <h3 v-else-if="!isTransferCreate && !isDisposalCreate">{{ props.service === 'transfer' ? '转运信息' : props.service === 'relabel' ? '退货信息' : '基础信息' }}</h3>
<div v-if="props.service === 'labeling' && orderInfoCollapsed" class="warehouse-order-info-summary"> <div v-if="props.service === 'labeling' && orderInfoCollapsed" class="warehouse-order-info-summary">
<span>{{ operationWarehouses.find(item => String(item.id) === form.warehouse)?.warehouseName || '未选择操作仓' }}</span> <span>{{ operationWarehouses.find(item => String(item.id) === form.warehouse)?.warehouseName || '未选择操作仓' }}</span>
<span>{{ relabelInboundOptions.find(item => item.value === selectedRelabelInboundOrderId)?.inboundOrderNo || '未选择来源单据' }}</span> <span>{{ relabelInboundOptions.find(item => item.value === selectedRelabelInboundOrderId)?.inboundOrderNo || '未选择来源单据' }}</span>
</div> </div>
<div v-show="props.service !== 'labeling' || !orderInfoCollapsed" class="warehouse-form-grid"> <div v-show="props.service !== 'labeling' || !orderInfoCollapsed" class="warehouse-form-grid">
<template v-if="isLabelingCreate">
<el-form-item label="海外操作仓(中转仓)" prop="warehouse">
<el-select v-model="form.warehouse" :loading="fbaWarehousesLoading || operationWarehouseDetailLoading" :disabled="Boolean(operationWarehousesError)" clearable placeholder="请选择海外操作仓" popper-class="labeling-create-popper" @change="selectOperationWarehouse"><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>
</el-form-item>
<el-form-item label="选择入仓单或退货单" prop="sourceOrder">
<el-select v-model="selectedRelabelInboundOrderId" filterable clearable :filter-method="(value: string) => relabelInventorySearch = value" :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="暂无匹配的可选库存" placeholder="请选择入仓单或退货单" popper-class="labeling-create-popper" @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>
</el-form-item>
<el-form-item v-if="labelingMode !== 'sku'" label="FBA 目的仓" prop="destination"><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择 FBA 目的仓" popper-class="labeling-create-popper"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></el-form-item>
<el-form-item label="海外仓签收联系人" prop="contact"><el-input v-model="form.contact" :placeholder="form.warehouse && !form.contact ? '仓库未维护,请填写签收联系人' : '请输入海外仓签收联系人'" /></el-form-item>
<el-form-item label="海外仓联系电话" prop="phone"><el-input v-model="form.phone" type="tel" :placeholder="form.warehouse && !form.phone ? '仓库未维护,请填写联系电话' : '请输入海外仓联系电话'" /></el-form-item>
<el-form-item label="贴标截止时间" prop="labelDeadline"><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择贴标截止时间" popper-class="labeling-create-popper" /></el-form-item>
<el-form-item v-if="labelingMode !== 'sku'" label="换标后派送方式" prop="deliveryMethod"><el-select v-model="form.deliveryMethod" placeholder="请选择换标后派送方式" popper-class="labeling-create-popper"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></el-form-item>
<el-form-item v-if="labelingMode !== 'sku' && form.deliveryMethod === 'EXPRESS'" label="是否打印面单"><el-checkbox v-model="form.relabel">需要仓库打印面单</el-checkbox></el-form-item>
<el-form-item v-if="canManageRelabelFiles && labelingMode !== 'sku' && form.deliveryMethod === 'EXPRESS' && form.relabel" label="上传面单文件" prop="shippingLabelFile"><input class="warehouse-native-file" type="file" accept="application/pdf,image/*,.pdf" @change="selectShippingLabel" /></el-form-item>
<el-form-item label="贴标要求"><el-input v-model="form.requirement" placeholder="请输入贴标要求(选填)" /></el-form-item>
<el-form-item class="warehouse-span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="4" resize="vertical" placeholder="请输入其他补充信息(选填)" /></el-form-item>
</template>
<template v-else-if="isTransferCreate">
<el-form-item label="入库海外仓" prop="warehouse"><el-select v-model="form.warehouse" :loading="fbaWarehousesLoading || operationWarehouseDetailLoading" :disabled="Boolean(operationWarehousesError)" clearable placeholder="请选择入库海外仓" popper-class="transfer-create-popper" @change="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></el-form-item>
<el-form-item label="库存转运来源(可选)"><el-select v-model="selectedTransferInventory" filterable clearable :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可用库存" placeholder="请选择库存来源(选填)" popper-class="transfer-create-popper" @change="selectTransferInventory"><el-option v-for="option in transferInventoryOptions" :key="option.value" :label="`${option.label}|可用 ${option.packages} 箱`" :value="option.value" /></el-select></el-form-item>
<el-form-item v-if="!inventoryTransfer" class="warehouse-transfer-choice" label="入仓方式" prop="deliveryMethod"><el-radio-group v-model="form.deliveryMethod" @change="selectTransferInboundMethod"><el-radio-button value="WAREHOUSE_PICKUP">仓库上门提货</el-radio-button><el-radio-button value="CUSTOMER_DELIVERY">客户送货到仓</el-radio-button></el-radio-group></el-form-item>
<template v-if="!inventoryTransfer && form.deliveryMethod === 'WAREHOUSE_PICKUP'">
<el-form-item class="warehouse-span-2" label="提货地址" prop="address"><el-input v-model="form.address" placeholder="请输入详细提货地址" /></el-form-item>
<el-form-item label="提货联系人" prop="pickupContact"><el-input v-model="form.pickupContact" placeholder="请输入提货联系人" maxlength="128" /></el-form-item>
<el-form-item label="提货联系人电话" prop="pickupPhone"><el-input v-model="form.pickupPhone" type="tel" placeholder="请输入提货联系电话" maxlength="64" /></el-form-item>
</template>
<div v-if="!inventoryTransfer && form.deliveryMethod === 'CUSTOMER_DELIVERY'" v-loading="operationWarehouseDetailLoading" class="warehouse-receiving-card warehouse-span-2">
<div class="warehouse-span-2"><span>海外仓收货信息</span><strong>{{ operationWarehouseAddress || '该海外仓暂未维护收货地址' }}</strong></div>
<el-form-item class="warehouse-span-2" label="送货地址" prop="address"><el-input v-model="form.address" placeholder="请输入详细送货地址" /></el-form-item>
<div><span>收货联系人</span><strong>{{ selectedOperationWarehouse?.warehouse.contactName || '未维护' }}</strong></div><div><span>联系电话</span><strong>{{ selectedOperationWarehouse?.warehouse.contactPhone || '未维护' }}</strong></div>
</div>
<el-form-item v-if="!inventoryTransfer" label="预计入仓时间" prop="estimatedArrivalAt"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计入仓时间" popper-class="transfer-create-popper" /></el-form-item>
<el-form-item label="期望发货时间"><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择期望发货时间(选填)" popper-class="transfer-create-popper" /></el-form-item>
<el-form-item class="warehouse-transfer-choice" label="目的去向"><el-radio-group v-model="transferDestinationType" @change="selectTransferDestinationType"><el-radio-button v-if="!inventoryTransfer" value="NONE">暂未确定</el-radio-button><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
<template v-if="transferDestinationType === 'FBA'">
<el-form-item label="FBA 目的仓" prop="destination"><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择 FBA 目的仓" popper-class="transfer-create-popper"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></el-form-item>
<el-form-item label="FBA 货件编号"><el-input v-model="form.fbaShipmentNo" placeholder="请输入 FBA 货件编号(选填)" /></el-form-item>
</template>
<template v-if="transferDestinationType === 'PRIVATE'">
<el-form-item class="warehouse-span-2" label="私人仓地址" prop="privateDestinationAddress"><el-input v-model="form.privateDestinationAddress" maxlength="1000" placeholder="请输入私人仓详细地址" /></el-form-item>
<el-form-item label="邮编" prop="privateDestinationPostalCode"><el-input v-model="form.privateDestinationPostalCode" maxlength="32" placeholder="请输入邮编" /></el-form-item>
<el-form-item label="私人仓联系人" prop="privateDestinationContact"><el-input v-model="form.privateDestinationContact" maxlength="128" placeholder="请输入私人仓联系人" /></el-form-item>
<el-form-item label="联系电话" prop="privateDestinationPhone"><el-input v-model="form.privateDestinationPhone" type="tel" maxlength="64" placeholder="请输入联系电话" /></el-form-item>
</template>
<el-form-item v-if="!inventoryTransfer && form.deliveryMethod === 'CUSTOMER_DELIVERY'" label="物流跟踪号"><el-input v-model="form.trackingNo" placeholder="请输入物流跟踪号(选填)" /></el-form-item>
<el-form-item class="warehouse-transfer-choice" label="是否贴标" required><el-radio-group v-model="form.relabel"><el-radio-button v-if="!transferRelabelRequired" :value="false">不需要贴标</el-radio-button><el-radio-button :value="true">需要贴标</el-radio-button></el-radio-group></el-form-item>
<el-form-item class="warehouse-span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" resize="vertical" placeholder="请输入转运要求或其他补充信息(选填)" /></el-form-item>
</template>
<template v-else-if="isDisposalCreate">
<el-form-item label="操作仓" prop="warehouse"><el-select v-model="form.warehouse" filterable clearable :loading="fbaWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择操作仓" popper-class="disposal-create-popper" @change="selectDiscardWarehouse"><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></el-form-item>
<el-form-item label="选择入库单" prop="sourceOrder"><el-select v-model="selectedDiscardOrderId" filterable clearable :loading="discardCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可废弃库存" placeholder="请选择入库单" popper-class="disposal-create-popper" @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></el-form-item>
<el-form-item label="废弃原因" prop="reason"><el-select v-model="form.reason" placeholder="请选择废弃原因" popper-class="disposal-create-popper"><el-option label="残损不可售" value="残损不可售" /><el-option label="其他" value="其他" /></el-select></el-form-item>
<el-form-item class="warehouse-span-2" label="备注" prop="remark" :rules="discardQuantityMode ? [{ required: true, whitespace: true, message: '请填写废弃备注', trigger: 'blur' }] : []"><el-input v-model="form.remark" type="textarea" :rows="3" resize="vertical" :placeholder="discardQuantityMode ? '请填写废弃备注' : '请输入废弃要求或其他补充信息(选填)'" /></el-form-item>
</template>
<template v-else>
<label v-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>操作仓</span><el-select v-model="form.warehouse" filterable clearable :loading="fbaWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="" @change="selectDiscardWarehouse"><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 === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>操作仓</span><el-select v-model="form.warehouse" filterable clearable :loading="fbaWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="" @change="selectDiscardWarehouse"><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 === '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 === '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>进仓编号</span><el-input v-model="form.inboundNo" placeholder="" /></label>
@@ -1732,6 +1857,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<label v-else-if="props.service === 'relabel' || (props.service === 'transfer' && form.deliveryMethod === 'CUSTOMER_DELIVERY')"><span>物流跟踪号</span><el-input v-model="form.trackingNo" :placeholder="props.service === 'transfer' ? '' : '请输入物流跟踪号(选填)'" /></label> <label v-else-if="props.service === 'relabel' || (props.service === 'transfer' && form.deliveryMethod === 'CUSTOMER_DELIVERY')"><span>物流跟踪号</span><el-input v-model="form.trackingNo" :placeholder="props.service === 'transfer' ? '' : '请输入物流跟踪号(选填)'" /></label>
<label class="warehouse-span-2"><span>{{ props.service === 'disposal' && discardQuantityMode ? '备注 *' : '备注' }}</span><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="" /></label> <label class="warehouse-span-2"><span>{{ props.service === 'disposal' && discardQuantityMode ? '备注 *' : '备注' }}</span><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="" /></label>
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-choice-field--relabel warehouse-span-2"><span><i>*</i>是否贴标</span><el-radio-group v-model="form.relabel"><el-radio-button v-if="!transferRelabelRequired" :value="false">不需要贴标</el-radio-button><el-radio-button :value="true">需要贴标</el-radio-button></el-radio-group></div> <div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-choice-field--relabel warehouse-span-2"><span><i>*</i>是否贴标</span><el-radio-group v-model="form.relabel"><el-radio-button v-if="!transferRelabelRequired" :value="false">不需要贴标</el-radio-button><el-radio-button :value="true">需要贴标</el-radio-button></el-radio-group></div>
</template>
</div> </div>
</div> </div>
<div class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card"> <div class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card">
@@ -1739,6 +1865,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<div class="warehouse-section-heading warehouse-labeling-heading"> <div class="warehouse-section-heading warehouse-labeling-heading">
<div><h3>货物明细</h3></div> <div><h3>货物明细</h3></div>
</div> </div>
<div class="warehouse-labeling-body">
<div class="warehouse-file-groups warehouse-file-groups--compact"> <div class="warehouse-file-groups warehouse-file-groups--compact">
<section v-if="canManageRelabelFiles" class="warehouse-file-group warehouse-file-group--primary"> <section v-if="canManageRelabelFiles" class="warehouse-file-group warehouse-file-group--primary">
<div class="warehouse-file-group__heading"><div><strong><span>1</span>贴标总文件</strong></div><em>{{ requiresBothTotalFiles ? '必传 · 两份文件' : isReturnRelabelSource ? labelingMode === 'sku' ? '必传 · SKU 标签文件' : '必传 · 箱唛文件' : '必传 · 二选一' }}</em></div> <div class="warehouse-file-group__heading"><div><strong><span>1</span>贴标总文件</strong></div><em>{{ requiresBothTotalFiles ? '必传 · 两份文件' : isReturnRelabelSource ? labelingMode === 'sku' ? '必传 · SKU 标签文件' : '必传 · 箱唛文件' : '必传 · 二选一' }}</em></div>
@@ -1782,17 +1909,23 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<div v-if="labelingMode === 'carton'"><el-button type="primary" size="small" @click="addRelabelCargoRow"><Plus :size="15" />手动添加行</el-button><el-button size="small" @click="clearRelabelRows">清空</el-button></div> <div v-if="labelingMode === 'carton'"><el-button type="primary" size="small" @click="addRelabelCargoRow"><Plus :size="15" />手动添加行</el-button><el-button size="small" @click="clearRelabelRows">清空</el-button></div>
<div v-else><el-button type="primary" size="small" @click="addSkuRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="skuRelabelRows = []">清空</el-button></div> <div v-else><el-button type="primary" size="small" @click="addSkuRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="skuRelabelRows = []">清空</el-button></div>
</div> </div>
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-label-table"><thead><tr><th>商品 SKU</th><th>中文品名 *</th><th>FBA 货件编号 *</th><th>箱数</th><th>仓库唛头 *</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in cartonDetailRows" :key="row.id"><td><el-input v-model="row.sku" placeholder="" /></td><td><el-input v-model="row.name" placeholder="" /></td><td><el-input v-model="row.shipmentNo" placeholder="" /></td><td><strong>1 箱</strong></td><td><el-input v-model="row.cartonMark" :readonly="isReturnRelabelSource" placeholder="" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="" /></td><td><button type="button" class="warehouse-delete" title="移出本次明细" @click="removeCargoRow(row.id)"><Trash2 :size="16" />移出</button></td></tr><tr v-if="cartonDetailRows.length === 0"><td colspan="8"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div> <div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-label-table"><thead><tr><th>商品 SKU</th><th>中文品名 *</th><th>FBA 货件编号 *</th><th>箱数</th><th>仓库唛头 *</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in cartonDetailRows" :key="row.id"><td><el-input v-model="row.sku" placeholder="请输入 SKU" /></td><td><el-input v-model="row.name" placeholder="请输入中文品名" /></td><td><el-input v-model="row.shipmentNo" placeholder="请输入 FBA 货件编号" /></td><td><strong>1 箱</strong></td><td><el-input v-model="row.cartonMark" :readonly="isReturnRelabelSource" placeholder="请输入仓库唛头" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="选填" /></td><td><button type="button" class="warehouse-delete" title="移出本次明细" @click="removeCargoRow(row.id)"><Trash2 :size="16" />移出</button></td></tr><tr v-if="cartonDetailRows.length === 0"><td colspan="8"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div>
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-sku-label-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th v-if="labelingMode === 'carton_and_sku'">新 FBA 箱唛 *</th><th v-if="labelingMode === 'carton_and_sku'">FBA 货件编号 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in skuRelabelRows" :key="row.id"><td><el-input v-model="row.cartonMark" :readonly="isReturnRelabelSource" placeholder="" @change="applyExistingCartonBinding(row)" /></td><td><el-input v-model="row.originalSku" :readonly="isReturnRelabelSource" placeholder="" @change="applyExistingSkuBinding(row)" /></td><td><el-input v-model="row.newSku" placeholder="" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.fbaCartonMark" placeholder="" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.shipmentNo" placeholder="" /></td><td><el-input v-model="row.remark" placeholder="" /></td><td><button type="button" class="warehouse-delete" title="删除" @click="removeSkuRelabelRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="skuRelabelRows.length === 0"><td :colspan="labelingMode === 'carton_and_sku' ? 8 : 6"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div> <div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-sku-label-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th v-if="labelingMode === 'carton_and_sku'">新 FBA 箱唛 *</th><th v-if="labelingMode === 'carton_and_sku'">FBA 货件编号 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in skuRelabelRows" :key="row.id"><td><el-input v-model="row.cartonMark" :readonly="isReturnRelabelSource" placeholder="请输入仓库唛头" @change="applyExistingCartonBinding(row)" /></td><td><el-input v-model="row.originalSku" :readonly="isReturnRelabelSource" placeholder="请输入旧 SKU" @change="applyExistingSkuBinding(row)" /></td><td><el-input v-model="row.newSku" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入数量" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.fbaCartonMark" placeholder="请输入新 FBA 箱唛" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.shipmentNo" placeholder="请输入 FBA 货件编号" /></td><td><el-input v-model="row.remark" placeholder="选填" /></td><td><button type="button" class="warehouse-delete" title="删除" @click="removeSkuRelabelRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="skuRelabelRows.length === 0"><td :colspan="labelingMode === 'carton_and_sku' ? 8 : 6"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div>
</div>
</template> </template>
<template v-else> <template v-else>
<div v-if="isTransferCreate || isDisposalCreate" class="warehouse-order-info-heading"><h3>{{ isDisposalCreate && discardQuantityMode ? '废弃数量' : isDisposalCreate && discardSelectedCandidate?.cartonSelectionSupported ? '选择废弃箱子' : '货物明细' }}</h3></div>
<div :class="{ 'warehouse-transfer-create-body': isTransferCreate, 'warehouse-disposal-create-body': isDisposalCreate }">
<section v-if="props.service === 'disposal' && discardSelectedCandidate" class="warehouse-discard-boxes"> <section v-if="props.service === 'disposal' && discardSelectedCandidate" class="warehouse-discard-boxes">
<div class="warehouse-section-heading"><div><h3>{{ discardQuantityMode ? '废弃数量' : '选择废弃箱子' }}</h3></div><div v-if="discardSelectedCandidate.cartonSelectionSupported" class="warehouse-heading-actions"><el-button size="small" @click="selectAllDiscardBoxes(true)">全选</el-button><el-button size="small" @click="selectAllDiscardBoxes(false)">清空选择</el-button></div></div> <div class="warehouse-section-heading"><div><h3>{{ discardQuantityMode ? '废弃数量' : '选择废弃箱子' }}</h3></div><div v-if="discardSelectedCandidate.cartonSelectionSupported" class="warehouse-heading-actions"><el-button size="small" @click="selectAllDiscardBoxes(true)">全选</el-button><el-button size="small" @click="selectAllDiscardBoxes(false)">清空选择</el-button></div></div>
<div v-if="discardQuantityMode" class="warehouse-disposal-field"><span>本次废弃箱数 *</span><el-input-number v-model="discardRequestedPackages" :min="1" :max="Number(discardSelectedCandidate.availablePackages)" :precision="0" controls-position="right" placeholder="请输入废弃箱数" /><span>可用 {{ discardSelectedCandidate.availablePackages }} 箱</span></div> <div v-if="isDisposalCreate && discardQuantityMode" class="warehouse-form-grid warehouse-disposal-quantity">
<el-form-item label="本次废弃箱数" prop="requestedPackages" :rules="[{ required: true, type: 'integer', min: 1, max: Number(discardSelectedCandidate.availablePackages), message: '请填写可用范围内的整数箱数', trigger: 'change' }]"><el-input-number v-model="discardRequestedPackages" :min="1" :max="Number(discardSelectedCandidate.availablePackages)" :precision="0" controls-position="right" placeholder="请输入废弃箱数" /></el-form-item>
<span class="warehouse-hint">可用 {{ discardSelectedCandidate.availablePackages }} 箱</span>
</div>
<div v-else-if="discardQuantityMode" class="warehouse-disposal-field"><span>本次废弃箱数 *</span><el-input-number v-model="discardRequestedPackages" :min="1" :max="Number(discardSelectedCandidate.availablePackages)" :precision="0" controls-position="right" placeholder="请输入废弃箱数" /><span>可用 {{ discardSelectedCandidate.availablePackages }} 箱</span></div>
<template v-else-if="discardSelectedCandidate.cartonSelectionSupported"> <template v-else-if="discardSelectedCandidate.cartonSelectionSupported">
<div class="warehouse-discard-box-toolbar"><el-input v-model="discardBoxSearch" clearable placeholder="" /><strong>已选 {{ discardSelectedBoxKeys.length }} / {{ discardSelectableBoxes.length }} 箱</strong></div> <div class="warehouse-discard-box-toolbar"><el-input v-model="discardBoxSearch" clearable :placeholder="isDisposalCreate ? '搜索箱唛或 SKU' : ''" /><strong>已选 {{ discardSelectedBoxKeys.length }} / {{ discardSelectableBoxes.length }} 箱</strong></div>
<div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>选择</th><th>箱唛</th><th>所属托盘</th><th>箱内 SKU</th><th>中文品名</th></tr></thead><tbody><tr v-for="box in discardVisibleBoxes" :key="box.key"><td><el-checkbox :model-value="discardSelectedBoxKeys.includes(box.key)" :aria-label="`选择废弃箱子 ${box.cartonMark}`" @change="toggleDiscardBox(box.key, Boolean($event))" /></td><td><strong>{{ box.cartonMark }}</strong></td><td>{{ box.palletNo || '散箱' }}</td><td>{{ box.skus.map(sku => sku.skuCode).join('、') }}</td><td>{{ box.skus.map(sku => sku.productName || '—').join('、') }}</td></tr><tr v-if="!discardVisibleBoxes.length"><td colspan="5"><div class="warehouse-empty">暂无匹配箱唛</div></td></tr></tbody></table></div> <div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>选择</th><th>箱唛</th><th>所属托盘</th><th>箱内 SKU</th><th>中文品名</th></tr></thead><tbody><tr v-for="box in discardVisibleBoxes" :key="box.key"><td><el-checkbox :model-value="discardSelectedBoxKeys.includes(box.key)" :aria-label="`选择废弃箱子 ${box.cartonMark}`" @change="toggleDiscardBox(box.key, Boolean($event))" /></td><td><strong>{{ box.cartonMark }}</strong></td><td>{{ box.palletNo || '散箱' }}</td><td>{{ box.skus.map(sku => sku.skuCode).join('、') }}</td><td>{{ box.skus.map(sku => sku.productName || '—').join('、') }}</td></tr><tr v-if="!discardVisibleBoxes.length"><td colspan="5"><div class="warehouse-empty">暂无匹配箱唛</div></td></tr></tbody></table></div>
<small v-if="!discardSelectedBoxKeys.length" class="warehouse-hint">请至少选择一个箱子。</small>
</template> </template>
<el-alert v-else type="info" :closable="false" :title="discardSelectedCandidate.cartonSelectionBlockReason || '当前库存缺少箱唛与托盘对应关系,将预留所选库存,由仓库核对相关托盘并填写实际废弃箱数,未废弃部分释放。'" /> <el-alert v-else type="info" :closable="false" :title="discardSelectedCandidate.cartonSelectionBlockReason || '当前库存缺少箱唛与托盘对应关系,将预留所选库存,由仓库核对相关托盘并填写实际废弃箱数,未废弃部分释放。'" />
<div v-if="!discardQuantityMode && !discardSelectedCandidate.cartonSelectionSupported" class="warehouse-hint">相关托盘:{{ [...new Set(discardSelectionSources.map(source => source.palletNo).filter(Boolean))].join('、') || '散箱/托盘信息未记录' }}</div> <div v-if="!discardQuantityMode && !discardSelectedCandidate.cartonSelectionSupported" class="warehouse-hint">相关托盘:{{ [...new Set(discardSelectionSources.map(source => source.palletNo).filter(Boolean))].join('、') || '散箱/托盘信息未记录' }}</div>
@@ -1807,13 +1940,24 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<div v-if="!transferSourceLoading && !transferSourceError && !transferSourceCartons.length" class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU</th><th>中文品名</th><th>库存箱数</th><th>原目的仓</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td>{{ row.sku || '—' }}</td><td>{{ row.name || '—' }}</td><td>{{ row.expectedPackageCount }}</td><td>{{ transferInventoryCandidates.filter(item => item.inboundCode === selectedTransferInventory && item.skuCode === row.sku).map(item => item.destinationFbaWarehouseCode || item.destinationFbaWarehouseName).filter(Boolean).join(' / ') || '未指定' }}</td></tr></tbody></table></div> <div v-if="!transferSourceLoading && !transferSourceError && !transferSourceCartons.length" class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU</th><th>中文品名</th><th>库存箱数</th><th>原目的仓</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td>{{ row.sku || '—' }}</td><td>{{ row.name || '—' }}</td><td>{{ row.expectedPackageCount }}</td><td>{{ transferInventoryCandidates.filter(item => item.inboundCode === selectedTransferInventory && item.skuCode === row.sku).map(item => item.destinationFbaWarehouseCode || item.destinationFbaWarehouseName).filter(Boolean).join(' / ') || '未指定' }}</td></tr></tbody></table></div>
</section> </section>
<div v-if="!inventoryTransfer && (props.service !== 'disposal' || (!discardQuantityMode && !discardSelectedCandidate?.cartonSelectionSupported))" class="warehouse-section-heading" :class="{ 'warehouse-step-heading': props.service === 'transfer' }"><div><h3><span v-if="props.service === 'transfer'" class="warehouse-step-number">1</span>货物明细</h3></div><div v-if="props.service !== 'disposal'" class="warehouse-heading-actions"><el-button v-if="props.service === 'transfer'" size="small" :loading="saving" @click="printForecastCartons"><Printer :size="15" />打印唛头</el-button><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div> <div v-if="!inventoryTransfer && (props.service !== 'disposal' || (!discardQuantityMode && !discardSelectedCandidate?.cartonSelectionSupported))" class="warehouse-section-heading" :class="{ 'warehouse-step-heading': props.service === 'transfer' }"><div><h3><span v-if="props.service === 'transfer'" class="warehouse-step-number">1</span>货物明细</h3></div><div v-if="props.service !== 'disposal'" class="warehouse-heading-actions"><el-button v-if="props.service === 'transfer'" size="small" :loading="saving" @click="printForecastCartons"><Printer :size="15" />打印唛头</el-button><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div>
<div v-if="!inventoryTransfer && (props.service !== 'disposal' || (!discardQuantityMode && !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> <div v-if="!inventoryTransfer && (props.service !== 'disposal' || (!discardQuantityMode && !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="isTransferCreate ? '请输入商品 SKU(选填)' : props.service === 'relabel' ? '请输入商品 SKU' : ''" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" :placeholder="isTransferCreate ? '请输入中文品名(选填)' : 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>
</div>
</template> </template>
</div> </div>
<TransferPaneDivider v-if="props.service === 'transfer' && form.relabel" v-model="transferPaneShare" class="warehouse-transfer-divider" /> <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 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-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-transfer-create-body': isTransferCreate, 'warehouse-disposal-create-body': isDisposalCreate }">
<div v-if="isTransferCreate" class="warehouse-form-grid warehouse-transfer-relabel-fields">
<el-form-item label="换标截止时间" prop="labelDeadline"><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择换标截止时间" popper-class="transfer-create-popper" /></el-form-item>
<el-form-item label="换标联系人" prop="relabelContact"><el-input v-model="form.relabelContact" maxlength="128" placeholder="请输入换标联系人" /></el-form-item>
<el-form-item label="换标联系人电话" prop="relabelPhone"><el-input v-model="form.relabelPhone" type="tel" maxlength="64" placeholder="请输入换标联系电话" /></el-form-item>
<el-form-item v-if="labelingMode !== 'sku'" label="换标后派送方式" prop="relabelDeliveryMethod"><el-select v-model="form.relabelDeliveryMethod" placeholder="请选择派送方式" popper-class="transfer-create-popper"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></el-form-item>
<el-form-item class="warehouse-span-2" label="整票换标货物目的 FBA 仓" prop="relabelDestination"><el-select v-model="transferRelabelDefaultDestination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择整票换标货物目的 FBA 仓" popper-class="transfer-create-popper"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></el-form-item>
<el-form-item label="FBA 货件编号"><el-input v-model="form.fbaShipmentNo" placeholder="请输入 FBA 货件编号(选填)" /></el-form-item>
<el-form-item class="warehouse-span-2" label="贴标要求"><el-input v-model="form.requirement" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入贴标要求(选填)" /></el-form-item>
</div>
<div v-else 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> <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>
<div class="warehouse-transfer-file-groups warehouse-file-groups--compact"> <div class="warehouse-transfer-file-groups warehouse-file-groups--compact">
<section v-if="canManageRelabelFiles" class="warehouse-file-group warehouse-transfer-total-files"> <section v-if="canManageRelabelFiles" class="warehouse-file-group warehouse-transfer-total-files">
@@ -1832,12 +1976,13 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
</div> </div>
<div v-if="importedRelabelBindings.length" class="warehouse-import-preview"><div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div><div class="warehouse-table-scroll"><table class="warehouse-binding-preview-table"><thead><tr><th>选择</th><th>关系类型</th><th>仓库唛头</th><th>旧 SKU</th><th>新 SKU</th><th>数量</th><th>新 FBA 箱唛</th></tr></thead><tbody><tr v-for="item in importedRelabelBindings" :key="item.id"><td><el-checkbox v-model="item.selected" /></td><td>{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}</td><td>{{ item.cartonMark }}</td><td>{{ item.originalSku || '—' }}</td><td>{{ item.newSku || '—' }}</td><td>{{ item.quantity }}</td><td>{{ item.fbaCartonMark || '—' }}</td></tr></tbody></table></div></div> <div v-if="importedRelabelBindings.length" class="warehouse-import-preview"><div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div><div class="warehouse-table-scroll"><table class="warehouse-binding-preview-table"><thead><tr><th>选择</th><th>关系类型</th><th>仓库唛头</th><th>旧 SKU</th><th>新 SKU</th><th>数量</th><th>新 FBA 箱唛</th></tr></thead><tbody><tr v-for="item in importedRelabelBindings" :key="item.id"><td><el-checkbox v-model="item.selected" /></td><td>{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}</td><td>{{ item.cartonMark }}</td><td>{{ item.originalSku || '—' }}</td><td>{{ item.newSku || '—' }}</td><td>{{ item.quantity }}</td><td>{{ item.fbaCartonMark || '—' }}</td></tr></tbody></table></div></div>
<div class="warehouse-section-heading warehouse-tab-actions warehouse-binding-heading"><div><strong><span class="warehouse-step-number">4</span>绑定明细</strong></div><div><el-button type="primary" size="small" @click="addTransferRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="clearTransferRelabelRows">清空</el-button></div></div> <div class="warehouse-section-heading warehouse-tab-actions warehouse-binding-heading"><div><strong><span class="warehouse-step-number">4</span>绑定明细</strong></div><div><el-button type="primary" size="small" @click="addTransferRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="clearTransferRelabelRows">清空</el-button></div></div>
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" placeholder="" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.fbaCartonMark" placeholder="" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="5"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div> <div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" :placeholder="isTransferCreate ? '请输入仓库唛头' : ''" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.fbaCartonMark" :placeholder="isTransferCreate ? '请输入新 FBA 箱唛' : ''" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" :placeholder="isTransferCreate ? '选填' : ''" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="5"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div>
<div v-else-if="labelingMode === 'sku'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" placeholder="" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="6"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div> <div v-else-if="labelingMode === 'sku'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" :placeholder="isTransferCreate ? '请输入仓库唛头' : ''" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" :placeholder="isTransferCreate ? '请输入新 SKU' : ''" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" :placeholder="isTransferCreate ? '请输入数量' : ''" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" :placeholder="isTransferCreate ? '选填' : ''" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="6"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div>
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" placeholder="" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="7"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div> <div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" :placeholder="isTransferCreate ? '请输入仓库唛头' : ''" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" :placeholder="isTransferCreate ? '请输入新 SKU' : ''" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" :placeholder="isTransferCreate ? '请输入数量' : ''" /></td><td><el-input v-model="row.fbaCartonMark" :placeholder="isTransferCreate ? '请输入新 FBA 箱唛' : ''" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" :placeholder="isTransferCreate ? '选填' : ''" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="7"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div>
</div> </div>
<div class="warehouse-submit-bar warehouse-transfer-submit-bar"><el-button v-if="mode === 'create'" native-type="button" @click="resetForm">重置</el-button><el-button v-if="props.service === 'transfer' && !inventoryTransfer" native-type="button" :loading="saving" @click="submit('draft')">保存草稿</el-button><el-button v-if="props.service === 'transfer'" type="primary" native-type="submit" :loading="saving">提交订单</el-button><el-button v-if="props.service !== 'transfer'" type="primary" native-type="submit" :loading="saving">提交{{ config.title }}</el-button></div> </div>
</form> <div class="warehouse-submit-bar warehouse-transfer-submit-bar"><el-button v-if="isServiceCreate" native-type="button" :disabled="saving" @click="returnToList">返回列表</el-button><el-button v-if="mode === 'create'" native-type="button" @click="resetForm">重置</el-button><el-button v-if="props.service === 'transfer' && !inventoryTransfer" native-type="button" :loading="saving" @click="submit('draft')">保存草稿</el-button><el-button v-if="props.service === 'transfer'" type="primary" native-type="submit" :loading="saving">提交订单</el-button><el-button v-if="props.service !== 'transfer'" type="primary" native-type="submit" :loading="saving">提交{{ config.title }}</el-button></div>
</component>
</template> </template>
<template v-else> <template v-else>
@@ -2180,3 +2325,141 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
.warehouse-service-page--detail.warehouse-service-page--transfer>.warehouse-card .warehouse-transfer-overview-grid>div{min-height:24px;gap:8px;padding:1px 0} .warehouse-service-page--detail.warehouse-service-page--transfer>.warehouse-card .warehouse-transfer-overview-grid>div{min-height:24px;gap:8px;padding:1px 0}
.warehouse-service-page--detail.warehouse-service-page--transfer>.warehouse-card .warehouse-transfer-overview-grid>div>span{flex:0 0 84px;min-width:0} .warehouse-service-page--detail.warehouse-service-page--transfer>.warehouse-card .warehouse-transfer-overview-grid>div>span{flex:0 0 84px;min-width:0}
</style> </style>
<style scoped>
.warehouse-create {
--el-color-primary: #2f7bff;
--el-text-color-regular: #1f2937;
--el-text-color-placeholder: #b0b7c3;
--client-border: #e3e8f0;
width: 100%; min-width: 0; gap: 0; background: #f4f7fc; color: #1f2937;
font: 14px/1.5 "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
}
.warehouse-create .warehouse-page-heading { margin-bottom: 12px; }
.warehouse-create .warehouse-page-heading h2 { margin: 0; color: #1f2937; font-size: 26px; font-weight: 700; }
.warehouse-create .warehouse-form-section { min-width: 0; margin-bottom: 12px; padding: 0; border: 0; background: transparent; box-shadow: none; }
.warehouse-create .warehouse-order-info-heading,
.warehouse-create .warehouse-labeling-heading { width: 100%; max-width: none; margin: 0 0 10px; padding: 0; border: 0; }
.warehouse-create .warehouse-form-section h3 { display: flex; align-items: center; gap: 10px; margin: 0; padding: 0; border: 0; color: #1f2937; font-size: 18px; font-weight: 700; }
.warehouse-create .warehouse-form-section h3::before { content: ''; width: 9px; height: 9px; flex: 0 0 9px; border-radius: 50%; background: #2f7bff; }
.warehouse-create .warehouse-labeling-heading h3::before { background: #0ea5a4; }
.warehouse-create .warehouse-form-grid,
.warehouse-create .warehouse-labeling-body,
.warehouse-create .warehouse-order-info-summary { width: 100%; max-width: none; min-width: 0; box-sizing: border-box; padding: 16px; border: 1px solid #e3e8f0; border-radius: 10px; background: #fff; }
.warehouse-create .warehouse-form-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 20px; }
.warehouse-create :deep(.el-form-item) { min-width: 0; margin: 0; }
.warehouse-create :deep(label.el-form-item__label) { display: flex; flex-direction: row; gap: 0; height: auto; margin: 0 0 6px; padding: 0; color: #374151; font-size: 14px; font-weight: 600; line-height: 20px; }
.warehouse-create :deep(.el-form-item.is-required .el-form-item__label::before) { color: #e5484d; }
.warehouse-create :deep(.el-form-item__content) { min-width: 0; line-height: 20px; }
/* Validation shares the label line so showing and clearing it cannot move the next row. */
.warehouse-create :deep(.el-form-item__error) { position: absolute; top: auto; bottom: calc(100% + 6px); right: 0; left: auto; width: auto; max-width: calc(100% - 170px); padding: 0; font-size: 12px; line-height: 20px; text-align: right; }
.warehouse-create .warehouse-form-grid :deep(.el-input),
.warehouse-create .warehouse-form-grid :deep(.el-select),
.warehouse-create .warehouse-form-grid :deep(.el-date-editor) { width: 100%; }
.warehouse-create .warehouse-form-grid :deep(.el-input__wrapper),
.warehouse-create .warehouse-form-grid :deep(.el-select__wrapper) { min-height: 44px; box-sizing: border-box; padding: 10px 14px; border: 1px solid #d5dce8; border-radius: 6px; background: #fff; box-shadow: none; }
.warehouse-create .warehouse-form-grid :deep(.el-input__inner) { height: 22px; line-height: 22px; color: #1f2937; font-size: 14px; }
.warehouse-create .warehouse-form-grid :deep(.el-textarea__inner) { min-height: 110px !important; padding: 11px 14px; border: 1px solid #d5dce8; border-radius: 6px; box-shadow: none; font-size: 14px; }
.warehouse-create .warehouse-form-grid :deep(.el-checkbox) { height: 44px; }
.warehouse-create .warehouse-form-grid :deep(.el-checkbox__label) { font-size: 14px; }
.warehouse-create .warehouse-form-grid :deep(.el-input__wrapper.is-focus),
.warehouse-create .warehouse-form-grid :deep(.el-select__wrapper.is-focused),
.warehouse-create .warehouse-form-grid :deep(.el-textarea__inner:focus) { border-color: #2f7bff; }
.warehouse-create .warehouse-form-grid :deep(.is-error .el-input__wrapper),
.warehouse-create .warehouse-form-grid :deep(.is-error .el-select__wrapper) { border-color: #e5484d; box-shadow: none; }
.warehouse-create :deep(input::placeholder),
.warehouse-create :deep(textarea::placeholder),
.warehouse-create :deep(.el-select__placeholder.is-transparent) { color: #b0b7c3; opacity: 1; }
.warehouse-create .warehouse-native-file { width: 100%; min-height: 44px; box-sizing: border-box; padding: 8px 10px; border-color: #d5dce8; border-radius: 6px; }
.warehouse-service-page.warehouse-create .warehouse-transfer-cargo-card .warehouse-section-heading,
.warehouse-service-page.warehouse-create .warehouse-transfer-cargo-card .warehouse-table-scroll,
.warehouse-service-page.warehouse-create .warehouse-file-groups--compact { width: 100%; max-width: none; }
.warehouse-service-page.warehouse-create .warehouse-file-groups { gap: 12px; margin-bottom: 12px; }
.warehouse-service-page.warehouse-create .warehouse-file-group { border: 1px solid #e3e8f0; border-radius: 6px; background: #fff; box-shadow: none; }
.warehouse-service-page.warehouse-create .warehouse-file-group__heading { padding: 10px 12px; background: #fff; }
.warehouse-service-page.warehouse-create .warehouse-file-group__options { padding: 10px 12px; }
.warehouse-service-page.warehouse-create .warehouse-labeling-mode { width: auto; max-width: none; gap: 12px; margin: 12px 0; padding: 10px 12px; border-color: #e3e8f0; border-radius: 6px; background: #fff; }
.warehouse-service-page.warehouse-create .warehouse-tab-actions { margin: 0 0 10px; }
.warehouse-service-page.warehouse-create :deep(.el-radio-button__inner),
.warehouse-service-page.warehouse-create .warehouse-labeling-body :deep(.el-input__wrapper),
.warehouse-service-page.warehouse-create .warehouse-labeling-body :deep(.el-textarea__inner) { box-shadow: none; }
.warehouse-service-page.warehouse-create .warehouse-labeling-body :deep(.el-input__wrapper),
.warehouse-service-page.warehouse-create .warehouse-labeling-body :deep(.el-textarea__inner) { border: 1px solid #d5dce8; border-radius: 6px; }
.warehouse-create .warehouse-submit-bar { justify-content: flex-end; gap: 12px; padding: 0; }
.warehouse-create :deep(.el-button) { box-shadow: none; }
.warehouse-create .warehouse-page-heading :deep(.el-button),
.warehouse-create .warehouse-submit-bar :deep(.el-button) { min-height: 44px; margin-left: 0; padding: 10px 18px; border: 1px solid #d5dce8; border-radius: 6px; background: #fff; color: #1d5fe0; font-size: 14px; font-weight: 600; }
.warehouse-create .warehouse-submit-bar :deep(.el-button--primary) { padding: 12px 28px; border-color: #2f7bff; background: #2f7bff; color: #fff; }
.warehouse-create .warehouse-submit-bar :deep(.el-button.is-disabled) { opacity: .55; }
:global(.labeling-create-popper.el-popper) { border: 1px solid #e3e8f0; border-radius: 6px; box-shadow: none; }
</style>
<style scoped>
.warehouse-transfer-create .warehouse-transfer-form { gap: 12px; }
.warehouse-transfer-create .warehouse-transfer-form--relabel { column-gap: 0; }
.warehouse-transfer-create .warehouse-form-section { margin-bottom: 0; padding: 0; }
.warehouse-transfer-create .warehouse-transfer-form .warehouse-form-grid { --el-component-size: 44px; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 20px; }
/* Preserve the existing resizable two-pane workflow when relabeling is selected. */
.warehouse-transfer-create .warehouse-transfer-form--relabel .warehouse-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.warehouse-transfer-create .warehouse-transfer-create-body { min-width: 0; padding: 16px; border: 1px solid #e3e8f0; border-radius: 10px; background: #fff; }
.warehouse-transfer-create .warehouse-transfer-create-body > .warehouse-section-heading h3 { display: none; }
.warehouse-transfer-create .warehouse-transfer-create-body > .warehouse-section-heading { justify-content: flex-end; }
.warehouse-transfer-create .warehouse-transfer-create-body .warehouse-form-grid { padding: 0; border: 0; border-radius: 0; }
.warehouse-transfer-create .warehouse-transfer-relabel { overflow: visible; }
.warehouse-transfer-create .warehouse-step-number { display: none; }
.warehouse-transfer-create .warehouse-transfer-form .warehouse-form-section h3 { margin: 0; padding: 0; font-size: 18px; }
.warehouse-transfer-create .warehouse-transfer-cargo-card > .warehouse-order-info-heading h3::before { background: #0ea5a4; }
.warehouse-transfer-create .warehouse-transfer-relabel > .warehouse-section-heading h3::before { background: #0ea5a4; }
.warehouse-transfer-create .warehouse-transfer-relabel > .warehouse-section-heading { margin-bottom: 10px; }
.warehouse-transfer-create .warehouse-transfer-create-body section h3 { font-size: 14px; }
.warehouse-transfer-create .warehouse-transfer-create-body section h3::before { display: none; }
.warehouse-transfer-create .warehouse-receiving-card { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 20px; padding: 12px; border: 1px solid #e3e8f0; border-radius: 6px; background: #f4f7fc; }
.warehouse-transfer-create .warehouse-receiving-card > .el-form-item { display: block; }
.warehouse-transfer-create .warehouse-receiving-card > div > span { font-size: 14px; }
.warehouse-transfer-create :deep(.el-form-item__error) { max-width: calc(100% - 140px); }
.warehouse-transfer-create .warehouse-form-grid :deep(.el-radio-button__inner) { padding: 11px 14px; font-size: 14px; line-height: 20px; box-shadow: none; }
.warehouse-transfer-create .warehouse-transfer-create-body .warehouse-transfer-file-groups { gap: 12px; margin-bottom: 12px; }
.warehouse-transfer-create .warehouse-transfer-create-body .warehouse-file-group { border: 1px solid #e3e8f0; border-radius: 6px; background: #fff; box-shadow: none; }
.warehouse-transfer-create .warehouse-transfer-create-body .warehouse-labeling-mode { margin: 12px 0; padding: 10px 12px; border: 1px solid #e3e8f0; border-radius: 6px; background: #fff; }
.warehouse-transfer-create .warehouse-transfer-create-body .warehouse-edit-table { width: 100%; font-size: 13px; }
.warehouse-transfer-create .warehouse-transfer-create-body .warehouse-edit-table th { height: 40px; font-size: 13px; }
.warehouse-transfer-create .warehouse-transfer-create-body .warehouse-edit-table td { padding: 8px; }
.warehouse-transfer-create .warehouse-table-scroll :deep(.el-input__wrapper),
.warehouse-transfer-create .warehouse-table-scroll :deep(.el-textarea__inner) { border: 1px solid #d5dce8; border-radius: 6px; box-shadow: none; }
.warehouse-transfer-create .warehouse-table-scroll :deep(.el-input__wrapper.is-focus),
.warehouse-transfer-create .warehouse-table-scroll :deep(.el-textarea__inner:focus) { border-color: #2f7bff; }
.warehouse-transfer-create .warehouse-transfer-form .warehouse-submit-bar { justify-content: flex-end; gap: 12px; padding: 0; }
.warehouse-transfer-create .warehouse-transfer-form .warehouse-submit-bar :deep(.el-button) { min-height: 44px; padding: 10px 18px; }
.warehouse-transfer-create .warehouse-transfer-form .warehouse-submit-bar :deep(.el-button--primary) { padding: 12px 28px; }
.warehouse-transfer-create .warehouse-form-grid :deep(.el-date-editor.el-input) { height: 44px; }
.warehouse-transfer-create .warehouse-transfer-form--relabel .warehouse-transfer-choice { grid-column: 1 / -1; }
.warehouse-transfer-create .warehouse-form-grid :deep(.el-input__wrapper.is-focus),
.warehouse-transfer-create .warehouse-form-grid :deep(.el-select__wrapper.is-focused),
.warehouse-transfer-create .warehouse-form-grid :deep(.el-textarea__inner:focus) { outline: 2px solid #2f7bff; outline-offset: 0; }
:global(.transfer-create-popper.el-popper) { border: 1px solid #e3e8f0; border-radius: 6px; box-shadow: none; }
</style>
<style scoped>
.warehouse-disposal-create .warehouse-form-grid { --el-component-size: 44px; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 20px; }
.warehouse-disposal-create .warehouse-disposal-create-body { min-width: 0; padding: 16px; border: 1px solid #e3e8f0; border-radius: 10px; background: #fff; }
.warehouse-disposal-create .warehouse-transfer-cargo-card > .warehouse-order-info-heading h3::before { background: #0ea5a4; }
.warehouse-disposal-create .warehouse-disposal-create-body > .warehouse-section-heading { display: none; }
.warehouse-disposal-create .warehouse-discard-boxes > .warehouse-section-heading h3 { display: none; }
.warehouse-disposal-create .warehouse-discard-boxes > .warehouse-section-heading { justify-content: flex-end; margin: 0 0 12px; }
.warehouse-disposal-create .warehouse-discard-box-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.warehouse-disposal-create .warehouse-discard-box-toolbar :deep(.el-input) { width: calc((100% - 40px) / 3); }
.warehouse-disposal-create .warehouse-edit-table { width: 100%; font-size: 13px; }
.warehouse-disposal-create .warehouse-edit-table th { height: 40px; font-size: 13px; }
.warehouse-disposal-create .warehouse-edit-table td { padding: 8px 12px; }
.warehouse-disposal-create .warehouse-disposal-create-body :deep(.el-input__wrapper) { border: 1px solid #d5dce8; border-radius: 6px; box-shadow: none; }
.warehouse-disposal-create .warehouse-form-grid :deep(.el-input__wrapper.is-focus),
.warehouse-disposal-create .warehouse-form-grid :deep(.el-select__wrapper.is-focused),
.warehouse-disposal-create .warehouse-form-grid :deep(.el-textarea__inner:focus),
.warehouse-disposal-create .warehouse-disposal-create-body :deep(.el-input__wrapper.is-focus) { outline: 2px solid #2f7bff; outline-offset: 0; }
.warehouse-disposal-create .warehouse-hint { margin: 10px 0 0; color: #6b7280; font-size: 13px; line-height: 20px; }
.warehouse-disposal-create .warehouse-disposal-quantity { padding: 0; border: 0; }
.warehouse-disposal-create .warehouse-disposal-quantity :deep(.el-input-number) { width: 100%; }
.warehouse-disposal-create .warehouse-disposal-quantity :deep(.el-input-number .el-input__wrapper) { padding-right: 40px; }
.warehouse-disposal-create .warehouse-disposal-quantity > .warehouse-hint { align-self: end; margin-bottom: 12px; }
:global(.disposal-create-popper.el-popper) { border: 1px solid #e3e8f0; border-radius: 6px; box-shadow: none; }
</style>
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { computed, reactive, ref } from 'vue'
import ts from 'typescript'
const source = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
const { default: Schema } = createRequire(import.meta.resolve('element-plus'))('async-validator')
const rulesSource = source.slice(source.indexOf('const labelingFormRules ='), source.indexOf('const orderInfoCollapsed ='))
const rulesCode = ts.transpileModule(rulesSource, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
function harness(mode = 'carton', patch = {}) {
const labelingMode = ref(mode)
const form = reactive({ warehouse: 'warehouse-a', sourceOrder: 'source-a', contact: '联系人', phone: '12345678', labelDeadline: '2026-11-10T12:00:00', destination: 'FBA-A', deliveryMethod: 'TRUCK', relabel: false, ...patch })
const rules = new Function('computed', 'labelingMode', 'form', `${rulesCode}\nreturn labelingFormRules`)(computed, labelingMode, form)
return { form, labelingMode, validate: () => new Schema(rules.value).validate(form) }
}
test('order information reports every missing required field and rejects whitespace contacts', async () => {
const state = harness('carton', { warehouse: '', sourceOrder: '', contact: ' ', phone: ' ', labelDeadline: '', destination: '', deliveryMethod: '' })
await assert.rejects(state.validate(), error => {
assert.deepEqual(Object.keys(error.fields).sort(), ['warehouse', 'sourceOrder', 'contact', 'phone', 'labelDeadline', 'destination', 'deliveryMethod'].sort())
return true
})
})
test('SKU-only labeling excludes hidden destination and delivery requirements', async () => {
const state = harness('sku', { destination: '', deliveryMethod: '' })
await state.validate()
state.labelingMode.value = 'carton_and_sku'
await assert.rejects(state.validate(), error => Object.keys(error.fields).sort().join(',') === 'deliveryMethod,destination')
})
test('shipping label file is required only when express printing is selected', async () => {
const state = harness('carton', { deliveryMethod: 'EXPRESS', relabel: true })
await assert.rejects(state.validate(), error => Boolean(error.fields.shippingLabelFile))
state.form.shippingLabelFile = new File(['label'], 'label.pdf', { type: 'application/pdf' })
await state.validate()
state.form.shippingLabelFile = null
state.form.relabel = false
await state.validate()
state.form.relabel = true
state.form.deliveryMethod = 'TRUCK'
await state.validate()
state.form.deliveryMethod = 'EXPRESS'
state.labelingMode.value = 'sku'
await state.validate()
})
test('invalid create fields expand the order section and stop before submission logic', async () => {
const start = source.indexOf("async function submit(action:")
const end = source.indexOf(" if (props.service === 'transfer' && inventoryTransfer.value", start)
const preamble = source.slice(start, end) + '\nreturn "continue"\n}'
const code = ts.transpileModule(preamble, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const collapsed = ref(true), saving = ref(false), isLabelingCreate = ref(true)
let validations = 0
const formRef = ref({ validate: async () => { validations++; throw new Error('invalid') } })
const submit = new Function('saving', 'isLabelingCreate', 'orderInfoCollapsed', 'nextTick', 'labelingFormRef', `${code}\nreturn submit`)(saving, isLabelingCreate, collapsed, async () => {}, formRef)
assert.equal(await submit(), undefined)
assert.equal(collapsed.value, false)
assert.equal(validations, 1)
isLabelingCreate.value = false
assert.equal(await submit(), 'continue')
assert.equal(validations, 1)
saving.value = true
assert.equal(await submit(), undefined)
})
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { computed, reactive, ref } from 'vue'
import ts from 'typescript'
const source = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
const { default: Schema } = createRequire(import.meta.resolve('element-plus'))('async-validator')
const ruleSource = source.slice(source.indexOf('const transferFormRules ='), source.indexOf('watch([inventoryTransfer,'))
const code = ts.transpileModule(ruleSource, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
function harness(patch = {}) {
const form = reactive({ warehouse: 'WH', deliveryMethod: 'WAREHOUSE_PICKUP', address: 'Street 1', pickupContact: 'Contact', pickupPhone: '123456', estimatedArrivalAt: '2026-11-01T12:00:00', relabel: false, ...patch })
const inventoryTransfer = ref(false), transferDestinationType = ref('NONE'), labelingMode = ref('carton')
const rules = new Function('computed', 'inventoryTransfer', 'transferDestinationType', 'labelingMode', 'form', code + '\nreturn transferFormRules')(computed, inventoryTransfer, transferDestinationType, labelingMode, form)
return { form, inventoryTransfer, transferDestinationType, labelingMode, validate: () => new Schema(rules.value).validate(form) }
}
const hasFields = (...fields) => error => {
assert.deepEqual(Object.keys(error.fields).sort(), fields.sort())
return true
}
test('pickup requires contact and phone; customer delivery hides those requirements', async () => {
const state = harness({ pickupContact: ' ', pickupPhone: '' })
await assert.rejects(state.validate(), hasFields('pickupContact', 'pickupPhone'))
state.form.deliveryMethod = 'CUSTOMER_DELIVERY'
await state.validate()
state.form.address = ''
await assert.rejects(state.validate(), hasFields('address'))
})
test('inventory transfer excludes inbound address, arrival and pickup fields', async () => {
const state = harness({ deliveryMethod: '', address: '', estimatedArrivalAt: '', pickupContact: '', pickupPhone: '' })
state.inventoryTransfer.value = true
await state.validate()
state.form.warehouse = ''
await assert.rejects(state.validate(), hasFields('warehouse'))
})
test('destination switch validates only FBA or private warehouse fields currently visible', async () => {
const state = harness()
await state.validate()
state.transferDestinationType.value = 'FBA'
await assert.rejects(state.validate(), hasFields('destination'))
state.transferDestinationType.value = 'PRIVATE'
await assert.rejects(state.validate(), hasFields('privateDestinationAddress', 'privateDestinationPostalCode', 'privateDestinationContact', 'privateDestinationPhone'))
Object.assign(state.form, { privateDestinationAddress: 'Private street', privateDestinationPostalCode: '10000', privateDestinationContact: 'Person', privateDestinationPhone: '123456' })
await state.validate()
state.transferDestinationType.value = 'NONE'
await state.validate()
})
test('relabeling requires its visible fields, with no delivery method for SKU-only mode', async () => {
const state = harness({ relabel: true })
await assert.rejects(state.validate(), hasFields('labelDeadline', 'relabelContact', 'relabelPhone', 'relabelDeliveryMethod', 'relabelDestination'))
Object.assign(state.form, { labelDeadline: '2026-11-01T12:00:00', relabelContact: 'Contact', relabelPhone: '123456', relabelDestination: 'FBA-A' })
state.labelingMode.value = 'sku'
await state.validate()
state.labelingMode.value = 'carton_and_sku'
await assert.rejects(state.validate(), hasFields('relabelDeliveryMethod'))
state.form.relabel = false
await state.validate()
})
test('draft and submit stop before business requests when create validation fails; edit remains unchanged', async () => {
const start = source.indexOf(' if (isTransferCreate.value) {', source.indexOf('async function submit('))
const end = source.indexOf(' const primaryWarehouse =', start)
const code = 'return async function submit(action) {' + source.slice(start, end) + '\nreturn action\n}'
const isTransferCreate = ref(true)
const labelingFormRef = ref({ validate: async () => { throw new Error('required') } })
const submit = new Function('isTransferCreate', 'labelingFormRef', 'nextTick', code)(isTransferCreate, labelingFormRef, async () => {})
assert.equal(await submit('draft'), undefined)
assert.equal(await submit('submit'), undefined)
isTransferCreate.value = false
assert.equal(await submit('draft'), 'draft')
assert.equal(await submit('submit'), 'submit')
})
@@ -1,8 +1,39 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { createRequire } from 'node:module'
import ts from 'typescript'
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8') const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
const { default: Schema } = createRequire(import.meta.resolve('element-plus'))('async-validator')
test('新增废弃逐字段校验仓库、来源单和原因,备注不影响提交资格', async () => {
const source = view.slice(view.indexOf('const disposalFormRules:'), view.indexOf('const labelingFormRef ='))
const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
const rules = new Function(code + '\nreturn disposalFormRules')()
const validator = new Schema(rules)
await assert.rejects(validator.validate({ warehouse: '', sourceOrder: '', reason: '' }), error => {
assert.deepEqual(Object.keys(error.fields).sort(), ['reason', 'sourceOrder', 'warehouse'])
return true
})
const form = { warehouse: 'WH-A', sourceOrder: 'document:IN-A', reason: '其他', remark: '' }
await validator.validate(form)
await assert.rejects(validator.validate({ ...form, sourceOrder: '' }), error => Boolean(error.fields.sourceOrder))
await assert.rejects(validator.validate({ ...form, reason: ' ' }), error => Boolean(error.fields.reason))
})
test('新增废弃校验失败不进入库存提交判断,其他页面不调用废弃表单校验', async () => {
const guard = view.match(/ if \(isDisposalCreate\.value && !await labelingFormRef[^\n]+/)[0]
let validations = 0
const submit = new Function('isDisposalCreate', 'labelingFormRef', 'return async () => {' + guard + '\nreturn "continue"\n}')
const formRef = { value: { validate: async () => { validations++; throw new Error('required') } } }
assert.equal(await submit({ value: true }, formRef)(), undefined)
assert.equal(validations, 1)
assert.equal(await submit({ value: false }, formRef)(), 'continue')
assert.equal(validations, 1)
formRef.value.validate = async () => true
assert.equal(await submit({ value: true }, formRef)(), 'continue')
})
test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => { test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => {
assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*@change="selectDiscardWarehouse"/s) assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*@change="selectDiscardWarehouse"/s)
@@ -322,7 +322,8 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
assert.match(view, /v-if="props\.service === 'transfer'"[^>]*type="primary"[^>]*native-type="submit"[^>]*>提交订单/) assert.match(view, /v-if="props\.service === 'transfer'"[^>]*type="primary"[^>]*native-type="submit"[^>]*>提交订单/)
assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/) assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/)
assert.match(view, /await submitTransfer\(transferNo\)/) assert.match(view, /await submitTransfer\(transferNo\)/)
assert.match(view, /<form [^>]*@submit\.prevent="submit\('submit'\)"/) assert.match(view, /const isLabelingCreate = computed\(\(\) => props.service === 'labeling' && mode.value === 'create'\)/)
assert.match(view, /<component :is="isServiceCreate \? ElForm : 'form'"[^>]*@submit\.prevent="submit\('submit'\)"/)
assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印预报箱唛/) assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印预报箱唛/)
assert.doesNotMatch(view, /row\.__status !== 'DRAFT'.*printTransferCartons\(row\['0'\]\)/s) assert.doesNotMatch(view, /row\.__status !== 'DRAFT'.*printTransferCartons\(row\['0'\]\)/s)
assert.doesNotMatch(view, /selectedDetail\?\.status === 'DRAFT'[^>]*[\s\S]*?>提交订单/) assert.doesNotMatch(view, /selectedDetail\?\.status === 'DRAFT'[^>]*[\s\S]*?>提交订单/)