This commit is contained in:
2026-11-06 10:42:39 +08:00
parent 8d80074266
commit f5f62af4a7
16 changed files with 184 additions and 30 deletions
@@ -23,7 +23,7 @@ const history = ref<OutsourcedBusinessHistory[]>([]), historyPage = ref(1), hist
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
const previewUrls = new Set<string>()
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '关联托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
const taskFields = computed(() => {
const row = detail.value?.row
@@ -68,7 +68,7 @@ onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previ
<template #actions><el-button v-if="detail && access.can(permission.outsourcedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.outsourcedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
<template #state><LoadErrorState v-if="error" title="外配任务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
<template #task><dl v-if="detail" class="density-fields outsourced-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="outsourced-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable></div><section class="outsourced-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="outsourced-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ (row.destinationKeys || [row.destinationKey]).map(destinationLabel).join('、') }}</template></DataTable></div><section class="outsourced-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><div class="workbench-table"><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ outsourcedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车承运金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></div></template>
</OutsourcedBusinessDetailLayout>
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
@@ -30,6 +30,19 @@ beforeEach(() => {
})
describe('outsourced business views', () => {
it('shows the complete destination set on a grouped looseboard order', async () => {
const detail = outsourcedBusinessFixture()
detail.row.destinations.push({ ...detail.row.destinations[0]!, destinationKey: 'destination-2', warehouseCode: 'FBA-2' })
detail.cargo = [{ ...detail.cargo[0]!, orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', destinationKeys: ['destination-1', 'destination-2'], packages: '9' }]
calls.detail.mockResolvedValue(detail)
const wrapper = mount(OutsourcedBusinessDetailView, { global }); await flushPromises()
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'route'); await flushPromises()
const table = wrapper.findAllComponents(Table).find(component => component.props('columns').some((column: { prop?: string }) => column.prop === 'orderNo'))!
expect(table.props('data')).toHaveLength(1)
expect(table.props('data')[0]).toMatchObject({ orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', packages: '9' })
expect(table.text()).toContain('FBA-1、FBA-2')
wrapper.unmount()
})
it('keeps an owned vehicle with pooled cargo in the outsourced task list', async () => {
const detail = outsourcedBusinessFixture()
detail.row.vehicle.ownership = 'OWNED'
@@ -23,7 +23,7 @@ const history = ref<OwnedBusinessHistory[]>([]), historyPage = ref(1), historySi
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
const previewUrls = new Set<string>()
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '关联托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
const taskFields = computed(() => {
const row = detail.value?.row
@@ -68,7 +68,7 @@ onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previ
<template #actions><el-button v-if="detail && access.can(permission.ownedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.ownedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
<template #state><LoadErrorState v-if="error" title="自有业务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
<template #task><dl v-if="detail" class="density-fields owned-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="owned-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable></div><section class="owned-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="owned-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ (row.destinationKeys || [row.destinationKey]).map(destinationLabel).join('、') }}</template></DataTable></div><section class="owned-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><div class="workbench-table"><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ ownedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><p>里程 {{ row.operations.actualDistanceMiles ?? '未登记' }} mile · 油耗 {{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }} L/100km</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></div></template>
</OwnedBusinessDetailLayout>
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
@@ -30,6 +30,19 @@ beforeEach(() => {
})
describe('owned business views', () => {
it('shows one looseboard order with all associated destinations and pallets', async () => {
const detail = ownedBusinessFixture()
detail.row.destinations.push({ ...detail.row.destinations[0]!, destinationKey: 'destination-2', warehouseCode: 'FBA-2' })
detail.cargo = [{ ...detail.cargo[0]!, orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', destinationKeys: ['destination-1', 'destination-2'], packages: '9' }]
calls.detail.mockResolvedValue(detail)
const wrapper = mount(OwnedBusinessDetailView, { global }); await flushPromises()
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'route'); await flushPromises()
const table = wrapper.findAllComponents(Table).find(component => component.props('columns').some((column: { prop?: string }) => column.prop === 'orderNo'))!
expect(table.props('data')).toHaveLength(1)
expect(table.props('data')[0]).toMatchObject({ orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', packages: '9' })
expect(table.text()).toContain('FBA-1、FBA-2')
wrapper.unmount()
})
it('keeps split prices authoritative and arrival times associated with each destination', async () => {
const detail = ownedBusinessFixture()
detail.row.operations.money = { currency: 'USD', amount: '0.00' }; detail.row.unitPrice = '0.00'