优化
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
|
import ElementPlus, { ElInputNumber, ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { AxiosError } from 'axios'
|
import { AxiosError } from 'axios'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
@@ -71,10 +71,28 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
|
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
|
||||||
expect(wrapper.text()).toContain('候选入库单与货物')
|
expect(wrapper.text()).toContain('候选入库单与货物')
|
||||||
expect(wrapper.text()).toContain('美森正班海卡包税')
|
expect(wrapper.text()).toContain('美森正班海卡包税')
|
||||||
expect(wrapper.text()).toContain('ORD-2')
|
expect(wrapper.text()).toContain('IN-2')
|
||||||
expect(wrapper.text()).toContain('CB-1 装柜方案')
|
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
|
||||||
expect(wrapper.text()).toContain('LP-99')
|
expect(wrapper.text()).not.toContain('LP-99')
|
||||||
expect(wrapper.text()).toContain('ORD-1')
|
expect(wrapper.text()).toContain('IN-1')
|
||||||
|
expect(wrapper.text()).not.toContain('ORD-1')
|
||||||
|
expect(wrapper.text()).not.toContain('ORD-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows one user-facing container number and hides internal manual or split references', async () => {
|
||||||
|
vi.mocked(listContainers).mockResolvedValue({
|
||||||
|
records: [{ ...container, cabinetNo: 'CAB-20260814-01', containerNo: 'SPLIT-814022117898chain1' }],
|
||||||
|
page: 1,
|
||||||
|
size: 100,
|
||||||
|
total: 1,
|
||||||
|
pages: 1,
|
||||||
|
})
|
||||||
|
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('CAB-20260814-01')
|
||||||
|
expect(wrapper.text()).not.toContain('SPLIT-814022117898chain1')
|
||||||
|
expect(wrapper.text()).not.toContain('LP-99')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('groups containers by channel and reloads candidates when the channel changes', async () => {
|
it('groups containers by channel and reloads candidates when the channel changes', async () => {
|
||||||
@@ -83,6 +101,7 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
id: '9007199254740998',
|
id: '9007199254740998',
|
||||||
planId: '9007199254740999',
|
planId: '9007199254740999',
|
||||||
cabinetNo: 'CB-2',
|
cabinetNo: 'CB-2',
|
||||||
|
containerNo: 'MSCU-2',
|
||||||
channelCode: 'HDHK',
|
channelCode: 'HDHK',
|
||||||
channelName: '合德海卡包税',
|
channelName: '合德海卡包税',
|
||||||
}
|
}
|
||||||
@@ -92,8 +111,9 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.findAll('.container-option')).toHaveLength(1)
|
expect(wrapper.findAll('.container-option')).toHaveLength(1)
|
||||||
expect(wrapper.text()).toContain('CB-1')
|
expect(wrapper.text()).toContain('MSCU-1')
|
||||||
expect(wrapper.text()).not.toContain('CB-2')
|
expect(wrapper.text()).not.toContain('MSCU-2')
|
||||||
|
expect(wrapper.text()).not.toContain('个柜次')
|
||||||
|
|
||||||
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text().includes('合德海卡包税'))
|
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text().includes('合德海卡包税'))
|
||||||
expect(tab).toBeDefined()
|
expect(tab).toBeDefined()
|
||||||
@@ -101,8 +121,8 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.findAll('.container-option')).toHaveLength(1)
|
expect(wrapper.findAll('.container-option')).toHaveLength(1)
|
||||||
expect(wrapper.text()).toContain('CB-2')
|
expect(wrapper.text()).toContain('MSCU-2')
|
||||||
expect(wrapper.text()).not.toContain('CB-1')
|
expect(wrapper.text()).not.toContain('MSCU-1')
|
||||||
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(other.id, other.warehouseId)
|
expect(listSmartLoadingCandidates).toHaveBeenLastCalledWith(other.id, other.warehouseId)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -129,16 +149,14 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
|
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps unsupported prototype filters out of the real workbench', async () => {
|
it('keeps only customer, inbound code and destination warehouse filters', async () => {
|
||||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const statusField = wrapper.findComponent(QueryToolbar).props('fields')
|
const fields = wrapper.findComponent(QueryToolbar).props('fields') as Array<{ key: string; options?: Array<{ label: string; value: string }> }>
|
||||||
.find((field: { key: string }) => field.key === 'status')
|
expect(fields.map((field) => field.key)).toEqual(['customerId', 'inboundCode', 'destinationWarehouse'])
|
||||||
expect(statusField?.options).toEqual([
|
expect(fields.find((field) => field.key === 'destinationWarehouse')?.options).toEqual([
|
||||||
{ label: '待装柜', value: 'WAIT_LOADING' },
|
{ label: 'LAX9', value: '9' },
|
||||||
{ label: '预装柜中', value: 'EDITING' },
|
|
||||||
{ label: '拣货中', value: 'LOADING' },
|
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(wrapper.text()).not.toContain('税率')
|
expect(wrapper.text()).not.toContain('税率')
|
||||||
@@ -152,11 +170,11 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||||
expect(wrapper.text()).toContain('CB-1 装柜方案')
|
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not overwrite the container status when selecting it', async () => {
|
it('keeps waiting and editing empty containers without exposing their status labels', async () => {
|
||||||
const waiting = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-WAIT', planStatus: 'WAIT_LOADING' as const }
|
const waiting = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-WAIT', containerNo: null, planStatus: 'WAIT_LOADING' as const }
|
||||||
vi.mocked(listContainers).mockResolvedValue({ records: [container, waiting], page: 1, size: 100, total: 2, pages: 1 })
|
vi.mocked(listContainers).mockResolvedValue({ records: [container, waiting], page: 1, size: 100, total: 2, pages: 1 })
|
||||||
vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => (
|
vi.mocked(getSmartLoadingPlanByBooking).mockImplementation(async (bookingId) => (
|
||||||
bookingId === waiting.id
|
bookingId === waiting.id
|
||||||
@@ -167,31 +185,27 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const waitingOption = wrapper.findAll('.container-option').find((item) => item.text().includes('CB-WAIT'))!
|
const waitingOption = wrapper.findAll('.container-option').find((item) => item.text().includes('CB-WAIT'))!
|
||||||
expect(waitingOption.text()).toContain('待装柜')
|
expect(waitingOption.text()).toContain('CB-WAIT')
|
||||||
|
expect(waitingOption.text()).not.toContain('待装柜')
|
||||||
|
|
||||||
await waitingOption.trigger('click')
|
await waitingOption.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(waiting.id)
|
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(waiting.id)
|
||||||
expect(waitingOption.text()).toContain('待装柜')
|
expect(waitingOption.text()).toContain('CB-WAIT')
|
||||||
expect(waitingOption.text()).not.toContain('拣货中')
|
expect(waitingOption.text()).not.toContain('拣货中')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps picking containers editable while hiding the push action', async () => {
|
it('excludes picking containers from the empty-container workbench', async () => {
|
||||||
const pushed = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-2', planStatus: 'LOADING' as const }
|
const pushed = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-2', planStatus: 'LOADING' as const }
|
||||||
const pickingPlan = { ...plan, bookingId: pushed.id, status: 'LOADING' as const }
|
|
||||||
vi.mocked(listContainers).mockResolvedValue({ records: [pushed], page: 1, size: 100, total: 1, pages: 1 })
|
vi.mocked(listContainers).mockResolvedValue({ records: [pushed], page: 1, size: 100, total: 1, pages: 1 })
|
||||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(pickingPlan)
|
|
||||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(pushed.id)
|
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
|
||||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(pushed.id, pushed.warehouseId)
|
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
|
||||||
expect(wrapper.text()).toContain('CB-2')
|
expect(wrapper.findAll('.container-option')).toHaveLength(0)
|
||||||
expect(wrapper.text()).toContain('拣货中')
|
expect(wrapper.text()).not.toContain('CB-2')
|
||||||
expect(wrapper.text()).toContain('智能推荐')
|
|
||||||
expect(wrapper.text()).toContain('加入')
|
|
||||||
expect(wrapper.findAll('button').some((item) => item.text().trim() === '推送预装')).toBe(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides completed containers from the smart loading workbench', async () => {
|
it('hides completed containers from the smart loading workbench', async () => {
|
||||||
@@ -206,6 +220,7 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('refreshes the latest plan after A0409 without losing the selected container', async () => {
|
it('refreshes the latest plan after A0409 without losing the selected container', async () => {
|
||||||
|
const warningMessage = vi.spyOn(ElMessage, 'warning').mockImplementation(() => undefined as never)
|
||||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, version: 3 })
|
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, version: 3 })
|
||||||
@@ -224,16 +239,12 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
|
|
||||||
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2)
|
expect(recommendSmartLoading).toHaveBeenCalledWith(plan.id, 2)
|
||||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
|
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
|
||||||
expect(wrapper.text()).toContain('版本 3')
|
expect(warningMessage).toHaveBeenCalledWith('方案已被其他人修改,已刷新最新版本,请重新确认后提交')
|
||||||
expect(wrapper.text()).toContain('已刷新最新版本')
|
expect(wrapper.text()).not.toContain('装柜操作失败')
|
||||||
expect(wrapper.text()).toContain('request-1')
|
expect(wrapper.text()).not.toContain('request-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a picking-specific message when a picked order cannot be removed', async () => {
|
it('shows a picking-specific message when a picked order cannot be removed', async () => {
|
||||||
const loadingContainer = { ...container, planStatus: 'LOADING' as const }
|
|
||||||
const loadingPlan = { ...plan, status: 'LOADING' as const }
|
|
||||||
vi.mocked(listContainers).mockResolvedValue({ records: [loadingContainer], page: 1, size: 100, total: 1, pages: 1 })
|
|
||||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(loadingPlan)
|
|
||||||
vi.mocked(removeSmartLoadingItem).mockRejectedValue(new AxiosError(
|
vi.mocked(removeSmartLoadingItem).mockRejectedValue(new AxiosError(
|
||||||
'conflict',
|
'conflict',
|
||||||
'ERR_BAD_REQUEST',
|
'ERR_BAD_REQUEST',
|
||||||
@@ -253,15 +264,80 @@ describe('GlobalSmartLoadingView', () => {
|
|||||||
|
|
||||||
expect(removeSmartLoadingItem).toHaveBeenCalled()
|
expect(removeSmartLoadingItem).toHaveBeenCalled()
|
||||||
expect(errorMessage).toHaveBeenCalledWith('该订单在拣货中不许删除')
|
expect(errorMessage).toHaveBeenCalledWith('该订单在拣货中不许删除')
|
||||||
expect(wrapper.text()).toContain('该订单在拣货中不许删除')
|
expect(wrapper.text()).not.toContain('装柜操作失败')
|
||||||
|
expect(wrapper.text()).not.toContain('该订单在拣货中不许删除')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes every item with the latest returned plan version', async () => {
|
||||||
|
const secondItem = { ...plan.items[0], id: '102', inboundDetailId: '4', inboundCode: 'IN-2' }
|
||||||
|
const initialPlan = { ...plan, items: [plan.items[0], secondItem] }
|
||||||
|
const afterFirst = { ...plan, version: 3, items: [secondItem] }
|
||||||
|
const emptyPlan = { ...plan, version: 4, items: [] }
|
||||||
|
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValueOnce(initialPlan).mockResolvedValue(emptyPlan)
|
||||||
|
vi.mocked(removeSmartLoadingItem).mockResolvedValueOnce(afterFirst).mockResolvedValueOnce(emptyPlan)
|
||||||
|
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
|
||||||
|
|
||||||
|
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
const removeAllButton = wrapper.findAll('button').find((button) => button.text().includes('全部移除'))
|
||||||
|
expect(removeAllButton).toBeDefined()
|
||||||
|
await removeAllButton!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(1, plan.id, plan.items[0].id, 2, 1)
|
||||||
|
expect(removeSmartLoadingItem).toHaveBeenNthCalledWith(2, plan.id, secondItem.id, 3, 1)
|
||||||
|
expect(wrapper.text()).toContain('当前柜次暂无已装货物')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limits adjusted package count to the assigned count plus remaining availability', async () => {
|
||||||
|
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 4, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
|
||||||
|
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const maxima = wrapper.findAllComponents(ElInputNumber).map((input) => input.props('max'))
|
||||||
|
expect(maxima).toContain(10)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses bordered assignment and adjustment actions at narrow widths', () => {
|
it('uses bordered assignment and adjustment actions at narrow widths', () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||||
expect(source).toContain('size="small"')
|
expect(source).toContain('size="small"')
|
||||||
expect(source).toContain('type="danger" plain')
|
expect(source).toContain('type="danger" plain')
|
||||||
|
expect(source).toContain(':max="adjustmentMax(row)"')
|
||||||
expect(source).not.toContain('link type="danger"')
|
expect(source).not.toContain('link type="danger"')
|
||||||
expect(source).toContain('@media (max-width: 720px)')
|
expect(source).toContain('@media (max-width: 720px)')
|
||||||
expect(source.match(/<DataTable[\s\S]*?\bstripe\b/g)).toHaveLength(2)
|
expect(source.match(/<DataTable[\s\S]*?\bstripe\b/g)).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('bounds all three workbench panes and keeps their content independently scrollable', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('height: clamp(520px, 68vh, 720px); min-height: 0;')
|
||||||
|
expect(source).toContain('.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }')
|
||||||
|
expect(source).toContain('.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { min-height: 0; flex: 1; }')
|
||||||
|
expect(source).toContain('.container-list { display: grid; min-height: 0; flex: 1;')
|
||||||
|
expect(source).toContain('grid-auto-rows: max-content;')
|
||||||
|
expect(source).toContain('overflow-wrap: anywhere;')
|
||||||
|
expect(source).toContain('.candidate-pane,.plan-pane { height: min(68vh, 560px); min-height: 420px; }')
|
||||||
|
expect(source).toContain('.container-pane { max-height: min(58vh, 460px); }')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses overflow-aware channel tabs with a compact right-aligned summary', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('<el-tabs :model-value="activeChannelCode"')
|
||||||
|
expect(source).toContain('<el-tab-pane v-for="channel in channelTabs"')
|
||||||
|
expect(source).toContain(':label="channel.name"')
|
||||||
|
expect(source).not.toContain('channel.containerCount')
|
||||||
|
expect(source).toContain('class="compact-summary" aria-label="当前渠道装柜统计"')
|
||||||
|
expect(source).toContain('<dt>候选货物</dt>')
|
||||||
|
expect(source).toContain('<dt>空柜</dt>')
|
||||||
|
expect(source).toContain('<dt>已装</dt>')
|
||||||
|
expect(source).toContain('<dt>总重量</dt>')
|
||||||
|
expect(source).toContain('<dt>总体积</dt>')
|
||||||
|
expect(source).toContain('.channel-tabs :deep(.el-tabs__content) { display: none; }')
|
||||||
|
expect(source).not.toContain('class="summary-band"')
|
||||||
|
expect(source).toContain('class="capacity-grid"')
|
||||||
|
expect(source).toContain('智能推荐')
|
||||||
|
expect(source).toContain('推送预装')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,13 +46,12 @@ const selectionLoading = ref(false)
|
|||||||
const mutating = ref(false)
|
const mutating = ref(false)
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const selectionError = ref<ApiErrorDetails | null>(null)
|
const selectionError = ref<ApiErrorDetails | null>(null)
|
||||||
const mutationError = ref<ApiErrorDetails | null>(null)
|
|
||||||
const noDraft = ref(false)
|
const noDraft = ref(false)
|
||||||
const assignOpen = ref(false)
|
const assignOpen = ref(false)
|
||||||
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
|
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
|
||||||
const assignForm = reactive({ packageCount: 1 })
|
const assignForm = reactive({ packageCount: 1 })
|
||||||
const quantityDrafts = reactive<Record<string, number>>({})
|
const quantityDrafts = reactive<Record<string, number>>({})
|
||||||
const query = ref<QueryModel>({ customerId: '', orderKeyword: '', containerKeyword: '', status: '' })
|
const query = ref<QueryModel>({ customerId: '', inboundCode: '', destinationWarehouse: '' })
|
||||||
const appliedQuery = ref<QueryModel>({ ...query.value })
|
const appliedQuery = ref<QueryModel>({ ...query.value })
|
||||||
let selectionRequest = 0
|
let selectionRequest = 0
|
||||||
|
|
||||||
@@ -62,15 +61,12 @@ const canDelete = computed(() => access.can('platform:firstleg:smart-loading:del
|
|||||||
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
|
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
|
||||||
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
|
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
|
||||||
const channelTabs = computed(() => {
|
const channelTabs = computed(() => {
|
||||||
const channels = new Map<string, { code: string; name: string; containerCount: number }>()
|
const channels = new Map<string, { code: string; name: string }>()
|
||||||
for (const item of containers.value) {
|
for (const item of containers.value) {
|
||||||
if (!['WAIT_LOADING', 'EDITING'].includes(item.planStatus) || !item.channelCode) continue
|
if (!['WAIT_LOADING', 'EDITING'].includes(item.planStatus) || !item.channelCode) continue
|
||||||
const current = channels.get(item.channelCode)
|
if (!channels.has(item.channelCode)) channels.set(item.channelCode, {
|
||||||
if (current) current.containerCount += 1
|
|
||||||
else channels.set(item.channelCode, {
|
|
||||||
code: item.channelCode,
|
code: item.channelCode,
|
||||||
name: item.channelName || item.channelCode,
|
name: item.channelName || item.channelCode,
|
||||||
containerCount: 1,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return [...channels.values()]
|
return [...channels.values()]
|
||||||
@@ -88,49 +84,45 @@ const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / vol
|
|||||||
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
|
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
|
||||||
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
|
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
|
||||||
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
|
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
|
||||||
|
const destinationWarehouseOptions = computed(() => {
|
||||||
|
const options = new Map<string, string>()
|
||||||
|
for (const item of candidates.value) {
|
||||||
|
const value = destinationWarehouseKey(item)
|
||||||
|
if (value) options.set(value, item.destinationWarehouseName || item.destinationWarehouseCode || value)
|
||||||
|
}
|
||||||
|
return [...options].map(([value, label]) => ({ label, value }))
|
||||||
|
})
|
||||||
const queryFields = computed<QueryField[]>(() => [
|
const queryFields = computed<QueryField[]>(() => [
|
||||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
|
||||||
{ key: 'orderKeyword', type: 'input', label: '订单或货物', placeholder: '订单号、入库单、货物', width: 250 },
|
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 240 },
|
||||||
{ key: 'containerKeyword', type: 'input', label: '柜号', placeholder: '请输入柜号', width: 230 },
|
{ key: 'destinationWarehouse', type: 'select', label: '目的仓', placeholder: '全部目的仓', width: 200, options: destinationWarehouseOptions.value, props: { filterable: true } },
|
||||||
{ key: 'status', type: 'select', label: '柜次状态', width: 160, options: [
|
|
||||||
{ label: '待装柜', value: 'WAIT_LOADING' },
|
|
||||||
{ label: '预装柜中', value: 'EDITING' },
|
|
||||||
] },
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const filteredContainers = computed(() => {
|
const filteredContainers = computed(() => containers.value.filter((item) => (
|
||||||
const keyword = String(appliedQuery.value.containerKeyword || '').trim().toLowerCase()
|
['WAIT_LOADING', 'EDITING'].includes(item.planStatus)
|
||||||
const status = String(appliedQuery.value.status || '')
|
&& Boolean(activeChannelCode.value)
|
||||||
return containers.value.filter((item) => {
|
&& item.channelCode === activeChannelCode.value
|
||||||
if (!['WAIT_LOADING', 'EDITING'].includes(item.planStatus)) return false
|
)))
|
||||||
if (!activeChannelCode.value || item.channelCode !== activeChannelCode.value) return false
|
|
||||||
if (status && item.planStatus !== status) return false
|
|
||||||
if (!keyword) return true
|
|
||||||
return displayContainerNo(item).toLowerCase().includes(keyword)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const filteredCandidates = computed(() => {
|
const filteredCandidates = computed(() => {
|
||||||
const customerId = String(appliedQuery.value.customerId || '')
|
const customerId = String(appliedQuery.value.customerId || '')
|
||||||
const keyword = String(appliedQuery.value.orderKeyword || '').trim().toLowerCase()
|
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
|
||||||
|
const destinationWarehouse = String(appliedQuery.value.destinationWarehouse || '')
|
||||||
return candidates.value.filter((item) => {
|
return candidates.value.filter((item) => {
|
||||||
if (customerId && item.accountId !== customerId) return false
|
if (customerId && item.accountId !== customerId) return false
|
||||||
if (!keyword) return true
|
if (destinationWarehouse && destinationWarehouseKey(item) !== destinationWarehouse) return false
|
||||||
return [item.orderNo, item.inboundCode, item.goodsName]
|
return !inboundCode || String(item.inboundCode || '').toLowerCase().includes(inboundCode)
|
||||||
.some((value) => String(value || '').toLowerCase().includes(keyword))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function preferredContainerId() {
|
function preferredContainerId() {
|
||||||
const visible = filteredContainers.value
|
const visible = filteredContainers.value
|
||||||
if (String(appliedQuery.value.status || '').trim()) return visible[0]?.id || ''
|
|
||||||
return visible.find((item) => item.planStatus === 'EDITING')?.id
|
return visible.find((item) => item.planStatus === 'EDITING')?.id
|
||||||
|| visible.find((item) => item.planStatus === 'WAIT_LOADING')?.id || visible[0]?.id || ''
|
|| visible.find((item) => item.planStatus === 'WAIT_LOADING')?.id || visible[0]?.id || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidateColumns: DataTableColumn[] = [
|
const candidateColumns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'order', label: '订单/入库单', minWidth: 170, slotName: 'order' },
|
{ key: 'order', label: '入仓码', minWidth: 170, slotName: 'order' },
|
||||||
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 150 },
|
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 150 },
|
||||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
||||||
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
|
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
|
||||||
@@ -139,7 +131,7 @@ const candidateColumns: DataTableColumn[] = [
|
|||||||
]
|
]
|
||||||
const itemColumns: DataTableColumn[] = [
|
const itemColumns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'order', label: '订单/入库单', minWidth: 170, slotName: 'order' },
|
{ key: 'order', label: '入仓码', minWidth: 170, slotName: 'order' },
|
||||||
{ key: 'goods', label: '货物', minWidth: 160, slotName: 'goods' },
|
{ key: 'goods', label: '货物', minWidth: 160, slotName: 'goods' },
|
||||||
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
|
{ key: 'packageCount', label: '分配箱数', width: 140, slotName: 'packageCount' },
|
||||||
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
|
{ key: 'volumeCbm', label: '体积', width: 105, slotName: 'volume' },
|
||||||
@@ -152,6 +144,14 @@ function formatNumber(value: number, digits = 3) {
|
|||||||
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
||||||
function candidateRow(source: object) { return source as SmartLoadingCandidate }
|
function candidateRow(source: object) { return source as SmartLoadingCandidate }
|
||||||
function itemRow(source: object) { return source as SmartLoadingItem }
|
function itemRow(source: object) { return source as SmartLoadingItem }
|
||||||
|
function adjustmentMax(source: object) {
|
||||||
|
const item = itemRow(source)
|
||||||
|
const remaining = candidates.value.find((candidate) => candidate.inboundDetailId === item.inboundDetailId)?.availablePackageCount || 0
|
||||||
|
return Math.max(1, item.packageCount + remaining)
|
||||||
|
}
|
||||||
|
function destinationWarehouseKey(candidate: SmartLoadingCandidate) {
|
||||||
|
return candidate.destinationWarehouseId || candidate.destinationWarehouseCode || candidate.destinationWarehouseName || ''
|
||||||
|
}
|
||||||
function displayContainerNo(container: ContainerBookingResource | null | undefined) {
|
function displayContainerNo(container: ContainerBookingResource | null | undefined) {
|
||||||
if (!container) return '柜号待生成'
|
if (!container) return '柜号待生成'
|
||||||
const actual = String(container.containerNo || '').trim()
|
const actual = String(container.containerNo || '').trim()
|
||||||
@@ -250,14 +250,12 @@ async function loadCustomers() {
|
|||||||
async function selectContainer(containerId: string) {
|
async function selectContainer(containerId: string) {
|
||||||
if (selectedContainerId.value === containerId) return
|
if (selectedContainerId.value === containerId) return
|
||||||
selectedContainerId.value = containerId
|
selectedContainerId.value = containerId
|
||||||
mutationError.value = null
|
|
||||||
await loadSelectedData()
|
await loadSelectedData()
|
||||||
}
|
}
|
||||||
async function selectChannel(channelCode: string) {
|
async function selectChannel(channelCode: string) {
|
||||||
if (activeChannelCode.value === channelCode) return
|
if (activeChannelCode.value === channelCode) return
|
||||||
activeChannelCode.value = channelCode
|
activeChannelCode.value = channelCode
|
||||||
selectedContainerId.value = preferredContainerId()
|
selectedContainerId.value = preferredContainerId()
|
||||||
mutationError.value = null
|
|
||||||
await loadSelectedData()
|
await loadSelectedData()
|
||||||
}
|
}
|
||||||
async function applyFilters(model: QueryModel) {
|
async function applyFilters(model: QueryModel) {
|
||||||
@@ -281,6 +279,10 @@ function openAssign(source: object) {
|
|||||||
}
|
}
|
||||||
async function assign() {
|
async function assign() {
|
||||||
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
|
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
|
||||||
|
if (assignForm.packageCount > selectedCandidate.value.availablePackageCount) {
|
||||||
|
ElMessage.warning(`本次最多可分配 ${selectedCandidate.value.availablePackageCount} 箱`)
|
||||||
|
return
|
||||||
|
}
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
acceptPlan(await assignSmartLoadingItem(plan.value!.id, {
|
acceptPlan(await assignSmartLoadingItem(plan.value!.id, {
|
||||||
inboundId: selectedCandidate.value!.inboundId,
|
inboundId: selectedCandidate.value!.inboundId,
|
||||||
@@ -289,6 +291,7 @@ async function assign() {
|
|||||||
expectedVersion: plan.value!.version,
|
expectedVersion: plan.value!.version,
|
||||||
}))
|
}))
|
||||||
assignOpen.value = false
|
assignOpen.value = false
|
||||||
|
await loadSelectedData()
|
||||||
ElMessage.success('货物已加入当前柜次')
|
ElMessage.success('货物已加入当前柜次')
|
||||||
}, '分配货物失败', { preserveAssign: true })
|
}, '分配货物失败', { preserveAssign: true })
|
||||||
}
|
}
|
||||||
@@ -296,6 +299,7 @@ async function recommend() {
|
|||||||
if (!plan.value) return
|
if (!plan.value) return
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version))
|
acceptPlan(await recommendSmartLoading(plan.value!.id, plan.value!.version))
|
||||||
|
await loadSelectedData()
|
||||||
ElMessage.success('智能推荐已生成')
|
ElMessage.success('智能推荐已生成')
|
||||||
}, '智能推荐失败')
|
}, '智能推荐失败')
|
||||||
}
|
}
|
||||||
@@ -304,23 +308,46 @@ async function adjust(source: object) {
|
|||||||
if (!plan.value) return
|
if (!plan.value) return
|
||||||
const count = Number(quantityDrafts[item.id])
|
const count = Number(quantityDrafts[item.id])
|
||||||
if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return }
|
if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return }
|
||||||
|
const max = adjustmentMax(item)
|
||||||
|
if (count > max) { ElMessage.warning(`该货物最多可分配 ${max} 箱`); return }
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
|
acceptPlan(await adjustSmartLoadingItem(plan.value!.id, item.id, count, plan.value!.version, item.version))
|
||||||
|
await loadSelectedData()
|
||||||
ElMessage.success('箱数已调整')
|
ElMessage.success('箱数已调整')
|
||||||
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
|
}, '调整箱数失败', { preserveDrafts: { ...quantityDrafts } })
|
||||||
}
|
}
|
||||||
async function remove(source: object) {
|
async function remove(source: object) {
|
||||||
const item = itemRow(source)
|
const item = itemRow(source)
|
||||||
if (!plan.value) return
|
if (!plan.value) return
|
||||||
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.orderNo || item.id}”吗?`, '移除装柜明细', {
|
const confirmed = await ElMessageBox.confirm(`确认从当前柜次移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', {
|
||||||
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
|
type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消',
|
||||||
}).catch(() => false)
|
}).catch(() => false)
|
||||||
if (!confirmed || !plan.value) return
|
if (!confirmed || !plan.value) return
|
||||||
await mutate(async () => {
|
await mutate(async () => {
|
||||||
acceptPlan(await removeSmartLoadingItem(plan.value!.id, item.id, plan.value!.version, item.version))
|
acceptPlan(await removeSmartLoadingItem(plan.value!.id, item.id, plan.value!.version, item.version))
|
||||||
|
await loadSelectedData()
|
||||||
ElMessage.success('装柜明细已移除')
|
ElMessage.success('装柜明细已移除')
|
||||||
}, '移除装柜明细失败', { pickingConflict: true })
|
}, '移除装柜明细失败', { pickingConflict: true })
|
||||||
}
|
}
|
||||||
|
async function removeAll() {
|
||||||
|
if (!plan.value?.items.length) return
|
||||||
|
const itemCount = plan.value.items.length
|
||||||
|
const confirmed = await ElMessageBox.confirm(`确认移除当前方案的全部 ${itemCount} 条装柜明细吗?`, '全部移除', {
|
||||||
|
type: 'warning', confirmButtonText: '全部移除', cancelButtonText: '取消',
|
||||||
|
}).catch(() => false)
|
||||||
|
if (!confirmed || !plan.value) return
|
||||||
|
await mutate(async () => {
|
||||||
|
let current = plan.value!
|
||||||
|
for (const original of [...current.items]) {
|
||||||
|
const item = current.items.find((value) => value.id === original.id)
|
||||||
|
if (!item) continue
|
||||||
|
current = await removeSmartLoadingItem(current.id, item.id, current.version, item.version)
|
||||||
|
acceptPlan(current)
|
||||||
|
}
|
||||||
|
await loadSelectedData()
|
||||||
|
ElMessage.success(`已移除全部 ${itemCount} 条装柜明细`)
|
||||||
|
}, '全部移除失败', { pickingConflict: true })
|
||||||
|
}
|
||||||
async function pushPreload() {
|
async function pushPreload() {
|
||||||
if (!plan.value) return
|
if (!plan.value) return
|
||||||
const confirmed = await ElMessageBox.confirm('推送后当前方案进入装柜流程,不能继续调整。确认推送预装清单?', '推送预装清单', {
|
const confirmed = await ElMessageBox.confirm('推送后当前方案进入装柜流程,不能继续调整。确认推送预装清单?', '推送预装清单', {
|
||||||
@@ -339,7 +366,6 @@ async function mutate(
|
|||||||
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
|
recovery: { preserveDrafts?: Record<string, number>; preserveAssign?: boolean; pickingConflict?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
mutating.value = true
|
mutating.value = true
|
||||||
mutationError.value = null
|
|
||||||
try {
|
try {
|
||||||
await action()
|
await action()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -349,12 +375,11 @@ async function mutate(
|
|||||||
const pickingMessage = recovery.pickingConflict && details.message.includes('拣货')
|
const pickingMessage = recovery.pickingConflict && details.message.includes('拣货')
|
||||||
? '该订单在拣货中不许删除'
|
? '该订单在拣货中不许删除'
|
||||||
: null
|
: null
|
||||||
mutationError.value = { ...details, message: pickingMessage || '方案已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
|
const message = pickingMessage || '方案已被其他人修改,已刷新最新版本,请重新确认后提交'
|
||||||
if (recovery.preserveAssign) assignOpen.value = true
|
if (recovery.preserveAssign) assignOpen.value = true
|
||||||
if (pickingMessage) ElMessage.error(pickingMessage)
|
if (pickingMessage) ElMessage.error(pickingMessage)
|
||||||
else ElMessage.warning(mutationError.value.message)
|
else ElMessage.warning(message)
|
||||||
} else {
|
} else {
|
||||||
mutationError.value = details
|
|
||||||
ElMessage.error(details.message)
|
ElMessage.error(details.message)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -368,26 +393,24 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
<template>
|
<template>
|
||||||
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
|
<component :is="props.embedded ? 'div' : AdminPage" :class="{ 'embedded-smart-loading': props.embedded }">
|
||||||
<section class="global-loading-page">
|
<section class="global-loading-page">
|
||||||
<nav v-if="channelTabs.length" class="channel-tabs" aria-label="物流渠道" role="tablist">
|
<div v-if="channelTabs.length" class="channel-summary-row">
|
||||||
<button
|
<el-tabs :model-value="activeChannelCode" class="channel-tabs" aria-label="物流渠道" @tab-change="selectChannel(String($event))">
|
||||||
v-for="channel in channelTabs"
|
<el-tab-pane v-for="channel in channelTabs" :key="channel.code" :label="channel.name" :name="channel.code" />
|
||||||
:key="channel.code"
|
</el-tabs>
|
||||||
type="button"
|
<dl class="compact-summary" aria-label="当前渠道装柜统计">
|
||||||
role="tab"
|
<div><dt>候选货物</dt><dd><strong>{{ filteredCandidates.length }}</strong> 条</dd></div>
|
||||||
:aria-selected="channel.code === activeChannelCode"
|
<div><dt>空柜</dt><dd><strong>{{ filteredContainers.length }}</strong> 个</dd></div>
|
||||||
:class="{ active: channel.code === activeChannelCode }"
|
<div><dt>已装</dt><dd><strong>{{ totalPackages }}</strong> 箱</dd></div>
|
||||||
@click="selectChannel(channel.code)"
|
<div><dt>总重量</dt><dd><strong>{{ formatNumber(totalWeight, 1) }}</strong> kg</dd></div>
|
||||||
>
|
<div><dt>总体积</dt><dd><strong>{{ formatNumber(totalVolume) }}</strong> m³</dd></div>
|
||||||
<span>{{ channel.name }}</span>
|
</dl>
|
||||||
<small>{{ channel.containerCount }} 个柜次</small>
|
</div>
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
<el-alert v-else-if="containers.length" type="warning" :closable="false" show-icon title="当前柜次缺少渠道快照,暂时不能参与智能装柜" />
|
<el-alert v-else-if="containers.length" type="warning" :closable="false" show-icon title="当前柜次缺少渠道快照,暂时不能参与智能装柜" />
|
||||||
<QueryToolbar
|
<QueryToolbar
|
||||||
v-model="query"
|
v-model="query"
|
||||||
ariaLabel="智能装柜筛选"
|
ariaLabel="智能装柜筛选"
|
||||||
:fields="queryFields"
|
:fields="queryFields"
|
||||||
preference-key="global-smart-loading-query"
|
preference-key="global-smart-loading-query-v2"
|
||||||
:search-on-reset="false"
|
:search-on-reset="false"
|
||||||
@search="applyFilters"
|
@search="applyFilters"
|
||||||
/>
|
/>
|
||||||
@@ -396,18 +419,6 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<LoadErrorState v-if="mutationError" title="装柜操作失败" :message="mutationError.message" compact retry-text="刷新当前柜次" @retry="loadSelectedData()">
|
|
||||||
<template #details><ApiErrorMeta :details="mutationError" /></template>
|
|
||||||
</LoadErrorState>
|
|
||||||
|
|
||||||
<section class="summary-band" aria-label="当前装柜汇总">
|
|
||||||
<div><span>候选货物</span><strong>{{ filteredCandidates.length }}</strong></div>
|
|
||||||
<div><span>可见空柜</span><strong>{{ filteredContainers.length }}</strong></div>
|
|
||||||
<div><span>当前已装箱数</span><strong>{{ totalPackages }}</strong></div>
|
|
||||||
<div><span>当前总体积</span><strong>{{ formatNumber(totalVolume) }} m³</strong></div>
|
|
||||||
<div><span>当前总重量</span><strong>{{ formatNumber(totalWeight, 1) }} kg</strong></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="dispatch-grid" aria-label="智能装柜调度台">
|
<section class="dispatch-grid" aria-label="智能装柜调度台">
|
||||||
<section class="dispatch-pane candidate-pane">
|
<section class="dispatch-pane candidate-pane">
|
||||||
<header class="pane-header"><div><strong>候选入库单与货物</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
|
<header class="pane-header"><div><strong>候选入库单与货物</strong><small>{{ activeChannel?.name || '未选择渠道' }} · {{ selectedContainer?.warehouseName || '请先选择柜次' }}</small></div></header>
|
||||||
@@ -430,7 +441,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
@click="openAssign(row)"
|
@click="openAssign(row)"
|
||||||
><Plus :size="15" />加入</el-button>
|
><Plus :size="15" />加入</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||||
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template>
|
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template>
|
||||||
<template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0), 1) }} kg</template>
|
<template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0), 1) }} kg</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
@@ -464,6 +475,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
<div class="plan-actions">
|
<div class="plan-actions">
|
||||||
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
|
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||||
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
||||||
|
<el-button v-if="canDelete && editable && plan?.items.length" type="danger" plain :loading="mutating" @click="removeAll"><Trash2 :size="16" />全部移除</el-button>
|
||||||
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
|
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -488,9 +500,9 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
</div>
|
</div>
|
||||||
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无已装货物">
|
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无已装货物">
|
||||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
||||||
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||||
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
|
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :max="adjustmentMax(row)" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
|
||||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
|
||||||
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
@@ -501,7 +513,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
<AppDialog v-model="assignOpen" title="加入当前柜次" :confirm-loading="mutating" confirm-text="确认加入" @confirm="assign">
|
<AppDialog v-model="assignOpen" title="加入当前柜次" :confirm-loading="mutating" confirm-text="确认加入" @confirm="assign">
|
||||||
<div v-if="selectedCandidate" class="assign-dialog">
|
<div v-if="selectedCandidate" class="assign-dialog">
|
||||||
<p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p>
|
<p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p>
|
||||||
<p>{{ selectedCandidate.orderNo || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p>
|
<p>{{ selectedCandidate.inboundCode || '入仓码暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p>
|
||||||
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
|
<el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form>
|
||||||
</div>
|
</div>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
@@ -513,30 +525,33 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.global-loading-page { display: grid; gap: 16px; min-width: 0; }
|
.global-loading-page { display: grid; gap: 16px; min-width: 0; }
|
||||||
.embedded-smart-loading { min-width: 0; }
|
.embedded-smart-loading { min-width: 0; }
|
||||||
.channel-tabs { display: flex; min-height: 48px; overflow-x: auto; border-bottom: 2px solid var(--cc-border); background: #fff; }
|
.channel-summary-row { display: grid; grid-template-columns: minmax(240px, 1fr) auto; min-width: 0; align-items: end; border-bottom: 2px solid var(--cc-border); background: #fff; }
|
||||||
.channel-tabs button { display: flex; min-width: 150px; min-height: 48px; flex: 0 0 auto; align-items: center; justify-content: center; gap: 7px; padding: 8px 18px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--cc-text); cursor: pointer; }
|
.channel-tabs { min-width: 0; }
|
||||||
.channel-tabs button > span { overflow: hidden; max-width: 220px; text-overflow: ellipsis; white-space: nowrap; }
|
.channel-tabs :deep(.el-tabs__header) { margin: 0 0 -2px; }
|
||||||
.channel-tabs button.active { margin-bottom: -2px; border-bottom-color: var(--el-color-primary); color: var(--el-color-primary); font-weight: 600; }
|
.channel-tabs :deep(.el-tabs__nav-wrap::after) { display: none; }
|
||||||
.channel-tabs button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: -3px; }
|
.channel-tabs :deep(.el-tabs__item) { overflow: hidden; max-width: 240px; height: 48px; padding-inline: 18px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.channel-tabs small { color: var(--cc-muted); font-weight: 400; white-space: nowrap; }
|
.channel-tabs :deep(.el-tabs__content) { display: none; }
|
||||||
.summary-band { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
.compact-summary { display: flex; min-height: 48px; align-items: center; justify-content: flex-end; gap: 16px; margin: 0; padding: 6px 12px; white-space: nowrap; }
|
||||||
.summary-band > div { display: grid; gap: 4px; padding: 12px 14px; background: #fff; }
|
.compact-summary > div { display: flex; align-items: baseline; gap: 4px; }
|
||||||
.summary-band span,.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
|
.compact-summary dt { color: var(--cc-muted); font-size: 12px; }
|
||||||
.summary-band strong { font-size: 16px; }
|
.compact-summary dd { margin: 0; color: var(--cc-text); font-size: 12px; }
|
||||||
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) 270px minmax(460px, 1.35fr); min-height: 620px; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
|
.compact-summary strong { color: var(--el-color-primary); font-size: 14px; }
|
||||||
|
.pane-header small,.container-option span,.global-loading-page small { color: var(--cc-muted); font-size: 12px; }
|
||||||
|
.dispatch-grid { display: grid; grid-template-columns: minmax(390px, 1.1fr) 270px minmax(460px, 1.35fr); height: clamp(520px, 68vh, 720px); min-height: 0; overflow-x: auto; border: 1px solid var(--cc-border); background: #fff; }
|
||||||
.embedded-smart-loading .dispatch-grid { min-height: 520px; height: min(62vh, 700px); }
|
.embedded-smart-loading .dispatch-grid { min-height: 520px; height: min(62vh, 700px); }
|
||||||
.dispatch-pane { display: flex; min-width: 0; flex-direction: column; }
|
.dispatch-pane { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
|
||||||
.dispatch-pane + .dispatch-pane { border-left: 1px solid var(--cc-border); }
|
.dispatch-pane + .dispatch-pane { border-left: 1px solid var(--cc-border); }
|
||||||
.pane-header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; }
|
.pane-header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; }
|
||||||
.pane-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
|
.pane-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
|
||||||
.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { min-height: 0; flex: 1; }
|
.candidate-pane :deep(.data-table),.plan-pane :deep(.data-table) { min-height: 0; flex: 1; }
|
||||||
.candidate-pane :deep(.el-table),.plan-pane :deep(.el-table) { height: 100%; }
|
.candidate-pane :deep(.el-table),.plan-pane :deep(.el-table) { height: 100%; }
|
||||||
.container-pane { background: #fafafa; }
|
.container-pane { background: #fafafa; }
|
||||||
.container-list { display: grid; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
|
.container-list { display: grid; min-height: 0; flex: 1; grid-auto-rows: max-content; align-content: start; gap: 8px; padding: 10px; overflow-y: auto; }
|
||||||
.container-option { display: grid; width: 100%; min-height: 118px; gap: 5px; padding: 10px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); text-align: left; cursor: pointer; }
|
.container-option { display: grid; width: 100%; min-height: 0; height: auto; align-content: start; gap: 5px; padding: 10px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-text); line-height: 1.35; text-align: left; cursor: pointer; }
|
||||||
.container-option:hover,.container-option:focus-visible { border-color: var(--el-color-primary); outline: none; }
|
.container-option:hover,.container-option:focus-visible { border-color: var(--el-color-primary); outline: none; }
|
||||||
.container-option.selected { border-color: var(--el-color-primary); box-shadow: inset 3px 0 0 var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
.container-option.selected { border-color: var(--el-color-primary); box-shadow: inset 3px 0 0 var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||||
.container-option__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text) !important; }
|
.container-option__top { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text) !important; }
|
||||||
|
.container-option span,.container-option strong { min-width: 0; overflow-wrap: anywhere; }
|
||||||
.plan-header { flex-wrap: wrap; }
|
.plan-header { flex-wrap: wrap; }
|
||||||
.plan-actions,.row-actions,.meter-title { display: flex; align-items: center; gap: 8px; }
|
.plan-actions,.row-actions,.meter-title { display: flex; align-items: center; gap: 8px; }
|
||||||
.plan-actions,.row-actions { flex-wrap: wrap; }
|
.plan-actions,.row-actions { flex-wrap: wrap; }
|
||||||
@@ -550,18 +565,20 @@ onMounted(() => { void load(); void loadCustomers() })
|
|||||||
.assign-dialog p { margin: 0 0 10px; }
|
.assign-dialog p { margin: 0 0 10px; }
|
||||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.dispatch-grid { grid-template-columns: 1fr; overflow: visible; }
|
.dispatch-grid { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
|
||||||
.embedded-smart-loading .dispatch-grid { height: auto; }
|
.embedded-smart-loading .dispatch-grid { height: auto; }
|
||||||
.dispatch-pane + .dispatch-pane { border-top: 1px solid var(--cc-border); border-left: 0; }
|
.dispatch-pane + .dispatch-pane { border-top: 1px solid var(--cc-border); border-left: 0; }
|
||||||
.candidate-pane,.plan-pane { height: min(70vh, 600px); }
|
.candidate-pane,.plan-pane { height: min(68vh, 560px); min-height: 420px; }
|
||||||
.container-list { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); max-height: 360px; }
|
.container-pane { max-height: min(58vh, 460px); }
|
||||||
|
.container-list { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); max-height: none; }
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.summary-band,.capacity-grid { grid-template-columns: 1fr; }
|
.channel-summary-row { grid-template-columns: minmax(0, 1fr); align-items: stretch; }
|
||||||
|
.compact-summary { min-height: 0; justify-content: flex-start; flex-wrap: wrap; gap: 6px 14px; padding: 8px 12px; white-space: normal; }
|
||||||
|
.capacity-grid { grid-template-columns: 1fr; }
|
||||||
.pane-header,.plan-actions,.meter-title { align-items: stretch; flex-direction: column; }
|
.pane-header,.plan-actions,.meter-title { align-items: stretch; flex-direction: column; }
|
||||||
.plan-actions :deep(.el-button) { width: 100%; min-height: 40px; margin-left: 0; }
|
.plan-actions :deep(.el-button) { width: 100%; min-height: 40px; margin-left: 0; }
|
||||||
.container-list { grid-template-columns: 1fr; }
|
.container-list { grid-template-columns: 1fr; }
|
||||||
.channel-tabs button { min-width: 156px; align-items: flex-start; flex-direction: column; gap: 2px; padding-inline: 12px; text-align: left; }
|
.channel-tabs :deep(.el-tabs__item) { max-width: 180px; padding-inline: 12px; }
|
||||||
.channel-tabs button > span { max-width: 132px; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -43,12 +43,14 @@ describe('SmartLoadingWorkbenchView', () => {
|
|||||||
expect(getContainer).toHaveBeenCalledWith(container.id)
|
expect(getContainer).toHaveBeenCalledWith(container.id)
|
||||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, '8')
|
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, '8')
|
||||||
expect(wrapper.text()).toContain('LP-99')
|
expect(wrapper.text()).toContain('MSCU-1 装柜方案')
|
||||||
expect(wrapper.text()).toContain('ORD-1')
|
expect(wrapper.text()).not.toContain('LP-99')
|
||||||
expect(wrapper.text()).toContain('按 110% 上限')
|
expect(wrapper.text()).toContain('IN-1')
|
||||||
|
expect(wrapper.text()).not.toContain('ORD-1')
|
||||||
|
expect(wrapper.text()).toContain('110% 容量上限')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps picking containers editable while hiding the push action', async () => {
|
it('keeps picking containers out of intelligent loading and links to the preload list', async () => {
|
||||||
vi.mocked(getContainer).mockResolvedValue({ ...container, planStatus: 'LOADING' })
|
vi.mocked(getContainer).mockResolvedValue({ ...container, planStatus: 'LOADING' })
|
||||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'LOADING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
|
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'LOADING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
|
||||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||||||
@@ -56,12 +58,12 @@ describe('SmartLoadingWorkbenchView', () => {
|
|||||||
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
|
||||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.id, container.warehouseId)
|
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
|
||||||
expect(wrapper.text()).toContain('拣货中')
|
expect(wrapper.text()).toContain('该柜号已进入拣货流程')
|
||||||
expect(wrapper.text()).toContain('智能推荐')
|
expect(wrapper.text()).toContain('查看预装清单')
|
||||||
expect(wrapper.text()).toContain('ORD-1')
|
expect(wrapper.text()).not.toContain('智能推荐')
|
||||||
expect(wrapper.findAll('button').some((button) => button.text().includes('推送预装'))).toBe(false)
|
expect(wrapper.text()).not.toContain('ORD-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refreshes the latest plan after A0409', async () => {
|
it('refreshes the latest plan after A0409', async () => {
|
||||||
@@ -80,7 +82,6 @@ describe('SmartLoadingWorkbenchView', () => {
|
|||||||
await wrapper.findAll('button').find((button) => button.text().includes('智能推荐'))!.trigger('click')
|
await wrapper.findAll('button').find((button) => button.text().includes('智能推荐'))!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
|
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
|
||||||
expect(wrapper.text()).toContain('版本 3')
|
|
||||||
expect(wrapper.text()).toContain('已刷新最新版本')
|
expect(wrapper.text()).toContain('已刷新最新版本')
|
||||||
expect(wrapper.text()).toContain('request-1')
|
expect(wrapper.text()).toContain('request-1')
|
||||||
})
|
})
|
||||||
@@ -92,6 +93,8 @@ describe('SmartLoadingWorkbenchView', () => {
|
|||||||
expect(source).toContain("canPush && plan?.status === 'EDITING'")
|
expect(source).toContain("canPush && plan?.status === 'EDITING'")
|
||||||
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo")
|
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo")
|
||||||
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
|
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
|
||||||
|
expect(source).not.toContain("label: '目的仓'")
|
||||||
|
expect(source).not.toContain('plan.planNo')
|
||||||
expect(source.match(/<DataTable[^>]*\bstripe\b/g)).toHaveLength(2)
|
expect(source.match(/<DataTable[^>]*\bstripe\b/g)).toHaveLength(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const displayedWeightPercent = computed(() => percentage(weightPercent.value))
|
|||||||
const itemColumns: DataTableColumn[] = [
|
const itemColumns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
|
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
|
||||||
{ key: 'order', label: '订单/入库单', minWidth: 190, slotName: 'order' },
|
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
|
||||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
||||||
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
|
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
|
||||||
{ key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' },
|
{ key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' },
|
||||||
@@ -62,7 +62,7 @@ const itemColumns: DataTableColumn[] = [
|
|||||||
]
|
]
|
||||||
const candidateColumns: DataTableColumn[] = [
|
const candidateColumns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'order', label: '订单/入库单', minWidth: 190, slotName: 'order' },
|
{ key: 'order', label: '入仓码', minWidth: 190, slotName: 'order' },
|
||||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
||||||
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 180 },
|
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 180 },
|
||||||
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
|
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
|
||||||
@@ -157,7 +157,7 @@ async function adjust(source: object) {
|
|||||||
async function remove(source: object) {
|
async function remove(source: object) {
|
||||||
const item = itemRow(source)
|
const item = itemRow(source)
|
||||||
if (!plan.value) return
|
if (!plan.value) return
|
||||||
await ElMessageBox.confirm(`确认从计划移除“${item.productNameZh || item.orderNo || '该货物'}”吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
await ElMessageBox.confirm(`确认从计划移除“${item.productNameZh || item.inboundCode || '该货物'}”吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||||
if (!confirmed || !plan.value) return
|
if (!confirmed || !plan.value) return
|
||||||
mutating.value = true
|
mutating.value = true
|
||||||
mutationError.value = null
|
mutationError.value = null
|
||||||
@@ -232,7 +232,7 @@ onMounted(load)
|
|||||||
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
||||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
||||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
||||||
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||||
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||||
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
|
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
|
||||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||||
@@ -240,13 +240,13 @@ onMounted(load)
|
|||||||
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
|
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可排柜货物">
|
||||||
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 110% 容量上限校验。</p></div></template>
|
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 110% 容量上限校验。</p></div></template>
|
||||||
<template #actions="{ row }"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1" @click="openAssign(row)"><Plus :size="15" />分配</el-button></template>
|
<template #actions="{ row }"><el-button v-if="canAssign && editable" size="small" type="primary" plain :disabled="assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1" @click="openAssign(row)"><Plus :size="15" />分配</el-button></template>
|
||||||
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||||
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template><template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0),1) }} kg</template>
|
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template><template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0),1) }} kg</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<AppDialog v-model="assignOpen" title="分配候选货物" :confirm-loading="mutating" confirm-text="确认分配" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p><p>{{ selectedCandidate.orderNo || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /></div></AppDialog>
|
<AppDialog v-model="assignOpen" title="分配候选货物" :confirm-loading="mutating" confirm-text="确认分配" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p><p>{{ selectedCandidate.inboundCode || '入仓码暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /></div></AppDialog>
|
||||||
</section>
|
</section>
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
@@ -262,7 +262,7 @@ onMounted(load)
|
|||||||
.workbench h2 + p,.workbench small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
.workbench h2 + p,.workbench small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
||||||
.header-actions,.row-actions { flex-wrap: wrap; gap: 8px; }
|
.header-actions,.row-actions { flex-wrap: wrap; gap: 8px; }
|
||||||
.header-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
.header-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
.summary-band { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
.summary-band { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
||||||
.summary-band>div { display: grid; gap: 5px; padding: 14px; background: #fff; }
|
.summary-band>div { display: grid; gap: 5px; padding: 14px; background: #fff; }
|
||||||
.summary-band span { color: var(--cc-muted); font-size: 12px; }
|
.summary-band span { color: var(--cc-muted); font-size: 12px; }
|
||||||
.capacity-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
.capacity-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
|
|||||||
type FilterModel = QueryModel & {
|
type FilterModel = QueryModel & {
|
||||||
customerId: string
|
customerId: string
|
||||||
inboundCode: string
|
inboundCode: string
|
||||||
orderNo: string
|
|
||||||
warehouseId: string
|
warehouseId: string
|
||||||
expectedArrivalRange: string[]
|
expectedArrivalRange: string[]
|
||||||
}
|
}
|
||||||
@@ -36,7 +35,7 @@ const pageSize = ref(20)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
|
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
|
||||||
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', expectedArrivalRange: [] })
|
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', warehouseId: '', expectedArrivalRange: [] })
|
||||||
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
|
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
|
||||||
const customers = ref<PlatformTenantResource[]>([])
|
const customers = ref<PlatformTenantResource[]>([])
|
||||||
const customerLoadError = ref<ApiErrorDetails | null>(null)
|
const customerLoadError = ref<ApiErrorDetails | null>(null)
|
||||||
@@ -72,7 +71,6 @@ const queryFields = computed<QueryField[]>(() => [
|
|||||||
},
|
},
|
||||||
{ key: 'warehouseId', type: 'select', label: '定位仓库', width: 220, placeholder: '全部仓库', options: warehouseOptions.value, props: { filterable: true } },
|
{ key: 'warehouseId', type: 'select', label: '定位仓库', width: 220, placeholder: '全部仓库', options: warehouseOptions.value, props: { filterable: true } },
|
||||||
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 220, placeholder: '请输入入仓码' },
|
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 220, placeholder: '请输入入仓码' },
|
||||||
{ key: 'orderNo', type: 'input', label: '订单号', width: 220, placeholder: '请输入订单号' },
|
|
||||||
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到达时间', width: 320, placeholder: '请选择日期范围' },
|
{ key: 'expectedArrivalRange', type: 'daterange', label: '预计到达时间', width: 320, placeholder: '请选择日期范围' },
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -142,7 +140,6 @@ async function loadTasks() {
|
|||||||
size: pageSize.value,
|
size: pageSize.value,
|
||||||
warehouseId: applied.warehouseId || undefined,
|
warehouseId: applied.warehouseId || undefined,
|
||||||
inboundCode: applied.inboundCode.trim() || undefined,
|
inboundCode: applied.inboundCode.trim() || undefined,
|
||||||
orderNo: applied.orderNo.trim() || undefined,
|
|
||||||
expectedArrivalFrom: dateParam(applied.expectedArrivalRange[0]),
|
expectedArrivalFrom: dateParam(applied.expectedArrivalRange[0]),
|
||||||
expectedArrivalTo: dateParam(applied.expectedArrivalRange[1], true),
|
expectedArrivalTo: dateParam(applied.expectedArrivalRange[1], true),
|
||||||
}))
|
}))
|
||||||
@@ -163,7 +160,7 @@ function applyFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
filters.value = { customerId: '', inboundCode: '', orderNo: '', warehouseId: '', expectedArrivalRange: [] }
|
filters.value = { customerId: '', inboundCode: '', warehouseId: '', expectedArrivalRange: [] }
|
||||||
appliedFilters.value = { ...filters.value, expectedArrivalRange: [] }
|
appliedFilters.value = { ...filters.value, expectedArrivalRange: [] }
|
||||||
if (page.value === 1) void loadTasks()
|
if (page.value === 1) void loadTasks()
|
||||||
else page.value = 1
|
else page.value = 1
|
||||||
@@ -260,7 +257,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
|||||||
<span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span>
|
<span v-if="!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action">无操作权限</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #inboundCode="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
|
<template #inboundCode="{ row }"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></template>
|
||||||
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
|
<template #processStatus="{ row }"><el-tag effect="light">{{ inboundStatusLabel((row as InboundOrderSummary).processStatus, inboundProcessStatusLabels) }}</el-tag></template>
|
||||||
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
|
<template #measureReviewStatus="{ row }">{{ inboundStatusLabel((row as InboundOrderSummary).measureReviewStatus, inboundReviewStatusLabels) }}</template>
|
||||||
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
|
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ describe('InboundWorkflowWorkspace', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('opens measurement editing and review in prototype-sized centered dialogs', () => {
|
it('opens measurement editing and review in prototype-sized centered dialogs', () => {
|
||||||
expect(source).toContain('title="编辑入库测量数据"')
|
expect(source).toContain(':title="`测量 · ${title}`"')
|
||||||
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
|
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
|
||||||
expect(source).toContain('title="审核入库测量数据"')
|
expect(source).toContain(':title="`测量审核 · ${title}`"')
|
||||||
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
|
expect(source).toContain('width="min(1240px, calc(100vw - 32px))"')
|
||||||
expect(source).toContain('height: 92vh')
|
expect(source).toContain('height: 92vh')
|
||||||
expect(source).toContain('max-height: 92vh')
|
expect(source).toContain('max-height: 92vh')
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ onBeforeUnmount(() => {
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="measurementDialogOpen"
|
v-model="measurementDialogOpen"
|
||||||
class="inbound-operation-dialog inbound-measurement-dialog"
|
class="inbound-operation-dialog inbound-measurement-dialog"
|
||||||
title="编辑入库测量数据"
|
:title="`测量 · ${title}`"
|
||||||
width="min(1240px, calc(100vw - 32px))"
|
width="min(1240px, calc(100vw - 32px))"
|
||||||
top="4vh"
|
top="4vh"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
@@ -221,7 +221,7 @@ onBeforeUnmount(() => {
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="reviewDialogOpen"
|
v-model="reviewDialogOpen"
|
||||||
class="inbound-operation-dialog inbound-review-dialog"
|
class="inbound-operation-dialog inbound-review-dialog"
|
||||||
title="审核入库测量数据"
|
:title="`测量审核 · ${title}`"
|
||||||
width="min(1240px, calc(100vw - 32px))"
|
width="min(1240px, calc(100vw - 32px))"
|
||||||
top="4vh"
|
top="4vh"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ function label(value: string | null, labels: Record<string, string>) { return in
|
|||||||
<div class="summary-heading">
|
<div class="summary-heading">
|
||||||
<span class="summary-icon" aria-hidden="true"><Box :size="20" /></span>
|
<span class="summary-icon" aria-hidden="true"><Box :size="20" /></span>
|
||||||
<div class="summary-title">
|
<div class="summary-title">
|
||||||
<h3>入库码:{{ summary.inboundCode || `入库单 ${summary.id}` }}</h3>
|
<h3>入仓码:{{ summary.inboundCode || '暂缺' }}</h3>
|
||||||
<p class="summary-subtitle">订单 {{ value(summary.orderNo) }} · {{ value(summary.customerName) }}</p>
|
<p class="summary-subtitle">{{ value(summary.customerName) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<el-tag :type="processType(summary.processStatus)" effect="light">{{ processLabel(summary.processStatus) }}</el-tag>
|
<el-tag :type="processType(summary.processStatus)" effect="light">{{ processLabel(summary.processStatus) }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,7 +39,6 @@ function label(value: string | null, labels: Record<string, string>) { return in
|
|||||||
<section aria-labelledby="inbound-basic-heading">
|
<section aria-labelledby="inbound-basic-heading">
|
||||||
<h4 id="inbound-basic-heading">基本信息</h4>
|
<h4 id="inbound-basic-heading">基本信息</h4>
|
||||||
<dl class="summary-grid">
|
<dl class="summary-grid">
|
||||||
<div><dt>头程订单号</dt><dd>{{ value(summary.orderNo) }}</dd></div>
|
|
||||||
<div><dt>订单状态</dt><dd>{{ label(summary.orderStatusSnapshot, inboundOrderStatusLabels) }}</dd></div>
|
<div><dt>订单状态</dt><dd>{{ label(summary.orderStatusSnapshot, inboundOrderStatusLabels) }}</dd></div>
|
||||||
<div><dt>客户</dt><dd>{{ value(summary.customerName) }}</dd></div>
|
<div><dt>客户</dt><dd>{{ value(summary.customerName) }}</dd></div>
|
||||||
<div><dt>公司</dt><dd>{{ value(summary.companyName) }}</dd></div>
|
<div><dt>公司</dt><dd>{{ value(summary.companyName) }}</dd></div>
|
||||||
|
|||||||
@@ -149,13 +149,13 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
const wrapper = await mountPanel()
|
const wrapper = await mountPanel()
|
||||||
|
|
||||||
expect(getInboundMeasurement).toHaveBeenCalledWith('10001', '7001')
|
expect(getInboundMeasurement).toHaveBeenCalledWith('10001', '7001')
|
||||||
expect((wrapper.find('input[readonly]').element as HTMLInputElement).value).toBe('RK202608040001')
|
|
||||||
expect(wrapper.text()).toContain('RK202608040001')
|
expect(wrapper.text()).toContain('RK202608040001')
|
||||||
expect(wrapper.text()).toContain('周长(cm)')
|
|
||||||
expect(wrapper.find('.measurement-table').text()).toContain('190')
|
|
||||||
expect(wrapper.find('.measurement-table').text()).toContain('0.6')
|
expect(wrapper.find('.measurement-table').text()).toContain('0.6')
|
||||||
|
expect(wrapper.find('.measurement-table').text()).not.toContain('周长')
|
||||||
await wrapper.find('.el-table__expand-icon').trigger('click')
|
await wrapper.find('.el-table__expand-icon').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('箱规周长')
|
||||||
|
expect(wrapper.text()).toContain('190 cm')
|
||||||
expect(wrapper.text()).toContain('MJ20210327')
|
expect(wrapper.text()).toContain('MJ20210327')
|
||||||
expect(wrapper.text()).toContain('2026-08-04 10:30:00+08:00')
|
expect(wrapper.text()).toContain('2026-08-04 10:30:00+08:00')
|
||||||
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
|
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
|
||||||
@@ -189,27 +189,29 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
|
|
||||||
const wrapper = await mountPanel()
|
const wrapper = await mountPanel()
|
||||||
|
|
||||||
expect(wrapper.findAll('input[readonly]').some((input) => (input.element as HTMLInputElement).value === '未知来源')).toBe(true)
|
expect(wrapper.text()).toContain('未知来源')
|
||||||
expect(wrapper.text()).not.toContain('LEGACY_DEVICE')
|
expect(wrapper.text()).not.toContain('LEGACY_DEVICE')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('follows the prototype grouping, labels, and editable field order', async () => {
|
it('keeps the first view focused on key totals and moves secondary fields behind disclosure', async () => {
|
||||||
const wrapper = await mountPanel()
|
const wrapper = await mountPanel()
|
||||||
const text = wrapper.text()
|
const text = wrapper.text()
|
||||||
|
|
||||||
expect(text).toContain('原单数据与测量汇总')
|
expect(text).toContain('实测箱数')
|
||||||
expect(text).toContain('扫描箱测量数据明细')
|
expect(text).toContain('货物重量')
|
||||||
expect(text).toContain('订单总箱数')
|
expect(text).toContain('总体积')
|
||||||
expect(text).toContain('确认箱数合计')
|
expect(text).toContain('材积重')
|
||||||
expect(text).toContain('货物总重量')
|
expect(text).toContain('申报信息')
|
||||||
expect(text).toContain('实测总体积')
|
expect(text).toContain('箱规明细')
|
||||||
expect(text).toContain('实测总材积重')
|
|
||||||
expect(text).not.toContain('租户')
|
expect(text).not.toContain('租户')
|
||||||
|
expect(text).not.toContain('数据版本')
|
||||||
|
|
||||||
expect(wrapper.find('.measurement-table').exists()).toBe(true)
|
expect(wrapper.find('.measurement-table').exists()).toBe(true)
|
||||||
expect(wrapper.find('.measurement-item').exists()).toBe(false)
|
expect(wrapper.find('.measurement-item').exists()).toBe(false)
|
||||||
expect(wrapper.find('.measurement-table').text()).toContain('周长(cm)')
|
expect(wrapper.find('.measurement-table').text()).not.toContain('周长')
|
||||||
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
|
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
|
||||||
|
expect(source).toContain('<el-collapse-item name="declaration" title="申报信息">')
|
||||||
|
expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
|
||||||
expect(source).toContain('.measurement-form-grid, .measurement-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
|
expect(source).toContain('.measurement-form-grid, .measurement-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
|
||||||
expect(source).toContain('.measurement-table { width: 100%; font-size: 12px; }')
|
expect(source).toContain('.measurement-table { width: 100%; font-size: 12px; }')
|
||||||
expect(source).toContain('.measurement-data-grid :deep(.el-input__wrapper)')
|
expect(source).toContain('.measurement-data-grid :deep(.el-input__wrapper)')
|
||||||
@@ -224,7 +226,7 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
wrapper.vm.updateActualGrossWeight(125.75)
|
wrapper.vm.updateActualGrossWeight(125.75)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.findAll('input[readonly]').some((input) => (input.element as HTMLInputElement).value === '125.75')).toBe(true)
|
expect(wrapper.find('.measurement-metrics').text()).toContain('125.75')
|
||||||
expect((countInput.element as HTMLInputElement).value).toBe('9')
|
expect((countInput.element as HTMLInputElement).value).toBe('9')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -232,8 +234,8 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
const wrapper = await mountPanel()
|
const wrapper = await mountPanel()
|
||||||
|
|
||||||
expect(wrapper.classes()).toContain('surface-panel')
|
expect(wrapper.classes()).toContain('surface-panel')
|
||||||
expect(wrapper.find('.measurement-panel__heading').exists()).toBe(true)
|
expect(wrapper.find('.measurement-summary-header').exists()).toBe(true)
|
||||||
expect(wrapper.findAll('input[readonly]').some((input) => (input.element as HTMLInputElement).value === '测量中')).toBe(true)
|
expect(wrapper.find('.measurement-summary-status').text()).toContain('测量中')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saves only the changed confirmed package count with the current measurement version', async () => {
|
it('saves only the changed confirmed package count with the current measurement version', async () => {
|
||||||
@@ -343,8 +345,9 @@ describe('InboundMeasurementPanel', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(getInboundMeasurement).toHaveBeenLastCalledWith('10002', '7002')
|
expect(getInboundMeasurement).toHaveBeenLastCalledWith('10002', '7002')
|
||||||
expect(wrapper.findAll('input[readonly]').some((input) => (input.element as HTMLInputElement).value === 'RK202608040002')).toBe(true)
|
expect(wrapper.find('.measurement-summary-header').text()).toContain('RK202608040002')
|
||||||
expect(wrapper.findAll('input[readonly]').some((input) => (input.element as HTMLInputElement).value === '测试企业')).toBe(false)
|
expect(wrapper.find('.measurement-summary-header').text()).toContain('第二企业')
|
||||||
|
expect(wrapper.find('.measurement-summary-header').text()).not.toContain('测试企业')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders a permission-specific read-only state without requesting data', async () => {
|
it('renders a permission-specific read-only state without requesting data', async () => {
|
||||||
|
|||||||
@@ -516,32 +516,45 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
|||||||
<form class="measurement-form" @submit.prevent="saveDraft">
|
<form class="measurement-form" @submit.prevent="saveDraft">
|
||||||
<fieldset :disabled="!mayMaintain || saving">
|
<fieldset :disabled="!mayMaintain || saving">
|
||||||
<legend class="sr-only">测量草稿</legend>
|
<legend class="sr-only">测量草稿</legend>
|
||||||
<section class="measurement-section">
|
<section class="measurement-section measurement-summary-section">
|
||||||
<div class="measurement-section__heading measurement-panel__heading">
|
<header class="measurement-summary-header">
|
||||||
<div><h3>原单数据与测量汇总</h3></div>
|
<div>
|
||||||
|
<span>入仓码</span>
|
||||||
|
<strong>{{ detail.inboundCode || '—' }}</strong>
|
||||||
|
<p>{{ detail.companyName || '客户未命名' }} · {{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="measurement-summary-status">
|
||||||
|
<el-tag effect="light">{{ processStatusLabel(detail.processStatus) }}</el-tag>
|
||||||
|
<span>{{ sourceLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<dl class="measurement-metrics" aria-label="测量结果汇总">
|
||||||
|
<div :class="{ 'measurement-metric--warning': !packageCountMatched }">
|
||||||
|
<dt>实测箱数</dt>
|
||||||
|
<dd>{{ formatNumber(confirmedPackageCount, 0) }} <small>/ 申报 {{ formatNumber(expectedPackageCount, 0) }}</small></dd>
|
||||||
|
</div>
|
||||||
|
<div class="measurement-metric--primary"><dt>货物重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>kg</small></dd></div>
|
||||||
|
<div><dt>总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>m³</small></dd></div>
|
||||||
|
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>kg</small></dd></div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<el-collapse class="measurement-more">
|
||||||
|
<el-collapse-item name="declaration" title="申报信息">
|
||||||
<div class="measurement-data-grid">
|
<div class="measurement-data-grid">
|
||||||
<label><span>入仓号</span><el-input :model-value="detail.inboundCode || '—'" readonly /></label>
|
<label><span>申报箱数</span><el-input :model-value="formatNumber(expectedPackageCount, 0)" readonly /></label>
|
||||||
<label><span>公司</span><el-input :model-value="detail.companyName || '—'" readonly /></label>
|
<label><span>申报毛重(kg)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" controls-position="right" /></label>
|
||||||
<label><span>渠道</span><el-input :model-value="detail.channelName || detail.channelCode || '—'" readonly /></label>
|
<label><span>申报体积(m³)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" controls-position="right" /></label>
|
||||||
<label><span>测量来源</span><el-input :model-value="sourceLabel" readonly /></label>
|
|
||||||
<label><span>当前状态</span><el-input :model-value="processStatusLabel(detail.processStatus)" readonly /></label>
|
|
||||||
<label><span>数据版本</span><el-input :model-value="String(form.version)" readonly /></label>
|
|
||||||
<label><span>确认箱数合计</span><el-input :model-value="formatNumber(confirmedPackageCount, 0)" readonly /></label>
|
|
||||||
<label><span>订单总箱数</span><el-input :model-value="formatNumber(expectedPackageCount, 0)" readonly /></label>
|
|
||||||
<label><span>货物总重量(kg)</span><el-input :model-value="formatNumber(detail.actualGrossWeightKg)" readonly /></label>
|
|
||||||
<label><span>原单总毛重(T.K.GW)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" controls-position="right" /></label>
|
|
||||||
<label><span>实测总体积(T.CBM)</span><el-input :model-value="formatNumber(detail.actualVolumeCbm, 4)" readonly /></label>
|
|
||||||
<label><span>原单总体积(T.CBM)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" controls-position="right" /></label>
|
|
||||||
<label><span>实测总材积重(kg)</span><el-input :model-value="formatNumber(detail.volumeWeightKg)" readonly /></label>
|
|
||||||
<label><span>原单据号码(CKW)</span><el-input v-model="form.originalDocumentNo" placeholder="请输入原单据号码" maxlength="128" /></label>
|
|
||||||
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="128" /></label>
|
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="128" /></label>
|
||||||
|
<label><span>原单据号码</span><el-input v-model="form.originalDocumentNo" placeholder="请输入原单据号码" maxlength="128" /></label>
|
||||||
</div>
|
</div>
|
||||||
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="measurement-section measurement-items-section">
|
<section class="measurement-section measurement-items-section">
|
||||||
<div class="measurement-section__heading">
|
<div class="measurement-section__heading">
|
||||||
<div><h3>扫描箱测量数据明细</h3><p>每条记录对应一个实际扫描箱;人工只确认该条记录代表的箱数,设备实测值不可在此修改。</p></div>
|
<div><h3>箱规明细</h3></div>
|
||||||
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
|
<el-button v-if="canManualEntry" type="primary" plain :disabled="!mayCreateManualEntry" @click="openManualEntry"><Plus :size="15" />手动录入箱规</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -559,7 +572,10 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
|||||||
<dl class="measurement-row-details">
|
<dl class="measurement-row-details">
|
||||||
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
|
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
|
||||||
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
|
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
|
||||||
|
<div><dt>每箱毛重</dt><dd>{{ formatNumber(row.unitWeightKg) }} kg</dd></div>
|
||||||
|
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }} m³</dd></div>
|
||||||
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
|
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
|
||||||
|
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
|
||||||
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
|
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</template>
|
</template>
|
||||||
@@ -585,24 +601,12 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="每箱毛重(kg)" width="106" align="right">
|
<el-table-column label="总重量(kg)" width="112" align="right">
|
||||||
<template #default="{ row }">{{ formatNumber(row.unitWeightKg) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="单箱体积(m³)" width="112" align="right">
|
|
||||||
<template #default="{ row }">{{ formatNumber(row.serverValues.unitVolumeCbm, 4) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="周长(cm)" width="92" align="right">
|
|
||||||
<template #default="{ row }">{{ formatNumber(row.serverValues.girthCm) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="总毛重(kg)" width="104" align="right">
|
|
||||||
<template #default="{ row }">{{ formatNumber(row.serverValues.totalWeightKg) }}</template>
|
<template #default="{ row }">{{ formatNumber(row.serverValues.totalWeightKg) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="总体积(m³)" width="108" align="right">
|
<el-table-column label="总体积(m³)" width="108" align="right">
|
||||||
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}</template>
|
<template #default="{ row }">{{ formatNumber(row.serverValues.totalVolumeCbm, 4) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="总材积重(kg)" width="116" align="right">
|
|
||||||
<template #default="{ row }">{{ formatNumber(row.serverValues.volumeWeightKg) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="图片" width="82" align="center">
|
<el-table-column label="图片" width="82" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tooltip :content="row.intelligentImageFileId ? '预览测量图片' : '暂无测量图片'">
|
<el-tooltip :content="row.intelligentImageFileId ? '预览测量图片' : '暂无测量图片'">
|
||||||
@@ -620,7 +624,7 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
|||||||
|
|
||||||
<footer v-if="canEdit || canSubmit" class="measurement-panel__footer">
|
<footer v-if="canEdit || canSubmit" class="measurement-panel__footer">
|
||||||
<span v-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
|
<span v-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
|
||||||
<span v-else>当前显示版本 {{ form.version }}</span>
|
<span v-else>所有修改已保存</span>
|
||||||
<div>
|
<div>
|
||||||
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft"><Save :size="15" />保存测量数据</el-button>
|
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft"><Save :size="15" />保存测量数据</el-button>
|
||||||
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview"><Send :size="15" />提交审核</el-button>
|
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview"><Send :size="15" />提交审核</el-button>
|
||||||
@@ -680,20 +684,33 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
|||||||
.measurement-panel__actions, .measurement-panel__footer > div { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
.measurement-panel__actions, .measurement-panel__footer > div { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||||
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
|
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
|
||||||
.measurement-skeleton { padding: 24px; }
|
.measurement-skeleton { padding: 24px; }
|
||||||
.measurement-context { padding: 16px 20px; border-bottom: 1px solid var(--cc-border); }
|
|
||||||
.measurement-context__identity { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
|
||||||
.measurement-context__identity > div:first-child { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
|
||||||
.measurement-context__identity strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 15px; }
|
|
||||||
.measurement-context__identity span { color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
|
||||||
.measurement-tags { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 7px; }
|
|
||||||
.measurement-tags > span:last-child { color: var(--cc-muted); font-size: 11px; }
|
|
||||||
.measurement-metrics { display: none; }
|
|
||||||
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: 16px 20px 0; width: auto; }
|
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: 16px 20px 0; width: auto; }
|
||||||
.measurement-alert p { margin: 5px 0 0; line-height: 1.55; }
|
.measurement-alert p { margin: 5px 0 0; line-height: 1.55; }
|
||||||
.measurement-alert ul { margin: 7px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.65; }
|
.measurement-alert ul { margin: 7px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.65; }
|
||||||
.measurement-alert .el-button { margin-top: 9px; }
|
.measurement-alert .el-button { margin-top: 9px; }
|
||||||
.measurement-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
|
.measurement-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||||
.measurement-section { padding: 18px 20px; border-top: 1px solid var(--cc-border); }
|
.measurement-section { padding: 18px 20px; border-top: 1px solid var(--cc-border); }
|
||||||
|
.measurement-summary-section { display: grid; gap: 16px; border-top: 0; }
|
||||||
|
.measurement-summary-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||||
|
.measurement-summary-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
|
||||||
|
.measurement-summary-header span { color: var(--cc-muted); font-size: 11px; }
|
||||||
|
.measurement-summary-header strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 20px; line-height: 1.3; }
|
||||||
|
.measurement-summary-header p { margin: 0; color: var(--cc-muted); font-size: 12px; }
|
||||||
|
.measurement-summary-status { display: flex; flex: 0 0 auto; align-items: center; gap: 9px; }
|
||||||
|
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
||||||
|
.measurement-metrics > div { display: grid; min-width: 0; min-height: 78px; align-content: center; gap: 6px; padding: 13px 16px; border-right: 1px solid var(--cc-border); }
|
||||||
|
.measurement-metrics > div:last-child { border-right: 0; }
|
||||||
|
.measurement-metrics dt { color: var(--cc-muted); font-size: 12px; }
|
||||||
|
.measurement-metrics dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 19px; font-weight: 700; overflow-wrap: anywhere; }
|
||||||
|
.measurement-metrics small { color: var(--cc-muted); font-size: 11px; font-weight: 500; }
|
||||||
|
.measurement-metric--primary { background: #f0f7ff; }
|
||||||
|
.measurement-metric--primary dd { color: var(--cc-primary); font-size: 23px; }
|
||||||
|
.measurement-metric--warning { background: var(--el-color-warning-light-9); }
|
||||||
|
.measurement-metric--warning dd { color: var(--el-color-warning-dark-2); }
|
||||||
|
.measurement-more { border-top: 0; border-bottom: 0; }
|
||||||
|
.measurement-more :deep(.el-collapse-item__header) { height: 38px; color: var(--cc-muted); font-size: 12px; }
|
||||||
|
.measurement-more :deep(.el-collapse-item__wrap) { border-bottom: 0; }
|
||||||
|
.measurement-more :deep(.el-collapse-item__content) { padding: 4px 0 2px; }
|
||||||
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||||||
.measurement-section__heading > div { min-width: 0; }
|
.measurement-section__heading > div { min-width: 0; }
|
||||||
.measurement-form-grid, .measurement-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; }
|
.measurement-form-grid, .measurement-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; }
|
||||||
@@ -732,11 +749,11 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.measurement-panel__heading, .measurement-context__identity, .measurement-section__heading, .measurement-panel__footer { align-items: stretch; flex-direction: column; }
|
.measurement-panel__heading, .measurement-summary-header, .measurement-section__heading, .measurement-panel__footer { align-items: stretch; flex-direction: column; }
|
||||||
.measurement-panel__actions, .measurement-panel__actions :deep(.el-button), .measurement-panel__footer > div, .measurement-panel__footer :deep(.el-button) { width: 100%; }
|
.measurement-panel__actions, .measurement-panel__actions :deep(.el-button), .measurement-panel__footer > div, .measurement-panel__footer :deep(.el-button) { width: 100%; }
|
||||||
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button), .measurement-section__heading :deep(.el-button) { min-height: 44px; }
|
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button), .measurement-section__heading :deep(.el-button) { min-height: 44px; }
|
||||||
.measurement-tags { justify-content: flex-start; }
|
.measurement-tags { justify-content: flex-start; }
|
||||||
.measurement-context, .measurement-section { padding-right: 14px; padding-left: 14px; }
|
.measurement-section { padding-right: 14px; padding-left: 14px; }
|
||||||
.measurement-panel > :deep(.el-alert), .measurement-alert { margin-right: 14px; margin-left: 14px; }
|
.measurement-panel > :deep(.el-alert), .measurement-alert { margin-right: 14px; margin-left: 14px; }
|
||||||
.measurement-form-grid, .measurement-data-grid { grid-template-columns: 1fr; }
|
.measurement-form-grid, .measurement-data-grid { grid-template-columns: 1fr; }
|
||||||
.measurement-row-details { grid-template-columns: 1fr; padding-left: 54px; }
|
.measurement-row-details { grid-template-columns: 1fr; padding-left: 54px; }
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { inboundFeeStatusLabels, inboundLoadingStatusLabels, inboundOrderStatusL
|
|||||||
type FilterModel = QueryModel & {
|
type FilterModel = QueryModel & {
|
||||||
customerId: string
|
customerId: string
|
||||||
inboundCode: string
|
inboundCode: string
|
||||||
orderNo: string
|
|
||||||
warehouseId: string
|
warehouseId: string
|
||||||
deliveryWarehouseId: string
|
deliveryWarehouseId: string
|
||||||
processStatus: string
|
processStatus: string
|
||||||
@@ -44,7 +43,7 @@ const loading = ref(false)
|
|||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const printingInboundId = ref('')
|
const printingInboundId = ref('')
|
||||||
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
|
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
|
||||||
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
|
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
|
||||||
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
|
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
|
||||||
const selectedCustomerId = ref('')
|
const selectedCustomerId = ref('')
|
||||||
const appliedCustomerId = ref('')
|
const appliedCustomerId = ref('')
|
||||||
@@ -86,7 +85,6 @@ const allQueryFields = computed<QueryField[]>(() => [
|
|||||||
{ key: 'warehouseId', type: 'select', width: 180, label: '定位仓库', placeholder: warehousesUnavailable.value ? '定位仓库加载失败' : '定位仓库', options: warehouseOptions.value, props: { 'aria-label': '定位仓库', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
|
{ key: 'warehouseId', type: 'select', width: 180, label: '定位仓库', placeholder: warehousesUnavailable.value ? '定位仓库加载失败' : '定位仓库', options: warehouseOptions.value, props: { 'aria-label': '定位仓库', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
|
||||||
{ key: 'deliveryWarehouseId', type: 'select', width: 180, label: '交货仓', placeholder: warehousesUnavailable.value ? '交货仓加载失败' : '交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
|
{ key: 'deliveryWarehouseId', type: 'select', width: 180, label: '交货仓', placeholder: warehousesUnavailable.value ? '交货仓加载失败' : '交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
|
||||||
{ key: 'inboundCode', type: 'input', width: 220, label: '入仓码', placeholder: '入仓码' },
|
{ key: 'inboundCode', type: 'input', width: 220, label: '入仓码', placeholder: '入仓码' },
|
||||||
{ key: 'orderNo', type: 'input', width: 220, label: '头程订单号', placeholder: '头程订单号' },
|
|
||||||
{ key: 'processStatus', type: 'select', width: 160, label: '流程状态', placeholder: '流程状态', options: [
|
{ key: 'processStatus', type: 'select', width: 160, label: '流程状态', placeholder: '流程状态', options: [
|
||||||
{ label: '待测量', value: 'WAIT_MEASURE' }, { label: '测量中', value: 'MEASURING' },
|
{ label: '待测量', value: 'WAIT_MEASURE' }, { label: '测量中', value: 'MEASURING' },
|
||||||
{ label: '待测量审核', value: 'MEASURE_PENDING' }, { label: '测量已驳回', value: 'MEASURE_REJECTED' },
|
{ label: '待测量审核', value: 'MEASURE_PENDING' }, { label: '测量已驳回', value: 'MEASURE_REJECTED' },
|
||||||
@@ -251,7 +249,6 @@ async function loadInbounds() {
|
|||||||
size: pageSize.value,
|
size: pageSize.value,
|
||||||
tab: tab.value || undefined,
|
tab: tab.value || undefined,
|
||||||
inboundCode: queryValue(applied.inboundCode),
|
inboundCode: queryValue(applied.inboundCode),
|
||||||
orderNo: queryValue(applied.orderNo),
|
|
||||||
warehouseId: queryValue(applied.warehouseId),
|
warehouseId: queryValue(applied.warehouseId),
|
||||||
deliveryWarehouseId: queryValue(applied.deliveryWarehouseId),
|
deliveryWarehouseId: queryValue(applied.deliveryWarehouseId),
|
||||||
processStatus: queryValue(applied.processStatus),
|
processStatus: queryValue(applied.processStatus),
|
||||||
@@ -416,7 +413,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; customerRequestId +
|
|||||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="inbound-orders-table" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
|
||||||
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
|
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
|
||||||
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button size="small" type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button size="small" plain type="primary" :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button size="small" plain type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
|
<template #actions="{ row }"><div v-if="access.can('platform:firstleg:inbound:view')" class="row-actions"><el-button size="small" type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button size="small" plain type="primary" :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button size="small" plain type="primary" :disabled="!(row as InboundOrderSummary).orderId" @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div><span v-else class="muted-action">无查看权限</span></template>
|
||||||
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
|
<template #inbound="{ row }"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong></template>
|
||||||
<template #processStatus="{ row }"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag></template>
|
<template #processStatus="{ row }"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag></template>
|
||||||
<template #orderStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</template>
|
<template #orderStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</template>
|
||||||
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
|
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
import DataTable from '@/shared/components/DataTable.vue'
|
import { downloadWeightImage, downloadWeightThumbnail } from '../weight/api/weightOcr'
|
||||||
|
import WeightImageThumbnail from '../weight/components/WeightImageThumbnail.vue'
|
||||||
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory, rejectMeasurement } from './api'
|
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory, rejectMeasurement } from './api'
|
||||||
import MeasurementReviewPanel from './MeasurementReviewPanel.vue'
|
import MeasurementReviewPanel from './MeasurementReviewPanel.vue'
|
||||||
|
|
||||||
@@ -16,6 +17,11 @@ vi.mock('./api', () => ({
|
|||||||
releaseRejectedMeasurement: vi.fn(),
|
releaseRejectedMeasurement: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../weight/api/weightOcr', () => ({
|
||||||
|
downloadWeightImage: vi.fn(),
|
||||||
|
downloadWeightThumbnail: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
const detail = {
|
const detail = {
|
||||||
inboundId: '90001', inboundCode: 'RK90001', companyName: '柏悦客户', channelCode: 'SEA', channelName: '海运',
|
inboundId: '90001', inboundCode: 'RK90001', companyName: '柏悦客户', channelCode: 'SEA', channelName: '海运',
|
||||||
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: 'STANDARD', reviewReason: null, reviewerId: null, reviewerName: null,
|
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: 'STANDARD', reviewReason: null, reviewerId: null, reviewerName: null,
|
||||||
@@ -31,6 +37,11 @@ enableAutoUnmount(afterEach)
|
|||||||
describe('MeasurementReviewPanel', () => {
|
describe('MeasurementReviewPanel', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn((blob: Blob) => blob.size > 5 ? 'blob:weight-original' : 'blob:weight-thumbnail'),
|
||||||
|
})
|
||||||
|
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
auth.token = 'token'
|
auth.token = 'token'
|
||||||
@@ -38,31 +49,84 @@ describe('MeasurementReviewPanel', () => {
|
|||||||
useAccessPreviewStore().initializeAfterLogin()
|
useAccessPreviewStore().initializeAfterLogin()
|
||||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(detail)
|
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(detail)
|
||||||
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||||
|
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' }))
|
||||||
|
vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['original-image'], { type: 'image/jpeg' }))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads and compares original, device and OCR data without exposing a normal fee action', async () => {
|
it('keeps the review focused on weight evidence and omits carton measurement details', async () => {
|
||||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('原单申报')
|
expect(wrapper.text()).toContain('入仓码')
|
||||||
expect(wrapper.text()).toContain('设备实测')
|
expect(wrapper.text()).toContain('RK90001')
|
||||||
expect(wrapper.text()).toContain('设备采集')
|
expect(wrapper.text()).toContain('柏悦客户 · 海运')
|
||||||
expect(wrapper.text()).not.toContain('DEVICE')
|
expect(wrapper.text()).toContain('合计货物重量')
|
||||||
expect(wrapper.text()).toContain('货物总重量')
|
expect(wrapper.text()).toContain('全部称重图片')
|
||||||
expect(wrapper.text()).toContain('实测长 × 宽 × 高(cm)')
|
expect(wrapper.text()).toContain('称重图片')
|
||||||
expect(wrapper.text()).toContain('周长超过渠道限制')
|
|
||||||
expect(wrapper.text()).toContain('OCR 原文:设备屏幕原始识别内容')
|
|
||||||
expect(wrapper.text()).toContain('TP-001')
|
|
||||||
expect(wrapper.text()).toContain('托盘皮重')
|
|
||||||
expect(wrapper.text()).toContain('货物重量')
|
expect(wrapper.text()).toContain('货物重量')
|
||||||
expect(wrapper.text()).toContain('调整原因:修正 OCR 毛重读数')
|
expect(wrapper.text()).toContain('110 kg')
|
||||||
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
|
expect(wrapper.text()).not.toContain('申报毛重')
|
||||||
|
expect(wrapper.text()).not.toContain('托盘皮重')
|
||||||
|
expect(wrapper.text()).not.toContain('OCR 原文')
|
||||||
|
expect(wrapper.text()).not.toContain('设备屏幕原始识别内容')
|
||||||
|
expect(wrapper.text()).not.toContain('置信度')
|
||||||
|
expect(wrapper.text()).not.toContain('调整原因')
|
||||||
|
expect(wrapper.findAllComponents(WeightImageThumbnail)).toHaveLength(1)
|
||||||
|
expect(wrapper.text()).not.toContain('测量明细')
|
||||||
|
expect(wrapper.text()).not.toContain('实测长 × 宽 × 高(cm)')
|
||||||
|
expect(wrapper.text()).not.toContain('设备实测体积')
|
||||||
|
expect(wrapper.text()).not.toContain('材积重')
|
||||||
expect(wrapper.text()).not.toContain('租户')
|
expect(wrapper.text()).not.toContain('租户')
|
||||||
expect(wrapper.text()).toContain('通过并进入待分柜')
|
expect(wrapper.text()).toContain('通过并进入待分柜')
|
||||||
expect(wrapper.text()).not.toContain('确认费用')
|
expect(wrapper.text()).not.toContain('确认费用')
|
||||||
expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001')
|
expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001')
|
||||||
|
expect(downloadWeightThumbnail).toHaveBeenCalledWith('10001', '90001', 'file-1')
|
||||||
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
|
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
|
||||||
expect(source).toContain('defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })')
|
expect(source).toContain('defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })')
|
||||||
|
expect(source).not.toContain('measurementColumns')
|
||||||
|
expect(source.indexOf('全部称重图片')).toBeLessThan(source.indexOf('审核结论'))
|
||||||
|
expect(source).not.toContain('<DataTable')
|
||||||
|
expect(source).toContain('<el-collapse class="review-history">')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows every weight image at once with its cargo weight below', async () => {
|
||||||
|
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({
|
||||||
|
...detail,
|
||||||
|
actualGrossWeightKg: 330,
|
||||||
|
weightResults: [
|
||||||
|
detail.weightResults[0],
|
||||||
|
{ ...detail.weightResults[0], id: '3', fileId: 'file-2', netWeightKg: 100 },
|
||||||
|
{ ...detail.weightResults[0], id: '4', fileId: 'file-3', netWeightKg: 120 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('.weight-evidence-card')).toHaveLength(3)
|
||||||
|
expect(wrapper.findAllComponents(WeightImageThumbnail)).toHaveLength(3)
|
||||||
|
expect(wrapper.findAll('.weight-evidence-card__weight').map((card) => card.text())).toEqual([
|
||||||
|
'货物重量110 kg',
|
||||||
|
'货物重量100 kg',
|
||||||
|
'货物重量120 kg',
|
||||||
|
])
|
||||||
|
expect(wrapper.find('.weight-overview').text()).toContain('330 kg')
|
||||||
|
expect(downloadWeightThumbnail).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the authenticated original image with the reviewed weight facts', async () => {
|
||||||
|
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] }, attachTo: document.body })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.weight-evidence-card .weight-thumb').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(downloadWeightImage).toHaveBeenCalledWith('10001', '90001', 'file-1')
|
||||||
|
const preview = document.body.querySelector('.weight-preview')
|
||||||
|
expect(preview?.textContent).toContain('TP-001 · 木托盘')
|
||||||
|
expect(preview?.textContent).toContain('120 kg')
|
||||||
|
expect(preview?.textContent).toContain('10 kg')
|
||||||
|
expect(preview?.textContent).toContain('110 kg')
|
||||||
|
expect(preview?.querySelector('img')?.getAttribute('src')).toBe('blob:weight-original')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the rejected release action only when the permission and rejected state exist', async () => {
|
it('shows the rejected release action only when the permission and rejected state exist', async () => {
|
||||||
@@ -107,8 +171,9 @@ describe('MeasurementReviewPanel', () => {
|
|||||||
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click')
|
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(approveMeasurement).toHaveBeenCalledOnce()
|
||||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||||
expect(wrapper.text()).toContain('初算失败不阻断审核')
|
expect(wrapper.text()).not.toContain('初算失败不阻断审核')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects with the inbound optimistic-lock version instead of the measurement version', async () => {
|
it('rejects with the inbound optimistic-lock version instead of the measurement version', async () => {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Check, CircleAlert, History, RotateCcw, ShieldAlert, X } from '@lucide/vue'
|
import { Check, CircleAlert, RotateCcw, ShieldAlert, X } from '@lucide/vue'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
|
||||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||||
import { inboundReviewStatusLabels, inboundStatusLabel, weightRecognitionStatusLabels } from '../status'
|
import { inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
||||||
|
import { downloadWeightImage } from '../weight/api/weightOcr'
|
||||||
|
import WeightImageThumbnail from '../weight/components/WeightImageThumbnail.vue'
|
||||||
import {
|
import {
|
||||||
approveMeasurement,
|
approveMeasurement,
|
||||||
getMeasurementReviewDetail,
|
getMeasurementReviewDetail,
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
rejectMeasurement,
|
rejectMeasurement,
|
||||||
releaseRejectedMeasurement,
|
releaseRejectedMeasurement,
|
||||||
} from './api'
|
} from './api'
|
||||||
import type { MeasurementReviewDetail, MeasurementReviewItem, MeasurementReviewRecord } from './types'
|
import type { MeasurementReviewDetail, MeasurementReviewRecord } from './types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
customerId?: string
|
customerId?: string
|
||||||
@@ -39,6 +40,13 @@ const historyError = ref<ApiErrorDetails | null>(null)
|
|||||||
const reasonDialog = ref<'reject' | 'release' | null>(null)
|
const reasonDialog = ref<'reject' | 'release' | null>(null)
|
||||||
const reason = ref('')
|
const reason = ref('')
|
||||||
const requestSerial = ref(0)
|
const requestSerial = ref(0)
|
||||||
|
const previewOpen = ref(false)
|
||||||
|
const previewUrl = ref('')
|
||||||
|
const previewLoading = ref(false)
|
||||||
|
const previewError = ref<ApiErrorDetails | null>(null)
|
||||||
|
const previewResult = ref<MeasurementReviewDetail['weightResults'][number] | null>(null)
|
||||||
|
const previewIndex = ref(0)
|
||||||
|
let previewRequestSerial = 0
|
||||||
const reasonDialogOpen = computed({
|
const reasonDialogOpen = computed({
|
||||||
get: () => reasonDialog.value !== null,
|
get: () => reasonDialog.value !== null,
|
||||||
set: (open: boolean) => { if (!open) reasonDialog.value = null },
|
set: (open: boolean) => { if (!open) reasonDialog.value = null },
|
||||||
@@ -52,34 +60,6 @@ const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTE
|
|||||||
const confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.netWeightKg !== null).length ?? 0)
|
const confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.netWeightKg !== null).length ?? 0)
|
||||||
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
|
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
|
||||||
|
|
||||||
const measurementColumns: DataTableColumn[] = [
|
|
||||||
{ key: 'boxCode', label: '扫描箱号', minWidth: 190, slotName: 'boxCode' },
|
|
||||||
{ key: 'packageCount', label: '确认箱数', width: 90, slotName: 'packageCount' },
|
|
||||||
{ key: 'dimensions', label: '实测长 × 宽 × 高(cm)', minWidth: 190, slotName: 'dimensions' },
|
|
||||||
{ key: 'unitWeightKg', label: '实测重量(kg)', minWidth: 115, slotName: 'unitWeightKg' },
|
|
||||||
{ key: 'machineNo', label: '机器编号', minWidth: 130, slotName: 'machineNo' },
|
|
||||||
{ key: 'measuredAt', label: '测量时间', minWidth: 150, slotName: 'measuredAt' },
|
|
||||||
{ key: 'intelligentImageFileId', label: '当前图片', minWidth: 110, slotName: 'image' },
|
|
||||||
{ key: 'unitVolumeCbm', label: '每箱体积(m³)', minWidth: 125, slotName: 'unitVolumeCbm' },
|
|
||||||
{ key: 'totalWeightKg', label: '总毛重(kg)', minWidth: 110, slotName: 'totalWeightKg' },
|
|
||||||
{ key: 'totalVolumeCbm', label: '总体积(m³)', minWidth: 110, slotName: 'totalVolumeCbm' },
|
|
||||||
{ key: 'volumeWeightKg', label: '材积重(kg)', minWidth: 110, slotName: 'volumeWeightKg' },
|
|
||||||
{ key: 'note', label: '超限提示 / 备注', minWidth: 200, slotName: 'note' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const weightColumns: DataTableColumn[] = [
|
|
||||||
{ key: 'file', label: '称重文件 / OCR 原文', minWidth: 260, slotName: 'file' },
|
|
||||||
{ key: 'pallet', label: '托盘', minWidth: 170, slotName: 'pallet' },
|
|
||||||
{ key: 'status', label: '识别状态', width: 100, slotName: 'status' },
|
|
||||||
{ key: 'ocrWeight', label: 'OCR 含托盘毛重', minWidth: 140, slotName: 'ocrWeight' },
|
|
||||||
{ key: 'adjustedWeight', label: '人工修正毛重', minWidth: 130, slotName: 'adjustedWeight' },
|
|
||||||
{ key: 'grossWeight', label: '服务端毛重', minWidth: 115, slotName: 'grossWeight' },
|
|
||||||
{ key: 'tareWeight', label: '托盘皮重', minWidth: 110, slotName: 'tareWeight' },
|
|
||||||
{ key: 'netWeight', label: '货物重量', minWidth: 110, slotName: 'netWeight' },
|
|
||||||
{ key: 'confidence', label: '置信度', width: 90, slotName: 'confidence' },
|
|
||||||
{ key: 'reason', label: '调整原因', minWidth: 180, slotName: 'reason' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function formatNumber(value: number | null | undefined, unit = '') {
|
function formatNumber(value: number | null | undefined, unit = '') {
|
||||||
if (value === null || value === undefined || Number.isNaN(value)) return '-'
|
if (value === null || value === undefined || Number.isNaN(value)) return '-'
|
||||||
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })}${unit}`
|
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })}${unit}`
|
||||||
@@ -95,13 +75,33 @@ function resultLabel(value: string | null | undefined) {
|
|||||||
return '未知结果'
|
return '未知结果'
|
||||||
}
|
}
|
||||||
|
|
||||||
function recognitionStatusLabel(value: string | null | undefined) {
|
function releasePreview() {
|
||||||
return inboundStatusLabel(value, weightRecognitionStatusLabels, '待确认')
|
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||||
|
previewUrl.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function measurementSourceLabel(value: string | null | undefined) {
|
async function openWeightImage(row: unknown, index: number) {
|
||||||
if (!value) return '-'
|
previewResult.value = row as MeasurementReviewDetail['weightResults'][number]
|
||||||
return ({ DEVICE: '设备采集', THIRD_PARTY: '第三方设备', MANUAL: '人工录入' } as Record<string, string>)[value] || '其他来源'
|
previewIndex.value = index
|
||||||
|
previewOpen.value = true
|
||||||
|
await loadPreviewImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreviewImage() {
|
||||||
|
const row = previewResult.value
|
||||||
|
if (!row?.fileId) return
|
||||||
|
const serial = ++previewRequestSerial
|
||||||
|
releasePreview()
|
||||||
|
previewError.value = null
|
||||||
|
previewLoading.value = true
|
||||||
|
try {
|
||||||
|
const blob = await downloadWeightImage(customerId.value, props.inboundId, row.fileId)
|
||||||
|
if (serial === previewRequestSerial) previewUrl.value = URL.createObjectURL(blob)
|
||||||
|
} catch (error) {
|
||||||
|
if (serial === previewRequestSerial) previewError.value = getApiErrorDetails(error, '称重原图读取失败,请重新加载')
|
||||||
|
} finally {
|
||||||
|
if (serial === previewRequestSerial) previewLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isConflict(error: unknown) {
|
function isConflict(error: unknown) {
|
||||||
@@ -225,13 +225,27 @@ onMounted(() => {
|
|||||||
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
|
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(previewOpen, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
previewRequestSerial += 1
|
||||||
|
previewResult.value = null
|
||||||
|
previewError.value = null
|
||||||
|
releasePreview()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onBeforeUnmount(releasePreview)
|
||||||
|
|
||||||
defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })
|
defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="review-panel" aria-labelledby="measurement-review-title">
|
<section class="review-panel" aria-labelledby="measurement-review-title">
|
||||||
<header class="review-header">
|
<header class="review-header">
|
||||||
<h2 id="measurement-review-title">测量信息</h2>
|
<div>
|
||||||
|
<span>入仓码</span>
|
||||||
|
<h2 id="measurement-review-title">{{ detail?.inboundCode || '-' }}</h2>
|
||||||
|
<p>{{ detail ? `${detail.companyName || '客户未命名'} · ${detail.channelName || detail.channelCode || '渠道未填写'}` : '称重结果' }}</p>
|
||||||
|
</div>
|
||||||
<div class="review-header-status">
|
<div class="review-header-status">
|
||||||
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
|
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
|
||||||
<el-button text :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
|
<el-button text :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
|
||||||
@@ -246,66 +260,41 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
|
|||||||
<el-skeleton v-if="loading && !detail" :rows="8" animated />
|
<el-skeleton v-if="loading && !detail" :rows="8" animated />
|
||||||
|
|
||||||
<template v-else-if="detail">
|
<template v-else-if="detail">
|
||||||
<dl class="review-fields">
|
<dl class="weight-overview" aria-label="重量审核摘要">
|
||||||
<div><dt>入库单号</dt><dd>{{ detail.inboundCode || '-' }}</dd></div>
|
<div class="weight-overview__primary"><dt>合计货物重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</dd></div>
|
||||||
<div><dt>客户名称</dt><dd>{{ detail.companyName || '-' }}</dd></div>
|
<div><dt>称重图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }} 张已确认</dd></div>
|
||||||
<div><dt>原单申报</dt><dd>{{ detail.originalDocumentNo || '-' }}</dd></div>
|
|
||||||
<div><dt>渠道</dt><dd>{{ detail.channelName || detail.channelCode || '-' }}</dd></div>
|
|
||||||
<div><dt>申报箱数</dt><dd>{{ formatNumber(detail.originalPackageCount, ' 箱') }}</dd></div>
|
|
||||||
<div><dt>实测箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, ' 箱') }}</dd></div>
|
|
||||||
<div><dt>申报毛重</dt><dd>{{ formatNumber(detail.originalGrossWeightKg, ' kg') }}</dd></div>
|
|
||||||
<div><dt>货物总重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</dd></div>
|
|
||||||
<div><dt>申报体积</dt><dd>{{ formatNumber(detail.originalVolumeCbm, ' CBM') }}</dd></div>
|
|
||||||
<div><dt>设备实测体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, ' CBM') }}</dd></div>
|
|
||||||
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg, ' kg') }}</dd></div>
|
|
||||||
<div><dt>测量来源</dt><dd>{{ measurementSourceLabel(detail.sourceType) }}</dd></div>
|
|
||||||
<div><dt>已确认称重图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }}</dd></div>
|
|
||||||
<div><dt>审核状态</dt><dd><StatusBadge :status="reviewStatusLabel" /></dd></div>
|
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div v-if="detail.reviewReason" class="review-reason">
|
<div v-if="detail.reviewReason" class="review-reason">
|
||||||
<CircleAlert :size="17" /><span><strong>最近审核说明:</strong>{{ detail.reviewReason }}</span>
|
<CircleAlert :size="17" /><span><strong>最近审核说明:</strong>{{ detail.reviewReason }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="review-section">
|
<div class="review-section weight-evidence-section">
|
||||||
<div class="section-heading"><h3>测量明细</h3><span class="section-count">{{ detail.items.length }} 条</span></div>
|
<div class="section-heading"><h3>全部称重图片</h3><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
|
||||||
<DataTable :data="detail.items" :columns="measurementColumns" empty-text="暂无设备测量明细" border stripe>
|
<div v-if="detail.weightResults.length" class="weight-evidence-grid">
|
||||||
<template #boxCode="{ row }">{{ (row as MeasurementReviewItem).boxCode || (row as MeasurementReviewItem).itemNo || '-' }}</template>
|
<article v-for="(row, index) in detail.weightResults" :key="row.id" class="weight-evidence-card">
|
||||||
<template #packageCount="{ row }">{{ (row as MeasurementReviewItem).packageCount ?? '-' }}</template>
|
<div class="weight-evidence-card__image">
|
||||||
<template #dimensions="{ row }">{{ [(row as MeasurementReviewItem).lengthCm, (row as MeasurementReviewItem).widthCm, (row as MeasurementReviewItem).heightCm].map((value) => formatNumber(value)).join(' × ') }}</template>
|
<WeightImageThumbnail
|
||||||
<template #outerDimensions="{ row }">{{ [(row as MeasurementReviewItem).outerLengthCm, (row as MeasurementReviewItem).outerWidthCm, (row as MeasurementReviewItem).outerHeightCm].map((value) => formatNumber(value)).join(' × ') }}</template>
|
v-if="row.fileId"
|
||||||
<template #girthCm="{ row }">{{ formatNumber((row as MeasurementReviewItem).girthCm) }}</template>
|
:customer-id="customerId"
|
||||||
<template #unitWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).unitWeightKg) }}</template>
|
:inbound-id="inboundId"
|
||||||
<template #machineNo="{ row }">{{ (row as MeasurementReviewItem).machineNo || '-' }}</template>
|
:file-id="row.fileId"
|
||||||
<template #measuredAt="{ row }">{{ formatDate((row as MeasurementReviewItem).measuredAt) }}</template>
|
:alt="`称重图片 ${index + 1}`"
|
||||||
<template #image="{ row }">{{ (row as MeasurementReviewItem).intelligentImageFileId ? '已关联' : '暂无' }}</template>
|
@open="openWeightImage(row, index)"
|
||||||
<template #unitVolumeCbm="{ row }">{{ formatNumber((row as MeasurementReviewItem).unitVolumeCbm) }}</template>
|
/>
|
||||||
<template #totalWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).totalWeightKg) }}</template>
|
<span v-else class="missing-evidence">未关联图片</span>
|
||||||
<template #totalVolumeCbm="{ row }">{{ formatNumber((row as MeasurementReviewItem).totalVolumeCbm) }}</template>
|
|
||||||
<template #volumeWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).volumeWeightKg) }}</template>
|
|
||||||
<template #note="{ row }"><div class="measurement-note"><strong v-if="(row as MeasurementReviewItem).overLimitWarning">{{ (row as MeasurementReviewItem).overLimitWarning }}</strong><span>{{ (row as MeasurementReviewItem).remark || ((row as MeasurementReviewItem).overLimitWarning ? '' : '-') }}</span></div></template>
|
|
||||||
<template #empty><el-empty description="暂无设备测量明细" :image-size="48" /></template>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="weight-evidence-card__weight">
|
||||||
<div class="review-section">
|
<span>货物重量</span>
|
||||||
<div class="section-heading"><h3>称重图片识别结果</h3><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
|
<strong>{{ formatNumber(row.netWeightKg, ' kg') }}</strong>
|
||||||
<DataTable :data="detail.weightResults" :columns="weightColumns" empty-text="暂无称重图片识别结果" border stripe>
|
</div>
|
||||||
<template #file="{ row }"><div class="table-wrap"><strong>{{ row.fileId || '未关联文件' }}</strong><span v-if="row.ocrRawText">OCR 原文:{{ row.ocrRawText }}</span></div></template>
|
</article>
|
||||||
<template #pallet="{ row }"><div class="table-wrap"><strong>{{ row.palletCode }}</strong><span>{{ row.palletType }} · {{ formatNumber(row.palletCapacityCbm, ' m³') }}</span></div></template>
|
</div>
|
||||||
<template #status="{ row }">{{ recognitionStatusLabel(row.recognitionStatus) }}</template>
|
<el-empty v-else description="暂无称重图片" :image-size="56" />
|
||||||
<template #ocrWeight="{ row }">{{ formatNumber(row.ocrWeightValue, ` ${row.ocrWeightUnit || ''}`) }}</template>
|
|
||||||
<template #adjustedWeight="{ row }">{{ formatNumber(row.adjustedWeightValue, ` ${row.adjustedWeightUnit || ''}`) }}</template>
|
|
||||||
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg, ' kg') }}</template>
|
|
||||||
<template #tareWeight="{ row }">{{ formatNumber(row.palletTareWeightKg, ' kg') }}</template>
|
|
||||||
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg, ' kg') }}</template>
|
|
||||||
<template #confidence="{ row }">{{ row.confidence === null ? '-' : `${Math.round(row.confidence * 100)}%` }}</template>
|
|
||||||
<template #reason="{ row }">{{ row.adjustmentReason ? `调整原因:${row.adjustmentReason}` : '-' }}</template>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="review-actions" :class="{ 'review-actions--disabled': !isPending && !isRejected }">
|
<div class="review-actions" :class="{ 'review-actions--disabled': !isPending && !isRejected }">
|
||||||
<div><strong>审核结论</strong><span v-if="isPending">通过后进入待分柜;费用仅做初算,初算失败不阻断审核。</span><span v-else-if="isRejected">当前测量已驳回,可修正后重新提交,或按权限申请异常放行。</span><span v-else>当前记录不在可操作的测量审核状态。</span></div>
|
<div><strong>审核结论</strong><span v-if="isPending">通过后进入待分柜</span><span v-else-if="isRejected">已驳回,可修正后重新提交</span><span v-else>当前记录不可审核</span></div>
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<el-button v-if="isPending && canReview" type="danger" plain :loading="actionLoading === 'reject'" @click="openReasonDialog('reject')"><X :size="16" />驳回</el-button>
|
<el-button v-if="isPending && canReview" type="danger" plain :loading="actionLoading === 'reject'" @click="openReasonDialog('reject')"><X :size="16" />驳回</el-button>
|
||||||
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
|
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
|
||||||
@@ -313,20 +302,39 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="review-section history-section">
|
<el-collapse class="review-history">
|
||||||
<div class="section-heading"><div><h3><History :size="17" />审核历史</h3><p>每次审核均保留操作人、原因和放行开关快照。</p></div></div>
|
<el-collapse-item name="history" :title="`审核历史(${history.length})`">
|
||||||
<el-alert v-if="historyError" type="warning" :closable="false" show-icon><template #title><div class="review-alert-title"><span>{{ historyError.message }}</span><ApiErrorMeta :details="historyError" /></div></template><el-button link type="warning" @click="loadHistory">重试</el-button></el-alert>
|
<el-alert v-if="historyError" type="warning" :closable="false" show-icon><template #title><div class="review-alert-title"><span>{{ historyError.message }}</span><ApiErrorMeta :details="historyError" /></div></template><el-button link type="warning" @click="loadHistory">重试</el-button></el-alert>
|
||||||
<el-skeleton v-else-if="historyLoading" :rows="3" animated />
|
<el-skeleton v-else-if="historyLoading" :rows="3" animated />
|
||||||
<el-empty v-else-if="!history.length" description="暂无审核历史" :image-size="48" />
|
<el-empty v-else-if="!history.length" description="暂无审核历史" :image-size="48" />
|
||||||
<ol v-else class="history-list">
|
<ol v-else class="history-list">
|
||||||
<li v-for="item in history" :key="item.id">
|
<li v-for="item in history" :key="item.id">
|
||||||
<div class="history-marker" :class="{ 'history-marker--success': item.reviewResult === 'APPROVED', 'history-marker--danger': item.reviewResult === 'REJECTED' }" />
|
<div class="history-marker" :class="{ 'history-marker--success': item.reviewResult === 'APPROVED', 'history-marker--danger': item.reviewResult === 'REJECTED' }" />
|
||||||
<div class="history-content"><div><strong>{{ resultLabel(item.reviewResult) }}{{ item.exceptionRelease ? ' · 异常放行' : '' }}</strong><time>{{ formatDate(item.reviewedAt) }}</time></div><p>{{ item.releaseReason || item.reviewReason || '未填写说明' }}</p><span>{{ item.reviewerName || '系统' }} · {{ item.allowRejectedConfig ? '放行开关已开启' : '放行开关未开启' }}</span></div>
|
<div class="history-content"><div><strong>{{ resultLabel(item.reviewResult) }}{{ item.exceptionRelease ? ' · 异常放行' : '' }}</strong><time>{{ formatDate(item.reviewedAt) }}</time></div><p>{{ item.releaseReason || item.reviewReason || '未填写说明' }}</p><span>{{ item.reviewerName || '系统' }}</span></div>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</el-collapse-item>
|
||||||
|
</el-collapse>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<el-dialog v-model="previewOpen" :title="`称重原图 ${previewIndex + 1}`" width="min(880px, calc(100vw - 28px))" destroy-on-close append-to-body>
|
||||||
|
<div v-if="previewResult" class="weight-preview">
|
||||||
|
<div v-loading="previewLoading" class="weight-preview__canvas">
|
||||||
|
<img v-if="previewUrl" :src="previewUrl" :alt="`称重原图 ${previewIndex + 1}`" />
|
||||||
|
<el-alert v-else-if="previewError" type="error" :closable="false" show-icon>
|
||||||
|
<template #title><div class="review-alert-title"><span>{{ previewError.message }}</span><ApiErrorMeta :details="previewError" /></div></template>
|
||||||
|
<el-button link type="danger" :loading="previewLoading" @click="loadPreviewImage">重新加载原图</el-button>
|
||||||
|
</el-alert>
|
||||||
|
</div>
|
||||||
|
<dl class="weight-preview__facts">
|
||||||
|
<div><dt>托盘</dt><dd>{{ previewResult.palletCode }} · {{ previewResult.palletType }}</dd></div>
|
||||||
|
<div><dt>含托盘重量</dt><dd>{{ formatNumber(previewResult.grossWeightKg, ' kg') }}</dd></div>
|
||||||
|
<div><dt>托盘皮重</dt><dd>{{ formatNumber(previewResult.palletTareWeightKg, ' kg') }}</dd></div>
|
||||||
|
<div><dt>货物重量</dt><dd>{{ formatNumber(previewResult.netWeightKg, ' kg') }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="reasonDialogOpen" :title="reasonDialog === 'reject' ? '填写驳回原因' : '填写异常放行原因'" width="min(520px, calc(100vw - 28px))" destroy-on-close>
|
<el-dialog v-model="reasonDialogOpen" :title="reasonDialog === 'reject' ? '填写驳回原因' : '填写异常放行原因'" width="min(520px, calc(100vw - 28px))" destroy-on-close>
|
||||||
<el-alert v-if="reasonDialog === 'release'" title="异常放行不会抹去驳回记录;请说明为什么允许该测量继续进入自动计费入库。" type="warning" :closable="false" show-icon />
|
<el-alert v-if="reasonDialog === 'release'" title="异常放行不会抹去驳回记录;请说明为什么允许该测量继续进入自动计费入库。" type="warning" :closable="false" show-icon />
|
||||||
<el-input v-model="reason" type="textarea" :rows="5" maxlength="1000" show-word-limit :placeholder="reasonDialog === 'reject' ? '请说明需要修正的测量问题' : '请填写异常放行的业务原因'" />
|
<el-input v-model="reason" type="textarea" :rows="5" maxlength="1000" show-word-limit :placeholder="reasonDialog === 'reject' ? '请说明需要修正的测量问题' : '请填写异常放行的业务原因'" />
|
||||||
@@ -336,22 +344,35 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.review-panel { display: grid; gap: 20px; color: var(--cc-text); }
|
.review-panel { display: grid; gap: 16px; color: var(--cc-text); }
|
||||||
.review-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; padding-bottom: 4px; }
|
.review-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; padding-bottom: 4px; }
|
||||||
.review-header h2 { margin: 0 0 5px; font-size: 22px; line-height: 1.25; }
|
.review-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
|
||||||
|
.review-header > div:first-child > span { color: var(--cc-muted); font-size: 11px; }
|
||||||
|
.review-header h2 { margin: 0; overflow-wrap: anywhere; font-size: 20px; line-height: 1.25; }
|
||||||
.review-header p, .section-heading p { margin: 0; color: var(--cc-muted); font-size: 13px; line-height: 1.55; }
|
.review-header p, .section-heading p { margin: 0; color: var(--cc-muted); font-size: 13px; line-height: 1.55; }
|
||||||
.review-header-status { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
|
.review-header-status { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
|
||||||
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
.review-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 32px; margin: 0; border-top: 1px solid var(--cc-border); }
|
.weight-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); }
|
||||||
.review-fields > div { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; min-width: 0; padding: 10px 0; border-bottom: 1px solid var(--cc-border); font-size: 13px; }
|
.weight-overview > div { display: grid; align-content: center; min-width: 0; min-height: 82px; gap: 7px; padding: 14px 18px; border-right: 1px solid var(--cc-border); }
|
||||||
.review-fields dt { color: var(--cc-muted); }.review-fields dd { min-width: 0; margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
|
.weight-overview > div:last-child { border-right: 0; }.weight-overview dt { color: var(--cc-muted); font-size: 12px; }.weight-overview dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 17px; font-weight: 700; overflow-wrap: anywhere; }.weight-overview__primary { background: #f0f7ff; }.weight-overview__primary dd { color: var(--cc-primary); font-size: 24px; }
|
||||||
.review-reason { display: flex; gap: 8px; align-items: flex-start; padding: 12px 14px; border: 1px solid #f3d19e; border-radius: 8px; background: #fdf6ec; color: #8a5a00; font-size: 13px; line-height: 1.55; }
|
.review-reason { display: flex; gap: 8px; align-items: flex-start; padding: 12px 14px; border: 1px solid #f3d19e; border-radius: 8px; background: #fdf6ec; color: #8a5a00; font-size: 13px; line-height: 1.55; }
|
||||||
.review-section { display: grid; gap: 13px; padding-top: 2px; }.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }.section-heading h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 4px; font-size: 16px; }.section-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
.review-section { display: grid; gap: 13px; padding-top: 2px; }.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }.section-heading h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 4px; font-size: 16px; }.section-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||||
.measurement-note { overflow-wrap: anywhere; }.measurement-note strong, .measurement-note span { display: block; }.measurement-note strong { color: var(--el-color-warning-dark-2); }.measurement-note strong + span { margin-top: 4px; color: var(--cc-muted); }
|
.weight-evidence-section { min-width: 0; }
|
||||||
.table-wrap { display: grid; gap: 4px; min-width: 0; }.table-wrap span { color: var(--cc-muted); font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; }
|
.weight-evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
|
||||||
|
.weight-evidence-card { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
||||||
|
.weight-evidence-card__image { display: grid; aspect-ratio: 4 / 3; overflow: hidden; place-items: center; background: #f2f4f7; }
|
||||||
|
.weight-evidence-card__image :deep(.weight-thumb) { height: 100%; border-radius: 0; }
|
||||||
|
.weight-evidence-card__weight { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; }
|
||||||
|
.weight-evidence-card__weight span { color: var(--cc-muted); font-size: 12px; }
|
||||||
|
.weight-evidence-card__weight strong { color: #067647; font-size: 20px; overflow-wrap: anywhere; text-align: right; }
|
||||||
|
.missing-evidence { color: var(--cc-muted); font-size: 12px; }
|
||||||
|
.weight-preview { display: grid; gap: 14px; }.weight-preview__canvas { display: grid; min-height: 320px; max-height: 62vh; overflow: hidden; place-items: center; border: 1px solid var(--cc-border); border-radius: 6px; background: #f2f4f7; }.weight-preview__canvas img { display: block; max-width: 100%; max-height: 62vh; object-fit: contain; }.weight-preview__canvas .el-alert { width: min(520px, calc(100% - 24px)); }.weight-preview__facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; }.weight-preview__facts > div { display: grid; min-width: 0; gap: 5px; padding: 12px 14px; border-right: 1px solid var(--cc-border); }.weight-preview__facts > div:last-child { border-right: 0; }.weight-preview__facts dt { color: var(--cc-muted); font-size: 12px; }.weight-preview__facts dd { min-width: 0; margin: 0; font-weight: 700; overflow-wrap: anywhere; }
|
||||||
.review-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 16px; border: 1px solid #b3d8ff; border-radius: 10px; background: #ecf5ff; }.review-actions > div:first-child { display: grid; gap: 4px; min-width: 0; }.review-actions span { color: #606266; font-size: 12px; line-height: 1.5; }.review-actions--disabled { border-color: var(--cc-border); background: var(--cc-surface-muted); }.action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
.review-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 16px; border: 1px solid #b3d8ff; border-radius: 10px; background: #ecf5ff; }.review-actions > div:first-child { display: grid; gap: 4px; min-width: 0; }.review-actions span { color: #606266; font-size: 12px; line-height: 1.5; }.review-actions--disabled { border-color: var(--cc-border); background: var(--cc-surface-muted); }.action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||||
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }.history-marker--success { border-color: #67c23a; }.history-marker--danger { border-color: #f56c6c; }.history-content { min-width: 0; }.history-content > div { display: flex; justify-content: space-between; gap: 12px; }.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
|
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }.history-marker--success { border-color: #67c23a; }.history-marker--danger { border-color: #f56c6c; }.history-content { min-width: 0; }.history-content > div { display: flex; justify-content: space-between; gap: 12px; }.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
|
||||||
|
.review-history { border-top: 1px solid var(--cc-border); border-bottom: 0; }
|
||||||
|
.review-history :deep(.el-collapse-item__header) { height: 42px; color: var(--cc-muted); font-size: 12px; }
|
||||||
|
.review-history :deep(.el-collapse-item__wrap) { border-bottom: 0; }
|
||||||
.el-button { min-height: 36px; }.el-button :deep(svg) { flex: 0 0 auto; }.review-panel :deep(.el-dialog__body) { display: grid; gap: 14px; }
|
.el-button { min-height: 36px; }.el-button :deep(svg) { flex: 0 0 auto; }.review-panel :deep(.el-dialog__body) { display: grid; gap: 14px; }
|
||||||
@media (max-width: 720px) { .review-header, .section-heading, .review-actions { align-items: flex-start; flex-direction: column; }.review-header-status, .action-buttons { width: 100%; justify-content: space-between; }.review-fields { grid-template-columns: 1fr; gap: 0; }.review-actions .action-buttons .el-button { flex: 1 1 140px; }.review-actions .action-buttons { justify-content: stretch; }.review-actions .action-buttons :deep(.el-button) { width: 100%; } }
|
@media (max-width: 720px) { .review-header, .section-heading, .review-actions { align-items: flex-start; flex-direction: column; }.review-header-status, .action-buttons { width: 100%; justify-content: space-between; }.weight-overview { grid-template-columns: 1fr; }.weight-overview > div { min-height: 68px; border-right: 0; border-bottom: 1px solid var(--cc-border); }.weight-overview > div:last-child { border-bottom: 0; }.weight-evidence-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.weight-preview__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }.weight-preview__facts > div:nth-child(2) { border-right: 0; }.weight-preview__facts > div:nth-child(-n + 2) { border-bottom: 1px solid var(--cc-border); }.review-actions .action-buttons .el-button { flex: 1 1 140px; }.review-actions .action-buttons { justify-content: stretch; }.review-actions .action-buttons :deep(.el-button) { width: 100%; } }
|
||||||
@media (max-width: 380px) { .review-header h2 { font-size: 19px; }.review-header p { font-size: 12px; }.review-fields > div { grid-template-columns: 100px minmax(0, 1fr); }.review-actions { padding: 13px; } }
|
@media (max-width: 380px) { .review-header h2 { font-size: 19px; }.review-header p { font-size: 12px; }.weight-overview { grid-template-columns: 1fr; }.weight-overview > div { min-height: 68px; border-right: 0; border-bottom: 1px solid var(--cc-border); }.weight-overview > div:last-child { border-bottom: 0; }.weight-overview__primary dd { font-size: 21px; }.weight-evidence-grid { grid-template-columns: 1fr; }.review-actions { padding: 13px; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
import { ApiResponseError } from '@/shared/api/http'
|
import { ApiResponseError } from '@/shared/api/http'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
import {
|
import {
|
||||||
confirmWeightResult,
|
confirmWeightResult,
|
||||||
correctWeightResult,
|
correctWeightResult,
|
||||||
@@ -92,11 +93,9 @@ function mountPanel(permissions = allWeightPermissions) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openWeightDialog(wrapper: VueWrapper, reviewEvidence = true) {
|
async function openWeightDialog(wrapper: VueWrapper) {
|
||||||
await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click')
|
await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const evidence = document.body.querySelector<HTMLInputElement>('.ocr-review-check input[type="checkbox"]')
|
|
||||||
if (reviewEvidence && evidence) await new DOMWrapper(evidence).setValue(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dialogWeightInput() {
|
function dialogWeightInput() {
|
||||||
@@ -117,12 +116,6 @@ function dialogConfirmButton() {
|
|||||||
return new DOMWrapper(button)
|
return new DOMWrapper(button)
|
||||||
}
|
}
|
||||||
|
|
||||||
function dialogEvidenceCheckbox() {
|
|
||||||
const input = document.body.querySelector<HTMLInputElement>('.ocr-review-check input[type="checkbox"]')
|
|
||||||
if (!input) throw new Error('OCR evidence checkbox is missing')
|
|
||||||
return new DOMWrapper(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('WeightOcrPanel confirmation behavior', () => {
|
describe('WeightOcrPanel confirmation behavior', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@@ -178,7 +171,9 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
expect(uploadWeightImage).toHaveBeenCalledWith('10001', '20001', file)
|
expect(uploadWeightImage).toHaveBeenCalledWith('10001', '20001', file)
|
||||||
expect(createWeightResult).toHaveBeenCalledWith('10001', '20001', expect.objectContaining({ palletId: '50001' }))
|
expect(createWeightResult).toHaveBeenCalledWith('10001', '20001', expect.objectContaining({ palletId: '50001' }))
|
||||||
expect(document.body.textContent).toContain('确认称重图片')
|
expect(document.body.textContent).toContain('确认称重图片')
|
||||||
expect(document.body.textContent).toContain('识别重量 12.500 KG')
|
expect(document.body.textContent).toContain('当前托盘皮重')
|
||||||
|
expect(document.body.textContent).toContain('OCR 识别毛重')
|
||||||
|
expect(document.body.textContent).toContain('12.500 KG')
|
||||||
expect(document.body.querySelector('input[aria-label="当前图片确认重量"]')).not.toBeNull()
|
expect(document.body.querySelector('input[aria-label="当前图片确认重量"]')).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -215,17 +210,21 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
expect(deleteWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', 0)
|
expect(deleteWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires image review before confirming even a high-confidence OCR value', async () => {
|
it('shows only the key weights and allows direct confirmation', async () => {
|
||||||
const wrapper = mountPanel()
|
const wrapper = mountPanel()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await openWeightDialog(wrapper, false)
|
await openWeightDialog(wrapper)
|
||||||
|
|
||||||
expect(document.body.textContent).toContain('OCR 原文')
|
const dialogText = document.body.textContent || ''
|
||||||
expect(document.body.textContent).toContain('285.5')
|
expect(dialogText).toContain('当前托盘皮重')
|
||||||
expect(document.body.textContent).toContain('98%')
|
expect(dialogText).toContain('12.000 KG')
|
||||||
expect(document.body.textContent).toContain('OCR 可能误读数字或小数点')
|
expect(dialogText).toContain('OCR 识别毛重')
|
||||||
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
|
expect(dialogText).toContain('285.500 KG')
|
||||||
await dialogEvidenceCheckbox().setValue(true)
|
expect(dialogText).toContain('确认含托盘毛重')
|
||||||
|
for (const hiddenLabel of ['托盘编码', '托盘类型', '托盘容量', '识别状态', '置信度', '服务端毛重', '服务端净重', 'OCR 原文']) {
|
||||||
|
expect(dialogText).not.toContain(hiddenLabel)
|
||||||
|
}
|
||||||
|
expect(dialogConfirmButton().attributes('disabled')).toBeUndefined()
|
||||||
await dialogConfirmButton().trigger('click')
|
await dialogConfirmButton().trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -255,6 +254,18 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
expect(wrapper.text()).toContain('285.500 KG')
|
expect(wrapper.text()).toContain('285.500 KG')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('closes the confirmation dialog immediately after a successful confirmation', async () => {
|
||||||
|
const wrapper = mountPanel()
|
||||||
|
await flushPromises()
|
||||||
|
await openWeightDialog(wrapper)
|
||||||
|
|
||||||
|
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
|
||||||
|
await dialogConfirmButton().trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('requires a pallet selection before opening the file picker', async () => {
|
it('requires a pallet selection before opening the file picker', async () => {
|
||||||
const wrapper = mountPanel()
|
const wrapper = mountPanel()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -292,22 +303,24 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
expect(getWeightPalletOptions).not.toHaveBeenCalled()
|
expect(getWeightPalletOptions).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires explicit image review before confirming a low-confidence OCR value', async () => {
|
it('keeps a low-confidence OCR result focused on the editable weight', async () => {
|
||||||
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||||
...workspace,
|
...workspace,
|
||||||
weightResults: [{ ...pendingWeight, ocrRawText: '5360', ocrWeightValue: 5360, confidence: 0.51863 }],
|
weightResults: [{ ...pendingWeight, ocrRawText: '5360', ocrWeightValue: 5360, confidence: 0.51863 }],
|
||||||
})
|
})
|
||||||
const wrapper = mountPanel()
|
const wrapper = mountPanel()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await openWeightDialog(wrapper, false)
|
await openWeightDialog(wrapper)
|
||||||
|
|
||||||
expect(document.body.textContent).toContain('OCR 可能误读数字或小数点')
|
const dialogText = document.body.textContent || ''
|
||||||
expect(document.body.textContent).toContain('OCR 原文')
|
expect(dialogText).toContain('OCR 识别毛重')
|
||||||
expect(document.body.textContent).toContain('5360')
|
expect(dialogText).toContain('5360.000 KG')
|
||||||
expect(document.body.textContent).toContain('52%')
|
expect(dialogText).toContain('确认含托盘毛重')
|
||||||
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
|
expect(dialogText).not.toContain('置信度')
|
||||||
|
expect(dialogText).not.toContain('OCR 原文')
|
||||||
|
expect(dialogText).not.toContain('OCR 可能误读数字或小数点')
|
||||||
|
expect(dialogConfirmButton().attributes('disabled')).toBeUndefined()
|
||||||
|
|
||||||
await dialogEvidenceCheckbox().setValue(true)
|
|
||||||
await dialogConfirmButton().trigger('click')
|
await dialogConfirmButton().trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -338,16 +351,16 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
await openWeightDialog(wrapper)
|
await openWeightDialog(wrapper)
|
||||||
|
|
||||||
await dialogWeightInput().setValue('10.125')
|
await dialogWeightInput().setValue('30.125')
|
||||||
await dialogUnit(1).trigger('click')
|
await dialogUnit(1).trigger('click')
|
||||||
await dialogConfirmButton().trigger('click')
|
await dialogConfirmButton().trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||||
version: 0,
|
version: 0,
|
||||||
adjustedWeightValue: 10.125,
|
adjustedWeightValue: 30.125,
|
||||||
adjustedWeightUnit: 'LB',
|
adjustedWeightUnit: 'LB',
|
||||||
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 10.125 LB',
|
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 30.125 LB',
|
||||||
})
|
})
|
||||||
expect(correctWeightResult).not.toHaveBeenCalled()
|
expect(correctWeightResult).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -364,7 +377,8 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
expect(confirmWeightResult).not.toHaveBeenCalled()
|
expect(confirmWeightResult).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts the smallest LB value that remains representable in kilograms', async () => {
|
it('rejects a positive gross weight that does not exceed the pallet tare', async () => {
|
||||||
|
const warning = vi.spyOn(ElMessage, 'warning')
|
||||||
const wrapper = mountPanel()
|
const wrapper = mountPanel()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await openWeightDialog(wrapper)
|
await openWeightDialog(wrapper)
|
||||||
@@ -374,12 +388,8 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
await dialogConfirmButton().trigger('click')
|
await dialogConfirmButton().trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
expect(confirmWeightResult).not.toHaveBeenCalled()
|
||||||
version: 0,
|
expect(warning).toHaveBeenCalledWith('称重毛重必须大于托盘皮重 12.000 KG')
|
||||||
adjustedWeightValue: 0.002,
|
|
||||||
adjustedWeightUnit: 'LB',
|
|
||||||
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 0.002 LB',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clamps a value outside the safe three-decimal JavaScript range', async () => {
|
it('clamps a value outside the safe three-decimal JavaScript range', async () => {
|
||||||
@@ -407,16 +417,16 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
await openWeightDialog(wrapper)
|
await openWeightDialog(wrapper)
|
||||||
const input = dialogWeightInput()
|
const input = dialogWeightInput()
|
||||||
|
|
||||||
await input.setValue('1.2345')
|
await input.setValue('13.2345')
|
||||||
expect((input.element as HTMLInputElement).value).toBe('1.235')
|
expect((input.element as HTMLInputElement).value).toBe('13.235')
|
||||||
await dialogConfirmButton().trigger('click')
|
await dialogConfirmButton().trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||||
version: 0,
|
version: 0,
|
||||||
adjustedWeightValue: 1.235,
|
adjustedWeightValue: 13.235,
|
||||||
adjustedWeightUnit: 'KG',
|
adjustedWeightUnit: 'KG',
|
||||||
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 1.235 KG',
|
adjustmentReason: 'OCR 原值 285.500 KG,人工确认 13.235 KG',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -487,16 +497,15 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
await openWeightDialog(wrapper)
|
await openWeightDialog(wrapper)
|
||||||
|
|
||||||
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
|
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
|
||||||
await dialogWeightInput().setValue('1.500')
|
await dialogWeightInput().setValue('13.500')
|
||||||
await dialogEvidenceCheckbox().setValue(true)
|
|
||||||
await dialogConfirmButton().trigger('click')
|
await dialogConfirmButton().trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||||
version: 0,
|
version: 0,
|
||||||
adjustedWeightValue: 1.5,
|
adjustedWeightValue: 13.5,
|
||||||
adjustedWeightUnit: 'KG',
|
adjustedWeightUnit: 'KG',
|
||||||
adjustmentReason: 'OCR 原值 —,人工确认 1.500 KG',
|
adjustmentReason: 'OCR 原值 —,人工确认 13.500 KG',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -556,7 +565,10 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the edited draft available when confirmation fails', async () => {
|
it('keeps the edited draft available when confirmation fails', async () => {
|
||||||
vi.mocked(confirmWeightResult).mockRejectedValueOnce(new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 409))
|
const errorMessage = vi.spyOn(ElMessage, 'error')
|
||||||
|
vi.mocked(confirmWeightResult).mockRejectedValueOnce(
|
||||||
|
new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 409, undefined, 'request-1'),
|
||||||
|
)
|
||||||
const wrapper = mountPanel()
|
const wrapper = mountPanel()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await openWeightDialog(wrapper)
|
await openWeightDialog(wrapper)
|
||||||
@@ -567,7 +579,13 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect((input.element as HTMLInputElement).value).toBe('286.125')
|
expect((input.element as HTMLInputElement).value).toBe('286.125')
|
||||||
expect(wrapper.text()).toContain('数据版本已变化,请刷新后重试')
|
expect(wrapper.text()).not.toContain('操作未完成')
|
||||||
|
expect(errorMessage).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
message: '数据版本已变化,请刷新后重试(请求 ID:request-1)',
|
||||||
|
duration: 5_000,
|
||||||
|
showClose: true,
|
||||||
|
}))
|
||||||
|
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
|
||||||
expect(confirmWeightResult).toHaveBeenCalledTimes(1)
|
expect(confirmWeightResult).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ describe('WeightOcrPanel contract', () => {
|
|||||||
expect(source).toContain('palletId: selectedPalletId.value')
|
expect(source).toContain('palletId: selectedPalletId.value')
|
||||||
expect(source).toContain('downloadWeightImage')
|
expect(source).toContain('downloadWeightImage')
|
||||||
expect(source).toContain('await openPreview(created, URL.createObjectURL(file))')
|
expect(source).toContain('await openPreview(created, URL.createObjectURL(file))')
|
||||||
expect(source).toContain('previewShowsRecognition.value = Boolean(localUrl)')
|
|
||||||
expect(source).toContain('deleteWeightResult')
|
expect(source).toContain('deleteWeightResult')
|
||||||
expect(source).toContain('confirmWeightResult')
|
expect(source).toContain('confirmWeightResult')
|
||||||
expect(source).toContain('class="weight-card__value"')
|
expect(source).toContain('class="weight-card__value"')
|
||||||
expect(source).toContain('v-model="weightDrafts[previewResult.id].value"')
|
expect(source).toContain('v-model="weightDrafts[previewResult.id].value"')
|
||||||
expect(source).toContain('识别重量')
|
expect(source).toContain('当前托盘皮重')
|
||||||
|
expect(source).toContain('OCR 识别毛重')
|
||||||
|
expect(source).toContain('确认含托盘毛重')
|
||||||
expect(source).toContain('payload.adjustedWeightValue = Number(draft.value)')
|
expect(source).toContain('payload.adjustedWeightValue = Number(draft.value)')
|
||||||
expect(source).toContain('payload.adjustmentReason = buildAdjustmentReason(row)')
|
expect(source).toContain('payload.adjustmentReason = buildAdjustmentReason(row)')
|
||||||
expect(source).toContain('货物总重量')
|
expect(source).toContain('货物总重量')
|
||||||
@@ -38,11 +39,15 @@ describe('WeightOcrPanel contract', () => {
|
|||||||
expect(source).toContain("['image/jpeg', 'image/png', 'image/bmp']")
|
expect(source).toContain("['image/jpeg', 'image/png', 'image/bmp']")
|
||||||
expect(source).toContain('5 * 1024 * 1024')
|
expect(source).toContain('5 * 1024 * 1024')
|
||||||
expect(source).toContain('ocrRawText: getWeightOcrRawText(upload)')
|
expect(source).toContain('ocrRawText: getWeightOcrRawText(upload)')
|
||||||
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
|
expect(source).not.toContain('ocr-review-check')
|
||||||
expect(template).not.toContain('置信度')
|
expect(source).not.toContain('OCR 可能误读数字或小数点')
|
||||||
expect(template).not.toContain('OCR 原文')
|
expect(source).not.toContain('<span>置信度</span>')
|
||||||
expect(template).not.toContain('提供方')
|
expect(source).not.toContain('<span>OCR 原文</span>')
|
||||||
expect(template).not.toContain('文件 ID')
|
expect(source).not.toContain('<span>托盘编码</span>')
|
||||||
|
expect(source).not.toContain('<span>托盘类型</span>')
|
||||||
|
expect(source).not.toContain('<span>托盘容量</span>')
|
||||||
|
expect(source).not.toContain('<span>服务端毛重</span>')
|
||||||
|
expect(source).not.toContain('<span>服务端净重</span>')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('gates every action with the implemented permissions and editable statuses', () => {
|
it('gates every action with the implemented permissions and editable statuses', () => {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from '../api/weightOcr'
|
} from '../api/weightOcr'
|
||||||
import type { WeightPalletOption, WeightResultResource, WeightWorkspaceResource } from '../types'
|
import type { WeightPalletOption, WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||||
import WeightImageThumbnail from './WeightImageThumbnail.vue'
|
import WeightImageThumbnail from './WeightImageThumbnail.vue'
|
||||||
import { inboundProcessStatusLabels, inboundStatusLabel, weightRecognitionStatusLabels } from '../../status'
|
import { inboundProcessStatusLabels, inboundStatusLabel } from '../../status'
|
||||||
|
|
||||||
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
|
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -42,16 +42,13 @@ const palletError = ref<ApiErrorDetails | null>(null)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const loadError = ref<ApiErrorDetails | null>(null)
|
const loadError = ref<ApiErrorDetails | null>(null)
|
||||||
const actionError = ref<ApiErrorDetails | null>(null)
|
|
||||||
const activeAction = ref('')
|
const activeAction = ref('')
|
||||||
const previewOpen = ref(false)
|
const previewOpen = ref(false)
|
||||||
const previewLoading = ref(false)
|
const previewLoading = ref(false)
|
||||||
const previewError = ref<ApiErrorDetails | null>(null)
|
const previewError = ref<ApiErrorDetails | null>(null)
|
||||||
const previewUrl = ref('')
|
const previewUrl = ref('')
|
||||||
const previewShowsRecognition = ref(false)
|
|
||||||
const previewResultId = ref('')
|
const previewResultId = ref('')
|
||||||
const previewSnapshot = ref<WeightResultResource | null>(null)
|
const previewSnapshot = ref<WeightResultResource | null>(null)
|
||||||
const previewEvidenceReviewed = ref(false)
|
|
||||||
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB' }>>({})
|
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB' }>>({})
|
||||||
let loadSequence = 0
|
let loadSequence = 0
|
||||||
|
|
||||||
@@ -72,25 +69,12 @@ const previewConfirmDisabled = computed(() => {
|
|||||||
return !row
|
return !row
|
||||||
|| !isWeightDraftValid(row)
|
|| !isWeightDraftValid(row)
|
||||||
|| Boolean(activeAction.value)
|
|| Boolean(activeAction.value)
|
||||||
|| (requiresEvidenceReview(row) && !previewEvidenceReviewed.value)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatWeight(value: number | null, unit = 'KG') {
|
function formatWeight(value: number | null, unit = 'KG') {
|
||||||
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
|
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatConfidence(value: number | null) {
|
|
||||||
return value == null ? '未返回' : `${Math.round(value * 100)}%`
|
|
||||||
}
|
|
||||||
|
|
||||||
function recognitionLabel(status: string) {
|
|
||||||
return inboundStatusLabel(status, weightRecognitionStatusLabels, '未知状态')
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiresEvidenceReview(row: WeightResultResource) {
|
|
||||||
return row.recognitionStatus !== 'CONFIRMED' && row.adjustedWeightValue == null
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionKey(action: string, id: string) {
|
function actionKey(action: string, id: string) {
|
||||||
return `${action}:${id}`
|
return `${action}:${id}`
|
||||||
}
|
}
|
||||||
@@ -130,6 +114,23 @@ function isWeightDraftValid(row: WeightResultResource) {
|
|||||||
&& value <= maximumSafeWeight)
|
&& value <= maximumSafeWeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function draftGrossWeightKg(row: WeightResultResource) {
|
||||||
|
const draft = weightDrafts.value[row.id]
|
||||||
|
if (!draft || !Number.isFinite(Number(draft.value))) return null
|
||||||
|
const value = Number(draft.value)
|
||||||
|
return draft.unit === 'LB' ? value * 0.45359237 : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function showActionError(error: unknown, fallback: string) {
|
||||||
|
const details = getApiErrorDetails(error, fallback)
|
||||||
|
const requestReference = details.requestId ? `(请求 ID:${details.requestId})` : ''
|
||||||
|
ElMessage.error({
|
||||||
|
message: `${details.message}${requestReference}`,
|
||||||
|
duration: 5_000,
|
||||||
|
showClose: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function cardWeight(row: WeightResultResource) {
|
function cardWeight(row: WeightResultResource) {
|
||||||
if (row.recognitionStatus === 'CONFIRMED') return formatWeight(row.netWeightKg)
|
if (row.recognitionStatus === 'CONFIRMED') return formatWeight(row.netWeightKg)
|
||||||
const draft = weightDrafts.value[row.id]
|
const draft = weightDrafts.value[row.id]
|
||||||
@@ -233,7 +234,6 @@ async function handleFileChange(event: Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
actionError.value = null
|
|
||||||
try {
|
try {
|
||||||
const upload = await uploadWeightImage(customerId.value, props.inboundId, file)
|
const upload = await uploadWeightImage(customerId.value, props.inboundId, file)
|
||||||
const created = await createWeightResult(customerId.value, props.inboundId, {
|
const created = await createWeightResult(customerId.value, props.inboundId, {
|
||||||
@@ -251,7 +251,7 @@ async function handleFileChange(event: Event) {
|
|||||||
ensureWeightDraft(created)
|
ensureWeightDraft(created)
|
||||||
emitWorkspaceChanged()
|
emitWorkspaceChanged()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
actionError.value = getApiErrorDetails(error, '称重图片上传失败,请检查后重试')
|
showActionError(error, '称重图片上传失败,请检查后重试')
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
}
|
}
|
||||||
@@ -263,13 +263,13 @@ async function confirmResult(row: WeightResultResource, closeAfterSuccess = fals
|
|||||||
ElMessage.warning('请输入大于 0 的有效重量')
|
ElMessage.warning('请输入大于 0 的有效重量')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (requiresEvidenceReview(row) && !previewEvidenceReviewed.value) {
|
const grossWeightKg = draftGrossWeightKg(row)
|
||||||
ElMessage.warning('请先对照图片核对小数点和重量单位')
|
if (grossWeightKg == null || grossWeightKg <= Number(row.palletTareWeightKg)) {
|
||||||
|
ElMessage.warning(`称重毛重必须大于托盘皮重 ${formatWeight(row.palletTareWeightKg)}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const key = actionKey('confirm', row.id)
|
const key = actionKey('confirm', row.id)
|
||||||
activeAction.value = key
|
activeAction.value = key
|
||||||
actionError.value = null
|
|
||||||
try {
|
try {
|
||||||
const payload = { version: row.version } as Parameters<typeof confirmWeightResult>[3]
|
const payload = { version: row.version } as Parameters<typeof confirmWeightResult>[3]
|
||||||
if (hasWeightDraftChanged(row)) {
|
if (hasWeightDraftChanged(row)) {
|
||||||
@@ -282,13 +282,13 @@ async function confirmResult(row: WeightResultResource, closeAfterSuccess = fals
|
|||||||
payload.adjustmentReason = buildAdjustmentReason(row)
|
payload.adjustmentReason = buildAdjustmentReason(row)
|
||||||
}
|
}
|
||||||
await confirmWeightResult(customerId.value, props.inboundId, row.id, payload)
|
await confirmWeightResult(customerId.value, props.inboundId, row.id, payload)
|
||||||
|
if (closeAfterSuccess) previewOpen.value = false
|
||||||
ElMessage.success('重量已确认')
|
ElMessage.success('重量已确认')
|
||||||
await loadWorkspace()
|
await loadWorkspace()
|
||||||
if (closeAfterSuccess) previewOpen.value = false
|
|
||||||
emitWorkspaceChanged()
|
emitWorkspaceChanged()
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
actionError.value = getApiErrorDetails(error, '重量确认失败,请刷新后重试')
|
showActionError(error, '重量确认失败,请刷新后重试')
|
||||||
} finally {
|
} finally {
|
||||||
activeAction.value = ''
|
activeAction.value = ''
|
||||||
}
|
}
|
||||||
@@ -304,8 +304,6 @@ async function openPreview(row: WeightResultResource, localUrl = '') {
|
|||||||
releasePreview()
|
releasePreview()
|
||||||
previewResultId.value = row.id
|
previewResultId.value = row.id
|
||||||
previewSnapshot.value = row
|
previewSnapshot.value = row
|
||||||
previewEvidenceReviewed.value = false
|
|
||||||
previewShowsRecognition.value = Boolean(localUrl)
|
|
||||||
previewError.value = null
|
previewError.value = null
|
||||||
previewOpen.value = true
|
previewOpen.value = true
|
||||||
if (localUrl) {
|
if (localUrl) {
|
||||||
@@ -338,7 +336,6 @@ async function removeResult(row: WeightResultResource) {
|
|||||||
}
|
}
|
||||||
const key = actionKey('delete', row.id)
|
const key = actionKey('delete', row.id)
|
||||||
activeAction.value = key
|
activeAction.value = key
|
||||||
actionError.value = null
|
|
||||||
try {
|
try {
|
||||||
await deleteWeightResult(customerId.value, props.inboundId, row.id, row.version)
|
await deleteWeightResult(customerId.value, props.inboundId, row.id, row.version)
|
||||||
if (previewResultId.value === row.id) previewOpen.value = false
|
if (previewResultId.value === row.id) previewOpen.value = false
|
||||||
@@ -346,7 +343,7 @@ async function removeResult(row: WeightResultResource) {
|
|||||||
await loadWorkspace()
|
await loadWorkspace()
|
||||||
emitWorkspaceChanged()
|
emitWorkspaceChanged()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
actionError.value = getApiErrorDetails(error, '称重图片删除失败,请刷新后重试')
|
showActionError(error, '称重图片删除失败,请刷新后重试')
|
||||||
} finally {
|
} finally {
|
||||||
activeAction.value = ''
|
activeAction.value = ''
|
||||||
}
|
}
|
||||||
@@ -362,7 +359,6 @@ watch(
|
|||||||
palletError.value = null
|
palletError.value = null
|
||||||
weightDrafts.value = {}
|
weightDrafts.value = {}
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
actionError.value = null
|
|
||||||
previewOpen.value = false
|
previewOpen.value = false
|
||||||
releasePreview()
|
releasePreview()
|
||||||
void loadWorkspace()
|
void loadWorkspace()
|
||||||
@@ -373,7 +369,6 @@ watch(
|
|||||||
|
|
||||||
watch(previewOpen, (open) => {
|
watch(previewOpen, (open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
previewEvidenceReviewed.value = false
|
|
||||||
releasePreview()
|
releasePreview()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -387,7 +382,6 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
<header class="weight-heading">
|
<header class="weight-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
|
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
|
||||||
<p>先选择托盘,再上传图片核对含托盘毛重;皮重与货物净重由服务端计算。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="weight-heading__actions">
|
<div class="weight-heading__actions">
|
||||||
<div v-if="workspace" class="weight-summary" aria-live="polite">
|
<div v-if="workspace" class="weight-summary" aria-live="polite">
|
||||||
@@ -472,21 +466,9 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
当前入库状态为 {{ inboundStatusLabel(workspace.processStatus, inboundProcessStatusLabels, '未知状态') }},称重数据仅供查看。
|
当前入库状态为 {{ inboundStatusLabel(workspace.processStatus, inboundProcessStatusLabels, '未知状态') }},称重数据仅供查看。
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<LoadErrorState
|
|
||||||
v-if="actionError"
|
|
||||||
title="操作未完成"
|
|
||||||
:message="actionError.message"
|
|
||||||
:show-retry="false"
|
|
||||||
compact
|
|
||||||
class="weight-action-error"
|
|
||||||
>
|
|
||||||
<template #details><ApiErrorMeta :details="actionError" /></template>
|
|
||||||
</LoadErrorState>
|
|
||||||
|
|
||||||
<div v-loading="loading" class="weight-list" aria-live="polite">
|
<div v-loading="loading" class="weight-list" aria-live="polite">
|
||||||
<div v-if="!loading && rows.length === 0" class="weight-empty">
|
<div v-if="!loading && rows.length === 0" class="weight-empty">
|
||||||
<strong>尚未上传称重图片</strong>
|
<strong>尚未上传称重图片</strong>
|
||||||
<p>上传 JPG、PNG 或 BMP 图片,单张不超过 5MB。</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="weight-grid">
|
<div v-else class="weight-grid">
|
||||||
@@ -538,7 +520,7 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
:show-confirm="Boolean(previewResult && previewResult.recognitionStatus !== 'CONFIRMED' && canConfirm && editable)"
|
:show-confirm="Boolean(previewResult && previewResult.recognitionStatus !== 'CONFIRMED' && canConfirm && editable)"
|
||||||
confirm-text="确认重量"
|
confirm-text="确认重量"
|
||||||
confirm-type="success"
|
confirm-type="success"
|
||||||
width="min(680px, calc(100vw - 28px))"
|
width="min(640px, calc(100vw - 28px))"
|
||||||
@confirm="previewResult && confirmResult(previewResult, true)"
|
@confirm="previewResult && confirmResult(previewResult, true)"
|
||||||
>
|
>
|
||||||
<LoadErrorState
|
<LoadErrorState
|
||||||
@@ -552,35 +534,11 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
</LoadErrorState>
|
</LoadErrorState>
|
||||||
<template v-else-if="previewResult">
|
<template v-else-if="previewResult">
|
||||||
<img v-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
|
<img v-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
|
||||||
<dl class="ocr-evidence" aria-label="OCR 识别证据">
|
<div class="weight-key-facts" aria-label="称重关键数据">
|
||||||
<div><dt>托盘编码</dt><dd>{{ previewResult.palletCode }}</dd></div>
|
<div><span>当前托盘皮重</span><strong>{{ formatWeight(previewResult.palletTareWeightKg) }}</strong></div>
|
||||||
<div><dt>托盘类型</dt><dd>{{ previewResult.palletType }}</dd></div>
|
<div><span>OCR 识别毛重</span><strong>{{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || 'KG') }}</strong></div>
|
||||||
<div><dt>托盘皮重</dt><dd>{{ formatWeight(previewResult.palletTareWeightKg) }}</dd></div>
|
|
||||||
<div><dt>托盘容量</dt><dd>{{ previewResult.palletCapacityCbm }} m³</dd></div>
|
|
||||||
<div><dt>识别状态</dt><dd>{{ recognitionLabel(previewResult.recognitionStatus) }}</dd></div>
|
|
||||||
<div><dt>置信度</dt><dd>{{ formatConfidence(previewResult.confidence) }}</dd></div>
|
|
||||||
<div><dt>服务端毛重</dt><dd>{{ formatWeight(previewResult.grossWeightKg) }}</dd></div>
|
|
||||||
<div><dt>服务端净重</dt><dd>{{ formatWeight(previewResult.netWeightKg) }}</dd></div>
|
|
||||||
<div class="ocr-evidence__raw"><dt>OCR 原文</dt><dd>{{ previewResult.ocrRawText || '未识别到有效重量' }}</dd></div>
|
|
||||||
<div v-if="previewResult.adjustmentReason" class="ocr-evidence__raw">
|
|
||||||
<dt>人工修正</dt><dd>{{ previewResult.adjustmentReason }}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
<div v-if="weightDrafts[previewResult.id]" class="preview-weight">
|
||||||
<el-alert
|
|
||||||
v-if="previewResult.recognitionStatus !== 'CONFIRMED' && requiresEvidenceReview(previewResult)"
|
|
||||||
class="ocr-review-alert"
|
|
||||||
type="warning"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
title="OCR 可能误读数字或小数点,确认前请对照原图核对。"
|
|
||||||
/>
|
|
||||||
<div v-if="previewResult.recognitionStatus === 'CONFIRMED'" class="preview-weight is-confirmed">
|
|
||||||
<span>货物净重</span><strong>{{ formatWeight(previewResult.netWeightKg) }}</strong>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="weightDrafts[previewResult.id]" class="preview-weight">
|
|
||||||
<span v-if="previewShowsRecognition" class="preview-weight__recognized">
|
|
||||||
识别重量 {{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || '') }}
|
|
||||||
</span>
|
|
||||||
<label>
|
<label>
|
||||||
<span>确认含托盘毛重</span>
|
<span>确认含托盘毛重</span>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
@@ -590,21 +548,15 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
:max="maximumSafeWeight"
|
:max="maximumSafeWeight"
|
||||||
:precision="3"
|
:precision="3"
|
||||||
:controls="false"
|
:controls="false"
|
||||||
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
:disabled="previewResult.recognitionStatus === 'CONFIRMED' || Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<el-segmented
|
<el-segmented
|
||||||
v-model="weightDrafts[previewResult.id].unit"
|
v-model="weightDrafts[previewResult.id].unit"
|
||||||
aria-label="当前图片重量单位"
|
aria-label="当前图片重量单位"
|
||||||
:options="['KG', 'LB']"
|
:options="['KG', 'LB']"
|
||||||
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
:disabled="previewResult.recognitionStatus === 'CONFIRMED' || Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
||||||
/>
|
/>
|
||||||
<el-checkbox
|
|
||||||
v-if="requiresEvidenceReview(previewResult)"
|
|
||||||
v-model="previewEvidenceReviewed"
|
|
||||||
class="ocr-review-check"
|
|
||||||
:disabled="Boolean(activeAction)"
|
|
||||||
>已对照图片核对小数点和重量单位</el-checkbox>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
@@ -616,7 +568,6 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
.weight-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
.weight-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||||
.weight-heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; }
|
.weight-heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; }
|
||||||
.weight-heading h3 svg { color: var(--cc-primary); }
|
.weight-heading h3 svg { color: var(--cc-primary); }
|
||||||
.weight-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
|
||||||
.weight-heading__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 16px; }
|
.weight-heading__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 16px; }
|
||||||
.pallet-picker { display: grid; min-width: 260px; gap: 4px; }
|
.pallet-picker { display: grid; min-width: 260px; gap: 4px; }
|
||||||
.pallet-picker small { color: var(--cc-muted); font-size: 11px; line-height: 1.4; }
|
.pallet-picker small { color: var(--cc-muted); font-size: 11px; line-height: 1.4; }
|
||||||
@@ -625,10 +576,9 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
.weight-summary strong { color: #067647; font-size: 18px; }
|
.weight-summary strong { color: #067647; font-size: 18px; }
|
||||||
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||||
.weight-state-note, .weight-action-error { margin-bottom: 14px; }
|
.weight-state-note, .weight-action-error { margin-bottom: 14px; }
|
||||||
.weight-list { min-height: 120px; margin-top: 16px; }
|
.weight-list { min-height: 0; margin-top: 14px; }
|
||||||
.weight-empty { display: grid; min-height: 180px; place-items: center; align-content: center; gap: 8px; padding: 24px; color: var(--cc-muted); text-align: center; }
|
.weight-empty { display: flex; min-height: 0; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 4px; background: var(--cc-surface-muted); color: var(--cc-muted); }
|
||||||
.weight-empty strong { color: #344054; font-size: 14px; }
|
.weight-empty strong { color: #344054; font-size: 14px; }
|
||||||
.weight-empty p { margin: 0; font-size: 12px; }
|
|
||||||
.weight-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
|
.weight-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
|
||||||
.weight-card { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
.weight-card { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
||||||
.weight-card__body { padding: 12px; text-align: center; }
|
.weight-card__body { padding: 12px; text-align: center; }
|
||||||
@@ -640,22 +590,14 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
.weight-card__facts div { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; line-height: 1.4; }
|
.weight-card__facts div { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; line-height: 1.4; }
|
||||||
.weight-card__facts dt { color: var(--cc-muted); }
|
.weight-card__facts dt { color: var(--cc-muted); }
|
||||||
.weight-card__facts dd { min-width: 0; margin: 0; color: #344054; overflow-wrap: anywhere; text-align: right; }
|
.weight-card__facts dd { min-width: 0; margin: 0; color: #344054; overflow-wrap: anywhere; text-align: right; }
|
||||||
.weight-preview { display: block; width: 100%; max-height: min(58vh, 560px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
|
.weight-preview { display: block; width: 100%; max-height: min(50vh, 460px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
|
||||||
.ocr-evidence { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 18px; margin: 14px 0 0; padding: 12px 0; border-block: 1px solid var(--cc-border); }
|
.weight-key-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; margin-top: 14px; padding: 14px 0; border-block: 1px solid var(--cc-border); }
|
||||||
.ocr-evidence div { min-width: 0; }
|
.weight-key-facts div { display: grid; min-width: 0; gap: 4px; }
|
||||||
.ocr-evidence dt { color: var(--cc-muted); font-size: 12px; }
|
.weight-key-facts span { color: var(--cc-muted); font-size: 12px; }
|
||||||
.ocr-evidence dd { margin: 4px 0 0; color: #344054; font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; }
|
.weight-key-facts strong { color: #101828; font-size: 18px; }
|
||||||
.ocr-evidence__raw { grid-column: 1 / -1; }
|
|
||||||
.ocr-review-alert { margin-top: 12px; }
|
|
||||||
.preview-weight { display: grid; grid-template-columns: minmax(0, 1fr) 120px; align-items: end; gap: 12px; margin-top: 14px; }
|
.preview-weight { display: grid; grid-template-columns: minmax(0, 1fr) 120px; align-items: end; gap: 12px; margin-top: 14px; }
|
||||||
.preview-weight__recognized { grid-column: 1 / -1; color: var(--cc-muted); font-size: 12px; }
|
|
||||||
.preview-weight label { display: grid; gap: 6px; color: #475467; font-size: 12px; }
|
.preview-weight label { display: grid; gap: 6px; color: #475467; font-size: 12px; }
|
||||||
.preview-weight :deep(.el-input-number), .preview-weight :deep(.el-segmented) { width: 100%; }
|
.preview-weight :deep(.el-input-number), .preview-weight :deep(.el-segmented) { width: 100%; }
|
||||||
.ocr-review-check { grid-column: 1 / -1; white-space: normal; }
|
|
||||||
.ocr-review-check :deep(.el-checkbox__label) { line-height: 1.5; white-space: normal; }
|
|
||||||
.preview-weight.is-confirmed { display: flex; align-items: baseline; justify-content: space-between; padding: 12px 0 0; }
|
|
||||||
.preview-weight.is-confirmed span { color: var(--cc-muted); font-size: 12px; }
|
|
||||||
.preview-weight.is-confirmed strong { color: #067647; font-size: 20px; }
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.weight-heading { flex-direction: column; }
|
.weight-heading { flex-direction: column; }
|
||||||
@@ -670,9 +612,8 @@ defineExpose({ reload: loadWorkspace })
|
|||||||
@media (max-width: 360px) {
|
@media (max-width: 360px) {
|
||||||
.weight-grid { grid-template-columns: 1fr; }
|
.weight-grid { grid-template-columns: 1fr; }
|
||||||
.preview-weight { grid-template-columns: 1fr; }
|
.preview-weight { grid-template-columns: 1fr; }
|
||||||
.preview-weight__recognized { grid-column: 1; }
|
.weight-key-facts { grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
.ocr-evidence { grid-template-columns: 1fr; }
|
.weight-key-facts strong { font-size: 15px; }
|
||||||
.ocr-evidence__raw { grid-column: 1; }
|
|
||||||
.weight-card__facts div { align-items: flex-start; flex-direction: column; gap: 2px; }
|
.weight-card__facts div { align-items: flex-start; flex-direction: column; gap: 2px; }
|
||||||
.weight-card__facts dd { text-align: left; }
|
.weight-card__facts dd { text-align: left; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,13 @@ describe('ocean bookings api', () => {
|
|||||||
|
|
||||||
it('uses version zero for create and extracts the id from the returned Detail', async () => {
|
it('uses version zero for create and extracts the id from the returned Detail', async () => {
|
||||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse() })
|
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: detailResponse() })
|
||||||
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: '456', customerName: '客户', channelCode: 'SEA', warehouseId: '8', warehouseName: '义乌仓', parties: [], containerRequirements: [] } })
|
const created = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: 'SEA', warehouseId: '8', warehouseName: '义乌仓', serviceTerm: null, freightTerm: null, parties: [], containerRequirements: [] } })
|
||||||
expect(post.mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 0 })
|
const sentPayload = post.mock.calls[0]?.[1] as { expectedVersion: number; data: Record<string, unknown> }
|
||||||
|
expect(sentPayload).toMatchObject({ expectedVersion: 0, data: { customerId: null, customerName: null, serviceTerm: null, freightTerm: null } })
|
||||||
|
expect(sentPayload).not.toMatchObject({ data: { customerId: '', customerName: '' } })
|
||||||
|
for (const releaseOnlyField of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
|
||||||
|
expect(sentPayload.data).not.toHaveProperty(releaseOnlyField)
|
||||||
|
}
|
||||||
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
|
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
|
||||||
expect(created.id).toBe('123')
|
expect(created.id).toBe('123')
|
||||||
expect(created.version).toBe(0)
|
expect(created.version).toBe(0)
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/p
|
|||||||
|
|
||||||
describe('OceanBookingDetailView interaction contract', () => {
|
describe('OceanBookingDetailView interaction contract', () => {
|
||||||
it('selects business objects and automatically keeps their ids and names together', () => {
|
it('selects business objects and automatically keeps their ids and names together', () => {
|
||||||
expect(source).toContain('placeholder="请选择客户"')
|
expect(source).toContain('placeholder="请选择客户(可选)"')
|
||||||
|
expect(source).toContain('filterable clearable')
|
||||||
|
expect(source).toContain('@update:model-value="selectCustomer"')
|
||||||
|
expect(source).toContain('no-data-text="暂无可选客户"')
|
||||||
|
expect(source).toContain('customerId: null, customerName: null')
|
||||||
|
expect(source).toContain("value == null || value === '' ? null : String(value)")
|
||||||
|
expect(source).toContain('form.data.customerName = form.data.customerId ? form.data.customerName : null')
|
||||||
|
expect(source).toContain('normalizeDraftData()')
|
||||||
|
expect(source).not.toContain("customerId: [{ required: true")
|
||||||
expect(source).toContain('placeholder="请选择船公司"')
|
expect(source).toContain('placeholder="请选择船公司"')
|
||||||
expect(source).toContain('placeholder="请选择订舱代理"')
|
expect(source).toContain('placeholder="请选择订舱代理"')
|
||||||
expect(source).toContain("selectPartner('shippingCompany'")
|
expect(source).toContain("selectPartner('shippingCompany'")
|
||||||
@@ -38,22 +46,37 @@ describe('OceanBookingDetailView interaction contract', () => {
|
|||||||
|
|
||||||
it('provides guidance for every free-text booking field', () => {
|
it('provides guidance for every free-text booking field', () => {
|
||||||
for (const placeholder of [
|
for (const placeholder of [
|
||||||
'请输入提单号(可选)', '请输入船名', '请输入航次', '例如:CNNGB', '例如:宁波港',
|
'例如:CNNGB', '例如:宁波港', '例如:USLAX', '例如:洛杉矶港',
|
||||||
'例如:USLAX', '例如:洛杉矶港', '请输入最终交货地点', '请输入订舱备注(可选)',
|
'请输入最终交货地点', '请输入订舱备注(可选)',
|
||||||
]) expect(source).toContain(`placeholder="${placeholder}"`)
|
]) expect(source).toContain(`placeholder="${placeholder}"`)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses compact choices and keeps capacity snapshots behind one specification control', () => {
|
it('keeps release-only transport facts out of draft editing and saving', () => {
|
||||||
expect(source).toContain("const serviceTermOptions = ['CY-CY', 'CY-DOOR', 'DOOR-CY', 'DOOR-DOOR']")
|
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
|
||||||
expect(source).toContain("const freightTermOptions = ['PREPAID', 'COLLECT']")
|
expect(source).not.toContain(`v-model="form.data.${field}"`)
|
||||||
|
}
|
||||||
|
expect(source).toContain(':model-value="detail?.billOfLadingNo || \'\'" disabled placeholder="确认放舱时录入"')
|
||||||
|
expect(source).toContain(':model-value="detail?.vesselName || \'\'" disabled placeholder="确认放舱时录入"')
|
||||||
|
expect(source).toContain('v-model="releaseForm.billOfLadingNo"')
|
||||||
|
expect(source).toContain('v-model="releaseForm.vesselName"')
|
||||||
|
expect(source).toContain('v-model="releaseForm.voyageNo"')
|
||||||
|
expect(source).toContain('v-model="releaseForm.estimatedSailingAt"')
|
||||||
|
expect(source).toContain('v-model="releaseForm.estimatedArrivalAt"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses source-backed free text terms and keeps capacity snapshots behind one specification control', () => {
|
||||||
|
expect(source).not.toContain('serviceTermOptions')
|
||||||
|
expect(source).not.toContain('freightTermOptions')
|
||||||
expect(source).toContain("const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC']")
|
expect(source).toContain("const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC']")
|
||||||
expect(source).toContain('<el-select v-model="form.data.serviceTerm"')
|
expect(source).toContain('label="主要运输条款 Service Term" prop="serviceTerm"')
|
||||||
expect(source).toContain('<el-select v-model="form.data.freightTerm"')
|
expect(source).toContain('<el-input v-model="form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term"')
|
||||||
|
expect(source).toContain('label="运费条款 Freight Term" prop="freightTerm"')
|
||||||
|
expect(source).toContain('<el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term"')
|
||||||
expect(source).toContain('<el-select v-model="container.containerType"')
|
expect(source).toContain('<el-select v-model="container.containerType"')
|
||||||
expect(source).toContain('<el-select v-model="container.containerKind"')
|
expect(source).toContain('<el-select v-model="container.containerKind"')
|
||||||
expect(source).toContain(':aria-label="`设置箱型 ${index + 1} 的容量与限重`"')
|
expect(source).toContain(':aria-label="`设置箱型 ${index + 1} 的容量与限重`"')
|
||||||
expect(source).toContain('function containerSpecificationLabel(')
|
expect(source).toContain('function containerSpecificationLabel(')
|
||||||
expect(source).not.toContain('<el-input v-model="form.data.serviceTerm"')
|
expect(source).not.toContain("['PREPAID', 'COLLECT']")
|
||||||
expect(source).not.toContain('<el-input v-model="container.containerType"')
|
expect(source).not.toContain('<el-input v-model="container.containerType"')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -124,7 +147,7 @@ describe('OceanBookingDetailView interaction contract', () => {
|
|||||||
|
|
||||||
it('shows client-side validation failures instead of rendering an empty error alert', () => {
|
it('shows client-side validation failures instead of rendering an empty error alert', () => {
|
||||||
expect(source).toContain(':title="actionError.message"')
|
expect(source).toContain(':title="actionError.message"')
|
||||||
expect(source).toContain("actionError.value = { message: '请完善客户、装柜仓库和物流渠道等必填信息' }")
|
expect(source).toContain("actionError.value = { message: '请完善装柜仓库、物流渠道和运输条款等必填信息' }")
|
||||||
expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }")
|
expect(source).toContain("actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }")
|
||||||
expect(source).toContain('规格(必填)')
|
expect(source).toContain('规格(必填)')
|
||||||
expect(source).toContain('placeholder="单位名称(必填)"')
|
expect(source).toContain('placeholder="单位名称(必填)"')
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ const partyTypes = [
|
|||||||
{ value: 'CONSIGNEE', label: '收货人' },
|
{ value: 'CONSIGNEE', label: '收货人' },
|
||||||
{ value: 'NOTIFY_PARTY', label: '通知人' },
|
{ value: 'NOTIFY_PARTY', label: '通知人' },
|
||||||
] as const
|
] as const
|
||||||
const serviceTermOptions = ['CY-CY', 'CY-DOOR', 'DOOR-CY', 'DOOR-DOOR']
|
|
||||||
const freightTermOptions = ['PREPAID', 'COLLECT']
|
|
||||||
const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC']
|
const containerTypeOptions = ['20GP', '40GP', '40HC', '40NR', '45HC']
|
||||||
const containerKindOptions = [
|
const containerKindOptions = [
|
||||||
{ value: 'FCL', label: '整箱 FCL' },
|
{ value: 'FCL', label: '整箱 FCL' },
|
||||||
@@ -100,15 +98,16 @@ function blankParties(): OceanBookingParty[] {
|
|||||||
}
|
}
|
||||||
function blankData(): OceanBookingDraftData {
|
function blankData(): OceanBookingDraftData {
|
||||||
return {
|
return {
|
||||||
customerId: '', customerName: '', channelCode: '', warehouseId: '', warehouseName: '', parties: blankParties(),
|
customerId: null, customerName: null, channelCode: '', warehouseId: '', warehouseName: '', serviceTerm: null, freightTerm: null, parties: blankParties(),
|
||||||
containerRequirements: [createBlankContainerRequirement()],
|
containerRequirements: [createBlankContainerRequirement()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
|
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
customerId: [{ required: true, message: '请选择客户', trigger: 'change' }],
|
|
||||||
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
|
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
|
||||||
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
|
channelCode: [{ required: true, message: '请选择物流渠道', trigger: 'change' }],
|
||||||
|
serviceTerm: [{ required: true, message: '请输入主要运输条款 Service Term', trigger: 'blur' }],
|
||||||
|
freightTerm: [{ required: true, message: '请输入运费条款 Freight Term', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
|
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
|
||||||
const canSave = computed(() => isNew.value
|
const canSave = computed(() => isNew.value
|
||||||
@@ -143,10 +142,17 @@ async function loadOptions() {
|
|||||||
optionsLoading.value = false
|
optionsLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function selectCustomer(customerId: string) {
|
function selectCustomer(value: unknown) {
|
||||||
|
const customerId = value == null || value === '' ? null : String(value)
|
||||||
form.data.customerId = customerId
|
form.data.customerId = customerId
|
||||||
const customer = customers.value.find((item) => String(item.id) === customerId)
|
const customer = customerId == null ? null : customers.value.find((item) => String(item.id) === customerId)
|
||||||
form.data.customerName = customer?.tenantName || ''
|
form.data.customerName = customer?.tenantName || null
|
||||||
|
}
|
||||||
|
function normalizeDraftData() {
|
||||||
|
form.data.customerId = form.data.customerId || null
|
||||||
|
form.data.customerName = form.data.customerId ? form.data.customerName : null
|
||||||
|
form.data.serviceTerm = form.data.serviceTerm?.trim() || null
|
||||||
|
form.data.freightTerm = form.data.freightTerm?.trim() || null
|
||||||
}
|
}
|
||||||
function selectWarehouse(value: unknown) {
|
function selectWarehouse(value: unknown) {
|
||||||
const warehouseId = String(value || '')
|
const warehouseId = String(value || '')
|
||||||
@@ -190,14 +196,12 @@ function applyDetail(value: OceanBookingDetail) {
|
|||||||
detail.value = value
|
detail.value = value
|
||||||
form.expectedVersion = value.version
|
form.expectedVersion = value.version
|
||||||
form.data = {
|
form.data = {
|
||||||
customerId: value.customerId || '', customerName: value.customerName || '', channelCode: value.channelCode || '', warehouseId: value.warehouseId || '', warehouseName: value.warehouseName || '', billOfLadingNo: value.billOfLadingNo,
|
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '', warehouseName: value.warehouseName || '',
|
||||||
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
|
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
|
||||||
bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName,
|
bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName,
|
||||||
vesselName: value.vesselName, voyageNo: value.voyageNo,
|
|
||||||
departurePortCode: value.departurePortCode, departurePortName: value.departurePortName,
|
departurePortCode: value.departurePortCode, departurePortName: value.departurePortName,
|
||||||
destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName,
|
destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName,
|
||||||
placeOfDelivery: value.placeOfDelivery, serviceTerm: value.serviceTerm, freightTerm: value.freightTerm,
|
placeOfDelivery: value.placeOfDelivery, serviceTerm: value.serviceTerm, freightTerm: value.freightTerm,
|
||||||
estimatedSailingAt: value.estimatedSailingAt, estimatedArrivalAt: value.estimatedArrivalAt,
|
|
||||||
remark: value.remark,
|
remark: value.remark,
|
||||||
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
|
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
|
||||||
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) => ({ containerType, containerKind, capacityCbm: capacityCbm ?? null, maxWeightKg: maxWeightKg ?? null, quantity: quantity ?? null })) : [createBlankContainerRequirement()],
|
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) => ({ containerType, containerKind, capacityCbm: capacityCbm ?? null, maxWeightKg: maxWeightKg ?? null, quantity: quantity ?? null })) : [createBlankContainerRequirement()],
|
||||||
@@ -217,8 +221,9 @@ async function load() {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (!canSave.value) return
|
if (!canSave.value) return
|
||||||
actionError.value = null
|
actionError.value = null
|
||||||
|
normalizeDraftData()
|
||||||
if (!await formRef.value?.validate().catch(() => false)) {
|
if (!await formRef.value?.validate().catch(() => false)) {
|
||||||
actionError.value = { message: '请完善客户、装柜仓库和物流渠道等必填信息' }
|
actionError.value = { message: '请完善装柜仓库、物流渠道和运输条款等必填信息' }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (form.data.parties.some((party) => !party.partyName.trim())) {
|
if (form.data.parties.some((party) => !party.partyName.trim())) {
|
||||||
@@ -339,7 +344,7 @@ onMounted(() => { void load(); void loadOptions() })
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="detail-heading">
|
<div class="detail-heading">
|
||||||
<el-button @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
|
<el-button @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱资料'" :description="detail ? `${detail.customerName || '—'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '填写并保存订舱资料'">
|
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱资料'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '填写并保存订舱资料'">
|
||||||
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
|
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
|
||||||
</PageToolbar>
|
</PageToolbar>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,33 +366,33 @@ onMounted(() => { void load(); void loadOptions() })
|
|||||||
<section class="booking-group" aria-label="基本信息">
|
<section class="booking-group" aria-label="基本信息">
|
||||||
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
|
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
|
||||||
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
|
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
|
||||||
<el-form-item label="客户" prop="customerId"><el-select :model-value="form.data.customerId" filterable placeholder="请选择客户" :loading="optionsLoading" :disabled="!isNew" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
|
<el-form-item label="客户(可选)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="请选择客户(可选)" no-data-text="暂无可选客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
|
||||||
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '待创建'" disabled /></el-form-item>
|
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '待创建'" disabled /></el-form-item>
|
||||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
|
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||||
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
|
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channels.some((channel) => channel.channelCode === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode"><span>{{ channel.channelName }}</span><small class="channel-code">{{ channel.channelCode }}</small></el-option></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
|
||||||
<el-form-item label="提单号"><el-input v-model="form.data.billOfLadingNo" placeholder="请输入提单号(可选)" /></el-form-item>
|
<el-form-item label="提单号"><el-input :model-value="detail?.billOfLadingNo || ''" disabled placeholder="确认放舱时录入" /></el-form-item>
|
||||||
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
||||||
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-if="form.data.bookingAgentId && !bookingAgents.some((partner) => String(partner.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-if="form.data.bookingAgentId && !bookingAgents.some((partner) => String(partner.id) === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="booking-group" aria-label="航线船期">
|
<section class="booking-group" aria-label="航线船期">
|
||||||
<h3>航线船期</h3><div class="booking-group__body booking-group__body--fields">
|
<h3>航线船期</h3><div class="booking-group__body booking-group__body--fields">
|
||||||
<el-form-item label="船名"><el-input v-model="form.data.vesselName" placeholder="请输入船名" /></el-form-item>
|
<el-form-item label="船名"><el-input :model-value="detail?.vesselName || ''" disabled placeholder="确认放舱时录入" /></el-form-item>
|
||||||
<el-form-item label="航次"><el-input v-model="form.data.voyageNo" placeholder="请输入航次" /></el-form-item>
|
<el-form-item label="航次"><el-input :model-value="detail?.voyageNo || ''" disabled placeholder="确认放舱时录入" /></el-form-item>
|
||||||
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" placeholder="例如:CNNGB" /></el-form-item>
|
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" placeholder="例如:CNNGB" /></el-form-item>
|
||||||
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" placeholder="例如:宁波港" /></el-form-item>
|
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" placeholder="例如:宁波港" /></el-form-item>
|
||||||
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" placeholder="例如:USLAX" /></el-form-item>
|
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" placeholder="例如:USLAX" /></el-form-item>
|
||||||
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" placeholder="例如:洛杉矶港" /></el-form-item>
|
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" placeholder="例如:洛杉矶港" /></el-form-item>
|
||||||
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
|
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
|
||||||
<el-form-item label="预计开船"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
|
<el-form-item label="预计开船"><el-date-picker :model-value="detail?.estimatedSailingAt || null" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" disabled placeholder="确认放舱时录入" style="width:100%" /></el-form-item>
|
||||||
<el-form-item label="预计到港"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
|
<el-form-item label="预计到港"><el-date-picker :model-value="detail?.estimatedArrivalAt || null" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" disabled placeholder="确认放舱时录入" style="width:100%" /></el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="booking-group" aria-label="条款与柜量">
|
<section class="booking-group" aria-label="条款与柜量">
|
||||||
<h3>条款与柜量</h3><div class="booking-group__body">
|
<h3>条款与柜量</h3><div class="booking-group__body">
|
||||||
<div class="booking-terms">
|
<div class="booking-terms">
|
||||||
<el-form-item label="服务条款"><el-select v-model="form.data.serviceTerm" clearable placeholder="请选择服务条款"><el-option v-for="value in serviceTermOptions" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
<el-form-item label="主要运输条款 Service Term" prop="serviceTerm"><el-input v-model="form.data.serviceTerm" clearable maxlength="32" placeholder="请输入 Service Term" /></el-form-item>
|
||||||
<el-form-item label="运费条款"><el-select v-model="form.data.freightTerm" clearable placeholder="请选择运费条款"><el-option v-for="value in freightTermOptions" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
<el-form-item label="运费条款 Freight Term" prop="freightTerm"><el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term" /></el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<div class="container-table-wrap" aria-label="箱型箱量">
|
<div class="container-table-wrap" aria-label="箱型箱量">
|
||||||
<table class="container-table">
|
<table class="container-table">
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ export interface OceanBookingParty { id?: string; partyType: string; partyName:
|
|||||||
export interface OceanBookingContainer { id?: string; containerType: string; quantity: number | null; containerKind?: string | null; capacityCbm: number | null; maxWeightKg: number | null }
|
export interface OceanBookingContainer { id?: string; containerType: string; quantity: number | null; containerKind?: string | null; capacityCbm: number | null; maxWeightKg: number | null }
|
||||||
export interface OceanBookingContainerAllocation extends OceanBookingContainer { allocatedQuantity?: number | null }
|
export interface OceanBookingContainerAllocation extends OceanBookingContainer { allocatedQuantity?: number | null }
|
||||||
export interface OceanBookingDraftData {
|
export interface OceanBookingDraftData {
|
||||||
customerId: string; customerName: string; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]
|
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]
|
||||||
channelCode: string; warehouseId: string; warehouseName: string
|
channelCode: string; warehouseId: string; warehouseName: string
|
||||||
billOfLadingNo?: string | null
|
|
||||||
shippingCompanyId?: string | null; shippingCompanyName?: string | null; bookingAgentId?: string | null; bookingAgentName?: string | null
|
shippingCompanyId?: string | null; shippingCompanyName?: string | null; bookingAgentId?: string | null; bookingAgentName?: string | null
|
||||||
departurePortCode?: string | null; departurePortName?: string | null; destinationPortCode?: string | null; destinationPortName?: string | null
|
departurePortCode?: string | null; departurePortName?: string | null; destinationPortCode?: string | null; destinationPortName?: string | null
|
||||||
placeOfDelivery?: string | null; serviceTerm?: string | null; freightTerm?: string | null
|
placeOfDelivery?: string | null; serviceTerm: string | null; freightTerm: string | null
|
||||||
estimatedSailingAt?: string | null; estimatedArrivalAt?: string | null; vesselName?: string | null; voyageNo?: string | null; remark?: string | null
|
remark?: string | null
|
||||||
}
|
}
|
||||||
export interface OceanBookingResource {
|
export interface OceanBookingResource {
|
||||||
id: string; bookingNo: string | null; status: OceanBookingStatus; version: number
|
id: string; bookingNo: string | null; status: OceanBookingStatus; version: number
|
||||||
|
|||||||
@@ -58,13 +58,14 @@ describe('OutboundDispatchesView', () => {
|
|||||||
const wrapper = mountPage()
|
const wrapper = mountPage()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(listOutboundDispatches).not.toHaveBeenCalled()
|
expect(listOutboundDispatches).not.toHaveBeenCalled()
|
||||||
expect(wrapper.text()).toContain('请选择订单 / 入库单或柜次后查询')
|
expect(wrapper.text()).toContain('请选择入仓码或柜次后查询')
|
||||||
const toolbar = await setFilters(wrapper, { orderId: '20001' })
|
const toolbar = await setFilters(wrapper, { orderId: '20001' })
|
||||||
toolbar.vm.$emit('search')
|
toolbar.vm.$emit('search')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(listOutboundDispatches).toHaveBeenCalledOnce()
|
expect(listOutboundDispatches).toHaveBeenCalledOnce()
|
||||||
expect(listOutboundDispatches).toHaveBeenCalledWith({ orderId: '20001', inboundId: '30001', containerBookingId: undefined })
|
expect(listOutboundDispatches).toHaveBeenCalledWith({ orderId: '20001', inboundId: '30001', containerBookingId: undefined })
|
||||||
expect(wrapper.text()).toContain('ORD-001')
|
expect(wrapper.text()).toContain('IN-001')
|
||||||
|
expect(wrapper.text()).not.toContain('ORD-001')
|
||||||
expect(wrapper.text()).toContain('已放舱')
|
expect(wrapper.text()).toContain('已放舱')
|
||||||
expect(wrapper.text()).toContain('已完成')
|
expect(wrapper.text()).toContain('已完成')
|
||||||
expect(wrapper.text()).toContain('待复核')
|
expect(wrapper.text()).toContain('待复核')
|
||||||
@@ -92,7 +93,7 @@ describe('OutboundDispatchesView', () => {
|
|||||||
await resetButton?.trigger('click')
|
await resetButton?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(listOutboundDispatches).toHaveBeenCalledOnce()
|
expect(listOutboundDispatches).toHaveBeenCalledOnce()
|
||||||
expect(wrapper.text()).toContain('请选择订单 / 入库单或柜次后查询')
|
expect(wrapper.text()).toContain('请选择入仓码或柜次后查询')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows loading, empty and recoverable error states', async () => {
|
it('shows loading, empty and recoverable error states', async () => {
|
||||||
@@ -116,7 +117,8 @@ describe('OutboundDispatchesView', () => {
|
|||||||
await retry?.trigger('click')
|
await retry?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(listOutboundDispatches).toHaveBeenCalledTimes(3)
|
expect(listOutboundDispatches).toHaveBeenCalledTimes(3)
|
||||||
expect(wrapper.text()).toContain('ORD-001')
|
expect(wrapper.text()).toContain('IN-001')
|
||||||
|
expect(wrapper.text()).not.toContain('ORD-001')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores stale list responses when a newer query wins', async () => {
|
it('ignores stale list responses when a newer query wins', async () => {
|
||||||
@@ -133,14 +135,16 @@ describe('OutboundDispatchesView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
first.resolve([row])
|
first.resolve([row])
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.text()).toContain('ORD-002')
|
expect(wrapper.text()).toContain('IN-002')
|
||||||
expect(wrapper.text()).not.toContain('ORD-001')
|
expect(wrapper.text()).not.toContain('IN-001')
|
||||||
|
expect(wrapper.text()).not.toContain('ORD-002')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the operation column first and guarded workflow actions', () => {
|
it('keeps the operation column first and guarded workflow actions', () => {
|
||||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
|
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
|
||||||
const columnSource = source.slice(source.indexOf('const columns'))
|
const columnSource = source.slice(source.indexOf('const columns'))
|
||||||
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("prop: 'orderNo'"))
|
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("prop: 'inboundCode'"))
|
||||||
|
expect(columnSource).not.toContain("prop: 'orderNo'")
|
||||||
expect(columnSource).toContain("fixed: 'left'")
|
expect(columnSource).toContain("fixed: 'left'")
|
||||||
expect(source).toContain('aggregate.gate?.items')
|
expect(source).toContain('aggregate.gate?.items')
|
||||||
expect(source).toContain('detail.timeline')
|
expect(source).toContain('detail.timeline')
|
||||||
|
|||||||
@@ -40,14 +40,13 @@ let disposed = false
|
|||||||
|
|
||||||
const fields = computed<QueryField[]>(() => [
|
const fields = computed<QueryField[]>(() => [
|
||||||
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), props: { filterable: true, loading: optionsLoading.value } },
|
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), props: { filterable: true, loading: optionsLoading.value } },
|
||||||
{ key: 'orderId', type: 'select', label: '订单 / 入库单', width: 280, placeholder: filters.value.customerId ? '请选择该客户的订单' : '请选择订单或入库单', options: orders.value.map((item) => ({ label: `${item.orderNo || item.orderId} / ${item.inboundCode || item.inboundId || '未入库'}`, value: String(item.orderId), disabled: item.inboundId == null })), props: { filterable: true, loading: optionsLoading.value } },
|
{ key: 'orderId', type: 'select', label: '入仓码', width: 280, placeholder: filters.value.customerId ? '请选择该客户的入仓码' : '请选择入仓码', options: orders.value.map((item) => ({ label: item.inboundCode || '入仓码暂缺', value: String(item.orderId), disabled: item.inboundId == null })), props: { filterable: true, loading: optionsLoading.value } },
|
||||||
{ key: 'containerBookingId', type: 'select', label: '柜次', width: 240, placeholder: '请选择柜号或集装箱号', options: containers.value.map((item) => ({ label: `${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } },
|
{ key: 'containerBookingId', type: 'select', label: '柜次', width: 240, placeholder: '请选择柜号或集装箱号', options: containers.value.map((item) => ({ label: `${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } },
|
||||||
])
|
])
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'actions', label: '操作', fixed: 'left', width: 240, slotName: 'actions', hideable: false, movable: false },
|
{ key: 'actions', label: '操作', fixed: 'left', width: 240, slotName: 'actions', hideable: false, movable: false },
|
||||||
{ prop: 'orderNo', label: '订单号', minWidth: 145 },
|
{ prop: 'inboundCode', label: '入仓码', minWidth: 145 },
|
||||||
{ prop: 'inboundCode', label: '入库单号', minWidth: 145 },
|
|
||||||
{ prop: 'customerName', label: '客户', minWidth: 140 },
|
{ prop: 'customerName', label: '客户', minWidth: 140 },
|
||||||
{ prop: 'containerNo', label: '柜号', minWidth: 130 },
|
{ prop: 'containerNo', label: '柜号', minWidth: 130 },
|
||||||
{ prop: 'oceanBookingStatus', label: '订舱', width: 105, slotName: 'booking' },
|
{ prop: 'oceanBookingStatus', label: '订舱', width: 105, slotName: 'booking' },
|
||||||
@@ -200,7 +199,7 @@ onBeforeUnmount(() => { disposed = true; listRequestId += 1; optionRequestId +=
|
|||||||
<LoadErrorState v-if="error" title="国内出仓加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
<LoadErrorState v-if="error" title="国内出仓加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
</template>
|
</template>
|
||||||
<section v-if="!error" class="surface-panel table-panel">
|
<section v-if="!error" class="surface-panel table-panel">
|
||||||
<el-empty v-if="!queried" description="请选择订单 / 入库单或柜次后查询" />
|
<el-empty v-if="!queried" description="请选择入仓码或柜次后查询" />
|
||||||
<DataTable v-else :data="rows" :columns="columns" :loading="loading" stripe row-key="inboundId" preference-key="outbound-dispatches" empty-text="暂无符合条件的出仓记录">
|
<DataTable v-else :data="rows" :columns="columns" :loading="loading" stripe row-key="inboundId" preference-key="outbound-dispatches" empty-text="暂无符合条件的出仓记录">
|
||||||
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" size="small" type="primary" plain @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus === 'READY_FOR_DISPATCH'" size="small" type="primary" @click="action(row, 'confirm-dispatch')"><Send :size="15" />确认</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:reverse') && row.dispatchStatus === 'WAITING_FOR_LOADING'" size="small" type="danger" plain @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></div></template>
|
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" size="small" type="primary" plain @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus === 'READY_FOR_DISPATCH'" size="small" type="primary" @click="action(row, 'confirm-dispatch')"><Send :size="15" />确认</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:reverse') && row.dispatchStatus === 'WAITING_FOR_LOADING'" size="small" type="danger" plain @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></div></template>
|
||||||
<template #booking="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.oceanBookingStatus)" /></template><template #truck="{ row }"><StatusBadge :status="truckStatusLabel(row.truckAppointmentStatus)" /></template><template #pick="{ row }"><StatusBadge :status="pickTaskStatusLabel(row.pickTaskStatus)" /></template><template #status="{ row }"><StatusBadge :status="outboundDispatchStatusLabel(row.dispatchStatus)" /></template><template #gate="{ row }"><el-tag :type="row.gate?.passed ? 'success' : 'danger'">{{ row.gate?.passed ? '通过' : '未通过' }}</el-tag></template>
|
<template #booking="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.oceanBookingStatus)" /></template><template #truck="{ row }"><StatusBadge :status="truckStatusLabel(row.truckAppointmentStatus)" /></template><template #pick="{ row }"><StatusBadge :status="pickTaskStatusLabel(row.pickTaskStatus)" /></template><template #status="{ row }"><StatusBadge :status="outboundDispatchStatusLabel(row.dispatchStatus)" /></template><template #gate="{ row }"><el-tag :type="row.gate?.passed ? 'success' : 'danger'">{{ row.gate?.passed ? '通过' : '未通过' }}</el-tag></template>
|
||||||
@@ -210,7 +209,7 @@ onBeforeUnmount(() => { disposed = true; listRequestId += 1; optionRequestId +=
|
|||||||
<AppDialog v-model="detailOpen" class="outbound-detail-dialog" title="国内出仓详情" width="min(900px, calc(100vw - 28px))" :show-confirm="false">
|
<AppDialog v-model="detailOpen" class="outbound-detail-dialog" title="国内出仓详情" width="min(900px, calc(100vw - 28px))" :show-confirm="false">
|
||||||
<LoadErrorState v-if="actionError" title="出仓详情加载失败" :message="actionError.message" :loading="detailLoading" @retry="retryDetail"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
|
<LoadErrorState v-if="actionError" title="出仓详情加载失败" :message="actionError.message" :loading="detailLoading" @retry="retryDetail"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
|
||||||
<div v-else-if="detailLoading" class="detail-loading" aria-busy="true"><el-skeleton :rows="5" animated /></div>
|
<div v-else-if="detailLoading" class="detail-loading" aria-busy="true"><el-skeleton :rows="5" animated /></div>
|
||||||
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="订单号">{{ detail.aggregate.orderNo }}</el-descriptions-item><el-descriptions-item label="入库单号">{{ detail.aggregate.inboundCode }}</el-descriptions-item><el-descriptions-item label="柜号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="outboundDispatchStatusLabel(detail.aggregate.dispatchStatus)" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table class="gate-table" :data="detail.aggregate.gate?.items || []" border stripe><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ timelineModuleLabel(entry.module) }} · {{ timelineActionLabel(entry.action) }}:{{ timelineStatusLabel(entry.module, entry.fromStatus) }} → {{ timelineStatusLabel(entry.module, entry.toStatus) }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template>
|
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="入仓码">{{ detail.aggregate.inboundCode || '—' }}</el-descriptions-item><el-descriptions-item label="柜号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="outboundDispatchStatusLabel(detail.aggregate.dispatchStatus)" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table class="gate-table" :data="detail.aggregate.gate?.items || []" border stripe><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ timelineModuleLabel(entry.module) }} · {{ timelineActionLabel(entry.action) }}:{{ timelineStatusLabel(entry.module, entry.fromStatus) }} → {{ timelineStatusLabel(entry.module, entry.toStatus) }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ describe('OrderFulfillmentTreePanel contract', () => {
|
|||||||
}
|
}
|
||||||
expect(source).toContain('历史双子单模型')
|
expect(source).toContain('历史双子单模型')
|
||||||
expect(source).toContain('新拆分不会再生成已装子单')
|
expect(source).toContain('新拆分不会再生成已装子单')
|
||||||
|
expect(source).toContain('inboundCode?: string | null')
|
||||||
|
expect(source).toContain("props.inboundCode || '暂缺'")
|
||||||
|
expect(source).not.toContain('tree.rootOrderNo')
|
||||||
|
expect(source).not.toContain('tree.rootOrderId')
|
||||||
|
expect(source).not.toContain('row.orderNo')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('covers loading, error, empty and responsive states', () => {
|
it('covers loading, error, empty and responsive states', () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
|
|||||||
import { getPlatformOrderFulfillmentTree, type UnifiedOrderFulfillmentTree } from '../api/orderFulfillment'
|
import { getPlatformOrderFulfillmentTree, type UnifiedOrderFulfillmentTree } from '../api/orderFulfillment'
|
||||||
import { unifiedOrderFulfillmentRoleLabel, unifiedOrderFulfillmentStatusLabel } from '../status'
|
import { unifiedOrderFulfillmentRoleLabel, unifiedOrderFulfillmentStatusLabel } from '../status'
|
||||||
|
|
||||||
const props = defineProps<{ tenantId: string; orderId: string; compact?: boolean }>()
|
const props = defineProps<{ tenantId: string; orderId: string; inboundCode?: string | null; compact?: boolean }>()
|
||||||
const tree = ref<UnifiedOrderFulfillmentTree | null>(null)
|
const tree = ref<UnifiedOrderFulfillmentTree | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<ApiErrorDetails | null>(null)
|
const error = ref<ApiErrorDetails | null>(null)
|
||||||
@@ -31,14 +31,14 @@ onMounted(() => { void load() })
|
|||||||
<div v-else-if="loading" class="tree-loading" aria-live="polite">正在加载拆分履约…</div>
|
<div v-else-if="loading" class="tree-loading" aria-live="polite">正在加载拆分履约…</div>
|
||||||
<template v-else-if="tree">
|
<template v-else-if="tree">
|
||||||
<div class="tree-summary">
|
<div class="tree-summary">
|
||||||
<div><span>根单</span><strong>{{ tree.rootOrderNo || tree.rootOrderId }}</strong></div>
|
<div><span>入仓码</span><strong>{{ props.inboundCode || '暂缺' }}</strong></div>
|
||||||
<div><span>原始总量</span><strong>{{ tree.originalPackageCount ?? '—' }} 箱</strong></div>
|
<div><span>原始总量</span><strong>{{ tree.originalPackageCount ?? '—' }} 箱</strong></div>
|
||||||
<div><span>累计已装</span><strong>{{ tree.cumulativeLoadedPackageCount ?? '—' }} 箱</strong></div>
|
<div><span>累计已装</span><strong>{{ tree.cumulativeLoadedPackageCount ?? '—' }} 箱</strong></div>
|
||||||
<div><span>当前余量</span><strong>{{ tree.currentRemainderPackageCount ?? '—' }} 箱</strong></div>
|
<div><span>当前余量</span><strong>{{ tree.currentRemainderPackageCount ?? '—' }} 箱</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<el-alert v-if="tree.historicalTwoChildModel" type="info" :closable="false" show-icon title="该记录使用历史双子单模型,仅兼容展示;新拆分不会再生成已装子单。" />
|
<el-alert v-if="tree.historicalTwoChildModel" type="info" :closable="false" show-icon title="该记录使用历史双子单模型,仅兼容展示;新拆分不会再生成已装子单。" />
|
||||||
<el-table :data="tree.fragments" row-key="orderId" border stripe empty-text="暂无拆分分片">
|
<el-table :data="tree.fragments" row-key="orderId" border stripe empty-text="暂无拆分分片">
|
||||||
<el-table-column label="分片订单" min-width="180"><template #default="{ row }"><strong>{{ row.orderNo || row.orderId }}</strong><small v-if="row.splitSequence != null"> · 第 {{ row.splitSequence }} 次</small></template></el-table-column>
|
<el-table-column label="分片" min-width="180"><template #default="{ row }"><strong>{{ unifiedOrderFulfillmentRoleLabel(row.splitRole) }}</strong><small v-if="row.splitSequence != null"> · 第 {{ row.splitSequence }} 次</small></template></el-table-column>
|
||||||
<el-table-column label="类型" width="120"><template #default="{ row }">{{ unifiedOrderFulfillmentRoleLabel(row.splitRole) }}</template></el-table-column>
|
<el-table-column label="类型" width="120"><template #default="{ row }">{{ unifiedOrderFulfillmentRoleLabel(row.splitRole) }}</template></el-table-column>
|
||||||
<el-table-column label="本单计划" width="100"><template #default="{ row }">{{ row.plannedPackageCount ?? '—' }} 箱</template></el-table-column>
|
<el-table-column label="本单计划" width="100"><template #default="{ row }">{{ row.plannedPackageCount ?? '—' }} 箱</template></el-table-column>
|
||||||
<el-table-column label="已拣" width="90"><template #default="{ row }">{{ row.pickedPackageCount ?? '—' }}</template></el-table-column>
|
<el-table-column label="已拣" width="90"><template #default="{ row }">{{ row.pickedPackageCount ?? '—' }}</template></el-table-column>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
expect(source).toContain('v-model:page-size="pageSize"')
|
expect(source).toContain('v-model:page-size="pageSize"')
|
||||||
const columnSource = source.slice(source.indexOf('const columns:'), source.indexOf('function emptyFilters'))
|
const columnSource = source.slice(source.indexOf('const columns:'), source.indexOf('function emptyFilters'))
|
||||||
const expectedColumnKeys = [
|
const expectedColumnKeys = [
|
||||||
'actions', 'orderNo', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
|
'actions', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
|
||||||
'destinationWarehouseName', 'totalPackageCount', 'measurementReviewStatus', 'actualGrossWeightKg',
|
'destinationWarehouseName', 'totalPackageCount', 'measurementReviewStatus', 'actualGrossWeightKg',
|
||||||
'actualVolumeCbm', 'chargeableWeightKg', 'expectedShipDate', 'loadingStatus', 'containerNo', 'cabinetNo',
|
'actualVolumeCbm', 'chargeableWeightKg', 'expectedShipDate', 'loadingStatus', 'containerNo', 'cabinetNo',
|
||||||
'sailingAt', 'portArrivalAt', 'customerRemark', 'latestInternalNote', 'createdAt', 'updatedAt',
|
'sailingAt', 'portArrivalAt', 'customerRemark', 'latestInternalNote', 'createdAt', 'updatedAt',
|
||||||
@@ -103,8 +103,8 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
'internalOrderStatus', 'expectedArrivalAt', 'feeReviewStatus', 'holdCargo', 'companyName',
|
'internalOrderStatus', 'expectedArrivalAt', 'feeReviewStatus', 'holdCargo', 'companyName',
|
||||||
'pickupMethod', 'chargeableVolumeCbm', 'recommendedPalletType', 'locationCodes', 'associatedFiles', 'createdBy',
|
'pickupMethod', 'chargeableVolumeCbm', 'recommendedPalletType', 'locationCodes', 'associatedFiles', 'createdBy',
|
||||||
]) expect(columnSource).not.toContain(`key: '${uncontractedKey}'`)
|
]) expect(columnSource).not.toContain(`key: '${uncontractedKey}'`)
|
||||||
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("key: 'orderNo'"))
|
expect(columnSource).not.toContain("key: 'orderNo'")
|
||||||
expect(columnSource.indexOf("key: 'orderNo'")).toBeLessThan(columnSource.indexOf("key: 'inboundCode'"))
|
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("key: 'inboundCode'"))
|
||||||
expect(columnSource).toContain("{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态'")
|
expect(columnSource).toContain("{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态'")
|
||||||
expect(columnSource).toContain("{ key: 'loadingStatus', prop: 'loadingStatus', label: '配载状态', minWidth: 130, slotName: 'field', defaultVisible: false }")
|
expect(columnSource).toContain("{ key: 'loadingStatus', prop: 'loadingStatus', label: '配载状态', minWidth: 130, slotName: 'field', defaultVisible: false }")
|
||||||
expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(8)
|
expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(8)
|
||||||
@@ -229,8 +229,8 @@ describe('UnifiedOrdersView contract', () => {
|
|||||||
|
|
||||||
it('distinguishes overflow action buttons by row and keeps a stable missing-field fallback', () => {
|
it('distinguishes overflow action buttons by row and keeps a stable missing-field fallback', () => {
|
||||||
expect(source).toContain('function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource)')
|
expect(source).toContain('function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource)')
|
||||||
expect(source).toContain('[compatibleOrder.inboundCode, compatibleOrder.orderNo, compatibleOrder.orderId, compatibleOrder.id]')
|
expect(source).toContain("String(row.inboundCode || '').trim() || '当前入仓单'")
|
||||||
expect(source).toContain("identifier || '当前订单'")
|
expect(source).not.toContain('compatibleOrder.orderNo')
|
||||||
expect(source).not.toContain('aria-label="更多订单操作"')
|
expect(source).not.toContain('aria-label="更多订单操作"')
|
||||||
expect(source).not.toContain('title="更多操作"')
|
expect(source).not.toContain('title="更多操作"')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -210,7 +210,6 @@ const queryFields = computed(() => showAdvancedFilters.value
|
|||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
const columns: DataTableColumn[] = [
|
||||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||||
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 190, slotName: 'order' },
|
|
||||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, slotName: 'inboundCode' },
|
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 190, slotName: 'inboundCode' },
|
||||||
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 130, slotName: 'mainStatus' },
|
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 130, slotName: 'mainStatus' },
|
||||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' },
|
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' },
|
||||||
@@ -676,11 +675,7 @@ function moreUnifiedOrderActions(row: UnifiedOrderResource) {
|
|||||||
return unifiedOrderSecondaryActions(row).slice(2)
|
return unifiedOrderSecondaryActions(row).slice(2)
|
||||||
}
|
}
|
||||||
function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource) {
|
function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource) {
|
||||||
const compatibleOrder = row as UnifiedOrderResource & { id?: string | number | null }
|
return `更多订单操作:${String(row.inboundCode || '').trim() || '当前入仓单'}`
|
||||||
const identifier = [compatibleOrder.inboundCode, compatibleOrder.orderNo, compatibleOrder.orderId, compatibleOrder.id]
|
|
||||||
.map((value) => String(value ?? '').trim())
|
|
||||||
.find(Boolean)
|
|
||||||
return `更多订单操作:${identifier || '当前订单'}`
|
|
||||||
}
|
}
|
||||||
function unifiedOrderActionLabel(action: UnifiedOrderRowAction) {
|
function unifiedOrderActionLabel(action: UnifiedOrderRowAction) {
|
||||||
if (action === 'note') return '添加备注'
|
if (action === 'note') return '添加备注'
|
||||||
@@ -763,8 +758,7 @@ onMounted(() => {
|
|||||||
<el-button link class="back-button" @click="closeDetail"><ArrowLeft :size="16" />返回列表</el-button>
|
<el-button link class="back-button" @click="closeDetail"><ArrowLeft :size="16" />返回列表</el-button>
|
||||||
<span class="order-mark" aria-hidden="true"><Box :size="19" /></span>
|
<span class="order-mark" aria-hidden="true"><Box :size="19" /></span>
|
||||||
<div class="order-title">
|
<div class="order-title">
|
||||||
<h1>{{ detail?.summary.inboundCode || (detail?.summary.inboundId ? `入库单 ${detail.summary.inboundId}` : '暂未生成') }}</h1>
|
<h1>{{ detail?.summary.inboundCode || '入仓码暂缺' }}</h1>
|
||||||
<p>{{ detail?.summary.orderNo || `订单 ${orderId}` }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge v-if="detail" :status="statusLabel(detail.summary.mainStatus)" />
|
<StatusBadge v-if="detail" :status="statusLabel(detail.summary.mainStatus)" />
|
||||||
</div>
|
</div>
|
||||||
@@ -792,7 +786,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<section :id="`order-detail-panel-${activeDetailTab}`" class="detail-panel" role="tabpanel" :aria-labelledby="`order-detail-tab-${activeDetailTab}`" tabindex="0">
|
<section :id="`order-detail-panel-${activeDetailTab}`" class="detail-panel" role="tabpanel" :aria-labelledby="`order-detail-tab-${activeDetailTab}`" tabindex="0">
|
||||||
<template v-if="activeDetailTab === 'basic'">
|
<template v-if="activeDetailTab === 'basic'">
|
||||||
<div class="panel-title"><div><h2>订单基本信息</h2><p>{{ detail.summary.orderNo || `订单 ${detail.summary.orderId}` }}</p></div><Box :size="19" /></div>
|
<div class="panel-title"><div><h2>订单基本信息</h2><p>{{ detail.summary.inboundCode || '入仓码暂缺' }}</p></div><Box :size="19" /></div>
|
||||||
<dl class="order-info-grid">
|
<dl class="order-info-grid">
|
||||||
<div><dt>公司</dt><dd>{{ customerName(detail.summary) }}</dd></div>
|
<div><dt>公司</dt><dd>{{ customerName(detail.summary) }}</dd></div>
|
||||||
<div><dt>运输方式</dt><dd>{{ firstLegTransportModeLabel(detail.order.order?.transportMode) }}</dd></div>
|
<div><dt>运输方式</dt><dd>{{ firstLegTransportModeLabel(detail.order.order?.transportMode) }}</dd></div>
|
||||||
@@ -898,7 +892,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template v-else-if="activeDetailTab === 'fulfillment'">
|
<template v-else-if="activeDetailTab === 'fulfillment'">
|
||||||
<div class="panel-title"><div><h2>拆分履约</h2><p>根单、已完成分片与当前余量</p></div><ListChecks :size="19" /></div>
|
<div class="panel-title"><div><h2>拆分履约</h2><p>根单、已完成分片与当前余量</p></div><ListChecks :size="19" /></div>
|
||||||
<OrderFulfillmentTreePanel :tenant-id="String(detail.summary.tenantId)" :order-id="String(detail.summary.orderId)" />
|
<OrderFulfillmentTreePanel :tenant-id="String(detail.summary.tenantId)" :order-id="String(detail.summary.orderId)" :inbound-code="detail.summary.inboundCode" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="activeDetailTab === 'workflow'">
|
<template v-else-if="activeDetailTab === 'workflow'">
|
||||||
@@ -1021,7 +1015,6 @@ onMounted(() => {
|
|||||||
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
|
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
|
||||||
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
|
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
|
||||||
<template #field="{ row, column }"><LineClampText v-if="['customerRemark', 'latestInternalNote'].includes(String(column.property))" :content="displayField(row as UnifiedOrderResource, column.property)" :lines="2" /><template v-else>{{ displayField(row as UnifiedOrderResource, column.property) }}</template></template>
|
<template #field="{ row, column }"><LineClampText v-if="['customerRemark', 'latestInternalNote'].includes(String(column.property))" :content="displayField(row as UnifiedOrderResource, column.property)" :lines="2" /><template v-else>{{ displayField(row as UnifiedOrderResource, column.property) }}</template></template>
|
||||||
<template #order="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).orderNo || `订单 ${(row as UnifiedOrderResource).orderId}` }}</strong><span v-if="(row as UnifiedOrderResource).inboundCode">入仓码 {{ (row as UnifiedOrderResource).inboundCode }}</span></div></template>
|
|
||||||
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
|
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
|
||||||
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong><span>客户 ID {{ (row as UnifiedOrderResource).tenantId }}</span></div></template>
|
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong><span>客户 ID {{ (row as UnifiedOrderResource).tenantId }}</span></div></template>
|
||||||
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
|
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
|
||||||
@@ -1043,6 +1036,7 @@ onMounted(() => {
|
|||||||
v-if="relationOrder"
|
v-if="relationOrder"
|
||||||
:tenant-id="String(relationOrder.tenantId)"
|
:tenant-id="String(relationOrder.tenantId)"
|
||||||
:order-id="String(relationOrder.orderId)"
|
:order-id="String(relationOrder.orderId)"
|
||||||
|
:inbound-code="relationOrder.inboundCode"
|
||||||
/>
|
/>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
|
|
||||||
@@ -1056,7 +1050,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="measurementDialogOpen"
|
v-model="measurementDialogOpen"
|
||||||
:title="measurementOrder ? `测量 · ${measurementOrder.inboundCode || measurementOrder.orderNo}` : '测量'"
|
:title="measurementOrder?.inboundCode ? `测量 · ${measurementOrder.inboundCode}` : '测量'"
|
||||||
width="min(1180px, calc(100vw - 32px))"
|
width="min(1180px, calc(100vw - 32px))"
|
||||||
top="4vh"
|
top="4vh"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const volumeRate = computed(() => utilization(usedVolume.value, container.value?
|
|||||||
const weightRate = computed(() => utilization(usedWeight.value, container.value?.maxWeightKg))
|
const weightRate = computed(() => utilization(usedWeight.value, container.value?.maxWeightKg))
|
||||||
|
|
||||||
const queryFields: QueryField[] = [
|
const queryFields: QueryField[] = [
|
||||||
{ key: 'keyword', type: 'input', label: '候选货物', placeholder: '订单号、入仓码、客户或品名', props: { 'aria-label': '候选货物关键词' } },
|
{ key: 'keyword', type: 'input', label: '候选货物', placeholder: '入仓码、客户或品名', props: { 'aria-label': '候选货物关键词' } },
|
||||||
{
|
{
|
||||||
key: 'eligibility', type: 'select', label: '可装状态', placeholder: '全部状态',
|
key: 'eligibility', type: 'select', label: '可装状态', placeholder: '全部状态',
|
||||||
options: [{ label: '可加入', value: 'ELIGIBLE' }, { label: '存在冲突', value: 'CONFLICT' }],
|
options: [{ label: '可加入', value: 'ELIGIBLE' }, { label: '存在冲突', value: 'CONFLICT' }],
|
||||||
@@ -138,7 +138,7 @@ const filteredCandidates = computed(() => {
|
|||||||
const keyword = String(appliedFilters.value.keyword || '').trim().toLowerCase()
|
const keyword = String(appliedFilters.value.keyword || '').trim().toLowerCase()
|
||||||
const eligibility = String(appliedFilters.value.eligibility || '')
|
const eligibility = String(appliedFilters.value.eligibility || '')
|
||||||
return candidates.value.filter((candidate) => {
|
return candidates.value.filter((candidate) => {
|
||||||
const searchable = [candidate.orderNo, candidate.inboundCode, candidate.customerName, candidate.goodsName].join(' ').toLowerCase()
|
const searchable = [candidate.inboundCode, candidate.customerName, candidate.goodsName].join(' ').toLowerCase()
|
||||||
if (keyword && !searchable.includes(keyword)) return false
|
if (keyword && !searchable.includes(keyword)) return false
|
||||||
const eligible = fittingCount(candidate) > 0
|
const eligible = fittingCount(candidate) > 0
|
||||||
return !eligibility || (eligibility === 'ELIGIBLE' ? eligible : !eligible)
|
return !eligibility || (eligibility === 'ELIGIBLE' ? eligible : !eligible)
|
||||||
@@ -309,7 +309,7 @@ onMounted(() => { void load() })
|
|||||||
<el-button size="small" plain type="primary" :disabled="!canAssign || !editable || Boolean(action)" @click="openAdjust(row as SmartLoadingPlanItem)"><Pencil :size="14" />调整</el-button>
|
<el-button size="small" plain type="primary" :disabled="!canAssign || !editable || Boolean(action)" @click="openAdjust(row as SmartLoadingPlanItem)"><Pencil :size="14" />调整</el-button>
|
||||||
<el-button size="small" plain type="danger" :disabled="!canDelete || !editable || Boolean(action)" @click="openRemove(row as SmartLoadingPlanItem)"><Trash2 :size="13" />移除</el-button>
|
<el-button size="small" plain type="danger" :disabled="!canDelete || !editable || Boolean(action)" @click="openRemove(row as SmartLoadingPlanItem)"><Trash2 :size="13" />移除</el-button>
|
||||||
</template>
|
</template>
|
||||||
<template #order="{ row }"><strong>{{ (row as SmartLoadingPlanItem).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingPlanItem).inboundCode || '—' }}</small></template>
|
<template #order="{ row }"><strong>{{ (row as SmartLoadingPlanItem).inboundCode || '—' }}</strong></template>
|
||||||
<template #weight="{ row }">{{ formatNumber((row as SmartLoadingPlanItem).grossWeightKg) }}</template>
|
<template #weight="{ row }">{{ formatNumber((row as SmartLoadingPlanItem).grossWeightKg) }}</template>
|
||||||
<template #volume="{ row }">{{ formatNumber((row as SmartLoadingPlanItem).volumeCbm) }}</template>
|
<template #volume="{ row }">{{ formatNumber((row as SmartLoadingPlanItem).volumeCbm) }}</template>
|
||||||
<template #destination="{ row }">{{ (row as SmartLoadingPlanItem).destinationWarehouseName || (row as SmartLoadingPlanItem).destinationWarehouseCode || '—' }}</template>
|
<template #destination="{ row }">{{ (row as SmartLoadingPlanItem).destinationWarehouseName || (row as SmartLoadingPlanItem).destinationWarehouseCode || '—' }}</template>
|
||||||
@@ -324,7 +324,7 @@ onMounted(() => { void load() })
|
|||||||
<DataTable :data="filteredCandidates" :columns="candidateColumns" :loading="loading" empty-text="当前柜次仓库暂无可用候选货物">
|
<DataTable :data="filteredCandidates" :columns="candidateColumns" :loading="loading" empty-text="当前柜次仓库暂无可用候选货物">
|
||||||
<template #actions="{ row }"><el-button size="small" plain type="primary" :disabled="!plan || !canAssign || !editable || fittingCount(row as SmartLoadingCandidate) <= 0 || Boolean(action)" @click="openAssign(row as SmartLoadingCandidate)"><Plus :size="14" />加入</el-button></template>
|
<template #actions="{ row }"><el-button size="small" plain type="primary" :disabled="!plan || !canAssign || !editable || fittingCount(row as SmartLoadingCandidate) <= 0 || Boolean(action)" @click="openAssign(row as SmartLoadingCandidate)"><Plus :size="14" />加入</el-button></template>
|
||||||
<template #priority="{ $index }"><span class="priority">{{ Number($index) + 1 }}</span></template>
|
<template #priority="{ $index }"><span class="priority">{{ Number($index) + 1 }}</span></template>
|
||||||
<template #order="{ row }"><strong>{{ (row as SmartLoadingCandidate).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingCandidate).inboundCode || '—' }}</small></template>
|
<template #order="{ row }"><strong>{{ (row as SmartLoadingCandidate).inboundCode || '—' }}</strong></template>
|
||||||
<template #destination="{ row }">{{ (row as SmartLoadingCandidate).destinationWarehouseName || (row as SmartLoadingCandidate).destinationWarehouseCode || '—' }}</template>
|
<template #destination="{ row }">{{ (row as SmartLoadingCandidate).destinationWarehouseName || (row as SmartLoadingCandidate).destinationWarehouseCode || '—' }}</template>
|
||||||
<template #unitVolume="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitVolumeCbm) }}</template>
|
<template #unitVolume="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitVolumeCbm) }}</template>
|
||||||
<template #unitWeight="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitWeightKg) }}</template>
|
<template #unitWeight="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitWeightKg) }}</template>
|
||||||
@@ -336,19 +336,19 @@ onMounted(() => { void load() })
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<AppDialog v-model="assignOpen" title="加入装柜方案" :confirm-loading="action === 'assign'" :confirm-disabled="packageCount < 1 || packageCount > (selectedCandidate ? fittingCount(selectedCandidate) : 0)" confirm-text="确认加入" @confirm="confirmAssign">
|
<AppDialog v-model="assignOpen" title="加入装柜方案" :confirm-loading="action === 'assign'" :confirm-disabled="packageCount < 1 || packageCount > (selectedCandidate ? fittingCount(selectedCandidate) : 0)" confirm-text="确认加入" @confirm="confirmAssign">
|
||||||
<p>{{ selectedCandidate?.orderNo || selectedCandidate?.inboundCode }} · 最多可加入 {{ selectedCandidate ? fittingCount(selectedCandidate) : 0 }} 箱</p>
|
<p>{{ selectedCandidate?.inboundCode || '入仓码暂缺' }} · 最多可加入 {{ selectedCandidate ? fittingCount(selectedCandidate) : 0 }} 箱</p>
|
||||||
<el-input-number v-model="packageCount" :min="1" :max="selectedCandidate ? fittingCount(selectedCandidate) : 1" controls-position="right" aria-label="加入箱数" />
|
<el-input-number v-model="packageCount" :min="1" :max="selectedCandidate ? fittingCount(selectedCandidate) : 1" controls-position="right" aria-label="加入箱数" />
|
||||||
<ApiErrorMeta v-if="actionError" :details="actionError" />
|
<ApiErrorMeta v-if="actionError" :details="actionError" />
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|
||||||
<AppDialog v-model="adjustOpen" title="调整装柜箱数" :confirm-loading="action === 'adjust'" :confirm-disabled="packageCount < 1" confirm-text="保存调整" @confirm="confirmAdjust">
|
<AppDialog v-model="adjustOpen" title="调整装柜箱数" :confirm-loading="action === 'adjust'" :confirm-disabled="packageCount < 1" confirm-text="保存调整" @confirm="confirmAdjust">
|
||||||
<p>{{ selectedItem?.orderNo || selectedItem?.inboundCode }} · 当前 {{ selectedItem?.packageCount || 0 }} 箱</p>
|
<p>{{ selectedItem?.inboundCode || '入仓码暂缺' }} · 当前 {{ selectedItem?.packageCount || 0 }} 箱</p>
|
||||||
<el-input-number v-model="packageCount" :min="1" controls-position="right" aria-label="调整后箱数" />
|
<el-input-number v-model="packageCount" :min="1" controls-position="right" aria-label="调整后箱数" />
|
||||||
<ApiErrorMeta v-if="actionError" :details="actionError" />
|
<ApiErrorMeta v-if="actionError" :details="actionError" />
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|
||||||
<AppDialog v-model="removeOpen" title="移除装柜明细" confirm-type="danger" confirm-text="确认移除" :confirm-loading="action === 'remove'" @confirm="confirmRemove">
|
<AppDialog v-model="removeOpen" title="移除装柜明细" confirm-type="danger" confirm-text="确认移除" :confirm-loading="action === 'remove'" @confirm="confirmRemove">
|
||||||
<p>将从当前草稿移除 {{ selectedItem?.orderNo || selectedItem?.inboundCode }} 的 {{ selectedItem?.packageCount || 0 }} 箱货物。此操作会写入审计历史。</p>
|
<p>将从当前草稿移除 {{ selectedItem?.inboundCode || '该入仓单' }} 的 {{ selectedItem?.packageCount || 0 }} 箱货物。此操作会写入审计历史。</p>
|
||||||
<ApiErrorMeta v-if="actionError" :details="actionError" />
|
<ApiErrorMeta v-if="actionError" :details="actionError" />
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
|
|
||||||
|
|||||||
@@ -548,7 +548,7 @@ onMounted(() => { void refresh() })
|
|||||||
<el-input v-model="preloadDraft.containerCode" maxlength="64" placeholder="请输入柜号" @input="preloadDraftError = ''" />
|
<el-input v-model="preloadDraft.containerCode" maxlength="64" placeholder="请输入柜号" @input="preloadDraftError = ''" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="货物引用" required :error="preloadDraftError === '货物引用不能为空' ? preloadDraftError : ''">
|
<el-form-item label="货物引用" required :error="preloadDraftError === '货物引用不能为空' ? preloadDraftError : ''">
|
||||||
<el-input v-model="preloadDraft.cargoReference" maxlength="128" placeholder="订单号、入仓码或业务引用" @input="preloadDraftError = ''" />
|
<el-input v-model="preloadDraft.cargoReference" maxlength="128" placeholder="入仓码或业务引用" @input="preloadDraftError = ''" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="preload-dialog-grid">
|
<div class="preload-dialog-grid">
|
||||||
<el-form-item label="箱数" required><el-input-number v-model="preloadDraft.packageCount" :min="1" :step="1" step-strictly controls-position="right" @change="preloadDraftError = ''" /></el-form-item>
|
<el-form-item label="箱数" required><el-input-number v-model="preloadDraft.packageCount" :min="1" :step="1" step-strictly controls-position="right" @change="preloadDraftError = ''" /></el-form-item>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const summary = computed(() => detail.value?.summary || null)
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!orderId.value || !tenantId.value) {
|
if (!orderId.value || !tenantId.value) {
|
||||||
error.value = { message: '订单作业上下文缺少订单号或客户标识' }
|
error.value = { message: '订单作业上下文不完整' }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|||||||
Reference in New Issue
Block a user