This commit is contained in:
2026-10-13 13:46:56 +08:00
parent 1de73b5f1c
commit 8bf7b20b89
10 changed files with 88 additions and 16 deletions
@@ -99,7 +99,7 @@ export interface LastMileOrderSummaryDto {
deliveryOrderNo?: string | null
outboundOrderNo?: string | null
relabelOrderNo?: string | null
relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; createdAt: string }>
relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; quantityUnit?: string; createdAt: string }>
relabelStatus?: string | null
palletMergeFlag?: string | null
/** Optional grouping facts returned by newer last-leg services. */
@@ -66,6 +66,8 @@ export interface RelabelSummaryDto {
totalPackageCount?: number | null
notifiedPackageCount?: number | null
skuCodes?: string | null
productNameZh?: string | null
labelRequirement?: string | null
totalFileId?: string | null
totalFileName?: string | null
totalFileUrl?: string | null
@@ -87,6 +89,8 @@ export interface RelabelSummaryDto {
export interface RelabelItemDto {
id: string
operationType?: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'
skuItems?: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }>
skuCode?: string | null
productNameZh?: string | null
totalPackageCount?: number | null
@@ -168,7 +168,7 @@ const cargoColumns = computed<DataTableColumn[]>(() => isReturnOrder.value ? [
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 120, align: 'right' },
])
const documentColumns: DataTableColumn[] = [
{ key: 'status', prop: 'status', label: '状态', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '换标数量(件)', minWidth: 130 },
{ key: 'status', prop: 'status', label: '状态', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '换标数量(箱/件)', minWidth: 150, formatter: (row: { quantity?: number; quantityUnit?: string }) => `${row.quantity ?? '—'}${row.quantityUnit || '件'}` },
{ key: 'type', prop: 'type', label: '单据类型', minWidth: 150 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 200 },
{ key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 170 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 150 },
]
@@ -0,0 +1,27 @@
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import { getRelabelOrder } from '../api/relabelOrders'
import RelabelOrderDetailView from './RelabelOrderDetailView.vue'
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'RL-1' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) }))
vi.mock('../api/relabelOrders', () => ({ getRelabelOrder: vi.fn(), confirmRelabelCompletion: vi.fn(), getRelabelReviewTask: vi.fn(), returnRelabelForRework: vi.fn(), downloadRelabelCompletionPhoto: vi.fn(), downloadRelabelPdf: vi.fn(), uploadRelabelPdf: vi.fn() }))
it('reloads authoritative status when a cached detail tab is reopened without double loading initially', async () => {
vi.mocked(getRelabelOrder).mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-1', status: 'PENDING_REVIEW' }, items: [], history: [] })
.mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }, items: [], history: [] })
const active = ref(true)
const Host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(RelabelOrderDetailView) : h('div') }) })
const wrapper = mount(Host, { global: { stubs: { OverseasReadOnlyDetail: true, AppDialog: true } } })
await flushPromises()
expect(getRelabelOrder).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent({ name: 'OverseasReadOnlyDetail' }).props('statusText')).toBe('待审核')
active.value = false
await flushPromises()
active.value = true
await flushPromises()
expect(getRelabelOrder).toHaveBeenCalledTimes(2)
expect(wrapper.findComponent({ name: 'OverseasReadOnlyDetail' }).props('statusText')).toBe('待处理')
wrapper.unmount()
})
@@ -18,6 +18,9 @@ describe('RelabelOrderDetailView', () => {
expect(source).toContain("PENDING_REVIEW: '待审核'")
expect(source).toContain('value?.application.customerOrderNo || value?.application.lastlegOrderNo')
expect(source).toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId')
expect(source).toContain('items.flatMap(relabelSkuCodes)')
expect(source).toContain('v-for="(sku, index) in item.skuItems"')
expect(source).toContain('{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件')
})
it('lets platform operators confirm completion or return PDA work for rework', () => {
@@ -1,6 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, onBeforeUnmount } from 'vue'
import { computed, onActivated, onMounted, ref, onBeforeUnmount } from 'vue'
import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -14,6 +14,7 @@ import { downloadRelabelCompletionPhoto, downloadRelabelPdf, getRelabelOrder, up
import { formatOverseasDateTime } from '../lastMilePresentation'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { relabelSkuCodes } from '../utils/relabelSkuDisplay'
const route = useRoute()
const router = useRouter()
@@ -80,7 +81,7 @@ const fieldValues = computed<Record<string, string | number>>(() => {
资金状态: walletStatus(value?.application.walletStatus),
创建时间: formatOverseasDateTime(value?.application.createdAt),
更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—',
商品SKU: items.map((item) => text(item.skuCode)).join('、') || '—',
商品SKU: [...new Set(items.flatMap(relabelSkuCodes))].join('、') || '—',
中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—',
箱数: Number(value?.application.totalPackageCount || 0),
预算总价: '—',
@@ -100,6 +101,7 @@ async function load() {
}
function back() { void router.push({ name: 'overseas-relabel-orders' }) }
onMounted(load)
onActivated(() => { if (!loading.value) void load() })
onBeforeUnmount(() => { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) })
async function uploadPdf(event: Event, scope: 'TOTAL' | 'ITEM', itemId?: string) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
@@ -135,7 +137,7 @@ async function previewCompletionPhoto(fileId: string) {
<table class="density-table">
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>明细PDF</th></tr></thead>
<tbody>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ relabelSkuCodes(item).join('、') || '—' }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td><template v-if="item.skuItems?.length"><div v-for="(sku, index) in item.skuItems" :key="index">{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件</div></template><span v-else>{{ text(item.newSkuCode) }}</span></td><td>{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
<tr v-if="!detail?.items.length"><td class="empty-cell" colspan="8">暂无箱唛换标信息</td></tr>
</tbody>
</table>
@@ -42,6 +42,25 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('RelabelOrdersView', () => {
it('shows every SKU and quantity in the review dialog while keeping one box', async () => {
vi.mocked(listRelabelOrders).mockResolvedValueOnce({ records: [{ id: '1', relabelNo: 'RL-MULTI', skuCodes: 'SKU-A,SKU-B', totalPackageCount: 1, status: 'PENDING_REVIEW' }], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getRelabelOrder).mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-MULTI', totalPackageCount: 1 }, items: [{ id: '1', skuCode: 'SKU-A', newSkuCode: 'NEW-A', skuItems: [{ skuCode: 'SKU-A', newSkuCode: 'NEW-A', quantity: 10 }, { skuCode: 'SKU-B', newSkuCode: 'NEW-B', quantity: 5 }] }], history: [] })
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('SKU-A,SKU-B')
await wrapper.findAll('button').find(button => button.text().trim() === '审核')!.trigger('click')
await flushPromises()
expect(formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SKU-A、SKU-B')
expect(formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('NEW-A × 10 件;NEW-B × 5 件')
expect(formItem(wrapper, '箱数').findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
})
it('renders product names and labeling requirements supplied by the list projection', async () => {
vi.mocked(listRelabelOrders).mockResolvedValueOnce({ records: [{ id: '1', relabelNo: 'RL-1', productNameZh: '商品甲、商品乙', labelRequirement: '贴标要求111', status: 'PENDING_PROCESSING' }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('商品甲、商品乙')
expect(wrapper.text()).toContain('贴标要求111')
})
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'
import { relabelSkuCodes } from '../utils/relabelSkuDisplay'
import { Plus, PackagePlus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
@@ -94,16 +95,15 @@ async function openForm(mode: 'create' | 'edit', source?: object) {
detailLoading.value = true
try {
const detail = await getRelabelOrder(String(row.orderNo))
const item = detail.items?.[0]
Object.assign(form, {
orderNo: String(detail.application?.relabelNo || row.orderNo || ''),
relatedOrderNo: String(detail.application?.lastlegOrderNo || detail.application?.customerOrderNo || row.relatedOrderNo || ''),
sku: String(item?.skuCode || row.sku || ''),
productName: String(item?.productNameZh || row.productName || ''),
cartons: optionalNumber(item?.totalPackageCount ?? detail.application?.totalPackageCount ?? row.cartons),
labelRequirement: String(item?.labelRequirement || row.labelRequirement || ''),
shipmentNo: String(item?.shipmentNo || ''),
newSkuCode: String(item?.newSkuCode || ''),
sku: [...new Set(detail.items.flatMap(relabelSkuCodes))].join('、') || row.sku || '',
productName: [...new Set(detail.items.map(line => line.productNameZh?.trim()).filter(Boolean))].join('、') || row.productName || '',
cartons: optionalNumber(detail.application?.totalPackageCount ?? row.cartons),
labelRequirement: [...new Set(detail.items.map(line => line.labelRequirement?.trim()).filter(Boolean))].join(';') || row.labelRequirement || '',
shipmentNo: [...new Set(detail.items.map(line => line.shipmentNo?.trim()).filter(Boolean))].join('、'),
newSkuCode: detail.items.flatMap(line => line.skuItems?.length ? line.skuItems.map(sku => `${sku.newSkuCode} × ${sku.quantity} 件`) : [line.newSkuCode || '']).filter(Boolean).join(';'),
relabelCost: optionalNumber(detail.application?.chargeAmount ?? row.relabelCost),
remark: String(detail.remark || detail.application?.reviewRemark || row.remark || ''),
})
@@ -205,7 +205,7 @@ async function load(model: QueryModel = filters.value) {
const result = await listRelabelOrders({ page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
rows.value = result.records.map((item) => ({
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
productName: item.productNameZh?.trim() || '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
reviewChannel: item.reviewChannel || undefined,
createdAt: formatOverseasDateTime(item.createdAt),
}))
@@ -216,6 +216,7 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
onActivated(() => { if (!loading.value) void load() })
watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</script>
@@ -241,7 +242,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" :disabled="dialogMode === 'edit'" placeholder="请输入关联订单号" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择 ACTIVE 客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择 ACTIVE+RECEIVING 海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" :disabled="dialogMode === 'edit'" placeholder="请输入商品SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" :type="dialogMode === 'edit' ? 'textarea' : 'text'" :autosize="{ minRows: 1, maxRows: 4 }" :disabled="dialogMode === 'edit'" placeholder="请输入商品SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" :disabled="dialogMode === 'edit'" placeholder="请输入中文品名" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :disabled="dialogMode === 'edit'" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></el-col>
@@ -251,7 +252,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</el-row><h3>作业要求</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" :disabled="dialogMode === 'edit'" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" placeholder="请输入货件号" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :disabled="dialogMode === 'edit'" placeholder="请输入新SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :type="dialogMode === 'edit' ? 'textarea' : 'text'" :autosize="{ minRows: 1, maxRows: 4 }" :disabled="dialogMode === 'edit'" placeholder="请输入新SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :disabled="dialogMode === 'edit'" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-col>
</el-row></el-form>
@@ -0,0 +1,7 @@
import { expect, it } from 'vitest'
import { relabelSkuCodes } from './relabelSkuDisplay'
it('uses every nested SKU, deduplicates and preserves carton-only SKU lists', () => {
expect(relabelSkuCodes({ skuCode: 'FIRST', skuItems: [{ skuCode: 'FIRST' }, { skuCode: 'SECOND' }, { skuCode: 'FIRST' }] })).toEqual(['FIRST', 'SECOND'])
expect(relabelSkuCodes({ skuCode: 'FIRST,SECOND', skuItems: [] })).toEqual(['FIRST', 'SECOND'])
})
@@ -0,0 +1,9 @@
interface RelabelSkuSource {
skuCode?: string | null
skuItems?: Array<{ skuCode: string }> | null
}
export function relabelSkuCodes(item: RelabelSkuSource): string[] {
const codes = item.skuItems?.length ? item.skuItems.map(line => line.skuCode) : (item.skuCode || '').split(',')
return [...new Set(codes.map(code => code.trim()).filter(Boolean))]
}