This commit is contained in:
2026-09-10 17:12:52 +08:00
parent 5c4578ce00
commit b506e4e59a
3 changed files with 186 additions and 72 deletions
+1 -1
View File
@@ -54,7 +54,7 @@
| 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 |
| 海外采购、美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;采购创建的 `customerName` 只取当前认证会话企业名称,不允许自由输入或冒用其他客户。Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 |
| 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID |
| 海外仓贴标、仓储转运、货物废弃、退货换标 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。模板上传和下载固定在货物明细外层:箱唛模式使用箱唛模板,SKU 模式使用包含我方箱唛、旧 SKU 标签、新 SKU 标签和换标数量的 SKU 模板,SKU+箱唛模板另含新 FBA 箱唛与目的仓。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
| 海外仓贴标、仓储转运、货物废弃、退货换标 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 四个路由均为 `active`;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以旧箱唛连接旧/新 SKU 和旧箱唛/新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
## 租户、认证与数据安全
@@ -21,8 +21,10 @@ import {
type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel'
type PageMode = 'list' | 'create' | 'detail'
type LabelingMode = 'carton' | 'sku' | 'carton_and_sku'
interface CargoRow { id: number; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string }
type RelabelTemplateType = 'carton' | 'sku' | 'mixed'
interface CargoRow { id: number; included?: boolean; sku: string; name: string; quantity: number; availableQuantity?: number; sourceOrderId?: string; sourceInventoryBalanceId?: string; sourceInventoryPositionId?: string; sourceInboundOrderId?: string; sourceInboundOrderNo?: string; sourceInboundCode?: string; sourceWarehouseName?: string; palletNo?: string; warehouseLocationCode?: string; fnsku: string; shipmentNo: string; newSkuCode: string; shippingMark: string; cartonMark: string; fbaCartonMark: string; destinationWarehouseId: string; remark: string }
interface SkuRelabelRow { id: number; sourceRowId: number | null; cartonMark: string; originalSku: string; newSku: string; quantity: number; fbaCartonMark: string; destinationWarehouseId: string; remark: string }
interface ImportedRelabelBinding extends SkuRelabelRow { templateType: RelabelTemplateType; selected: boolean; shipmentNo: string }
interface ServiceConfig { title: string; createTitle: string; numberLabel: string; emptyText: string; columns: string[] }
const props = defineProps<{ service: ServiceType }>()
@@ -54,6 +56,8 @@ const cargoRows = ref<CargoRow[]>([])
const labelingMode = ref<LabelingMode>('carton')
const shippingLabelFile = ref<File | null>(null)
const skuRelabelRows = ref<SkuRelabelRow[]>([])
const importedRelabelBindings = ref<ImportedRelabelBinding[]>([])
const cartonDetailRows = computed(() => cargoRows.value.filter((row) => row.included))
const labelingModeHint = computed(() => labelingMode.value === 'carton'
? '只替换外箱箱唛,不修改箱内商品 SKU 标签;按箱填写新的 FBA 箱唛和目的仓。'
: labelingMode.value === 'sku'
@@ -89,7 +93,9 @@ const relabelInboundOptions = computed(() => {
})
const loading = ref(false)
const saving = ref(false)
const templateInput = ref<HTMLInputElement | null>(null)
const cartonTemplateInput = ref<HTMLInputElement | null>(null)
const skuTemplateInput = ref<HTMLInputElement | null>(null)
const mixedTemplateInput = ref<HTMLInputElement | null>(null)
let cargoSequence = 0
let skuRelabelSequence = 0
@@ -102,6 +108,7 @@ function resetForm() {
cargoRows.value = []
labelingMode.value = 'carton'
skuRelabelRows.value = []
importedRelabelBindings.value = []
discardCandidates.value = []
selectedDiscardOrderId.value = ''
relabelCandidates.value = []
@@ -127,6 +134,22 @@ function addSkuRelabelRow() {
fbaCartonMark: '', destinationWarehouseId: source?.destinationWarehouseId || '', remark: '' })
}
function removeSkuRelabelRow(id: number) { skuRelabelRows.value = skuRelabelRows.value.filter((row) => row.id !== id) }
function applyExistingSkuBinding(row: SkuRelabelRow) {
if (!row.originalSku.trim() || row.newSku.trim()) return
const mapping = skuRelabelRows.value.find((item) => item.id !== row.id
&& item.originalSku.trim().toUpperCase() === row.originalSku.trim().toUpperCase() && item.newSku.trim())
if (mapping) row.newSku = mapping.newSku
}
function applyExistingCartonBinding(row: SkuRelabelRow) {
if (!row.cartonMark.trim()) return
const cartonMark = row.cartonMark.trim().toUpperCase()
const mapping = cargoRows.value.find((item) => item.cartonMark.trim().toUpperCase() === cartonMark && item.fbaCartonMark.trim())
|| skuRelabelRows.value.find((item) => item.id !== row.id
&& item.cartonMark.trim().toUpperCase() === cartonMark && item.fbaCartonMark.trim())
if (!mapping) return
if (!row.fbaCartonMark.trim()) row.fbaCartonMark = mapping.fbaCartonMark
if (!row.destinationWarehouseId) row.destinationWarehouseId = mapping.destinationWarehouseId
}
function selectShippingLabel(event: Event) { shippingLabelFile.value = (event.target as HTMLInputElement).files?.[0] || null }
function buildSkuRelabelItems(): RelabelCreate['items'] {
const groups = new Map<string, SkuRelabelRow[]>()
@@ -145,7 +168,7 @@ function buildSkuRelabelItems(): RelabelCreate['items'] {
throw new Error('填写的箱唛数量超过当前入仓单可用库存箱数')
}
return [...groups.entries()].map(([_, rows], groupIndex) => {
const source = anchors[groupIndex]
const source = cargoRows.value.find((item) => item.id === rows[0].sourceRowId) || anchors[groupIndex]
if (!source?.sourceInventoryBalanceId || !source.sourceInventoryPositionId || !source.sourceInboundOrderId) {
throw new Error('来源库存已变化,请重新选择有库存的历史入仓单')
}
@@ -180,32 +203,40 @@ function buildSkuRelabelItems(): RelabelCreate['items'] {
}
function addRelabelCargoRow() {
if (!selectedRelabelInboundOrderId.value) { ElMessage.warning('请先选择有库存的历史入仓单'); return }
ElMessage.info('货物明细已按所选入仓单的可用库存带出,请直接编辑现有明细')
const source = cargoRows.value.find((row) => !row.included)
if (!source) { ElMessage.warning('所选入仓单没有更多可添加的库存箱'); return }
source.included = true
}
function removeCargoRow(id: number) {
const item = cargoRows.value.find((row) => row.id === id)
if (props.service === 'labeling' && item?.sourceInventoryBalanceId) {
Object.assign(item, { included: false, shipmentNo: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' })
return
}
cargoRows.value = cargoRows.value.filter((row) => row.id !== id)
}
function clearRelabelRows() { cargoRows.value = []; selectedRelabelInboundOrderId.value = '' }
function clearRelabelRows() {
for (const row of cargoRows.value) Object.assign(row, { included: false, shipmentNo: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' })
}
function csvCell(value: string | number | undefined) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
function downloadRelabelTemplate() {
function downloadRelabelTemplate(templateType: RelabelTemplateType) {
if (!cargoRows.value.length) { ElMessage.warning('请先选择有库存的历史入仓单'); return }
const cartonTemplate = labelingMode.value === 'carton'
const combinedTemplate = labelingMode.value === 'carton_and_sku'
const cartonTemplate = templateType === 'carton'
const mixedTemplate = templateType === 'mixed'
const header = cartonTemplate
? ['明细行标识', '本次箱数', '商品SKU', '中文品名', 'FBA货件编号', '我方箱唛', 'FBA箱唛', 'FBA目的仓', '备注']
: ['我方箱唛', '旧SKU标签', '新SKU标签', '换标数量(件)',
...(combinedTemplate ? ['新FBA箱唛', 'FBA目的仓'] : []), '备注']
const rows = cartonTemplate
? cargoRows.value.map((item) => [item.id, item.quantity, item.sku, item.name, item.shipmentNo,
item.cartonMark, item.fbaCartonMark, item.destinationWarehouseId, item.remark])
: skuRelabelRows.value.map((item) => [item.cartonMark, item.originalSku, item.newSku, item.quantity,
...(combinedTemplate ? [item.fbaCartonMark, item.destinationWarehouseId] : []), item.remark])
? ['明细行标识', '旧箱唛', '新FBA箱唛', 'FBA目的仓', 'FBA货件编号', '备注']
: ['明细行标识', '旧箱唛', '旧SKU标签', '新SKU标签', '换标数量(件)',
...(mixedTemplate ? ['新FBA箱唛', 'FBA目的仓', 'FBA货件编号'] : []), '备注']
const rows = cargoRows.value.map((item) => cartonTemplate
? [item.id, item.cartonMark, item.fbaCartonMark, item.destinationWarehouseId, item.shipmentNo, item.remark]
: [item.id, item.cartonMark, item.sku, '', 1,
...(mixedTemplate ? [item.fbaCartonMark, item.destinationWarehouseId, item.shipmentNo] : []), item.remark])
const blob = new Blob([`\uFEFF${[header, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n')}\r\n`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = cartonTemplate ? '贴标申请-箱唛换标模板.csv'
: combinedTemplate ? '贴标申请-SKU和箱唛换标模板.csv' : '贴标申请-SKU换标模板.csv'
anchor.download = cartonTemplate ? '贴标申请-箱号关联模板.csv'
: mixedTemplate ? '贴标申请-SKU混箱换标模板.csv' : '贴标申请-SKU关联模板.csv'
anchor.click(); URL.revokeObjectURL(url)
}
function parseCsvLine(line: string) {
@@ -218,76 +249,112 @@ function parseCsvLine(line: string) {
}
values.push(value.trim()); return values
}
async function importRelabelTemplate(event: Event) {
async function importRelabelTemplate(event: Event, templateType: RelabelTemplateType) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file) return
const lines = (await file.text()).replace(/^\uFEFF/, '').split(/\r?\n/).filter((line) => line.trim())
if (lines.length < 2) { ElMessage.error('模板中没有可导入的明细'); return }
const headers = parseCsvLine(lines[0])
if (labelingMode.value !== 'carton') {
const combinedTemplate = labelingMode.value === 'carton_and_sku'
const required = ['我方箱唛', '旧SKU标签', '新SKU标签', '换标数量(件)',
...(combinedTemplate ? ['新FBA箱唛', 'FBA目的仓'] : [])]
if (templateType !== 'carton') {
const mixedTemplate = templateType === 'mixed'
const required = ['明细行标识', '旧箱唛', '旧SKU标签', '新SKU标签', '换标数量(件)',
...(mixedTemplate ? ['新FBA箱唛', 'FBA目的仓', 'FBA货件编号'] : [])]
if (required.some((name) => !headers.includes(name))) {
ElMessage.error('SKU 换标模板格式不正确,请重新下载当前换标类型的模板'); return
ElMessage.error('SKU 关联模板格式不正确,请重新下载 SKU 关联模板'); return
}
const imported = lines.slice(1).map(parseCsvLine)
.map((values) => Object.fromEntries(headers.map((key, index) => [key, values[index] || ''])))
.filter((item) => Object.values(item).some(Boolean))
.filter((item) => item['旧箱唛'] || item['新SKU标签'] || item['新FBA箱唛'])
if (!imported.length || imported.some((item) => required.some((key) => !item[key]))) {
ElMessage.error('我方箱唛、旧 SKU 标签、新 SKU 标签和换标数量必须填写完整'); return
ElMessage.error('旧箱唛、旧 SKU 标签、新 SKU 标签和换标数量必须填写完整'); return
}
const rows: SkuRelabelRow[] = []
const rows: ImportedRelabelBinding[] = []
for (const item of imported) {
const quantity = Number(item['换标数量(件)'])
if (!Number.isInteger(quantity) || quantity < 1) {
ElMessage.error(`箱唛 ${item['我方箱唛']} 的换标数量必须是大于 0 的整数`); return
ElMessage.error(`箱唛 ${item['旧箱唛']} 的换标数量必须是大于 0 的整数`); return
}
const source = cargoRows.value.find((row) => String(row.id) === item['明细行标识'])
if (!source) { ElMessage.error(`明细行 ${item['明细行标识']} 不属于当前库存,请重新下载模板`); return }
rows.push({
id: ++skuRelabelSequence,
sourceRowId: null,
cartonMark: item['我方箱唛'].trim(),
sourceRowId: source.id,
cartonMark: item['旧箱唛'].trim(),
originalSku: item['旧SKU标签'].trim(),
newSku: item['新SKU标签'].trim(),
quantity,
fbaCartonMark: combinedTemplate ? item['新FBA箱唛'].trim() : '',
destinationWarehouseId: combinedTemplate ? item['FBA目的仓'].trim() : '',
fbaCartonMark: mixedTemplate ? item['新FBA箱唛'].trim() : '',
destinationWarehouseId: mixedTemplate ? item['FBA目的仓'].trim() : '',
remark: item['备注'] || '',
templateType,
selected: true,
shipmentNo: mixedTemplate ? item['FBA货件编号'].trim() : '',
})
}
skuRelabelRows.value = rows
ElMessage.success(`已导入 ${rows.length} 条 SKU 换标明细`)
importedRelabelBindings.value = rows
ElMessage.success(`已读取 ${rows.length} 条绑定关系,请选择后导入到明细`)
return
}
const required = ['明细行标识', '本次箱数', 'FBA货件编号', '我方箱唛', 'FBA箱唛', 'FBA目的仓']
if (required.some((name) => !headers.includes(name))) { ElMessage.error('模板格式不正确,请重新下载模板填写'); return }
const required = ['明细行标识', '旧箱唛', '新FBA箱唛', 'FBA目的仓', 'FBA货件编号']
if (required.some((name) => !headers.includes(name))) { ElMessage.error('箱号关联模板格式不正确,请重新下载箱号关联模板'); return }
const imported = lines.slice(1).map(parseCsvLine)
.map((values) => Object.fromEntries(headers.map((key, index) => [key, values[index] || ''])))
.filter((item) => item['明细行标识'])
.filter((item) => item['旧箱唛'] || item['新FBA箱唛'])
if (!imported.length || imported.some((item) => required.some((key) => !item[key]))) {
ElMessage.error('模板中的明细行、箱数、货件、箱唛和 FBA 目的仓必须填写完整'); return
ElMessage.error('模板中的明细行、旧箱唛、新 FBA 箱唛、FBA 目的仓和货件编号必须填写完整'); return
}
if (new Set(imported.map((item) => item['明细行标识'])).size !== imported.length) {
ElMessage.error('模板中的明细行标识不能重复'); return
}
const importedByRow = new Map(imported.map((item) => [item['明细行标识'], item]))
if (importedByRow.size !== cargoRows.value.length || cargoRows.value.some((row) => !importedByRow.has(String(row.id)))) {
ElMessage.error('模板明细与当前已选入仓单库存不一致,请重新下载模板'); return
if (importedByRow.size !== imported.length || [...importedByRow.keys()].some((id) => !cargoRows.value.some((row) => String(row.id) === id))) {
ElMessage.error('模板存在重复或不属于当前库存的明细行,请重新下载模板'); return
}
for (const row of cargoRows.value) {
const item = importedByRow.get(String(row.id))!
const quantity = Number(item['本次箱数'])
if (!Number.isInteger(quantity) || quantity < 1 || quantity > Number(row.availableQuantity || 0)) {
ElMessage.error(`明细行 ${row.id} 的本次箱数不正确或超过可用库存`); return
const bindings: ImportedRelabelBinding[] = []
for (const [rowId, item] of importedByRow) {
const row = cargoRows.value.find((candidate) => String(candidate.id) === rowId)!
bindings.push({ id: ++skuRelabelSequence, sourceRowId: row.id, cartonMark: item['旧箱唛'],
originalSku: row.sku, newSku: '', quantity: 1, fbaCartonMark: item['新FBA箱唛'],
destinationWarehouseId: item['FBA目的仓'], remark: item['备注'] || '', templateType,
selected: true, shipmentNo: item['FBA货件编号'] })
}
importedRelabelBindings.value = bindings
ElMessage.success(`已读取 ${bindings.length} 条箱号关联,请选择后导入到明细`)
}
function selectAllImportedBindings(value: boolean) {
for (const item of importedRelabelBindings.value) item.selected = value
}
function importSelectedBindingsToDetails() {
const selected = importedRelabelBindings.value.filter((item) => item.selected)
if (!selected.length) { ElMessage.warning('请至少选择一条绑定关系'); return }
const templateType = selected[0].templateType
if (selected.some((item) => item.templateType !== templateType)) { ElMessage.warning('请重新上传同一种绑定模板'); return }
if (templateType === 'carton') {
for (const binding of selected) {
const source = cargoRows.value.find((row) => row.id === binding.sourceRowId)
if (!source) continue
Object.assign(source, { included: true, quantity: 1, shipmentNo: binding.shipmentNo,
cartonMark: binding.cartonMark, fbaCartonMark: binding.fbaCartonMark,
destinationWarehouseId: binding.destinationWarehouseId, remark: binding.remark })
}
row.quantity = quantity
row.shipmentNo = item['FBA货件编号']
row.cartonMark = item['我方箱唛']
row.fbaCartonMark = item['FBA箱唛']
row.destinationWarehouseId = item['FBA目的仓']
row.remark = item['备注'] || ''
labelingMode.value = 'carton'
} else {
const importedRows = selected.map(({ templateType: _, selected: __, shipmentNo, ...item }) => item)
for (const item of importedRows) {
skuRelabelRows.value = skuRelabelRows.value.filter((row) => !(row.cartonMark.trim().toUpperCase() === item.cartonMark.trim().toUpperCase()
&& row.originalSku.trim().toUpperCase() === item.originalSku.trim().toUpperCase()))
skuRelabelRows.value.push(item)
if (templateType === 'mixed') {
const source = cargoRows.value.find((row) => row.id === item.sourceRowId)
const shipmentNo = selected.find((binding) => binding.id === item.id)?.shipmentNo || ''
if (source) Object.assign(source, { included: true, cartonMark: item.cartonMark,
fbaCartonMark: item.fbaCartonMark, destinationWarehouseId: item.destinationWarehouseId, shipmentNo })
}
}
labelingMode.value = templateType === 'mixed' ? 'carton_and_sku' : 'sku'
}
ElMessage.success(`已导入 ${imported.length} 条关联面单明细`)
importedRelabelBindings.value = importedRelabelBindings.value.filter((item) => !item.selected)
ElMessage.success(`已将 ${selected.length} 条绑定关系导入本次换标明细`)
}
function selectDiscardInbound(orderId: string) {
const candidate = discardCandidates.value.find((item) => item.orderId === orderId)
@@ -356,14 +423,15 @@ async function load() {
function query() { void load() }
async function submit() {
const primaryWarehouse = props.service === 'relabel' ? form.sourceWarehouse : form.warehouse
const relabelRows = labelingMode.value === 'carton' ? cargoRows.value : skuRelabelRows.value
const relabelRows = labelingMode.value === 'carton' ? cartonDetailRows.value : skuRelabelRows.value
const invalidCargo = props.service === 'disposal'
? !selectedDiscardOrderId.value || cargoRows.value.every((item) => item.quantity < 1)
|| cargoRows.value.some((item) => item.quantity < 0 || item.quantity > Number(item.availableQuantity || 0))
: relabelRows.length === 0 || (props.service === 'labeling' && labelingMode.value !== 'carton'
? skuRelabelRows.value.some((item) => !item.cartonMark || !item.originalSku || !item.newSku || item.quantity < 1
|| (labelingMode.value === 'carton_and_sku' && (!item.fbaCartonMark || !item.destinationWarehouseId)))
: cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name || item.quantity < 1))
: cargoRows.value.some((item) => (props.service !== 'labeling' && !item.sku) || !item.name
|| (props.service !== 'labeling' && item.quantity < 1)))
if ((props.service === 'relabel' && !form.inboundNo) || (props.service !== 'disposal' && !primaryWarehouse) || invalidCargo) {
ElMessage.warning('请填写必填信息和完整的货物明细'); return
}
@@ -377,10 +445,10 @@ async function submit() {
&& form.relabel && !shippingLabelFile.value) {
ElMessage.warning('选择打印面单时必须上传面单文件'); return
}
if (props.service === 'labeling' && labelingMode.value === 'carton' && cargoRows.value.some((item) => !item.shipmentNo || !item.cartonMark || !item.fbaCartonMark)) {
if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.shipmentNo || !item.cartonMark || !item.fbaCartonMark)) {
ElMessage.warning('请上传关联面单,确保每箱的我方箱唛和 FBA 箱唛完整'); return
}
if (props.service === 'labeling' && labelingMode.value === 'carton' && cargoRows.value.some((item) => !item.destinationWarehouseId || !item.sourceInventoryPositionId)) {
if (props.service === 'labeling' && labelingMode.value === 'carton' && cartonDetailRows.value.some((item) => !item.destinationWarehouseId || !item.sourceInventoryPositionId)) {
ElMessage.warning('请选择有库存的历史入仓单,并为每项填写 FBA 目的仓'); return
}
if (props.service === 'disposal' && !form.reason) {
@@ -402,7 +470,7 @@ async function submit() {
if (props.service === 'labeling') {
const sourceInboundOrders = [...new Set(cargoRows.value.map((item) => item.sourceInboundOrderNo || item.sourceInboundCode).filter((value): value is string => Boolean(value)))].join('、').slice(0, 128)
const items = labelingMode.value === 'carton'
? cargoRows.value.map((item) => ({ sourceInventoryBalanceId: item.sourceInventoryBalanceId!, sourceInventoryPositionId: item.sourceInventoryPositionId!, sourceInboundOrderId: item.sourceInboundOrderId!, skuCode: item.sku, productNameZh: item.name, totalPackageCount: item.quantity, notifiedPackageCount: item.quantity, shipmentNo: item.shipmentNo, trackingCode: form.trackingNo || undefined, newSkuCode: undefined, labelRequirement: form.requirement || undefined, cartonMark: item.cartonMark, fbaCartonMark: item.fbaCartonMark, destinationFbaWarehouseCode: item.destinationWarehouseId, operationType: 'CARTON_LABEL' as const, skuItems: [], remark: item.remark || undefined }))
? cartonDetailRows.value.map((item) => ({ sourceInventoryBalanceId: item.sourceInventoryBalanceId!, sourceInventoryPositionId: item.sourceInventoryPositionId!, sourceInboundOrderId: item.sourceInboundOrderId!, skuCode: item.sku, productNameZh: item.name, totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: item.shipmentNo, trackingCode: form.trackingNo || undefined, newSkuCode: undefined, labelRequirement: form.requirement || undefined, cartonMark: item.cartonMark, fbaCartonMark: item.fbaCartonMark, destinationFbaWarehouseCode: item.destinationWarehouseId, operationType: 'CARTON_LABEL' as const, skuItems: [], remark: item.remark || undefined }))
: buildSkuRelabelItems()
const created = await createRelabel({ customerOrderNo: sourceInboundOrders || '贴标申请', operationWarehouseId: form.warehouse, labelDeadline: form.labelDeadline, contactName: form.contact, contactPhone: form.phone, chargeAmount: form.chargeAmount, remark: form.remark || undefined, idempotencyKey, items,
deliveryMethod: labelingMode.value === 'sku' ? undefined : form.deliveryMethod as 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP',
@@ -496,6 +564,7 @@ function selectTransferFbaAvailability(value: string | number | boolean | undefi
async function selectOperationWarehouse() {
cargoRows.value = []
skuRelabelRows.value = []
importedRelabelBindings.value = []
selectedRelabelInboundOrderId.value = ''
relabelCandidates.value = []
const warehouse = operationWarehouses.value.find((item) => String(item.id) === form.warehouse)
@@ -512,7 +581,7 @@ function selectRelabelInventory(inboundOrderId: string) {
.filter((candidate) => String(candidate.inboundOrderId) === inboundOrderId)
.flatMap((candidate) => candidate.positions.flatMap((position) => Array.from({ length: Math.min(position.availablePackages, 500) }, () => ({
id: ++cargoSequence, sku: candidate.skuCode || '', name: candidate.productNameZh || '库存货物',
quantity: 1, availableQuantity: 1,
included: false, quantity: 1, availableQuantity: 1,
sourceInventoryBalanceId: String(candidate.inventoryBalanceId), sourceInventoryPositionId: String(position.positionId),
sourceInboundOrderId: String(candidate.inboundOrderId), sourceInboundOrderNo: candidate.inboundOrderNo,
sourceInboundCode: candidate.inboundCode, sourceWarehouseName: candidate.overseasWarehouseName,
@@ -520,7 +589,9 @@ function selectRelabelInventory(inboundOrderId: string) {
fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', cartonMark: '', fbaCartonMark: '',
destinationWarehouseId: candidate.destinationFbaWarehouseCode || '', remark: '',
})))).slice(0, 500)
if (cargoRows.value.length) cargoRows.value[0].included = true
skuRelabelRows.value = []
importedRelabelBindings.value = []
shippingLabelFile.value = null
if (labelingMode.value !== 'carton') addSkuRelabelRow()
}
@@ -597,9 +668,15 @@ function selectRelabelInventory(inboundOrderId: string) {
<div class="warehouse-section-heading warehouse-labeling-heading">
<h3>货物明细</h3>
<div class="warehouse-template-actions">
<el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate"><Download :size="15" />下载{{ labelingMode === 'carton' ? '箱唛' : 'SKU' }}模板</el-button>
<el-button size="small" :disabled="!cargoRows.length" @click="templateInput?.click()"><Upload :size="15" />上传{{ labelingMode === 'carton' ? '箱唛' : 'SKU' }}模板</el-button>
<input ref="templateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate">
<el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('sku')"><Download :size="15" />下载 SKU 关联模板</el-button>
<el-button size="small" :disabled="!cargoRows.length" @click="skuTemplateInput?.click()"><Upload :size="15" />上传 SKU 关联</el-button>
<el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('carton')"><Download :size="15" />下载箱号关联模板</el-button>
<el-button size="small" :disabled="!cargoRows.length" @click="cartonTemplateInput?.click()"><Upload :size="15" />上传箱号关联</el-button>
<el-button size="small" :disabled="!cargoRows.length" @click="downloadRelabelTemplate('mixed')"><Download :size="15" />下载 SKU 混箱模板</el-button>
<el-button size="small" :disabled="!cargoRows.length" @click="mixedTemplateInput?.click()"><Upload :size="15" />上传 SKU 混箱</el-button>
<input ref="skuTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'sku')">
<input ref="cartonTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'carton')">
<input ref="mixedTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'mixed')">
</div>
</div>
<div class="warehouse-labeling-mode">
@@ -611,13 +688,17 @@ function selectRelabelInventory(inboundOrderId: string) {
</el-radio-group>
<p>{{ labelingModeHint }}</p>
</div>
<div v-if="importedRelabelBindings.length" class="warehouse-import-preview">
<div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong><p>可只勾选本次需要换标的部分,也可以全选后一次导入。</p></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><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><td>{{ warehouseDisplay(item.destinationWarehouseId) }}</td></tr></tbody></table></div>
</div>
<div class="warehouse-section-heading warehouse-tab-actions">
<p>系统只提供历史入仓单的大概库存位置,不代表箱唛已绑定具体托盘。</p>
<div><strong>绑定明细</strong><p>上传后在下方回显,也可以手动添加;SKU 与箱号关联通过旧箱唛合并。系统只提供大概库存位置,不代表箱唛已绑定具体托盘。</p></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>
<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>FBA 目的仓 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in cargoRows" :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="请输入FBA货件编号" /></td><td><el-input-number v-model="row.quantity" :min="1" :max="row.availableQuantity" controls-position="right" placeholder="请输入箱数" /></td><td><el-input v-model="row.cartonMark" placeholder="请输入我方箱唛" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-select v-model="row.destinationWarehouseId" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></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="cargoRows.length === 0"><td colspan="9"><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" placeholder="请输入我方箱唛" /></td><td><el-input v-model="row.originalSku" placeholder="请输入原 SKU" /></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-select v-model="row.destinationWarehouseId" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></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">暂无换标明细,请点击“添加 SKU 明细”</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>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="请输入FBA货件编号" /></td><td><strong>1 箱</strong></td><td><el-input v-model="row.cartonMark" placeholder="请输入旧箱唛" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-select v-model="row.destinationWarehouseId" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></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="9"><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" placeholder="请输入旧箱唛" @change="applyExistingCartonBinding(row)" /></td><td><el-input v-model="row.originalSku" 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-select v-model="row.destinationWarehouseId" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></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">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
</template>
<template v-else>
<div class="warehouse-section-heading"><h3>货物明细</h3><div v-if="props.service !== 'disposal'"><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div>
@@ -662,4 +743,5 @@ function selectRelabelInventory(inboundOrderId: string) {
.warehouse-labeling-tabs{margin-top:-8px}.warehouse-labeling-heading{align-items:flex-start}.warehouse-labeling-heading h3{margin-bottom:16px}.warehouse-template-actions{display:flex;gap:8px;padding-bottom:12px}.warehouse-tab-actions{align-items:center;margin:2px 0 14px}.warehouse-tab-actions p{margin:0;color:#7d8797;font-size:12px}.warehouse-tab-actions>div{display:flex;gap:8px}.warehouse-sku-notice{margin:0 0 14px;padding:10px 12px;border:1px solid #f2d39b;border-radius:4px;background:#fff8e8;color:#8a6116;font-size:12px}.warehouse-sku-label-table{min-width:980px}.warehouse-sku-label-table :deep(.el-input-number){width:160px}:deep(.warehouse-labeling-tabs>.el-tabs__header){margin-bottom:18px}:deep(.warehouse-labeling-tabs .el-tabs__item){height:42px;padding:0 26px;font-weight:600}.warehouse-labeling-mode{display:flex;align-items:center;gap:14px;margin:0 0 16px;padding:14px 16px;border:1px solid #dce5f5;border-radius:5px;background:#f7f9ff}.warehouse-labeling-mode>span{color:#34445c;font-size:13px;font-weight:650;white-space:nowrap}.warehouse-labeling-mode p{margin:0;color:#71809a;font-size:12px}.warehouse-labeling-mode :deep(.el-radio-group){display:flex;flex-shrink:0}.warehouse-labeling-mode :deep(.el-radio-button__inner){padding:8px 14px}
.warehouse-relabel-detail-table{min-width:1420px}.warehouse-relabel-detail-table td strong{color:#253f67}
.warehouse-native-file{min-height:32px;padding:5px 8px;border:1px solid #dcdfe6;border-radius:4px;background:#fff;color:#606266}.warehouse-fulfillment-grid{margin-bottom:18px}.warehouse-evidence{display:flex;align-items:center;gap:16px;padding:14px;border:1px solid #e7ebf1;border-radius:4px;background:#f8fafc}.warehouse-evidence>span{color:#71809a;font-size:12px}.warehouse-evidence>strong{color:#8a95a5;font-size:12px;font-weight:400}.warehouse-photo-actions{display:flex;flex-wrap:wrap;gap:8px}.warehouse-completion-remark{margin:10px 0 0;color:#596579;font-size:12px}
.warehouse-template-actions{flex-wrap:wrap;justify-content:flex-end}.warehouse-import-preview{margin:0 0 16px;padding:14px;border:1px solid #cfe0f5;border-radius:5px;background:#f8fbff}.warehouse-import-preview .warehouse-section-heading{margin-bottom:10px}.warehouse-import-preview .warehouse-section-heading>div{display:flex;align-items:center;gap:10px}.warehouse-import-preview p{margin:0;color:#7d8797;font-size:12px}.warehouse-binding-preview-table{min-width:980px;background:#fff}
</style>
@@ -27,21 +27,51 @@ test('客户贴标表单不展示客户单号、托盘和内部库存来源列',
test('贴标申请保留关联模板下载和上传入口', () => {
assert.match(view, /downloadRelabelTemplate/)
assert.match(view, /下载\{\{ labelingMode === 'carton' \? '箱唛' : 'SKU' \}\}模板/)
assert.match(view, /templateInput\?\.click\(\)/)
assert.match(view, /上传\{\{ labelingMode === 'carton' \? '箱唛' : 'SKU' \}\}模板/)
assert.match(view, /下载 SKU 关联模板/)
assert.match(view, /上传 SKU 关联/)
assert.match(view, /下载箱号关联模板/)
assert.match(view, /上传箱号关联/)
assert.match(view, /下载 SKU 混箱模板/)
assert.match(view, /上传 SKU 混箱/)
assert.match(view, /skuTemplateInput\?\.click\(\)/)
assert.match(view, /cartonTemplateInput\?\.click\(\)/)
assert.match(view, /importRelabelTemplate/)
assert.match(view, /明细行标识/)
assert.match(view, /'明细行标识', '旧箱唛', '旧SKU标签', '新SKU标签'/)
assert.match(view, /'旧箱唛', '新FBA箱唛', 'FBA目的仓'/)
assert.match(view, /SKU 与箱号关联通过旧箱唛合并/)
assert.doesNotMatch(view, /库存位置ID/)
})
test('贴标货物明细支持手动添加和编辑', () => {
assert.match(view, /手动添加行/)
assert.match(view, /addRelabelCargoRow/)
assert.match(view, /货物明细已按所选入仓单的可用库存带出/)
assert.match(view, /所选入仓单没有更多可添加的库存箱/)
assert.match(view, /<el-input v-model="row\.sku" :readonly="props\.service === 'disposal'"/)
})
test('上传关系支持部分或全量选择后导入本次换标明细', () => {
assert.match(view, /interface ImportedRelabelBinding/)
assert.match(view, /待导入绑定关系/)
assert.match(view, /全选/)
assert.match(view, /导入到明细/)
assert.match(view, /importSelectedBindingsToDetails/)
assert.match(view, /item\.selected/)
assert.match(view, /cartonDetailRows/)
assert.match(view, /included: false/)
assert.match(view, /cargoRows\.value\[0\]\.included = true/)
})
test('箱唛换标一行固定代表一箱', () => {
const cartonTable = view.match(/<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll">.+?(?=<div v-else class="warehouse-table-scroll">)/s)?.[0]
assert.ok(cartonTable)
assert.match(cartonTable, /<th>箱数<\/th>/)
assert.match(cartonTable, /<td><strong>1 箱<\/strong><\/td>/)
assert.doesNotMatch(cartonTable, /v-model="row\.quantity"/)
assert.doesNotMatch(view, /'本次箱数'/)
assert.match(view, /totalPackageCount: 1, notifiedPackageCount: 1/)
})
test('贴标货物明细通过单一换标类型选择三种模式', () => {
assert.match(view, /<el-radio-group v-model="labelingMode" aria-label="换标类型">/)
assert.match(view, /<el-radio-button value="carton">只换箱唛<\/el-radio-button>/)
@@ -49,12 +79,14 @@ test('贴标货物明细通过单一换标类型选择三种模式', () => {
assert.match(view, /<el-radio-button value="carton_and_sku">SKU 和箱唛都换<\/el-radio-button>/)
assert.doesNotMatch(view, /el-tab-pane label="(?:箱唛换标|SKU 换标)"/)
assert.match(view, /interface SkuRelabelRow/)
assert.match(view, /原 SKU \*/)
assert.match(view, /我方箱唛 \*/)
assert.match(view, /旧 SKU \*/)
assert.match(view, /旧箱唛 \*/)
assert.match(view, /换标数量(件)\*/)
assert.match(view, /旧SKU标签/)
assert.match(view, /新SKU标签/)
assert.match(view, /已导入 \$\{rows\.length\} 条 SKU 换标明细/)
assert.match(view, /已读取 \$\{rows\.length\} 条绑定关系/)
assert.match(view, /applyExistingSkuBinding/)
assert.match(view, /applyExistingCartonBinding/)
assert.match(view, /buildSkuRelabelItems/)
assert.match(view, /CARTON_AND_SKU/)
assert.match(view, /changeCarton/)