This commit is contained in:
2026-10-16 16:19:31 +08:00
parent 3d73f1c82d
commit 3d3ca053a6
3 changed files with 49 additions and 44 deletions
@@ -76,6 +76,10 @@ export function updateTransfer(transferNo: string, payload: TransferCreate) {
export function submitTransfer(transferNo: string) {
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/submit` })
}
/** 补充既有SKU的预报箱数,原箱数快照用于并发核对与安全重试。 */
export function increaseTransferForecastCartons(transferNo: string, payload: { expectedPackageCount: number; transferItemId: string; expectedItemPackageCount: number; packageCount: number }) {
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/increase-forecast-cartons`, data: payload })
}
export function listRelabels(params: Record<string, unknown>) {
return apiRequest<PageResult<RelabelSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications', params })
}
@@ -11,7 +11,7 @@ import CompletionConfirmationDialog from '../components/CompletionConfirmationDi
import { returnRelabelCandidate } from '../utils/returnRelabelCandidates'
import {
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel,
getTransferCartonLabel, submitTransfer, updateTransfer, getTransferSourceCartons, type TransferSourceCarton,
getTransferCartonLabel, submitTransfer, updateTransfer, increaseTransferForecastCartons, getTransferSourceCartons, type TransferSourceCarton,
downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, downloadDiscardPhoto, type DiscardPhoto, uploadRelabelShippingLabel,
uploadRelabelFile,
listDiscards, listDiscardCandidates, listRelabelInventoryCandidates, listRelabelHistoricalOrders, listRelabels, listTransfers, cancelDiscard,
@@ -148,6 +148,34 @@ interface ServiceDetail extends Record<string, unknown> {
cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string
}
const selectedDetail = ref<ServiceDetail | null>(null)
// 预报箱唛在提交后即可打印,与PDA实收清点明细分别展示。
const showForecastCartons = ref(false)
const increaseForecastOpen = ref(false)
const increasingForecast = ref(false)
const forecastSnapshot = ref<ServiceDetail | null>(null)
const increaseForecastForm = reactive({ itemId: '', additionalCount: 1 })
const canIncreaseForecast = computed(() => canCreate.value && selectedDetail.value?.transferNo === selectedDetail.value?.inboundCode && ['PENDING_REVIEW', 'WAIT_PDA_RECEIVING'].includes(selectedDetail.value?.status || ''))
/** 保存原预报快照,防止弹窗打开后其他设备的修改被覆盖。 */
function openIncreaseForecast() {
if (!canIncreaseForecast.value || !selectedDetail.value) return
forecastSnapshot.value = selectedDetail.value
increaseForecastForm.itemId = String(selectedDetail.value.items?.[0]?.id || '')
increaseForecastForm.additionalCount = 1; increaseForecastOpen.value = true
}
/** 只补箱唛,SKU预报件数不随包装箱数自动改变。 */
async function saveIncreaseForecast() {
if (increasingForecast.value || !forecastSnapshot.value?.transferNo) return
const snapshot = forecastSnapshot.value
const item = snapshot.items?.find(row => String(row.id) === increaseForecastForm.itemId)
if (!item || !Number.isSafeInteger(increaseForecastForm.additionalCount) || increaseForecastForm.additionalCount < 1) { ElMessage.warning('请选择SKU并填写新增箱数'); return }
increasingForecast.value = true
try {
const result = await increaseTransferForecastCartons(String(snapshot.transferNo), { expectedPackageCount: Number(snapshot.packageCount), transferItemId: String(item.id), expectedItemPackageCount: Number(item.packageCount), packageCount: Number(item.packageCount) + increaseForecastForm.additionalCount })
selectedDetail.value = result as ServiceDetail; increaseForecastOpen.value = false
ElMessage.success('预报箱数已增加,新增箱唛可打印;请通知PDA刷新后继续清点')
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '增加预报箱数失败') }
finally { increasingForecast.value = false }
}
const showTransferCartons = ref(false)
function transferCartonItem(carton: TransferCarton) {
return selectedDetail.value?.items?.find((item) => carton.transferItemId != null && String(item.id) === String(carton.transferItemId))
@@ -612,36 +640,6 @@ async function selectDiscardWarehouse() {
}
function createCargoRow(): CargoRow { return { id: ++cargoSequence, sku: '', name: '', quantity: 1, expectedPackageCount: 1, fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', cartonMark: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '' } }
function addCargoRow() { cargoRows.value.push(createCargoRow()) }
function escapePrintHtml(value: string) {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')
}
function printTransferDraftCartonMarks() {
if (props.service !== 'transfer') return
const validRows = cargoRows.value.filter((row) => row.sku.trim() && Number(row.expectedPackageCount) > 0)
if (!validRows.length || validRows.some((row) => !Number.isInteger(Number(row.expectedPackageCount)))) {
ElMessage.warning('请先填写商品 SKU 和对应的整数箱数'); return
}
const totalCartons = validRows.reduce((sum, row) => sum + Number(row.expectedPackageCount), 0)
if (totalCartons > 500) { ElMessage.warning('单次最多打印 500 张箱唛'); return }
const batch = new Date().toISOString().replace(/\D/g, '').slice(2, 14)
const labels: Array<{ mark: string; sku: string; index: number }> = []
let cartonIndex = 0
for (const [rowIndex, row] of validRows.entries()) {
let marks = transferCustomerMarks(row)
if (marks.length !== Number(row.expectedPackageCount)) {
marks = Array.from({ length: Number(row.expectedPackageCount) }, (_, index) => `CT-${batch}-${String(rowIndex + 1).padStart(2, '0')}-${String(index + 1).padStart(3, '0')}`)
row.customerCartonMarks = marks.join('\n')
}
for (const mark of marks) labels.push({ mark, sku: row.sku.trim(), index: ++cartonIndex })
}
const printWindow = window.open('', '_blank')
if (!printWindow) { ElMessage.warning('浏览器阻止了打印窗口,请允许弹窗后重试'); return }
printWindow.opener = null
const sections = labels.map((label) => `<section class="label"><div class="title">仓储转运客户箱唛</div><div class="mark">${escapePrintHtml(label.mark)}</div><div class="sku">SKU:${escapePrintHtml(label.sku)}</div><div class="meta">${label.index} / ${labels.length}</div></section>`).join('')
printWindow.document.open()
printWindow.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>仓储转运客户箱唛</title><style>@page{size:100mm 100mm;margin:8mm}body{margin:0;font-family:Arial,'Microsoft YaHei',sans-serif;color:#111}.label{height:84mm;display:flex;flex-direction:column;align-items:center;justify-content:center;page-break-after:always;border:1px solid #222;box-sizing:border-box;padding:8mm}.title{font-size:18px;font-weight:700}.mark{margin:11mm 0 6mm;font-size:26px;font-weight:700;letter-spacing:1px;word-break:break-all;text-align:center}.sku{font-size:17px;font-weight:650}.meta{font-size:12px;margin-top:4mm}</style></head><body>${sections}</body></html>`)
printWindow.document.close(); printWindow.focus(); printWindow.print()
}
function addTransferRelabelRow() {
const source = cargoRows.value[Math.min(transferRelabelRows.value.length, Math.max(0, cargoRows.value.length - 1))]
transferRelabelRows.value.push({ ...createCargoRow(), sku: source?.sku || '' })
@@ -1787,7 +1785,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
</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>
<div v-if="!inventoryTransfer && (props.service !== 'disposal' || !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" :disabled="!cargoRows.some((row) => row.sku.trim() && Number(row.expectedPackageCount) > 0)" @click="printTransferDraftCartonMarks"><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' || !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 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' || !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 === 'transfer' ? '选填' : '请输入商品 SKU'" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入中文品名'" /></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="请输入箱数" /></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">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div>
</template>
</div>
@@ -1833,6 +1831,14 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<h3>本次废弃箱子</h3><div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>入仓码</th><th>SKU</th><th>品名</th><th>申请数量</th><th>所选箱唛</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="index"><td>{{ item.inboundCode || '—' }}</td><td>{{ item.skuCode || '—' }}</td><td>{{ item.productName || '—' }}</td><td>{{ item.discardPackages ?? '—' }}</td><td>{{ item.cartonMarks?.join('、') || '全部库存(历史记录无箱唛快照)' }}</td></tr></tbody></table></div>
<h3>废弃现场照片</h3><div v-if="selectedDetail?.photos?.length" class="warehouse-photo-grid"><div v-for="photo in selectedDetail.photos" :key="photo.fileId" class="warehouse-photo-item"><el-image v-if="discardPhotoUrls[photo.fileId]" :src="discardPhotoUrls[photo.fileId]" :preview-src-list="discardPhotoPreviewUrls" :initial-index="discardPhotoPreviewUrls.indexOf(discardPhotoUrls[photo.fileId])" fit="cover" preview-teleported /><div v-else-if="discardPhotoLoading[photo.fileId]" class="warehouse-photo-placeholder">照片加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadDiscardPhoto(photo.fileId)">{{ discardPhotoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</button><small>{{ formatDateTime(photo.submittedAt) }}</small></div></div><div v-else class="warehouse-empty">暂无已提交的废弃现场照片</div>
</div>
<div v-if="props.service === 'transfer' && selectedDetail?.status !== 'DRAFT'" class="warehouse-card warehouse-form-section">
<div class="warehouse-section-heading warehouse-transfer-receipt-heading"><h3>预报箱唛({{ selectedDetail?.packageCount || 0 }} 箱)</h3><div class="warehouse-transfer-receipt-actions"><el-button v-if="canIncreaseForecast" type="primary" size="small" @click="openIncreaseForecast"><Plus :size="14" />增加预报箱数</el-button><el-button type="primary" size="small" @click="printTransferCartons(String(selectedDetail?.transferNo || ''))"><Printer :size="14" />打印预报箱唛</el-button><el-button size="small" @click="showForecastCartons = true">查看预报箱唛</el-button></div></div>
<el-dialog v-model="showForecastCartons" title="预报箱唛明细" width="900px" align-center destroy-on-close append-to-body><el-table :data="selectedDetail?.cartons || []" size="small" border><el-table-column prop="cartonMark" label="仓库唛头" min-width="300" /><el-table-column label="预报SKU" min-width="240"><template #default="{ row }">{{ selectedDetail?.items?.find(item => String(item.id) === String(row.transferItemId))?.skuCode || '—' }}</template></el-table-column><el-table-column label="清点状态" width="130"><template #default="{ row }">{{ row.status === 'COUNTED' ? '已清点' : row.status === 'NOT_RECEIVED' ? '未收到' : '待清点' }}</template></el-table-column></el-table><template #footer><el-button @click="showForecastCartons = false">关闭</el-button></template></el-dialog>
<el-dialog v-model="increaseForecastOpen" title="增加预报箱数" width="560px" align-center destroy-on-close append-to-body :close-on-click-modal="!increasingForecast" :show-close="!increasingForecast">
<el-form label-width="110px"><el-form-item label="预报SKU"><el-select v-model="increaseForecastForm.itemId" :disabled="increasingForecast" style="width:100%"><el-option v-for="item in forecastSnapshot?.items || []" :key="String(item.id)" :value="String(item.id)" :label="item.skuCode || item.productNameZh || '未命名SKU'" /></el-select></el-form-item><el-form-item label="新增箱数"><el-input-number v-model="increaseForecastForm.additionalCount" :disabled="increasingForecast" :min="1" :max="Math.max(1, 5000 - Number(forecastSnapshot?.packageCount || 0))" :precision="0" /></el-form-item></el-form><p class="warehouse-form-hint">保留原箱唛,仅补生成新增箱唛;SKU预报件数保持不变。</p>
<template #footer><el-button :disabled="increasingForecast" @click="increaseForecastOpen = false">取消</el-button><el-button type="primary" :loading="increasingForecast" @click="saveIncreaseForecast">确认增加</el-button></template>
</el-dialog>
</div>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card">
<div class="warehouse-section-heading"><h3>预报与实际签收</h3></div>
<div class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-comparison-table"><colgroup><col style="width:500px" /><col style="width:140px" /><col style="width:130px" /><col style="width:100px" /><col style="width:130px" /><col style="width:130px" /></colgroup><thead><tr><th>商品 SKU</th><th>中文品名</th><th>预报数量(件)</th><th>预计箱数</th><th>实际签收件数</th><th>实际签收箱数</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="String(item.id || index)"><td>{{ item.skuCode || '—' }}</td><td>{{ item.productNameZh || '—' }}</td><td>{{ item.quantity ?? '—' }}</td><td>{{ item.packageCount ?? '—' }}</td><td>{{ receivedItemQuantity(item.id) }}</td><td>{{ receivedItemCartons(item.id).length }}</td></tr><tr v-if="!selectedDetail?.items?.length"><td colspan="6"><div class="warehouse-empty">暂无货物明细</div></td></tr></tbody></table></div>
@@ -1855,7 +1861,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
<div class="warehouse-evidence warehouse-measurement-evidence"><span>测量打托现场照片</span><div v-if="selectedDetail?.measurementPhotos?.length" class="warehouse-photo-grid warehouse-measurement-photo-grid"><div v-for="(photo, index) in selectedDetail.measurementPhotos" :key="photo.fileId" class="warehouse-photo-item warehouse-measurement-photo-item" :title="photo.fileName || `测量照片 ${index + 1}`"><el-image v-if="measurementPhotoUrls[photo.fileId]" :src="measurementPhotoUrls[photo.fileId]" :preview-src-list="measurementPhotoPreviewUrls" :initial-index="measurementPhotoPreviewUrls.indexOf(measurementPhotoUrls[photo.fileId])" :alt="photo.fileName || `测量照片 ${index + 1}`" fit="cover" preview-teleported /><div v-else-if="measurementPhotoLoading[photo.fileId]" class="warehouse-photo-placeholder">照片 {{ index + 1 }} 加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadMeasurementPhoto(photo.fileId)">{{ measurementPhotoErrors[photo.fileId] ? `照片 ${index + 1} 加载失败,点击重试` : `加载照片 ${index + 1}` }}</button></div></div><strong v-else>仓库尚未上传测量打托照片</strong></div>
</div>
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-received-card">
<div class="warehouse-section-heading warehouse-transfer-receipt-heading"><h3>签收箱号明细({{ countedTransferCartons().length }} 箱)</h3><div class="warehouse-transfer-receipt-actions"><el-button v-if="selectedDetail?.status !== 'DRAFT'" native-type="button" type="primary" size="small" @click="printTransferCartons(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))"><Printer :size="14" />打印箱唛</el-button><el-button type="primary" size="small" :disabled="!countedTransferCartons().length" @click="showTransferCartons = true">查看箱号明细</el-button></div></div>
<div class="warehouse-section-heading warehouse-transfer-receipt-heading"><h3>签收箱号明细({{ countedTransferCartons().length }} 箱)</h3><div class="warehouse-transfer-receipt-actions"><el-button type="primary" size="small" :disabled="!countedTransferCartons().length" @click="showTransferCartons = true">查看箱号明细</el-button></div></div>
<div v-if="!countedTransferCartons().length" class="warehouse-empty">暂无实际签收箱,等待 PDA 清点。</div>
<el-dialog v-model="showTransferCartons" title="签收箱号明细" width="1120px" align-center destroy-on-close append-to-body>
<div class="warehouse-cartons-dialog-body">
@@ -102,15 +102,10 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
assert.doesNotMatch(view, /<th v-if="props\.service === 'transfer'">FNSKU \/ 箱唛<\/th>/)
})
test('仓储转运货物明细可按 SKU 和箱数打印客户预贴箱唛', () => {
assert.match(view, /printTransferDraftCartonMarks/)
assert.match(view, /请先填写商品 SKU 和对应的整数箱数/)
assert.match(view, /Array\.from\(\{ length: Number\(row\.expectedPackageCount\) \}/)
assert.match(view, /row\.customerCartonMarks = marks\.join\('\\n'\)/)
assert.match(view, /仓储转运客户箱唛/)
assert.match(view, /@click="printTransferDraftCartonMarks"/)
assert.match(view, /打印唛头/)
assert.match(view, /单次最多打印 500 张箱唛/)
test('仓储转运提交后统一打印服务端预报箱唛', () => {
assert.doesNotMatch(view, /printTransferDraftCartonMarks|CT-\$\{batch\}/)
assert.match(view, /getTransferCartonLabel\(transferNo\)/)
assert.match(view, /打印预报箱唛/)
})
test('仓储转运换标资料按步骤分组展示', () => {
@@ -330,7 +325,7 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/)
assert.match(view, /await submitTransfer\(transferNo\)/)
assert.match(view, /<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, /selectedDetail\?\.status === 'DRAFT'[^>]*[\s\S]*?>提交订单/)
assert.match(view, /native-type="button"[^>]*:loading="saving"[^>]*@click="submit\('draft'\)"/)
@@ -407,7 +402,7 @@ test('预报实收同区对照、托盘卡片换行、签收按钮紧邻标题',
assert.ok(section.includes('class="warehouse-transfer-pallet-sku"'))
assert.doesNotMatch(section, /打印箱唛/)
const receipt = view.slice(view.indexOf('warehouse-transfer-received-card', start)).split('<el-dialog')[0]
assert.ok(receipt.includes('打印箱唛'))
assert.ok(view.includes('打印预报箱唛')); assert.ok(view.includes('增加预报箱数'))
assert.ok(receipt.includes('查看箱号明细'))
assert.match(view, /warehouse-transfer-receipt-heading h3\{flex:0 0 auto/)
})