From 92bdd32f8679c64645a7e5f62bd58a505f2a1477 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 12 Oct 2026 11:45:00 +0800 Subject: [PATCH 01/57] fix(appointments): unify calendar layout and cabinet numbers --- .../AppointmentCalendarPanel.test.ts | 13 +++++++ .../AppointmentCalendarPanel.vue | 9 ++++- .../AppointmentCalendarView.test.ts | 37 ++++++++++++++----- .../AppointmentCalendarView.vue | 27 +++++++++----- .../AppointmentCargoPanel.test.ts | 29 +++++++++++++-- .../AppointmentCargoPanel.vue | 13 ++++--- .../AppointmentCargoTabs.vue | 28 ++++++++++++++ .../AppointmentExternalCandidates.vue | 7 +++- .../AppointmentTableRendering.test.ts | 4 ++ .../appointment-calendar/PROTOTYPE_MATRIX.md | 3 ++ src/modules/appointment-calendar/api.ts | 4 +- .../api/platformContainerLoading.test.ts | 2 +- .../components/ContainerOverviewPanel.test.ts | 2 +- .../components/OceanBookingLinkDialog.test.ts | 2 +- .../pages/ContainersView.test.ts | 22 +++++------ .../pages/ContainersView.vue | 2 +- .../pages/GlobalSmartLoadingView.test.ts | 4 +- .../pages/SmartLoadingWorkbenchView.test.ts | 2 +- src/modules/container-loading/types.ts | 4 +- .../pages/OutboundDispatchesView.test.ts | 2 +- .../pages/OutboundDispatchesView.vue | 2 +- .../components/FbaAppointmentFields.vue | 18 +++++---- .../ContainerPortSchedulePanel.test.ts | 2 +- .../components/ContainerPortSchedulePanel.vue | 4 +- src/styles/adminDensity.test.ts | 1 + 25 files changed, 178 insertions(+), 65 deletions(-) create mode 100644 src/modules/appointment-calendar/AppointmentCargoTabs.vue diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts index b252e473..753a569b 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts @@ -3,6 +3,19 @@ import { describe, expect, it } from 'vitest' import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue' describe('appointment calendar interactions', () => { + it('keeps creation beside the title and record actions beside data notices', () => { + const wrapper = mount(AppointmentCalendarPanel, { + props: { canCreate: true, events: [], notices: ['数据范围不完整'] }, + slots: { 'toolbar-actions': '' }, + global: { stubs: { ElButton: { template: '' } }, directives: { loading: {} } }, + }) + expect(wrapper.get('.calendar-label').text()).toContain('新增预约') + const actions = wrapper.get('[aria-label="预约记录与数据提示"]') + expect(actions.text()).toContain('未定时安排') + expect(actions.text()).toContain('取消记录') + expect(actions.get('summary').text()).toContain('数据提示') + wrapper.unmount() + }) const render = () => mount(AppointmentCalendarPanel, { props: { initialDate: '2026-09-24', canCreate: true, events: [ { key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true }, diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue index cd4d492f..013de53d 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue @@ -84,10 +84,9 @@ defineExpose({ selectedDate, mode })
预约日历 + + 新增预约
-
数据提示 · {{ notices.length }}

{{ notice }}

- + 新增预约
@@ -97,6 +96,10 @@ defineExpose({ selectedDate, mode }) 今天
+
+ +
数据提示 · {{ notices.length }}

{{ notice }}

+
船舶到港预约送仓预约提柜用车计划
@@ -149,6 +152,8 @@ defineExpose({ selectedDate, mode }) .calendar-main, .calendar-detail { background: #fff; border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; } .calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; } .calendar-label, .calendar-controls { display: flex; align-items: center; gap: 10px; } +.calendar-record-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); } +.calendar-record-actions :deep(.el-button + .el-button) { margin-left: 0; } .calendar-label > svg { color: #617ce9; } .calendar-label > strong { font-size: var(--admin-font-section); } .calendar-options { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--admin-table-border); } diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts index 8c19a985..201a7c6d 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarView.test.ts @@ -40,7 +40,7 @@ function render(openCalendar = true) { const unregister = register(() => !routing.containerPending) onBeforeUnmount(unregister) }, template: '
整柜跟进工作区
' }, - AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete', 'notices'], template: '
' }, + AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete', 'notices'], template: '
' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'beforeClose'], template: '
' }, ElForm: { template: '
' }, ElFormItem: { template: '' }, @@ -70,6 +70,19 @@ describe('appointment calendar data integration', () => { vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] }) }) + it('displays the existing D cabinet number in both arrival and pickup events', async () => { + vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', cabinetNo: 'D0007', arrivalAt: '2026-10-09T08:00:00', arrivalType: 'ESTIMATED' }]) + vi.mocked(listPickupCalendar).mockResolvedValueOnce({ records: [{ containerBookingId: '1', cabinetNo: 'D0007', containerNo: null, destinationPortName: null, destinationWarehouseCode: null, fleetSupplierName: null, pickupAppointmentAt: '2026-10-09T09:00:00', actualPickupAt: null }], truncated: false, unclassifiedCount: 0, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED' }) + const wrapper = render(); await flushPromises() + const events = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events') + expect(events).toHaveLength(2) + for (const event of events) { + expect(event.fields).toContainEqual({ label: '柜次号', value: 'D0007' }) + expect(event.title).toContain('D0007') + } + wrapper.unmount() + }) + it('opens a dated calendar appointment read-only for list-only users', async () => { routing.canWrite=false const record={version:0,id:'1',appointmentNo:'AP-DATED',plannedDate:'2026-10-15',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[]} @@ -90,6 +103,7 @@ describe('appointment calendar data integration', () => { vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [record], total: 1}) vi.mocked(getFbaAppointment).mockResolvedValue(record) const wrapper=render(); await flushPromises() + await wrapper.get('[data-unscheduled]').trigger('click') await wrapper.findAll('button').find(button=>button.text()==='查看 / 继续')!.trigger('click'); await flushPromises() expect(wrapper.find('option[value="CANCELLED"]').exists()).toBe(false) await wrapper.get('[data-cancel-editing]').trigger('click'); await flushPromises() @@ -97,13 +111,13 @@ describe('appointment calendar data integration', () => { expect(wrapper.find('[data-save]').exists()).toBe(false) expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount() }) - it('loads cancellation records only after expansion and opens cancelled appointments read-only', async () => { + it('loads cancellation records only after opening the dialog and opens cancelled appointments read-only', async () => { const record = { version: 1, id: '1', appointmentNo: 'AP-CANCEL', originWarehouseId: '100', appointmentStatus: 'CANCELLED', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] } vi.mocked(getFbaAppointment).mockResolvedValue(record) const wrapper=render();await flushPromises() expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false) - const records=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消记录')! - ;(records.element as HTMLDetailsElement).open=true;await records.trigger('toggle') + const records=wrapper.get('[data-cancellations]') + await records.trigger('click') await wrapper.get('[data-history-view]').trigger('click');await flushPromises() expect(getFbaAppointment).toHaveBeenCalledWith('AP-CANCEL') expect(wrapper.find('[data-save]').exists()).toBe(false) @@ -119,8 +133,8 @@ describe('appointment calendar data integration', () => { it('refreshes an expanded cancellation list after a successful cancellation', async () => { vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([{version:0,id:'1',appointmentNo:'AP-CANCEL',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[],plannedDate:'2026-10-15'}]) const wrapper=render();await flushPromises() - const records=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消记录')! - ;(records.element as HTMLDetailsElement).open=true;await records.trigger('toggle') + const records=wrapper.get('[data-cancellations]') + await records.trigger('click') const previous=wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm wrapper.getComponent({name:'AppointmentCalendarPanel'}).vm.$emit('delete',{key:'AP-CANCEL'});await flushPromises() wrapper.getComponent({name:'AppointmentCancellationDialog'}).vm.$emit('cancelled');await flushPromises() @@ -150,6 +164,7 @@ describe('appointment calendar data integration', () => { vi.mocked(listUnscheduledAppointments).mockResolvedValue({ records: [record], total: 1 }) vi.mocked(getFbaAppointment).mockResolvedValue(record) const wrapper = render(); await flushPromises() + await wrapper.get('[data-unscheduled]').trigger('click') await wrapper.findAll('button').find(b => b.text() === '查看 / 继续')!.trigger('click'); await flushPromises() expect(wrapper.find('[data-save]').exists()).toBe(false) const dialog = wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)! @@ -179,7 +194,7 @@ describe('appointment calendar data integration', () => { expect(wrapper.text()).not.toContain('缩小日期范围') }) it('shows the original pickup time once without claiming a port time zone and retains partial-source warnings', async () => { - vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',bookingNo: 'CB-TEST',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'}) + vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',cabinetNo: 'D0001',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'}) const wrapper=render(); await flushPromises() const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'}) expect(panel.props('events')).toEqual([expect.objectContaining({type:'pickup',date:'2026-10-09',time:'11:00',sourcePath:'/container-loading/overseas/123'})]) @@ -196,7 +211,7 @@ describe('appointment calendar data integration', () => { }) it('keeps successful arrival data visible when the pickup source fails', async () => { vi.mocked(listPickupCalendar).mockRejectedValueOnce(new Error('pickup unavailable')) - vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',bookingNo:'CB-1',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}]) + vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',cabinetNo:'D0001',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}]) const wrapper=render(); await flushPromises() const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'}) expect(panel.props('events')).toEqual([expect.objectContaining({type:'arrival',key:'arrival-1'})]) @@ -248,7 +263,7 @@ describe('appointment calendar data integration', () => { expect(listPlatformWarehouses).toHaveBeenCalledWith(expect.objectContaining({ warehouseType: 'FBA', status: 'ACTIVE', size: 50 })) expect(wrapper.find('input[placeholder="例如 ONT8"]').exists()).toBe(false) expect(wrapper.text()).not.toContain('查询可预约货物') - expect(wrapper.find('details').attributes('open')).toBeUndefined() + expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false) await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8') await flushPromises() expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' }) @@ -283,6 +298,8 @@ describe('appointment calendar data integration', () => { const wrapper=render(); await flushPromises() expect(wrapper.text()).toContain('未定时安排(1)') expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([]) + expect(wrapper.findComponent({name:'DataTable'}).exists()).toBe(false) + await wrapper.get('[data-unscheduled]').trigger('click') expect(wrapper.findComponent({name:'DataTable'}).props('data')[0]).not.toHaveProperty('appointmentAt') }) @@ -335,7 +352,7 @@ describe('appointment calendar data integration', () => { it('keeps available arrivals and marks the result incomplete when appointments fail', async () => { vi.mocked(listFbaAppointmentCalendar).mockRejectedValueOnce(new Error('预约暂不可用')) - vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', bookingNo: 'C-1', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }]) + vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', cabinetNo: 'D0001', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }]) const wrapper = render() await flushPromises() const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' }) diff --git a/src/modules/appointment-calendar/AppointmentCalendarView.vue b/src/modules/appointment-calendar/AppointmentCalendarView.vue index 843de4e2..ed87fa2e 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarView.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarView.vue @@ -31,6 +31,7 @@ import { listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, typ import { listTripCalendar, projectTripCalendar, tripIssues, type TripCalendarEntry } from './tripCalendar' const cancellationTarget = ref(null) +const unscheduledOpen = ref(false) const cancellationsOpen = ref(false), cancellationsRevision = ref(0), cancellationHistoryOpen = ref(false), forceReadOnly = ref(false) const appointmentReadOnly = computed(() => forceReadOnly.value || editing.value?.appointmentStatus === 'CANCELLED' || !access.can(permission.inboundOrderAppointment)) const access = useAccessPreviewStore() @@ -130,22 +131,22 @@ const columns: DataTableColumn[] = [ const events = computed(() => [...arrivals.value.map(arrival => ({ key: `arrival-${arrival.containerBookingId}`, type: 'arrival' as const, date: arrival.arrivalAt.slice(0, 10), time: arrival.arrivalAt.slice(11, 16), - title: `${arrival.voyageNo || arrival.bookingNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`, + title: `${arrival.voyageNo || arrival.cabinetNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`, fields: [ { label: '船名 / 航次', value: [arrival.vesselName, arrival.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: arrival.destinationPortName }, { label: '已装货柜号', value: arrival.containerNo }, - { label: '柜次号', value: arrival.bookingNo }, + { label: '柜次号', value: arrival.cabinetNo }, { label: '货物去向', value: arrival.destinationWarehouseCode }, ], })), ...pickups.value.map(pickup => ({ key: `pickup-${pickup.containerBookingId}`, type: 'pickup' as const, date: pickup.pickupAppointmentAt.slice(0, 10), time: pickup.pickupAppointmentAt.slice(11, 16), - title: `${pickup.containerNo || pickup.bookingNo || '未关联柜号'} 预约提柜`, + title: `${pickup.containerNo || pickup.cabinetNo || '未关联柜号'} 预约提柜`, sourcePath: `/container-loading/overseas/${encodeURIComponent(pickup.containerBookingId)}`, subtitle: '美国目的仓相关提柜安排', fields: [ - { label: '柜次号', value: pickup.bookingNo }, { label: '目的港', value: pickup.destinationPortName }, + { label: '柜次号', value: pickup.cabinetNo }, { label: '目的港', value: pickup.destinationPortName }, { label: '目的仓', value: pickup.destinationWarehouseCode }, { label: '车队', value: pickup.fleetSupplierName }, { label: '实际提柜', value: pickup.actualPickupAt || '尚未记录' }, { label: '时间口径', value: '原提柜业务时间,港口时区待核实' }, @@ -416,13 +417,23 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
- -
未定时安排({{ unscheduledTotal }})
-
取消记录
+
+ + + + + + + + +

这些车次缺少地点或时区,暂未计入日历。原计划时间保留 UTC。

@@ -470,7 +481,5 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ .calendar-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-6); } .calendar-form :deep(.el-select), .calendar-form :deep(.el-date-editor) { width: 100%; } .appointment-cargo { padding: 12px 0; line-height: 1.7; overflow-wrap: anywhere; font-size: 13px; } -.appointment-extra { margin-bottom: var(--admin-space-5); } -.appointment-extra summary { cursor: pointer; margin-bottom: var(--admin-space-3); font-size: var(--admin-font-body); } .appointment-hint { color: var(--el-text-color-secondary); font-size: var(--admin-font-body); margin: var(--admin-space-5) 0; } diff --git a/src/modules/appointment-calendar/AppointmentCargoPanel.test.ts b/src/modules/appointment-calendar/AppointmentCargoPanel.test.ts index f4be5639..8fed4418 100644 --- a/src/modules/appointment-calendar/AppointmentCargoPanel.test.ts +++ b/src/modules/appointment-calendar/AppointmentCargoPanel.test.ts @@ -7,15 +7,38 @@ vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSou vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 } -function render() { - return mount(AppointmentCargoPanel, { global: { stubs: { +function render(attach = false) { + return mount(AppointmentCargoPanel, { attachTo: attach ? document.body : undefined, global: { stubs: { DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '
' }, QueryToolbar: { name: 'QueryToolbar', emits: ['search'], template: '' }, - LoadErrorState: true, ApiErrorMeta: true, RowActions: true, + LoadErrorState: true, ApiErrorMeta: true, RowActions: true, AppointmentExternalCandidates: { name: 'AppointmentExternalCandidates', template: '
' }, } } }) } describe('appointment planning cargo query', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(listPlanningCargo).mockResolvedValue(empty) }) + it('switches cargo tabs by click and keyboard without reloading first-leg cargo', async () => { + const wrapper = render(true); await flushPromises() + const tabs = () => wrapper.findAll('[role="tab"]') + expect(tabs().map(tab => tab.text())).toEqual(['头程货物', '外接货物']) + expect(wrapper.find('.integrated-query-group [role="tablist"]').exists()).toBe(true) + expect(tabs()[0]!.attributes('aria-selected')).toBe('true') + await tabs()[1]!.trigger('click') + expect(tabs()[1]!.attributes('aria-selected')).toBe('true') + expect(wrapper.findComponent({ name: 'AppointmentExternalCandidates' }).exists()).toBe(true) + await tabs()[1]!.trigger('keydown', { key: 'ArrowRight' }) + await flushPromises() + expect(tabs()[0]!.attributes('aria-selected')).toBe('true') + expect(document.activeElement).toBe(tabs()[0]!.element) + expect(tabs()[1]!.attributes('tabindex')).toBe('-1') + await tabs()[0]!.trigger('keydown', { key: 'End' }) + await flushPromises() + expect(tabs()[1]!.attributes('aria-selected')).toBe('true') + expect(document.activeElement).toBe(tabs()[1]!.element) + await tabs()[1]!.trigger('keydown', { key: 'Home' }) + expect(tabs()[0]!.attributes('aria-selected')).toBe('true') + expect(listPlanningCargo).toHaveBeenCalledTimes(1) + wrapper.unmount() + }) it('uses one server-page request per entry, page change and reset', async () => { const wrapper = render(); await flushPromises() expect(listPlanningCargo).toHaveBeenCalledTimes(1) diff --git a/src/modules/appointment-calendar/AppointmentCargoPanel.vue b/src/modules/appointment-calendar/AppointmentCargoPanel.vue index 42484968..824bf14b 100644 --- a/src/modules/appointment-calendar/AppointmentCargoPanel.vue +++ b/src/modules/appointment-calendar/AppointmentCargoPanel.vue @@ -13,6 +13,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo, type PlanningSource } from './planningApi' import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue' import AppointmentExternalCandidates from './AppointmentExternalCandidates.vue' +import AppointmentCargoTabs from './AppointmentCargoTabs.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' const router = useRouter() @@ -149,11 +150,8 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller -
+

处理阶段

只标记已确认的推进状态
  1. {{ node.name }}{{ nodeState(node.phases) === 'done' ? '已确认完成' : nodeState(node.phases) === 'active' ? '正在处理' : current || !overview ? '等待状态确认' : '待机' }}

各仓最新数据

采集进度与报表发布分别确认

{{ group.code }}

{{ row.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}{{ row.lastPublishedAt ? beijingTime(row.lastPublishedAt) : '暂无发布结果' }}{{ row.lastPublishedAt ? '已有结果' : '尚未发布' }}{{ row.lastPublishedAt ? label(row.dataFreshness) : '—' }}
@@ -94,10 +109,11 @@ footer { justify-content: space-between; margin-top: var(--admin-space-5); paddi .account-main { display: grid; grid-template-columns: minmax(0, 1fr) 290px; gap: 32px; } .account-primary { min-width: 0; } .account-brand, .account-controls, .account-live, .account-section-title, h4 { display: flex; align-items: center; gap: 10px; } -.account-brand > svg { color: var(--el-color-primary); } -.account-header { background: var(--el-bg-color); padding: 16px 24px; } +.account-brand > svg { color: var(--el-color-primary); background: var(--el-color-primary-light-9); padding: var(--admin-space-3); box-sizing: content-box; border-radius: var(--admin-radius-lg); } +.account-header { background: linear-gradient(105deg, var(--el-color-primary-light-9), var(--el-bg-color) 65%); padding: 16px 24px; border-top: 3px solid var(--el-color-primary); } .account-header h2 { margin-bottom: 4px; } -.account-live { color: var(--el-text-color-secondary); } +.account-live { color: var(--el-text-color-secondary); background: var(--el-fill-color-light); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius-lg); } +[data-running="true"] .account-live { color: var(--el-color-success-dark-2); background: var(--el-color-success-light-9); } .account-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--el-text-color-secondary); } [data-running="true"] .account-live i { background: var(--el-color-success); animation: signal 2s ease-in-out infinite; } .account-controls :deep(.el-button) { gap: 8px; } @@ -106,7 +122,7 @@ footer { justify-content: space-between; margin-top: var(--admin-space-5); paddi .account-upnext ul { padding: 0; margin: 0; list-style: none; } .account-upnext li { display: flex; justify-content: space-between; padding: 12px 0; gap: 8px; border-bottom: 1px solid var(--el-border-color-light); font-size: var(--admin-font-caption); } .account-upnext > small { display: block; margin-top: 18px; line-height: 1.7; } -.account-next { display: grid; padding: 14px; margin-top: 20px; border-left-color: var(--el-color-primary); font-size: var(--admin-font-caption); line-height: 1.8; } +.account-next { display: grid; padding: 14px; margin-top: 20px; border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); border-radius: var(--admin-radius); font-size: var(--admin-font-caption); line-height: 1.8; } .account-empty { display: grid; grid-template-columns: 180px minmax(0, 1fr); align-items: center; gap: 32px; min-height: 220px; } .standby-ring { width: 168px; height: 168px; border: 10px solid var(--el-border-color-light); border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--el-text-color-secondary); box-sizing: border-box; } .last-result { display: flex; gap: 12px; padding: 16px; margin-top: 24px; background: var(--el-fill-color-light); border-radius: 6px; } @@ -118,21 +134,32 @@ footer { justify-content: space-between; margin-top: var(--admin-space-5); paddi h4 { margin: 0; font-size: var(--admin-font-body); } .account-section-title > span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); } .account-flow ol { display: grid; gap: 16px; list-style: none; margin: 0; padding: 0; } -.account-flow li { border-top: 2px solid var(--el-border-color-light); padding-top: 12px; display: grid; grid-template-columns: 28px 1fr; gap: 8px; align-items: center; } +.account-flow li { border-top: 2px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-lighter); padding: var(--admin-space-3); display: grid; grid-template-columns: 28px 1fr; gap: 8px; align-items: center; } .account-flow li small { grid-column: 2; } .account-flow li strong { font-size: var(--admin-font-body); } .step-index { display: grid; place-items: center; border-radius: 50%; width: 26px; height: 26px; background: var(--el-fill-color); font-size: var(--admin-font-caption); } -.account-flow li[data-state="active"] { border-color: var(--el-color-primary); color: var(--el-color-primary); } -.account-flow li[data-state="done"] { border-color: var(--el-color-success); } +.account-flow li[data-state="active"] { border-color: var(--el-color-primary); color: var(--el-color-primary); background: var(--el-color-primary-light-9); } +.account-flow li[data-state="active"] .step-index { background: var(--el-color-primary); color: white; } +.account-flow li[data-state="done"] { border-color: var(--el-color-success); background: var(--el-color-success-light-9); } +.account-flow li[data-state="done"] .step-index { background: var(--el-color-success); color: white; } .warehouse-results { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; } -.warehouse-results article { background: var(--el-fill-color-lighter); padding: 16px 20px; border: 1px solid var(--el-border-color-light); border-radius: 6px; } +.warehouse-results article { background: var(--el-fill-color-lighter); padding: 16px 20px; border: 1px solid var(--el-border-color-light); border-top: 3px solid var(--el-color-primary); border-radius: var(--admin-radius-lg); } +.warehouse-results article:nth-child(2n) { border-top-color: var(--el-color-success); } +.warehouse-results article h4 { color: var(--el-color-primary); } +.warehouse-results article:nth-child(2n) h4 { color: var(--el-color-success-dark-2); } .warehouse-result { display: flex; justify-content: space-between; gap: 12px; padding-top: 14px; font-size: var(--admin-font-body); } .warehouse-result small { display: block; margin-top: 4px; } .warehouse-result > span:last-child { text-align: right; } .result-ready { color: var(--el-color-success); } -.account-monitor :deep(.ring-value) { transition: stroke-dashoffset .5s ease; } -.account-monitor :deep(.collection-count-grid dd) { font-size: 36px; } +.account-monitor :deep(.collection-count-grid > div) { --metric-color: var(--el-color-primary); --metric-tint: var(--el-color-primary-light-9); position: relative; overflow: hidden; padding: var(--admin-space-4); border: 1px solid var(--metric-tint); border-radius: var(--admin-radius-lg); background: linear-gradient(135deg, var(--el-bg-color), var(--metric-tint)); } +.account-monitor :deep(.collection-count-grid > div:nth-child(2)) { --metric-color: var(--el-color-success-dark-2); --metric-tint: var(--el-color-success-light-9); } +.account-monitor :deep(.collection-count-grid > div:nth-child(3)) { --metric-color: var(--el-color-warning-dark-2); --metric-tint: var(--el-color-warning-light-9); } +.account-monitor :deep(.collection-count-grid > div::before) { content: ''; position: absolute; right: -18px; top: -24px; width: 86px; height: 86px; border-radius: 50%; background: var(--metric-color); opacity: .1; pointer-events: none; } +.account-monitor :deep(.collection-count-grid dt), .account-monitor :deep(.collection-count-grid dd) { position: relative; } +.account-monitor :deep(.collection-count-grid dd) { font-size: 36px; color: var(--metric-color); white-space: nowrap; } +.account-monitor :deep(.collection-ring) { background: var(--el-color-primary-light-9); border-radius: 50%; } +.account-monitor :deep(.ring-label strong) { color: var(--el-color-primary); } .account-monitor :deep(.collection-estimate) { padding: 16px; border-radius: 6px; } @keyframes signal { 50% { opacity: .45; } } -@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } .account-monitor :deep(.ring-value) { transition: none; } } +@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } } diff --git a/src/modules/self-operated/components/CollectionProgressGauge.vue b/src/modules/self-operated/components/CollectionProgressGauge.vue index e0f0a954..24815aaf 100644 --- a/src/modules/self-operated/components/CollectionProgressGauge.vue +++ b/src/modules/self-operated/components/CollectionProgressGauge.vue @@ -1,11 +1,13 @@ -

{{ trendError }}

+

{{ trendError }}

@@ -229,12 +260,9 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea .dashboard-main-charts { display: grid; grid-template-columns: 2fr 1fr; gap: var(--admin-space-4); } .dashboard-rankings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); } .dashboard-chart { min-width: 0; padding: 18px; background: var(--admin-surface); border: 1px solid #e5eaf4; border-radius: 8px; animation: dashboard-reveal .6s ease-out both; } -.dashboard-chart > h3, .dashboard-ranking-heading h3 { display: flex; align-items: center; gap: 8px; } -.dashboard-chart > h3::before, .dashboard-ranking-heading h3::before { content: ''; width: 3px; height: 14px; border-radius: 2px; background: #5575ed; } -.dashboard-chart > p { color: #76849c; font-size: var(--admin-font-caption); line-height: 1.7; } +.dashboard-chart p { color: #76849c; font-size: var(--admin-font-caption); line-height: 1.7; } .dashboard-rankings .dashboard-chart:nth-child(2) { animation-delay: .08s; } .dashboard-rankings .dashboard-chart:nth-child(3) { animation-delay: .16s; } -.dashboard-ranking-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-2); } .dashboard-placeholder { padding: 12px 0; color: #76849c; } .dashboard-skeleton-kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin: 12px 0; } .dashboard-skeleton-charts { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; } diff --git a/src/modules/self-operated/skuTrendChart.test.ts b/src/modules/self-operated/skuTrendChart.test.ts new file mode 100644 index 00000000..f5f56880 --- /dev/null +++ b/src/modules/self-operated/skuTrendChart.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import type { LineSeriesOption } from 'echarts' +import type { DailyPoint } from './types' +import { skuTrendOption } from './skuTrendChart' +const point = (day: number, status: string, value: string | null = '2'): DailyPoint => ({ businessDate: `2026-10-${day}`, dataStatus: status, outboundQuantity: value, observedQuantity: value, quantityUnit: 'PCS', reasonCodes: [] }) +describe('SKU ECharts data configuration', () => { + it('connects complete to partial days with dashed edges and resumes solid edges', () => { + const series = skuTrendOption([point(10, 'COMPLETE'), point(11, 'PARTIAL'), point(12, 'COMPLETE'), point(13, 'COMPLETE')]).series as LineSeriesOption[] + expect(series[1]?.lineStyle?.type).toBe('dashed') + expect(series[1]?.data).toEqual([2, 2, 2, null]) + expect(series[2]?.lineStyle?.type).toBe('solid') + expect(series[2]?.data).toEqual([null, null, 2, 2]) + }) + it('does not join across unknown or omitted dates and preserves zero', () => { + const series = skuTrendOption([point(10, 'COMPLETE', '0'), point(11, 'UNKNOWN', null), point(12, 'COMPLETE'), point(14, 'COMPLETE')]).series as LineSeriesOption[] + expect(series).toHaveLength(1) + expect(series[0]?.data?.[0]).toMatchObject({ value: 0 }) + expect(series[0]?.data?.[1]).toMatchObject({ value: null }) + expect(series[0]?.connectNulls).toBe(false) + }) +}) diff --git a/src/modules/self-operated/skuTrendChart.ts b/src/modules/self-operated/skuTrendChart.ts new file mode 100644 index 00000000..b40dc697 --- /dev/null +++ b/src/modules/self-operated/skuTrendChart.ts @@ -0,0 +1,48 @@ +import type { EChartsOption, LineSeriesOption } from 'echarts' +import type { DailyPoint } from './types' +import { label, quantityText, reasonsText, shownQuantity } from './state' + +export function skuTrendOption(points: DailyPoint[]): EChartsOption { + const dates = points.map(point => point.businessDate) + const values = points.map(point => { + const value = shownQuantity(point) + return point.dataStatus !== 'UNKNOWN' && value != null && Number.isFinite(Number(value)) ? Number(value) : null + }) + const series: LineSeriesOption[] = [{ + name: '每日出库量', type: 'line', connectNulls: false, lineStyle: { opacity: 0 }, + symbolSize: 8, showAllSymbol: true, + data: values.map((value, index) => ({ value, symbol: points[index]?.dataStatus === 'COMPLETE' ? 'circle' : 'emptyCircle' })), + itemStyle: { color: '#6078ea' }, emphasis: { scale: 1.5 }, + }] + let segment: { partial: boolean; data: (number | null)[] } | null = null + for (let index = 1; index < points.length; index++) { + if (values[index - 1] == null || values[index] == null || Date.parse(dates[index]!) - Date.parse(dates[index - 1]!) !== 86400000) { segment = null; continue } + const partial = points[index - 1]!.dataStatus !== 'COMPLETE' || points[index]!.dataStatus !== 'COMPLETE' + if (!segment || segment.partial !== partial) { + segment = { partial, data: Array(points.length).fill(null) } + series.push({ type: 'line', data: segment.data, symbol: 'none', silent: true, connectNulls: false, + tooltip: { show: false }, lineStyle: { color: '#6078ea', width: 3, type: partial ? 'dashed' : 'solid' }, + emphasis: { disabled: true }, animation: false }) + } + segment.data[index - 1] = values[index - 1]! + segment.data[index] = values[index]! + } + return { + animation: false, + grid: { left: 24, right: 32, top: 42, bottom: 80, containLabel: true }, + xAxis: { type: 'category', data: dates, name: '日期(北京时间)', nameLocation: 'middle', nameGap: 30, + boundaryGap: false, axisLine: { show: true }, axisTick: { show: true }, + axisLabel: { hideOverlap: true, showMinLabel: true, showMaxLabel: true, formatter: (value: string) => value.slice(5) } }, + yAxis: { type: 'value', name: '出库量(件)', min: 0, axisLine: { show: true }, axisTick: { show: true }, splitLine: { show: true, lineStyle: { type: 'dashed', color: '#e5eaf3' } } }, + tooltip: { trigger: 'axis', renderMode: 'richText', confine: true, axisPointer: { type: 'line' }, + formatter: params => { + const entries = Array.isArray(params) ? params : [params] + const item = entries.find(entry => entry.seriesIndex === 0) ?? entries[0] + const point = item ? points[item.dataIndex] : undefined + return point ? `${point.businessDate}\n出库量:${quantityText(shownQuantity(point))} 件\n${label(point.dataStatus)}\n覆盖截止:${point.coveredUntil ?? '未知'}\n${reasonsText(point.reasonCodes)}` : '' + } }, + dataZoom: [{ type: 'slider', bottom: 4, height: 20, start: 0, end: 100, filterMode: 'none' }], + aria: { enabled: true, description: 'SKU每日出库趋势。横轴为北京时间日期,纵轴为出库件数。完整日实线,未完整日虚线,缺失日期断线。详细数值可展开下方表格。' }, + series, + } +} diff --git a/src/modules/self-operated/state.test.ts b/src/modules/self-operated/state.test.ts index 61c79267..3dc8bd83 100644 --- a/src/modules/self-operated/state.test.ts +++ b/src/modules/self-operated/state.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { RequestFence, RetryIntent, RuntimePoller, contextParams, progressText, sameContext, shownQuantity, taskRetryAllowed, trendSegments, validateCustomRange } from './state' -import type { DailyPoint, QueryContext, Task } from './types' +import { RequestFence, RetryIntent, RuntimePoller, contextParams, progressText, sameContext, shownQuantity, taskRetryAllowed, validateCustomRange } from './state' +import type { QueryContext, Task } from './types' const context: QueryContext = { salesRunId: '9007199254740999', inventoryRunId: null, salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: '2026-10-12T15:00:00+08:00', queryExpiresAt: '2026-10-12T15:30:00+08:00', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12' } const task: Task = { taskNo: 'COL-TEST-001', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'FAILED', applyState: 'BLOCKED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: ['RETRY'], actionBlockReason: null, progress: null, queryFrom: null, queryUntil: null } @@ -52,15 +52,6 @@ describe('self-operated query and command protection', () => { expect(intent.create(task.taskNo, 'WH-A', '新的原因', key).requestKey).toBe('key-3') expect(first).not.toHaveProperty('taskId') }) - it('breaks trend at unknown days and retains a single reliable zero point', () => { - const point = (businessDate: string, value: string | null, status: string): DailyPoint => ({ businessDate, outboundQuantity: value, observedQuantity: value, dataStatus: status, reasonCodes: [], quantityUnit: 'PCS' }) - const segments = trendSegments([point('2026-10-10', '0', 'COMPLETE'), point('2026-10-11', null, 'UNKNOWN'), point('2026-10-12', '4', 'PARTIAL')]) - expect(segments).toHaveLength(2) - expect(segments[0]?.path).not.toContain(' L') - expect(segments[1]?.partial).toBe(true) - expect(trendSegments([point('2026-10-12', '0', 'COMPLETE')])).toHaveLength(1) - expect(trendSegments([point('2026-10-12', null, 'UNKNOWN')])).toEqual([]) - }) it('does not invent progress denominator or combine warehouse and account counts', () => { const text = progressText({ progressScope: 'ACCOUNT_SHARED', countBasis: 'ATTEMPT_ONLY', attemptNo: 2, scanRound: 2, phase: 'DIRECTORY', completed: 3, total: null, totalKnown: false, unit: 'PAGE', sliceFrom: null, sliceUntil: null }) expect(text).toContain('账号共享') diff --git a/src/modules/self-operated/state.ts b/src/modules/self-operated/state.ts index 7dd258fa..353fcd9e 100644 --- a/src/modules/self-operated/state.ts +++ b/src/modules/self-operated/state.ts @@ -1,4 +1,4 @@ -import type { BusinessQuery, DailyPoint, Progress, QueryContext, RetryCommand, Scope, Task } from './types' +import type { BusinessQuery, Progress, QueryContext, RetryCommand, Scope, Task } from './types' // Aborting saves work; generation fencing also protects against adapters that return after abort. export class RequestFence { @@ -90,7 +90,7 @@ export const reasonLabels: Record = { } export function reasonsText(reasons: readonly string[] | null | undefined) { return reasons?.map(reason => reasonLabels[reason] ?? statusLabels[reason] ?? '存在待核对项').join('、') || '—' } export function phaseText(phase: string | null | undefined) { - const phases: Record = { DIRECTORY: '获取出库目录', DISCOVERY: '获取出库目录', DETAIL: '获取出库详情', DETAILS: '获取出库详情', INVENTORY: '获取产品库存', VALIDATE: '校验', VALIDATION: '校验', MERGE: '归并', BUILD: '统计计算', PUBLISH: '发布', WAIT_UPSTREAM: '等待账号共享上游', WAIT_BUDGET: '预算等待', WAITING_SOURCE: '等待来源准备', WAITING: '等待执行', SAMPLE_DONE: '样本结束', FAILED: '执行失败', FETCH: '获取数据', PLANNED: '已规划', FETCHED: '数据已取完', VALIDATED: '已校验', BUILDING: '统计计算', BUILT: '计算完成', PUBLISHED: '已发布' } + const phases: Record = { DIRECTORY: '获取出库目录', DISCOVERY: '获取出库目录', DETAIL: '获取出库详情', DETAILS: '获取出库详情', INVENTORY: '获取产品库存', VALIDATE: '校验', VALIDATION: '校验', MERGE: '更新本仓出库数据', BUILD: '统计计算', PUBLISH: '发布', WAIT_UPSTREAM: '等待账号共享上游', WAIT_BUDGET: '预算等待', WAITING_SOURCE: '等待来源准备', WAITING: '等待执行', SAMPLE_DONE: '样本结束', FAILED: '执行失败', FETCH: '获取数据', PLANNED: '已规划', FETCHED: '数据已取完', VALIDATED: '已校验', BUILDING: '统计计算', BUILT: '计算完成', PUBLISHED: '已发布' } return phase ? phases[phase] ?? '阶段待核对' : '阶段待确认' } export function label(value: string | null | undefined) { return value ? statusLabels[value] ?? '待核对' : '—' } @@ -142,22 +142,3 @@ export class RuntimePoller { if (generation === this.generation && this.active && this.visible) this.timer = setTimeout(() => void this.tick(generation), busy ? 10000 : 60000) } } -export function trendSegments(points: DailyPoint[]) { - const max = Math.max(1, ...points.map(point => Number(shownQuantity(point) ?? 0))) - const segments: { path: string; partial: boolean }[] = [] - let path = '' - let partial = false - const flush = () => { if (path) segments.push({ path, partial }); path = '' } - points.forEach((point, index) => { - const quantity = shownQuantity(point) - if (quantity == null || point.dataStatus === 'UNKNOWN') { flush(); return } - const x = points.length === 1 ? 400 : 30 + index / (points.length - 1) * 740 - const y = 175 - Number(quantity) / max * 145 - const pointPartial = point.dataStatus !== 'COMPLETE' - if (path && partial !== pointPartial) { flush() } - path += `${path ? ' L' : 'M'}${x},${y}` - partial = pointPartial - }) - flush() - return segments -} diff --git a/src/modules/self-operated/types.ts b/src/modules/self-operated/types.ts index aa2cc02b..8209038a 100644 --- a/src/modules/self-operated/types.ts +++ b/src/modules/self-operated/types.ts @@ -181,12 +181,14 @@ export interface Run { progressUpdatedAt: string | null heartbeatAt: string | null executionHealth: string | null - elapsedSeconds: number | null + elapsedSeconds: Quantity errorCode: string | null failureSummary: string | null progress?: Partial | null } export interface Task { + createdAt?: string | null + elapsedSeconds?: Quantity stageEstimate?: StageEstimate | null dependencyState?: 'NONE' | 'WAITING_CONFIRMED' | 'WAITING_UNCONFIRMED' | 'SATISFIED' | 'FAILED' blockingReason?: string | null -- 2.54.0 From 2cf9129606f3f51f09d57366df1a90eee13f456b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 00:07:05 +0800 Subject: [PATCH 22/57] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/navigation/adminNavigation.test.ts | 2 +- src/core/navigation/adminNavigation.ts | 2 +- src/modules/self-operated/api/analytics.ts | 2 + .../components/AnalyticsView.vue | 19 +- .../components/CollectionAccountMonitor.vue | 22 +- .../components/InventoryTurnoverView.test.ts | 98 ++++++++ .../components/InventoryTurnoverView.vue | 236 ++++++++++++++++++ src/modules/self-operated/operation-matrix.md | 6 + .../self-operated/pages/InventoryView.test.ts | 29 +++ .../self-operated/pages/InventoryView.vue | 22 +- .../self-operated/pages/workbench.test.ts | 8 +- .../self-operated/turnoverNavigation.test.ts | 23 ++ .../self-operated/turnoverNavigation.ts | 48 ++++ .../turnoverPresentation.test.ts | 17 ++ .../self-operated/turnoverPresentation.ts | 30 +++ src/modules/self-operated/turnoverTypes.ts | 48 ++++ 16 files changed, 592 insertions(+), 20 deletions(-) create mode 100644 src/modules/self-operated/components/InventoryTurnoverView.test.ts create mode 100644 src/modules/self-operated/components/InventoryTurnoverView.vue create mode 100644 src/modules/self-operated/pages/InventoryView.test.ts create mode 100644 src/modules/self-operated/turnoverNavigation.test.ts create mode 100644 src/modules/self-operated/turnoverNavigation.ts create mode 100644 src/modules/self-operated/turnoverPresentation.test.ts create mode 100644 src/modules/self-operated/turnoverPresentation.ts create mode 100644 src/modules/self-operated/turnoverTypes.ts diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index b8fe7009..db1a7e2d 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -35,7 +35,7 @@ describe('adminNavigation', () => { { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, { title: '客户服务', children: ['异常订单'] }, { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, - { title: '自营业务', children: ['经营总览', '销量分析', '库存分析', '数据采集'] }, + { title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] }, { title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] }, { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 08e58634..65b13116 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -183,7 +183,7 @@ export const adminNavigation: AdminNavigationNode[] = [ }, { key: 'self-operated', - title: '自营业务', + title: '海外仓', icon: PrototypeSalesIcon, children: [ { key: 'self-operated-overview', title: '经营总览', icon: PrototypeDashboardIcon, path: '/self-operated/overview', permission: 'platform:self-operated-analytics:list' }, diff --git a/src/modules/self-operated/api/analytics.ts b/src/modules/self-operated/api/analytics.ts index f880b625..3ba7877d 100644 --- a/src/modules/self-operated/api/analytics.ts +++ b/src/modules/self-operated/api/analytics.ts @@ -1,4 +1,5 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { TurnoverQuery, TurnoverResponse } from '../turnoverTypes' import type { BusinessPage, BusinessQuery, Dashboard, DashboardQuery, DailyResponse, InventoryMetric, InventoryQuery, Metric, Overview, OwnerOption, OwnerOverview, OwnerOverviewQuery, OwnerQuery, RetryCommand, RetryResult, Run, RuntimePage, RuntimeQuery, SalesSummary, Scope, TaskDetail } from '../types' const base = '/platform/v1/last-leg/self-operated-analytics' @@ -7,6 +8,7 @@ async function get(path: string, params: object, signal?: AbortSignal): Promi return unwrapApiResponse(response) } export const analyticsApi = { + turnover: (query: TurnoverQuery, signal?: AbortSignal) => get('inventory-turnover', query, signal), dashboard: (query: DashboardQuery, signal?: AbortSignal) => get('dashboard', query, signal), owners: (query: OwnerQuery, signal?: AbortSignal) => get>('owners', query, signal), ownerOverview: (query: OwnerOverviewQuery, signal?: AbortSignal) => get('owner-overview', query, signal), diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue index 1f9b58e8..05b3f386 100644 --- a/src/modules/self-operated/components/AnalyticsView.vue +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -1,5 +1,6 @@ + + diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index f488b7fa..7f6d85a9 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -85,3 +85,9 @@ | 图表完整明细遮罩与定位 | 用户截图显示被卡片裁剪 | 所有新增图表弹框 append-to-body;不受卡片 transform 和滚动区域限制 | 待人工1920×1080、100%截图确认 | | 热销Top10/完整明细字段去重 | 用户截图中SKU、货主、出库量重复 | 热销专用1280px弹框表格,身份与各指标独立列,统计区间与覆盖提醒移至表头上方;其他榜单字段保留 | 同SKU不同货主、零/未知值、下钻回归;待人工截图确认 | + +| 库存分析/统计说明与采集时间 | 2026-10-13用户截图要求参考经营总览右上角信息按钮,原提示占据筛选与表格之间空间 | AnalyticsView共用库存/销量工作台将常规口径和采集时间移入右侧460px点击信息气泡,根部挂载、长内容内部滚动;保留页面查询失败、过期/新数据、日期未应用与来源条件提示 | 说明字段与查询逻辑不变;待人工1920×1080、100%截图确认 | + +| 数据采集/账号运行看板排版 | 用户本轮截图及此前原型:环图在左,三项数量与预计时间在右,下方阶段流程;当前右侧安排栏挤占进度区 | 主进度改为通栏,三项数字连续排列、预计时间对齐数字区域;后续安排移至阶段下方。使用现有 Admin 白底、蓝紫强调和统一间距,移除数量卡片渐变装饰;刷新仍在标题右侧,接口错误与未知数据如实保留 | 仅前端布局;待人工1920×1080、100%截图确认 | + +| 数据采集/后续安排位置(2026-10-14) | 用户截图反馈等待处理被移至阶段栏下方;工作区改动将主区域变为单列 | 恢复左侧当前进度、右侧290px后续安排双列,处理阶段保持下方整行;保留自动采集计划和其他业务信息 | 定向测试通过;待人工1920×1080、100%截图确认 | diff --git a/src/modules/self-operated/pages/InventoryView.test.ts b/src/modules/self-operated/pages/InventoryView.test.ts new file mode 100644 index 00000000..39a3a763 --- /dev/null +++ b/src/modules/self-operated/pages/InventoryView.test.ts @@ -0,0 +1,29 @@ +import { defineComponent, h, reactive } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import InventoryView from './InventoryView.vue' +const mocks = vi.hoisted(() => ({ route: { query: {} as Record }, push: vi.fn() })) +vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) })) +const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } }) +const options = { global: { stubs: { AnalyticsView: true, InventoryTurnoverView: true, 'el-button': button } } } +beforeEach(() => { mocks.route = reactive({ query: {} }); mocks.push.mockReset() }) +describe('inventory view entry', () => { + it('defaults to turnover and switches to detail without reusing turnover filters', async () => { + mocks.route.query = { warehouseCode: 'US02', focus: 'FROZEN' } + const w = mount(InventoryView, options) + expect(w.findComponent({ name: 'InventoryTurnoverView' }).exists()).toBe(true) + await w.findAll('button').find(b => b.text() === '库存明细')!.trigger('click') + expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/inventory', query: { view: 'DETAIL', warehouseCode: 'US02' } }) + w.unmount() + }) + it('preserves original strict detail links and rejects contradictory parameters', async () => { + mocks.route.query = { from: 'overview', context: 'fixed-version' } + const w = mount(InventoryView, options) + expect(w.findComponent({ name: 'AnalyticsView' }).props('mode')).toBe('inventory') + mocks.route.query = { from: 'overview', view: 'TURNOVER' }; await flushPromises() + expect(w.find('[role="alert"]').text()).toContain('不能混用') + expect(w.findComponent({ name: 'AnalyticsView' }).exists()).toBe(false) + expect(w.findComponent({ name: 'InventoryTurnoverView' }).exists()).toBe(false) + w.unmount() + }) +}) diff --git a/src/modules/self-operated/pages/InventoryView.vue b/src/modules/self-operated/pages/InventoryView.vue index afc9ffa5..b80abcff 100644 --- a/src/modules/self-operated/pages/InventoryView.vue +++ b/src/modules/self-operated/pages/InventoryView.vue @@ -1,4 +1,24 @@ - + + diff --git a/src/modules/self-operated/pages/workbench.test.ts b/src/modules/self-operated/pages/workbench.test.ts index f0ce5f7b..19727891 100644 --- a/src/modules/self-operated/pages/workbench.test.ts +++ b/src/modules/self-operated/pages/workbench.test.ts @@ -28,7 +28,7 @@ const button = defineComponent({ props: ['disabled', 'nativeType'], emits: ['cli const input = defineComponent({ props: ['modelValue', 'id'], emits: ['update:modelValue'], setup(props, { emit }) { return () => h('textarea', { id: props.id, value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value) }) } }) const loadError = defineComponent({ props: ['message'], setup(props) { return () => h('p', { role: 'alert' }, props.message) } }) const dialog = defineComponent({ props: ['modelValue', 'title', 'confirmLoading'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-test': 'detail-dialog' }, slots.default?.()) : null } }) -const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, AppDialog: dialog, SkuTrend: true, OwnerPicker: true, 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } } +const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, AppDialog: dialog, SkuTrend: true, OwnerPicker: true, 'el-popover': defineComponent({ setup(_, { slots }) { return () => h('aside', { 'data-test': 'information-popover' }, [slots.reference?.(), slots.default?.()]) } }), 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } } const wrappers: ReturnType[] = [] function findButton(wrapper: ReturnType, text: string) { const result = wrapper.findAll('button').find(item => item.text() === text); if (!result) throw new Error(`missing button ${text}`); return result } async function setFilters(wrapper: ReturnType, values: Record) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() } @@ -270,7 +270,8 @@ describe('self-operated actual workbench request state', () => { mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', lastPublishedAt: '2026-10-12T02:00:00Z', inventoryObservedFrom: '2026-10-12T01:30:00Z', inventoryObservedUntil: '2026-10-12T01:45:00Z' } as WarehouseOverview] }) const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper) await flushPromises() - const note = wrapper.get('[data-testid="inventory-observation"]') + expect(wrapper.find('[data-test="information-popover"] [aria-label="统计口径与采集时间"]').exists()).toBe(true) + const note = wrapper.get('[data-test="information-popover"] [data-testid="inventory-observation"]') expect(note.text()).toContain('2026/10/12 08:00:00 至 2026/10/12 08:12:00') expect(note.text()).toContain('本仓一轮完整分页观测') expect(note.text()).toContain('不代表同一瞬间的库存') @@ -312,7 +313,8 @@ describe('self-operated actual workbench request state', () => { expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).not.toHaveProperty('customerCode') expect(mocks.api.ownerOverview).not.toHaveBeenCalled() expect(wrapper.text()).not.toContain('货主经营') - expect(wrapper.find('details').attributes('open')).toBeUndefined() + expect(wrapper.find('details').exists()).toBe(false) + expect(wrapper.find('[aria-label="统计口径与采集时间"]').exists()).toBe(true) }) it('rejects missing threshold and drills inventory SKU to fixed 30-day daily data', async () => { const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper) diff --git a/src/modules/self-operated/turnoverNavigation.test.ts b/src/modules/self-operated/turnoverNavigation.test.ts new file mode 100644 index 00000000..f7b887b0 --- /dev/null +++ b/src/modules/self-operated/turnoverNavigation.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { inventoryView, turnoverRoute, validateTurnover, yesterdayBeijing } from './turnoverNavigation' +import type { TurnoverQuery } from './turnoverTypes' +const valid: TurnoverQuery = { sourceAccount: 'primary', warehouseCode: 'W', groupBy: 'SKU', endDate: '2026-10-12', baseWindowDays: 30, focus: 'ALL', sortBy: 'PHYSICAL_DESC', current: 1, size: 20, includeOverview: true } +describe('inventory view and turnover query contract', () => { + it('defaults to turnover but preserves strict dashboard and legacy detail links', () => { + expect(inventoryView({ warehouseCode: 'W', sku: 'X' })).toBe('TURNOVER') + for (const q of [{ from: 'overview' }, { drilldownFrom: 'DASHBOARD' }, { salesWindowDays: '10' }, { attention: 'FROZEN_STOCK' }, { view: 'DETAIL' }]) expect(inventoryView(q)).toBe('DETAIL') + }) + it('rejects conflicting views and unknown or duplicate parameters', () => { + for (const q of [{ view: 'TURNOVER', from: 'overview' }, { view: 'TURNOVER', attention: 'ALL' }, { view: 'DETAIL', groupBy: 'SKU' }, { view: ['TURNOVER'] }, { view: 'OTHER' }]) expect(() => inventoryView(q)).toThrow() + expect(() => turnoverRoute({ salesRunId: 'invented' })).toThrow() + expect(() => turnoverRoute({ groupBy: ['OWNER', 'SKU'] })).toThrow() + }) + it('validates independent focus, threshold, group, date and pagination boundaries', () => { + expect(() => validateTurnover(valid, '2026-10-12')).not.toThrow() + for (const patch of [{ endDate: '2026-02-30' }, { endDate: '2026-10-13' }, { groupBy: 'OWNER', sku: 'X' }, { groupBy: 'OWNER', sortBy: 'COVERAGE_DESC' }, { focus: 'COVERAGE_GT' }, { coverageDaysThreshold: 1.5 }, { size: 101 }, { current: 0 }, { baseWindowDays: 10 }]) expect(() => validateTurnover({ ...valid, ...patch } as TurnoverQuery, '2026-10-12')).toThrow() + expect(() => validateTurnover({ ...valid, focus: 'COVERAGE_LE', coverageDaysThreshold: 45 }, '2026-10-12')).not.toThrow() + }) + it('uses Beijing yesterday across UTC day boundaries', () => { + expect(yesterdayBeijing(new Date('2026-10-12T16:01:00Z'))).toBe('2026-10-12') + }) +}) diff --git a/src/modules/self-operated/turnoverNavigation.ts b/src/modules/self-operated/turnoverNavigation.ts new file mode 100644 index 00000000..63390c8a --- /dev/null +++ b/src/modules/self-operated/turnoverNavigation.ts @@ -0,0 +1,48 @@ +import type { TurnoverQuery, TurnoverSort } from './turnoverTypes' +export const turnoverSorts: TurnoverSort[] = ['PHYSICAL_DESC', 'AVAILABLE_DESC', 'OUTBOUND_7_DESC', 'OUTBOUND_15_DESC', 'OUTBOUND_30_DESC', 'COVERAGE_ASC', 'COVERAGE_DESC', 'TURNOVER_DESC', 'NO_OUTBOUND_PHYSICAL_DESC'] +const detailOnly = ['salesWindowDays', 'salesWindowEndDate', 'attention', 'coverageSelection', 'coverageBucket', 'bucketRuleVersion'] +const turnoverOnly = ['groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext'] +function scalar(q: Record, key: string) { + const v = q[key] + if (v == null) return undefined + if (typeof v !== 'string' || !v.trim()) throw new Error(`参数 ${key} 无效`) + return v +} +export function inventoryView(q: Record): 'TURNOVER' | 'DETAIL' { + const view = scalar(q, 'view') + if (view && view !== 'TURNOVER' && view !== 'DETAIL') throw new Error('库存视图参数无效') + const strict = q.from === 'overview' || q.drilldownFrom === 'DASHBOARD' + const detail = strict || detailOnly.some(k => q[k] != null) + if (view === 'TURNOVER' && detail) throw new Error('库存周转不能混用总览下钻或库存明细条件') + const selected = detail || view === 'DETAIL' ? 'DETAIL' : 'TURNOVER' + if (selected === 'DETAIL' && turnoverOnly.some(k => q[k] != null)) throw new Error('库存明细不能携带周转专属条件') + return selected +} +export function yesterdayBeijing(now = new Date()) { + const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now) + return new Date(Date.parse(`${date}T00:00:00Z`) - 86400000).toISOString().slice(0, 10) +} +export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing()) { + if (!q.warehouseCode.trim()) throw new Error('请选择可用仓库') + if (!/^\d{4}-\d{2}-\d{2}$/.test(q.endDate) || !Number.isFinite(Date.parse(q.endDate)) || new Date(q.endDate).toISOString().slice(0, 10) !== q.endDate || q.endDate > latestDate) throw new Error('截止日必须是已结束的北京时间日期') + if (![7, 15, 30].includes(q.baseWindowDays)) throw new Error('计算基准仅支持7、15、30天') + if (!['SKU', 'OWNER'].includes(q.groupBy)) throw new Error('分组无效') + if (q.groupBy === 'OWNER' && q.sku) throw new Error('货主汇总不能携带SKU搜索') + if (!['ALL', 'NO_OUTBOUND_30D', 'COVERAGE_GT', 'COVERAGE_LE', 'FROZEN', 'DEFECTIVE', 'UNASSESSABLE'].includes(q.focus)) throw new Error('关注条件无效') + if (q.coverageDaysThreshold != null && (!Number.isSafeInteger(q.coverageDaysThreshold) || q.coverageDaysThreshold <= 0)) throw new Error('阈值必须为正整数天数') + if (['COVERAGE_GT', 'COVERAGE_LE'].includes(q.focus) && q.coverageDaysThreshold == null) throw new Error('请填写快慢判断的天数阈值') + if (q.turnoverStatus && q.turnoverStatus !== 'INSUFFICIENT') throw new Error('周转样本条件无效') + if (!turnoverSorts.includes(q.sortBy) || q.groupBy === 'OWNER' && ['COVERAGE_ASC', 'COVERAGE_DESC'].includes(q.sortBy) || q.groupBy === 'SKU' && q.sortBy === 'NO_OUTBOUND_PHYSICAL_DESC') throw new Error('排序不适用于当前分组') + if (!Number.isSafeInteger(q.current) || q.current < 1 || !Number.isSafeInteger(q.size) || q.size < 1 || q.size > 100) throw new Error('分页参数无效') +} +export function turnoverRoute(q: Record): Partial { + if (inventoryView(q) !== 'TURNOVER') throw new Error('当前为库存明细入口') + const allowed = new Set(['view', 'warehouseCode', 'sku', 'customerCode', 'endDate', 'baseWindowDays', 'groupBy', 'focus', 'coverageDaysThreshold', 'turnoverStatus', 'sortBy', 'snapshotContext', 'current', 'size']) + for (const key of Object.keys(q)) if (!allowed.has(key)) throw new Error(`库存周转不接受参数 ${key}`) + const result: Record = {} + for (const key of allowed) { + const value = scalar(q, key) + if (value != null && key !== 'view') result[key] = ['baseWindowDays', 'coverageDaysThreshold', 'current', 'size'].includes(key) ? Number(value) : value + } + return result as Partial +} diff --git a/src/modules/self-operated/turnoverPresentation.test.ts b/src/modules/self-operated/turnoverPresentation.test.ts new file mode 100644 index 00000000..b9a8bdfe --- /dev/null +++ b/src/modules/self-operated/turnoverPresentation.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { turnoverChart, turnoverNumber, windowText } from './turnoverPresentation' +import type { TurnoverCharts, TurnoverWindowMetric } from './turnoverTypes' +describe('turnover honest precision and charts', () => { + it('keeps tiny positive values distinct from zero and unknown', () => { + expect(turnoverNumber(0.000001)).toBe('<0.01') + expect(turnoverNumber(0.01, 1)).toBe('<0.1') + expect(turnoverNumber(0)).toBe('0'); expect(turnoverNumber(null)).toBe('—') + }) + it('does not draw partial-window totals as daily averages or zero bars', () => { + const partial: TurnoverWindowMetric = { days: 15, startDate: '2026-09-28', endDateExclusive: '2026-10-13', dataStatus: 'PARTIAL', observedQuantity: 150, outboundQuantity: null, averageDailyOutboundQuantity: null, completeDayCount: 7, partialDayCount: 1, unknownDayCount: 7, reasonCodes: [] } + const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: [{ ...partial, days: 7, dataStatus: 'COMPLETE', averageDailyOutboundQuantity: 10 }, partial] } + const option = turnoverChart(charts, 'daily') + expect(JSON.stringify(option.series)).toContain('"data":[10,null]') + expect(windowText(partial)).toBe('已采集 150,覆盖不足') + }) +}) diff --git a/src/modules/self-operated/turnoverPresentation.ts b/src/modules/self-operated/turnoverPresentation.ts new file mode 100644 index 00000000..8da906c9 --- /dev/null +++ b/src/modules/self-operated/turnoverPresentation.ts @@ -0,0 +1,30 @@ +import type { Quantity } from './types' +import type { TurnoverCharts, TurnoverTotal, TurnoverWindowMetric } from './turnoverTypes' +import type { EChartsOption } from 'echarts' +export function turnoverNumber(value: Quantity | undefined, decimals = 2) { + if (value == null || value === '' || !Number.isFinite(Number(value))) return '—' + const n = Number(value) + if (n > 0 && n < 10 ** -decimals) return `<${(10 ** -decimals).toFixed(decimals)}` + return n.toLocaleString('zh-CN', { maximumFractionDigits: decimals }) +} +export function totalText(total: TurnoverTotal) { + return total.value != null ? turnoverNumber(total.value) : `已知 ${turnoverNumber(total.knownSubtotal)};${total.unknownSkuCount} SKU待核验` +} +export function windowText(metric: TurnoverWindowMetric | undefined) { + if (!metric) return '—' + return metric.dataStatus === 'COMPLETE' ? turnoverNumber(metric.outboundQuantity) : `已采集 ${turnoverNumber(metric.observedQuantity)},覆盖不足` +} +export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'daily'): EChartsOption { + const owners = kind === 'owners' + const rows = owners ? charts.ownerPhysicalTop : charts.averageDaily + return { + aria: { enabled: true }, animationDuration: 450, color: ['#6c7bea'], + tooltip: { trigger: 'axis', renderMode: 'richText' }, grid: { left: 12, right: 24, top: 16, bottom: 20, containLabel: true }, + xAxis: owners ? { type: 'value', name: '件' } : { type: 'category', data: charts.averageDaily.map(w => `${w.days}日`) }, + yAxis: owners ? { type: 'category', inverse: true, data: charts.ownerPhysicalTop.map(w => w.customerName || w.customerCode) } : { type: 'value', name: '件/日' }, + series: [{ type: 'bar', barMaxWidth: 24, itemStyle: { borderRadius: 4 }, data: rows.map((_, i) => { + const value = owners ? charts.ownerPhysicalTop[i]?.physicalQuantity : charts.averageDaily[i]?.dataStatus === 'COMPLETE' ? charts.averageDaily[i]?.averageDailyOutboundQuantity : null + return value == null ? null : Number(value) + }) }], + } +} diff --git a/src/modules/self-operated/turnoverTypes.ts b/src/modules/self-operated/turnoverTypes.ts new file mode 100644 index 00000000..d89c385d --- /dev/null +++ b/src/modules/self-operated/turnoverTypes.ts @@ -0,0 +1,48 @@ +import type { QueryContext, Quantity } from './types' + +// Inventory-turnover integration contract. Backend implementation and HTTP acceptance are pending. +export type TurnoverWindow = 7 | 15 | 30 +export type TurnoverGroup = 'SKU' | 'OWNER' +export type TurnoverFocus = 'ALL' | 'NO_OUTBOUND_30D' | 'COVERAGE_GT' | 'COVERAGE_LE' | 'FROZEN' | 'DEFECTIVE' | 'UNASSESSABLE' +export type TurnoverSort = 'PHYSICAL_DESC' | 'AVAILABLE_DESC' | 'OUTBOUND_7_DESC' | 'OUTBOUND_15_DESC' | 'OUTBOUND_30_DESC' | 'COVERAGE_ASC' | 'COVERAGE_DESC' | 'TURNOVER_DESC' | 'NO_OUTBOUND_PHYSICAL_DESC' +export interface TurnoverQuery { + sourceAccount: string; warehouseCode: string; endDate: string; baseWindowDays: TurnoverWindow + groupBy: TurnoverGroup; customerCode?: string; sku?: string; focus: TurnoverFocus + coverageDaysThreshold?: number; turnoverStatus?: 'INSUFFICIENT'; sortBy: TurnoverSort + current: number; size: number; includeOverview: boolean; snapshotContext?: string +} +export interface TurnoverWindowMetric { + days: TurnoverWindow; startDate: string; endDateExclusive: string + outboundQuantity: Quantity; observedQuantity: Quantity; averageDailyOutboundQuantity: Quantity + completeDayCount: number; partialDayCount: number; unknownDayCount: number + dataStatus: string; reasonCodes: string[] +} +export interface TurnoverTotal { value: Quantity; knownSubtotal: Quantity; unknownSkuCount: number; reasonCodes: string[] } +export interface TurnoverRisk { matchedSkuCount: Quantity; knownPhysicalQuantity: Quantity; unknownSkuCount: number; physicalQuantityRatio: Quantity } +export interface TurnoverRow { + customerCode: string; customerName: string | null; sku: string | null; productName: string | null + physicalQuantity: TurnoverTotal; availableQuantity: TurnoverTotal; frozenQuantity: TurnoverTotal; defectiveQuantity: TurnoverTotal + physicalSkuCount: Quantity; windows: TurnoverWindowMetric[]; averageDailyOutboundQuantity: Quantity + inventoryCoverageDays: Quantity; coverageStatus: string; averagePhysicalQuantity: Quantity + quantityTurnoverCount: Quantity; turnoverStatus: string; qualifiedSampleDays: number; requiredSampleDays: number + missingSampleDates: string[]; sampleReasonCodes: string[]; lastObservedOutboundDate: string | null + attentionReasons: string[]; noOutbound30d: TurnoverRisk; slowCoverage: TurnoverRisk; unassessableSkuCount: number +} +export interface TurnoverContext { + snapshotContext: string; sourceAccount: string; warehouseCode: string; endDate: string + baseWindowDays: TurnoverWindow; queryAsOf: string; queryExpiresAt: string; analyticsContext: QueryContext + inventoryObservedFrom: string | null; inventoryObservedUntil: string | null + sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1'; dailySamples: { date: string; runId: string | null }[] + missingSampleDates: string[]; accumulationStartedOn: string | null +} +export interface TurnoverOverview { + physicalQuantity: TurnoverTotal; availableQuantity: TurnoverTotal; noOutboundSkuCount: TurnoverTotal; unassessableSkuCount: TurnoverTotal +} +export interface TurnoverCharts { + ownerPhysicalTop: { customerCode: string; customerName: string | null; physicalQuantity: Quantity }[] + unknownOwnerCount: number; averageDaily: TurnoverWindowMetric[] +} +export interface TurnoverResponse { + context: TurnoverContext; summary: TurnoverOverview | null; charts: TurnoverCharts | null + rows: TurnoverRow[]; total: number +} -- 2.54.0 From 02a9d72ef818a2c5f05ff38291b61f9a8c4ff888 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 01:52:20 +0800 Subject: [PATCH 23/57] fix: preserve turnover deep-link snapshots and clarify data status --- .../components/InventoryTurnoverView.test.ts | 9 +++++++++ .../components/InventoryTurnoverView.vue | 8 ++++---- src/modules/self-operated/operation-matrix.md | 1 + .../self-operated/turnoverPresentation.test.ts | 7 ++++++- src/modules/self-operated/turnoverPresentation.ts | 14 +++++++++++++- src/modules/self-operated/turnoverTypes.ts | 4 ++-- 6 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/modules/self-operated/components/InventoryTurnoverView.test.ts b/src/modules/self-operated/components/InventoryTurnoverView.test.ts index 1bd76667..46eaf0e9 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.test.ts +++ b/src/modules/self-operated/components/InventoryTurnoverView.test.ts @@ -26,6 +26,15 @@ async function change(w: ReturnType, patch: object, search = false beforeEach(() => { vi.clearAllMocks(); mocks.allowed = true; mocks.route = reactive({ query: { endDate: '2026-10-12' } }); mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A' }, { warehouseCode: 'B' }] }); mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q)); sessionStorage.clear() }) afterEach(() => wrappers.splice(0).forEach(w => w.unmount())) describe('inventory turnover page fixed snapshot behavior', () => { + it('honors an explicit new route token in the same scope and reloads its overview', async () => { + const w = page(); await flushPromises() + expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBeUndefined() + mocks.route.query = { endDate: '2026-10-12', warehouseCode: 'A', snapshotContext: 'token-T2' } + await flushPromises() + expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ warehouseCode: 'A', snapshotContext: 'token-T2', includeOverview: true }) + w.findComponent(table).vm.$emit('update:page', 2); await flushPromises() + expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-T2', includeOverview: false, current: 2 }) + }) it('loads the first warehouse once and keeps charts/context across filtered pagination', async () => { const w = page(); await flushPromises() expect(mocks.turnover).toHaveBeenCalledTimes(1) diff --git a/src/modules/self-operated/components/InventoryTurnoverView.vue b/src/modules/self-operated/components/InventoryTurnoverView.vue index f8ba9f6f..56514983 100644 --- a/src/modules/self-operated/components/InventoryTurnoverView.vue +++ b/src/modules/self-operated/components/InventoryTurnoverView.vue @@ -11,11 +11,11 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { analyticsApi } from '../api/analytics' -import { RequestFence, beijingTime, contextParams, reasonsText } from '../state' +import { RequestFence, beijingTime, contextParams } from '../state' import type { DailyResponse, Overview } from '../types' import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes' import { turnoverRoute, validateTurnover, yesterdayBeijing, turnoverSorts } from '../turnoverNavigation' -import { totalText, turnoverChart, turnoverNumber, windowText } from '../turnoverPresentation' +import { totalText, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation' import AnalyticsChart from './AnalyticsChart.vue' import ChartDetailsDialog from './ChartDetailsDialog.vue' import SkuTrend from './SkuTrend.vue' @@ -90,9 +90,9 @@ async function load(refresh = false) { if (!warehouses.value.some(w => w.value === q.warehouseCode)) throw new Error('指定仓库不可用,请重新选择有权限的仓库') } catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return } const sameScope = applied.value && scopeKey(applied.value) === scopeKey(q) - q.snapshotContext = refresh ? undefined : sameScope ? data.value?.context.snapshotContext : linkedSnapshot + q.snapshotContext = refresh ? undefined : linkedSnapshot ?? (sameScope ? data.value?.context.snapshotContext : undefined) linkedSnapshot = undefined - q.includeOverview = refresh || !data.value?.summary || !applied.value || overviewKey(applied.value) !== overviewKey(q) + q.includeOverview = refresh || q.snapshotContext !== data.value?.context.snapshotContext || !data.value?.summary || !applied.value || overviewKey(applied.value) !== overviewKey(q) if (!q.snapshotContext) { q.current = 1; page.value = 1; q.includeOverview = true } const previous = data.value const request = fence.begin(); loading.value = true diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index 7f6d85a9..0afd7f85 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -4,6 +4,7 @@ | 页面 | 操作 | 依据与承载结论 | 布局与验证 | | --- | --- | --- | --- | +| /self-operated/inventory | 库存周转/库存明细、SKU/货主 | 当前维护入口 `docs/自营业务/库存周转视图设计.md`:默认周转、默认合法仓及SKU;可选货主仅限本视图。三窗同屏、四指标、两张240px图表、关注过滤和分页;原总览严格下钻继续进入库存明细 | 概览与列表共用固定上下文,显式深链换版同时换概览;SKU趋势1120px居中弹框,样本证据1040px居中弹框;未知不补零,待人工1920×1080、100%截图确认 | | 自营业务全部图表 | 统一ECharts | 2026-10-13 用户要求全面替换手写图表:经营总览日趋势、库存七桶与三个榜单、SKU日趋势、采集进度环统一通过AnalyticsChart使用ECharts Canvas;移除SVG路径生成与CSS条形绘图,保留真实数量、未知/零区分及原有下钻边界 | 日期点/分类轴/柱体点击及展开表格键盘操作同资格;未知进度仅空环;坐标、tooltip、缩放及尺寸由组件配置;待人工1920×1080、100%截图确认 | | 库存/销量 | SKU每日出库趋势连线与坐标 | 2026-10-13 用户反馈线未连接、坐标不显示,并明确要求图表组件:保留居中弹框,改用ECharts+vue-echarts(Canvas),配置日期轴、数量轴、网格、提示框、图例说明和范围缩放;相邻有效日连接,涉及未完整日用虚线,未知或缺失日期断线 | 库存/销量/总览的SKU弹框共用组件;总览主图的连线修复同步回归;不把未知补零;待人工1920×1080、100%截图确认 | | /self-operated/collection | 看板配色 | 2026-10-13 用户追加当前页面与原型首页截图:采用蓝紫主色、浅色圆形装饰数量卡、绿/橙数量强调;流程与仓库结果增加色彩层次,保持原有左右布局及真实状态文字 | 仅本看板作用域内应用;颜色不代替状态或代表未知完成度;待人工1920×1080、100%截图确认 | diff --git a/src/modules/self-operated/turnoverPresentation.test.ts b/src/modules/self-operated/turnoverPresentation.test.ts index b9a8bdfe..e7e0d3a6 100644 --- a/src/modules/self-operated/turnoverPresentation.test.ts +++ b/src/modules/self-operated/turnoverPresentation.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest' -import { turnoverChart, turnoverNumber, windowText } from './turnoverPresentation' +import { turnoverChart, turnoverNumber, windowText, turnoverReasonsText } from './turnoverPresentation' import type { TurnoverCharts, TurnoverWindowMetric } from './turnoverTypes' describe('turnover honest precision and charts', () => { + it('explains missing samples and zero average stock without exposing internal statuses', () => { + expect(turnoverReasonsText(['INSUFFICIENT'])).toBe('合格日库存样本不足') + expect(turnoverReasonsText(['ZERO_AVERAGE_STOCK'])).toContain('每日观测未捕获存量') + expect(turnoverReasonsText(['INVENTORY_UNKNOWN_OR_STALE'])).toContain('已过期') + }) it('keeps tiny positive values distinct from zero and unknown', () => { expect(turnoverNumber(0.000001)).toBe('<0.01') expect(turnoverNumber(0.01, 1)).toBe('<0.1') diff --git a/src/modules/self-operated/turnoverPresentation.ts b/src/modules/self-operated/turnoverPresentation.ts index 8da906c9..f1e828a0 100644 --- a/src/modules/self-operated/turnoverPresentation.ts +++ b/src/modules/self-operated/turnoverPresentation.ts @@ -1,6 +1,16 @@ import type { Quantity } from './types' import type { TurnoverCharts, TurnoverTotal, TurnoverWindowMetric } from './turnoverTypes' import type { EChartsOption } from 'echarts' +import { reasonsText } from './state' +const turnoverLabels: Record = { + INSUFFICIENT: '合格日库存样本不足', ZERO_AVERAGE_STOCK: '每日观测未捕获存量,无法计算周转', + INVENTORY_UNKNOWN_OR_STALE: '当前库存未知、异常或已过期', NOT_APPLICABLE: '货主不计算整体可售天数', + SALES_OR_STOCK_UNASSESSABLE: '部分商品的销量或库存不足以判断', + SALES_WINDOW_UNAVAILABLE: '同期销量或商品观察证据不足', +} +export function turnoverReasonsText(reasons: readonly string[] | null | undefined) { + return reasons?.map(reason => turnoverLabels[reason] ?? reasonsText([reason])).join('、') || '—' +} export function turnoverNumber(value: Quantity | undefined, decimals = 2) { if (value == null || value === '' || !Number.isFinite(Number(value))) return '—' const n = Number(value) @@ -8,6 +18,7 @@ export function turnoverNumber(value: Quantity | undefined, decimals = 2) { return n.toLocaleString('zh-CN', { maximumFractionDigits: decimals }) } export function totalText(total: TurnoverTotal) { + if (total.value == null && total.knownSubtotal == null && total.unknownSkuCount === 0) return turnoverReasonsText(total.reasonCodes) return total.value != null ? turnoverNumber(total.value) : `已知 ${turnoverNumber(total.knownSubtotal)};${total.unknownSkuCount} SKU待核验` } export function windowText(metric: TurnoverWindowMetric | undefined) { @@ -18,7 +29,8 @@ export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'daily'): const owners = kind === 'owners' const rows = owners ? charts.ownerPhysicalTop : charts.averageDaily return { - aria: { enabled: true }, animationDuration: 450, color: ['#6c7bea'], + aria: { enabled: true }, animation: typeof window === 'undefined' || !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, + animationDuration: 450, color: ['#6c7bea'], tooltip: { trigger: 'axis', renderMode: 'richText' }, grid: { left: 12, right: 24, top: 16, bottom: 20, containLabel: true }, xAxis: owners ? { type: 'value', name: '件' } : { type: 'category', data: charts.averageDaily.map(w => `${w.days}日`) }, yAxis: owners ? { type: 'category', inverse: true, data: charts.ownerPhysicalTop.map(w => w.customerName || w.customerCode) } : { type: 'value', name: '件/日' }, diff --git a/src/modules/self-operated/turnoverTypes.ts b/src/modules/self-operated/turnoverTypes.ts index d89c385d..0c16c2ff 100644 --- a/src/modules/self-operated/turnoverTypes.ts +++ b/src/modules/self-operated/turnoverTypes.ts @@ -1,6 +1,6 @@ import type { QueryContext, Quantity } from './types' -// Inventory-turnover integration contract. Backend implementation and HTTP acceptance are pending. +// Fixed-version read contract; Long identifiers and totals use the service's string serializer. export type TurnoverWindow = 7 | 15 | 30 export type TurnoverGroup = 'SKU' | 'OWNER' export type TurnoverFocus = 'ALL' | 'NO_OUTBOUND_30D' | 'COVERAGE_GT' | 'COVERAGE_LE' | 'FROZEN' | 'DEFECTIVE' | 'UNASSESSABLE' @@ -44,5 +44,5 @@ export interface TurnoverCharts { } export interface TurnoverResponse { context: TurnoverContext; summary: TurnoverOverview | null; charts: TurnoverCharts | null - rows: TurnoverRow[]; total: number + rows: TurnoverRow[]; total: string | number } -- 2.54.0 From 3291d34fb8b2508d7f6800d57f4af972a250ab65 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 08:57:21 +0800 Subject: [PATCH 24/57] fix(selfop): label order processing independently of detail requests --- src/modules/self-operated/collectionPresentation.test.ts | 2 +- .../self-operated/components/CollectionAccountMonitor.vue | 4 ++-- src/modules/self-operated/state.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/self-operated/collectionPresentation.test.ts b/src/modules/self-operated/collectionPresentation.test.ts index e929248f..1e778560 100644 --- a/src/modules/self-operated/collectionPresentation.test.ts +++ b/src/modules/self-operated/collectionPresentation.test.ts @@ -23,7 +23,7 @@ describe('plain collection task presentation', () => { expect(taskProgressText({ ...base, taskStatus: 'WAITING_SOURCE', attemptCount: 0 })).toBe('尚未开始,等待数据准备') }) it('shows a stage percentage without presenting it as overall completion', () => { - expect(taskProgressText(base)).toBe('获取出库详情,已处理 792 / 2014单(39.3%,当前阶段)') + expect(taskProgressText(base)).toBe('处理出库订单,已处理 792 / 2014单(39.3%,当前阶段)') expect(taskCompletionText(base)).toBe('完成时间暂无法预计') expect(taskCompletionText({ ...base, taskStatus: 'SUCCEEDED' })).toBe('执行完成,结果尚未发布') expect(taskCompletionText({ ...base, resultPublicationState: 'PUBLISHED' })).toBe('结果已可用') diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.vue b/src/modules/self-operated/components/CollectionAccountMonitor.vue index f9260125..a63aba87 100644 --- a/src/modules/self-operated/components/CollectionAccountMonitor.vue +++ b/src/modules/self-operated/components/CollectionAccountMonitor.vue @@ -41,7 +41,7 @@ const schedules = computed(() => { }) const nextStep = computed(() => { const phase = current.value?.progress?.phase - if (['DIRECTORY', 'DISCOVERY'].includes(phase ?? '')) return '获取出库详情 → 更新各仓出库数据 → 统计计算 → 发布结果' + if (['DIRECTORY', 'DISCOVERY'].includes(phase ?? '')) return '处理出库订单 → 更新各仓出库数据 → 统计计算 → 发布结果' if (['DETAIL', 'DETAILS'].includes(phase ?? '')) return '更新各仓出库数据 → 统计计算 → 发布结果' if (['INVENTORY', 'FETCH'].includes(phase ?? '')) return '库存完整性校验 → 统计计算 → 发布结果' if (['VALIDATE', 'VALIDATION', 'MERGE'].includes(phase ?? '')) return '统计计算 → 发布结果' @@ -61,7 +61,7 @@ const idleReason = computed(() => { }) const flow = computed(() => current.value?.dataFamily === 'INVENTORY' ? [ { name: '读取库存', phases: ['INVENTORY', 'FETCH'] }, { name: '完整性校验', phases: ['VALIDATE', 'VALIDATION'] }, { name: '统计计算', phases: ['BUILD', 'BUILDING'] }, { name: '发布结果', phases: ['PUBLISH', 'PUBLISHED'] }, -] : [{ name: '获取出库目录', phases: ['DIRECTORY', 'DISCOVERY'] }, { name: '获取出库详情', phases: ['DETAIL', 'DETAILS'] }, { name: '更新各仓出库数据', phases: ['MERGE', 'VALIDATE', 'VALIDATION'] }, { name: '统计计算', phases: ['BUILD', 'BUILDING'] }, { name: '发布结果', phases: ['PUBLISH', 'PUBLISHED'] }]) +] : [{ name: '获取出库目录', phases: ['DIRECTORY', 'DISCOVERY'] }, { name: '处理出库订单', phases: ['DETAIL', 'DETAILS'] }, { name: '更新各仓出库数据', phases: ['MERGE', 'VALIDATE', 'VALIDATION'] }, { name: '统计计算', phases: ['BUILD', 'BUILDING'] }, { name: '发布结果', phases: ['PUBLISH', 'PUBLISHED'] }]) function nodeState(phases: string[]) { const p = current.value?.progress if (!p) return 'idle' diff --git a/src/modules/self-operated/state.ts b/src/modules/self-operated/state.ts index 353fcd9e..4c6b1efd 100644 --- a/src/modules/self-operated/state.ts +++ b/src/modules/self-operated/state.ts @@ -90,7 +90,7 @@ export const reasonLabels: Record = { } export function reasonsText(reasons: readonly string[] | null | undefined) { return reasons?.map(reason => reasonLabels[reason] ?? statusLabels[reason] ?? '存在待核对项').join('、') || '—' } export function phaseText(phase: string | null | undefined) { - const phases: Record = { DIRECTORY: '获取出库目录', DISCOVERY: '获取出库目录', DETAIL: '获取出库详情', DETAILS: '获取出库详情', INVENTORY: '获取产品库存', VALIDATE: '校验', VALIDATION: '校验', MERGE: '更新本仓出库数据', BUILD: '统计计算', PUBLISH: '发布', WAIT_UPSTREAM: '等待账号共享上游', WAIT_BUDGET: '预算等待', WAITING_SOURCE: '等待来源准备', WAITING: '等待执行', SAMPLE_DONE: '样本结束', FAILED: '执行失败', FETCH: '获取数据', PLANNED: '已规划', FETCHED: '数据已取完', VALIDATED: '已校验', BUILDING: '统计计算', BUILT: '计算完成', PUBLISHED: '已发布' } + const phases: Record = { DIRECTORY: '获取出库目录', DISCOVERY: '获取出库目录', DETAIL: '处理出库订单', DETAILS: '处理出库订单', INVENTORY: '获取产品库存', VALIDATE: '校验', VALIDATION: '校验', MERGE: '更新本仓出库数据', BUILD: '统计计算', PUBLISH: '发布', WAIT_UPSTREAM: '等待账号共享上游', WAIT_BUDGET: '预算等待', WAITING_SOURCE: '等待来源准备', WAITING: '等待执行', SAMPLE_DONE: '样本结束', FAILED: '执行失败', FETCH: '获取数据', PLANNED: '已规划', FETCHED: '数据已取完', VALIDATED: '已校验', BUILDING: '统计计算', BUILT: '计算完成', PUBLISHED: '已发布' } return phase ? phases[phase] ?? '阶段待核对' : '阶段待确认' } export function label(value: string | null | undefined) { return value ? statusLabels[value] ?? '待核对' : '—' } -- 2.54.0 From 3f728cd0b2c8392960ad9487fd10e991282ea1ee Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 09:23:29 +0800 Subject: [PATCH 25/57] fix(selfop): render inventory turnover cells and data status --- .../components/AnalyticsView.vue | 6 +- .../components/InventoryTurnoverView.test.ts | 52 ++++++++++- .../components/InventoryTurnoverView.vue | 88 +++++++++++++------ src/modules/self-operated/operation-matrix.md | 3 + .../self-operated/pages/InventoryView.test.ts | 9 +- .../self-operated/pages/InventoryView.vue | 18 +++- .../self-operated/turnoverPresentation.ts | 18 +++- 7 files changed, 158 insertions(+), 36 deletions(-) diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue index 05b3f386..39a6e834 100644 --- a/src/modules/self-operated/components/AnalyticsView.vue +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -18,6 +18,7 @@ import { RequestFence, RuntimePoller, contextParams, hasActiveTask, label, beiji import SkuTrend from './SkuTrend.vue' import { expired, inventoryExpired, parseDashboardDrilldown, previousDate, type DashboardDrilldown } from '../dashboardNavigation' +const emit = defineEmits<{ warehouseSelected: [code: string] }>() const props = defineProps<{ mode: 'sales' | 'inventory' }>() const route = useRoute() const router = useRouter() @@ -74,7 +75,7 @@ const sourceAccount = 'lingxing-primary' const warehouseCode = computed(() => String(filters.value.warehouseCode ?? '')) let selectedWarehouse = warehouseCode.value function defaultWarehouse() { return overview.value?.warehouses.find(row => row.warehouseCode)?.warehouseCode ?? '' } -watch(warehouseCode, code => { if (code) selectedWarehouse = code }) +watch(warehouseCode, code => { if (code) { selectedWarehouse = code; emit('warehouseSelected', code) } }, { immediate: true }) const fields = computed(() => { const result: QueryField[] = [ { key: 'warehouseCode', type: 'select', label: '仓库', width: 210, placeholder: '请选择仓库(必选)', props: { clearable: false }, options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] }, @@ -84,7 +85,7 @@ const fields = computed(() => { { key: 'salesWindowDays', type: 'select', label: '销量窗口', width: 150, props: { clearable: false }, options: [{ value: 10, label: '10个完整自然日' }, { value: 30, label: '30个完整自然日' }] }, { key: 'salesWindowEndDate', type: 'date', label: '统计截止日(含)', width: 180, props: { clearable: false } }, { key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, ...(defaultInventoryWindow.value ? [{ value: 'NO_OUTBOUND_30D', label: '近30天无出库' }] : []), { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] }, - { key: 'threshold', type: 'input', label: '可售天数阈值', width: 160, placeholder: '可售天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } }, + { key: 'threshold', type: 'input', label: '可售天数阈值', width: 180, placeholder: '天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } }, { key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, ...(defaultInventoryWindow.value ? [{ value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }] : [])] }, ) else { @@ -283,6 +284,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu

没有自营经营数据查看权限。

+ diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index 0afd7f85..42968086 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -92,3 +92,6 @@ | 数据采集/账号运行看板排版 | 用户本轮截图及此前原型:环图在左,三项数量与预计时间在右,下方阶段流程;当前右侧安排栏挤占进度区 | 主进度改为通栏,三项数字连续排列、预计时间对齐数字区域;后续安排移至阶段下方。使用现有 Admin 白底、蓝紫强调和统一间距,移除数量卡片渐变装饰;刷新仍在标题右侧,接口错误与未知数据如实保留 | 仅前端布局;待人工1920×1080、100%截图确认 | | 数据采集/后续安排位置(2026-10-14) | 用户截图反馈等待处理被移至阶段栏下方;工作区改动将主区域变为单列 | 恢复左侧当前进度、右侧290px后续安排双列,处理阶段保持下方整行;保留自动采集计划和其他业务信息 | 定向测试通过;待人工1920×1080、100%截图确认 | + +| 库存周转/概览与明细(2026-10-14) | 用户截图要求优化样式,并出现对象文本、英文状态、空白出库列 | 概览移出筛选区,四色指标卡区分主数值与核验提示,图表白底分组、空数据提示,快捷筛选显示选中状态;统计说明移入右上角信息气泡。修正 DataTable slotName 契约,趋势及证据弹框根部挂载 | 自动验证另列;待人工1920×1080、100%截图确认 | +| 库存分析/视图切换 | 用户示例要求切换置于搜索内 | 库存周转与库存明细共用下划线导航,经 view-tabs 插槽放入各自表格搜索区顶部;切换保留当前仓库,清除另一视图专属条件 | 待人工1920×1080、100%截图确认 | diff --git a/src/modules/self-operated/pages/InventoryView.test.ts b/src/modules/self-operated/pages/InventoryView.test.ts index 39a3a763..eb49c13d 100644 --- a/src/modules/self-operated/pages/InventoryView.test.ts +++ b/src/modules/self-operated/pages/InventoryView.test.ts @@ -5,15 +5,22 @@ import InventoryView from './InventoryView.vue' const mocks = vi.hoisted(() => ({ route: { query: {} as Record }, push: vi.fn() })) vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) })) const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } }) -const options = { global: { stubs: { AnalyticsView: true, InventoryTurnoverView: true, 'el-button': button } } } +const options = { global: { stubs: { AnalyticsView: defineComponent({ name: 'AnalyticsView', props: ['mode'], setup(_, { slots }) { return () => h('section', { class: 'search-region' }, slots['view-tabs']?.()) } }), InventoryTurnoverView: defineComponent({ name: 'InventoryTurnoverView', setup(_, { slots }) { return () => h('section', { class: 'search-region' }, slots['view-tabs']?.()) } }), 'el-button': button } } } beforeEach(() => { mocks.route = reactive({ query: {} }); mocks.push.mockReset() }) describe('inventory view entry', () => { it('defaults to turnover and switches to detail without reusing turnover filters', async () => { mocks.route.query = { warehouseCode: 'US02', focus: 'FROZEN' } const w = mount(InventoryView, options) expect(w.findComponent({ name: 'InventoryTurnoverView' }).exists()).toBe(true) + expect(w.find('.search-region .inventory-views').exists()).toBe(true) + expect(w.find('button[aria-pressed="true"]').text()).toBe('库存周转') await w.findAll('button').find(b => b.text() === '库存明细')!.trigger('click') expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/inventory', query: { view: 'DETAIL', warehouseCode: 'US02' } }) + mocks.route.query = { view: 'DETAIL', warehouseCode: 'US02' }; await flushPromises() + expect(w.find('button[aria-pressed="true"]').text()).toBe('库存明细') + w.findComponent({ name: 'AnalyticsView' }).vm.$emit('warehouseSelected', 'US01') + await w.findAll('button').find(b => b.text() === '库存周转')!.trigger('click') + expect(mocks.push).toHaveBeenLastCalledWith({ path: '/self-operated/inventory', query: { view: 'TURNOVER', warehouseCode: 'US01' } }) w.unmount() }) it('preserves original strict detail links and rejects contradictory parameters', async () => { diff --git a/src/modules/self-operated/pages/InventoryView.vue b/src/modules/self-operated/pages/InventoryView.vue index b80abcff..64512c6c 100644 --- a/src/modules/self-operated/pages/InventoryView.vue +++ b/src/modules/self-operated/pages/InventoryView.vue @@ -16,9 +16,19 @@ function switchView(view: 'TURNOVER' | 'DETAIL') { } - + diff --git a/src/modules/self-operated/turnoverPresentation.ts b/src/modules/self-operated/turnoverPresentation.ts index f1e828a0..7c3a3df3 100644 --- a/src/modules/self-operated/turnoverPresentation.ts +++ b/src/modules/self-operated/turnoverPresentation.ts @@ -1,4 +1,4 @@ -import type { Quantity } from './types' +import type { Quantity, WarehouseOverview } from './types' import type { TurnoverCharts, TurnoverTotal, TurnoverWindowMetric } from './turnoverTypes' import type { EChartsOption } from 'echarts' import { reasonsText } from './state' @@ -21,6 +21,22 @@ export function totalText(total: TurnoverTotal) { if (total.value == null && total.knownSubtotal == null && total.unknownSkuCount === 0) return turnoverReasonsText(total.reasonCodes) return total.value != null ? turnoverNumber(total.value) : `已知 ${turnoverNumber(total.knownSubtotal)};${total.unknownSkuCount} SKU待核验` } +export function turnoverTotalNote(total: TurnoverTotal) { + if (total.value != null) return '已确认' + const known = total.knownSubtotal == null ? '暂无已知小计' : `已知小计 ${turnoverNumber(total.knownSubtotal)}` + return `${known} · ${total.unknownSkuCount} SKU待核验` +} +export function inventoryCollectionNotice(warehouse: WarehouseOverview | undefined) { + const task = warehouse?.currentTask + if (!task || !['DEAD', 'RETRY_WAIT'].includes(task.taskStatus) || task.latestAttempt?.status !== 'FAILED') return '' + const reasons: Record = { + SELFOP_STOCK_DUPLICATE: '库存分页出现重复记录', + SELFOP_STOCK_COUNT_MISMATCH: '库存扫描条数与来源总数不符', + SELFOP_PAGE_TOTAL_CHANGED: '库存扫描过程中分页总数变化', + } + const reason = reasons[task.latestAttempt.errorCode ?? ''] ?? '库存采集未成功' + return `${task.taskStatus === 'RETRY_WAIT' ? '库存采集失败,等待重试' : '库存采集失败,已停止自动重试'}:${reason}。任务 ${task.taskNo}` +} export function windowText(metric: TurnoverWindowMetric | undefined) { if (!metric) return '—' return metric.dataStatus === 'COMPLETE' ? turnoverNumber(metric.outboundQuantity) : `已采集 ${turnoverNumber(metric.observedQuantity)},覆盖不足` -- 2.54.0 From e0f54edcc932b9d5cc12e5e469318e0320b56b1d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 11:02:56 +0800 Subject: [PATCH 26/57] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/self-operated/AGENTS.md | 3 +- .../components/AnalyticsChart.vue | 4 +- .../components/AnalyticsView.vue | 23 +++-- .../CollectionAccountMonitor.test.ts | 1 + .../components/CollectionAccountMonitor.vue | 30 +++--- .../components/CollectionProgressGauge.vue | 12 ++- .../components/CollectionTaskProgress.test.ts | 16 ++++ .../components/CollectionTaskProgress.vue | 2 +- .../InventoryRankingDiagnostics.vue | 6 +- .../components/InventoryTurnoverView.test.ts | 90 +++++++++++++++++- .../components/InventoryTurnoverView.vue | 91 +++++++++++++++---- .../self-operated/dashboardChartOptions.ts | 9 +- .../self-operated/dashboardNavigation.test.ts | 9 +- .../self-operated/dashboardNavigation.ts | 12 ++- src/modules/self-operated/operation-matrix.md | 26 +++++- .../self-operated/pages/OverviewView.test.ts | 32 ++++++- .../self-operated/pages/OverviewView.vue | 25 +++-- .../self-operated/pages/dashboard.fixture.ts | 6 +- .../self-operated/pages/workbench.test.ts | 17 ++-- .../self-operated/turnoverNavigation.ts | 3 +- .../self-operated/turnoverPresentation.ts | 4 +- src/modules/self-operated/turnoverSorting.ts | 22 +++++ src/modules/self-operated/turnoverTypes.ts | 3 +- src/modules/self-operated/types.ts | 11 +-- 24 files changed, 352 insertions(+), 105 deletions(-) create mode 100644 src/modules/self-operated/turnoverSorting.ts diff --git a/src/modules/self-operated/AGENTS.md b/src/modules/self-operated/AGENTS.md index f6a48f2a..cdd9bd6a 100644 --- a/src/modules/self-operated/AGENTS.md +++ b/src/modules/self-operated/AGENTS.md @@ -1,7 +1,8 @@ # 自营经营分析 - 模块入口 `routes.ts`;正式页面 `pages/OverviewView.vue`、`SalesView.vue`、`InventoryView.vue`、`CollectionView.vue`。根路由、导航、IAM 由总控装配。 -- 经营总览唯一设计入口 `docs/自营业务/经营总览图表设计.md`;`analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;时间筛选同时影响区间出库、日趋势与热销榜,热销按已采集量排名并标明覆盖不足,库存评估仍使用固定30日。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD`,过期禁止冒充原图结果。不得恢复货主独立筛选或面板。 +- 文档路径从工作区根起算;本目录代码路径从当前模块起算。总览设计变更查 `docs/自营业务/经营总览图表设计.md`,采集页面变更查 `docs/自营业务/数据采集页面设计.md`;纯文案或局部代码定位不默认加载设计全文。 +- `analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;时间筛选统一影响区间出库、日均、日趋势、库存分布及三个榜单,热销按已采集量排名并标明覆盖不足,库存评估按所选区间完整销量及观察资格计算;库存数量仍取固定当前版本。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD` 并从原图上下文传递 CUSTOM 起止日期,不能混用普通库存的10/30日窗口,过期禁止冒充原图结果。不得恢复货主独立筛选或面板。 - API 只经统一 `shared/api/http.ts` 消费 Lastleg 的平台自营统计合同;不得调用 Integration 或持有来源凭据。 - 北京时间 `Asia/Shanghai`,每 SKU 件数 `PCS`;货主不合并同名 SKU。未知数量保留空值,库存可售天数只用服务端结果。 - 经营查询固定服务端版本和 queryAsOf;显式刷新/切仓重新取上下文,轮询只读运行状态。请求取消和世代校验共同防迟到响应。 diff --git a/src/modules/self-operated/components/AnalyticsChart.vue b/src/modules/self-operated/components/AnalyticsChart.vue index a06d99e5..7d9fe572 100644 --- a/src/modules/self-operated/components/AnalyticsChart.vue +++ b/src/modules/self-operated/components/AnalyticsChart.vue @@ -6,9 +6,9 @@ import { BarChart, GaugeChart, LineChart } from 'echarts/charts' import { AriaComponent, DataZoomComponent, GridComponent, TooltipComponent } from 'echarts/components' import { CanvasRenderer } from 'echarts/renderers' use([LineChart, BarChart, GaugeChart, GridComponent, TooltipComponent, DataZoomComponent, AriaComponent, CanvasRenderer]) -defineProps<{ option: EChartsOption }>() +defineProps<{ option: EChartsOption; mergeUpdates?: boolean }>() defineEmits<{ select: [event: ECElementEvent] }>() diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue index 39a6e834..cb0c7342 100644 --- a/src/modules/self-operated/components/AnalyticsView.vue +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -38,7 +38,6 @@ function initialFilters(): QueryModel { return { warehouseCode: typeof route.que const filters = ref(initialFilters()) const applied = ref({ sku: String(filters.value.sku) }) const appliedWindow = ref({ salesWindowDays: 30 as 10 | 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }) -const defaultInventoryWindow = computed(() => Number(filters.value.salesWindowDays) === 30 && filters.value.salesWindowEndDate === previousDate(context.value?.anchorDate ?? beijingToday())) const displayedWindowDays = computed(() => context.value?.calculationWindowDays ?? appliedWindow.value.salesWindowDays) const overview = ref(null) const context = ref(drilldown.value?.context ?? null) @@ -82,11 +81,11 @@ const fields = computed(() => { { key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' }, ] if (props.mode === 'inventory') result.push( - { key: 'salesWindowDays', type: 'select', label: '销量窗口', width: 150, props: { clearable: false }, options: [{ value: 10, label: '10个完整自然日' }, { value: 30, label: '30个完整自然日' }] }, - { key: 'salesWindowEndDate', type: 'date', label: '统计截止日(含)', width: 180, props: { clearable: false } }, - { key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, ...(defaultInventoryWindow.value ? [{ value: 'NO_OUTBOUND_30D', label: '近30天无出库' }] : []), { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] }, + ...(!drilldown.value ? [{ key: 'salesWindowDays', type: 'select' as const, label: '销量窗口', width: 150, props: { clearable: false }, options: [{ value: 10, label: '10个完整自然日' }, { value: 30, label: '30个完整自然日' }] }, + { key: 'salesWindowEndDate', type: 'date' as const, label: '统计截止日(含)', width: 180, props: { clearable: false } }] : [{ key: 'customRange', type: 'daterange' as const, label: '原图统计区间', width: 300 }]), + { key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] }, { key: 'threshold', type: 'input', label: '可售天数阈值', width: 180, placeholder: '天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } }, - { key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, ...(defaultInventoryWindow.value ? [{ value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }] : [])] }, + { key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, { value: 'OUTBOUND_ASC', label: '区间出库从低到高' }] }, ) else { result.push({ key: 'analysis', type: 'select', label: '销量类别', width: 170, props: { clearable: false }, options: [{ value: 'summary', label: 'SKU销量' }, { value: 'hot', label: '热销Top100' }, { value: 'slow', label: '滞销Top100' }] }) @@ -137,7 +136,11 @@ function query(): InventoryQuery { if (drilldown.value?.analysis === 'hot') Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate }) else result.windowDays = windowDays.value } - if (props.mode === 'inventory') Object.assign(result, appliedInventory.value, appliedWindow.value, drilldown.value?.inventory) + if (props.mode === 'inventory') { + Object.assign(result, appliedInventory.value, drilldown.value?.inventory) + if (drilldown.value) Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate }) + else Object.assign(result, appliedWindow.value) + } return result } async function load(reset = false, keepContext = false) { @@ -214,16 +217,12 @@ function applySalesRange(anchor?: string) { function search() { if (summaryMode.value && !applySalesRange(origin.value?.context.anchorDate ?? beijingToday())) return let nextWindow = appliedWindow.value - if (props.mode === 'inventory') { + if (props.mode === 'inventory' && !drilldown.value) { const end = String(filters.value.salesWindowEndDate ?? '') const days = Number(filters.value.salesWindowDays) const problem = validateCustomRange(end, end, previousDate(origin.value?.context.anchorDate ?? beijingToday())) if (problem || ![10, 30].includes(days)) { error.value = problem || '请选择10天或30天销量窗口'; return } nextWindow = { salesWindowDays: days as 10 | 30, salesWindowEndDate: end } - if (!defaultInventoryWindow.value) { - if (attention.value === 'NO_OUTBOUND_30D') filters.value.attention = 'ALL' - if (sortBy.value === 'OUTBOUND_30D_ASC') filters.value.sortBy = 'AVAILABLE_DESC' - } } const rawThreshold = thresholdInput.value.trim() if (props.mode === 'inventory' && ((rawThreshold && (!/^\d+$/.test(rawThreshold) || !Number.isSafeInteger(Number(rawThreshold)) || Number(rawThreshold) <= 0)) || (attention.value === 'COVERAGE_GT_THRESHOLD' && !rawThreshold))) { @@ -303,7 +302,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu

销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。

-

{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按所选截止日及10/30个完整自然日计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。

+

{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。

预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口无出库时不推算有限天数。实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。

查询时间:{{ beijingTime(context.queryAsOf) }};有效至 {{ beijingTime(context.queryExpiresAt) }}。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }}。{{ context.rebuildRequired ? '数据修订待重建。' : '' }}

实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含)。

diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.test.ts b/src/modules/self-operated/components/CollectionAccountMonitor.test.ts index 2e0a09b7..e1de39e5 100644 --- a/src/modules/self-operated/components/CollectionAccountMonitor.test.ts +++ b/src/modules/self-operated/components/CollectionAccountMonitor.test.ts @@ -69,6 +69,7 @@ describe('single account collection monitor', () => { const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview(), stale: true } }) expect(w.text()).toContain('339'); expect(w.text()).toContain('状态更新失败') expect(w.text()).toContain('已暂停估算') + expect(w.find('.account-flow li[data-state="active"]').exists()).toBe(false) w.unmount() }) }) diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.vue b/src/modules/self-operated/components/CollectionAccountMonitor.vue index a63aba87..834abffd 100644 --- a/src/modules/self-operated/components/CollectionAccountMonitor.vue +++ b/src/modules/self-operated/components/CollectionAccountMonitor.vue @@ -67,7 +67,7 @@ function nodeState(phases: string[]) { if (!p) return 'idle' const evidence = [p, ...(p.stages ?? [])].find(s => phases.includes(s.phase ?? '') && s.attemptNo != null && s.attemptNo === p.attemptNo && s.scanRound === p.scanRound && s.sliceFrom === p.sliceFrom && s.sliceUntil === p.sliceUntil && s.progressScope === p.progressScope && s.stageFinishedAt) if (evidence) return 'done' - return phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle' + return !props.stale && phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle' } -
+
最近推进 {{ beijingTime(current?.progressUpdatedAt) }}状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间
diff --git a/src/modules/self-operated/components/CollectionProgressGauge.vue b/src/modules/self-operated/components/CollectionProgressGauge.vue index 24815aaf..37a88994 100644 --- a/src/modules/self-operated/components/CollectionProgressGauge.vue +++ b/src/modules/self-operated/components/CollectionProgressGauge.vue @@ -4,7 +4,7 @@ import AnalyticsChart from './AnalyticsChart.vue' import { progressGaugeOption } from '../dashboardChartOptions' import type { Progress } from '../types' import { beijingTime, phaseText, quantityText, unitText } from '../state' -const props = defineProps<{ progress: Partial | null | undefined; taskNo: string; stale?: boolean; compact?: boolean }>() +const props = defineProps<{ progress: Partial | null | undefined; taskNo: string; stale?: boolean; compact?: boolean; running?: boolean }>() const identity = computed(() => JSON.stringify([props.taskNo, props.progress?.progressScope, props.progress?.countBasis, props.progress?.attemptNo, props.progress?.phase, props.progress?.scanRound, props.progress?.sliceFrom, props.progress?.sliceUntil, props.progress?.unit])) const changedTotal = ref(false) watch([identity, () => props.progress?.total], ([id, total], [oldId, oldTotal]) => { changedTotal.value = id === oldId ? changedTotal.value || (oldTotal != null && total != null && total !== oldTotal) : false }) @@ -16,14 +16,15 @@ const counts = computed(() => { return { remaining: total - done, percent: Number(BigInt(done) * 1000n / BigInt(total)) / 10 } }) const scope = computed(() => props.progress?.progressScope === 'ACCOUNT_SHARED' ? '账号共享' : props.progress?.progressScope === 'WAREHOUSE' ? '本仓' : '范围待确认') -const option = computed(() => progressGaugeOption(counts.value?.percent ?? null)) +const indeterminate = computed(() => Boolean(props.running && !props.stale && !counts.value)) +const option = computed(() => progressGaugeOption(counts.value?.percent ?? null, indeterminate.value)) const readingDirectory = computed(() => !props.progress?.totalKnown && ['DIRECTORY', 'DISCOVERY'].includes(props.progress?.phase ?? ''))

{{ task.blockingReason ? blockingText : task.latestAttempt?.failureSummary }}

- +
{{ compact && !stale && visible !== false && task.blockingReason ? blockingText : estimateText }}{{ task.latestAttempt.failureSummary }}最近速度 {{ quantityText(estimate.ratePerSecond) }} {{ unitText(progress?.unit) }}/秒;估算更新 {{ beijingTime(estimate.estimatedAt) }}仅当前阶段;整个任务完成时间尚未估算。

任务累计耗时 {{ taskElapsed == null ? '—' : `${Math.floor(taskElapsed / 60)} 分 ${taskElapsed % 60} 秒` }}(含排队与重试)

diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue index 0633d7bb..7e3ecc76 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue @@ -274,6 +274,28 @@ onMounted(load) +

合箱记录

+ + + + + + + +

当前关联实物箱

+ + + +

来源单:{{ current.carton.sourceReturnNo }} · 状态:{{ current.carton.status }} · 我方箱唛:{{ current.labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}

+ + + + + +

{{ current.canRelabel ? '后续贴标按此当前箱唛选择整箱;跨来源单同 SKU 的部分贴标须先现场分拣确认。' : current.blockReason }}

+
+
+ diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts b/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts index 6ad71fc8..18e19759 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts +++ b/src/modules/overseas-warehouse/pages/OutboundsView.selection.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import OutboundsView from './OutboundsView.vue' import { listPlatformTenants } from '@/modules/iam' -const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn(), returnCandidates: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn() })) +const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn(), returnCandidates: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn(), transfers: vi.fn(), bindDestination: vi.fn() })) vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.carton })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) @@ -14,6 +14,7 @@ vi.mock('../api/outbounds', () => ({ listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }), listOneClickOutboundCandidates: api.candidates, createOneClickDropshipOutbound: api.create, + listWarehouseTransferOutboundCandidates: api.transfers, bindWarehouseTransferDestination: api.bindDestination, cancelOutbound: vi.fn(), createReturnOutbound: vi.fn(), listReturnOutboundCandidates: api.returnCandidates, })) @@ -84,6 +85,8 @@ describe('outbound candidate search and selection', () => { vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined }) api.candidates.mockReset().mockResolvedValue(fixtures.map(item => ({ ...item }))) api.create.mockReset().mockResolvedValue({}) + api.transfers.mockReset().mockResolvedValue([]) + api.bindDestination.mockReset().mockResolvedValue([]) api.warehouses.mockReset().mockResolvedValue({ records: [ { id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' }, { id: 'warehouse-b', warehouseName: '纽约仓', warehouseCode: 'US-NY-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' }, @@ -91,6 +94,40 @@ describe('outbound candidate search and selection', () => { }) afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() }) + it('groups pallet candidates by customer and transfer and binds each selected transfer once', async () => { + const transfer = { customerId: 'customer-a', transferNo: 'WT-TEST', overseasWarehouseId: 'warehouse-a', transferStatus: 'WAIT_FBA_DESTINATION', needRelabel: false } + api.transfers.mockResolvedValue([ + { ...transfer, packageCount: 4, volumeCbm: 0.15 }, + { ...transfer, packageCount: 6, volumeCbm: 0.08 }, + { ...transfer, customerId: 'customer-b', packageCount: 2, volumeCbm: 0.1 }, + ]) + api.warehouses.mockResolvedValue({ records: [ + { id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' }, + { id: 'fba-a', warehouseName: 'ABE5', warehouseCode: 'ABE5', warehouseType: 'FBA', status: 'ACTIVE' }, + ] }) + await openCreate() + const type = wrapper.findComponent({ name: 'ElRadioGroup' }) + type.vm.$emit('update:modelValue', 'WAREHOUSE_TRANSFER_OUTBOUND') + type.vm.$emit('change') + await flushPromises() + const table = wrapper.findAllComponents({ name: 'ElTable' }).find(row => row.classes().includes('dropship-candidate-table'))! + expect(table.props('data')).toEqual([ + expect.objectContaining({ customerId: 'customer-a', packageCount: 10, volumeCbm: 0.23 }), + expect.objectContaining({ customerId: 'customer-b', packageCount: 2, volumeCbm: 0.1 }), + ]) + for (const checkbox of table.findAllComponents(ElCheckbox)) checkbox.vm.$emit('change', true) + const destination = wrapper.findAllComponents(ElSelect).find(row => row.props('placeholder') === '请选择FBA目的仓')! + destination.vm.$emit('update:modelValue', 'fba-a') + await flushPromises() + await wrapper.find('[data-test="submit-outbound"]').trigger('click') + await flushPromises() + expect(api.bindDestination).toHaveBeenCalledOnce() + expect(api.bindDestination).toHaveBeenCalledWith(expect.objectContaining({ transfers: [ + { customerId: 'customer-a', transferNo: 'WT-TEST' }, + { customerId: 'customer-b', transferNo: 'WT-TEST' }, + ], destinationWarehouseCode: 'ABE5' })) + }) + it('offers enabled customers from later pages when creating a return outbound', async () => { vi.mocked(listPlatformTenants) .mockResolvedValueOnce({ records: [{ id: 'customer-a', tenantName: '柏悦物流demo2', status: 'ACTIVE' }], page: 1, size: 200, total: 201, pages: 2 } as Awaited>) diff --git a/src/modules/overseas-warehouse/pages/OutboundsView.vue b/src/modules/overseas-warehouse/pages/OutboundsView.vue index 4adc9670..27fe7eaf 100644 --- a/src/modules/overseas-warehouse/pages/OutboundsView.vue +++ b/src/modules/overseas-warehouse/pages/OutboundsView.vue @@ -138,7 +138,19 @@ async function changeOutboundType() { selectedReturnNo.value = ''; selectedRetur async function loadTransferCandidates() { inventoryLoading.value = true try { - transferCandidates.value = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined }) + const pallets = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined }) + // The API returns pallet rows, but destination binding applies to the whole transfer. + const transfers = new Map() + for (const pallet of pallets) { + const key = transferSelectionKey(pallet) + const transfer = transfers.get(key) + if (transfer) { + transfer.packageCount += pallet.packageCount + transfer.grossWeightKg = sumTransferMeasurement(transfer.grossWeightKg, pallet.grossWeightKg) + transfer.volumeCbm = sumTransferMeasurement(transfer.volumeCbm, pallet.volumeCbm) + } else transfers.set(key, { ...pallet }) + } + transferCandidates.value = [...transfers.values()] const candidateKeys = new Set(transferCandidates.value.map(transferSelectionKey)) selectedTransferPalletIds.value = selectedTransferPalletIds.value.filter(key => candidateKeys.has(key)) if (transferCandidates.value.length === 1) selectedTransferPalletIds.value = [transferSelectionKey(transferCandidates.value[0])] @@ -149,6 +161,9 @@ async function loadTransferCandidates() { } finally { inventoryLoading.value = false } } function transferSelectionKey(row: WarehouseTransferOutboundCandidate) { return `${row.customerId}:${row.transferNo}` } +function sumTransferMeasurement(left?: number | null, right?: number | null) { + return left == null && right == null ? null : Number(((left ?? 0) + (right ?? 0)).toFixed(6)) +} function toggleTransferPallet(row: WarehouseTransferOutboundCandidate, checked: boolean | string | number) { const key = transferSelectionKey(row); selectedTransferPalletIds.value = checked ? [...new Set([...selectedTransferPalletIds.value, key])] : selectedTransferPalletIds.value.filter(id => id !== key) } function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) { if (row.destinationType === 'PRIVATE' || row.privateDestinationAddress?.trim()) { diff --git a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue index 89476278..1b9e77db 100644 --- a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue +++ b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue @@ -177,9 +177,18 @@ function openPlanDetail(row: Record) { planDetail.value = planRows.value.find(plan => plan.planNo === row.planNo); planDetailOpen.value = !!planDetail.value; } -const planCartonItemRows = computed(() => (planDetail.value?.resultCartons || []).flatMap(carton => +const planCartonItemRows = computed(() => (planDetail.value?.completionCartons || []).flatMap(carton => carton.items.map(item => ({ ...carton, skuCode: item.skuCode, quantity: item.quantity })), )); +const planSourceRows = computed(() => (planDetail.value?.sources || []).flatMap(source => { + const original = source.originalItems?.length ? source.originalItems : source.items; + return original.map(item => { + const take = source.items.find(line => line.skuCode === item.skuCode)?.quantity || 0; + return { cartonMark: source.cartonMark, sourceReturnNo: source.sourceReturnNo, participation: source.participation, + skuCode: item.skuCode, originalQuantity: source.originalItems?.length ? item.quantity : null, take, + remaining: source.originalItems?.length ? item.quantity - take : null }; + }); +})); const plannedItemRows = computed(() => (planDetail.value?.boxes || []).flatMap((box, index) => (box.items || []).map(item => ({ boxNo: index + 1, skuCode: item.skuCode, quantity: item.quantity })), )); @@ -858,12 +867,34 @@ const tableColumns = computed(() => >{{ planDetail.remark || "—" }} + > +

计划来源箱明细

+ + + + + + + + + + + - - + -- 2.54.0 From 6b30e35d0718cef0ab8c1f1b572f168caa9320ca Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 14 Oct 2026 12:14:29 +0800 Subject: [PATCH 30/57] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packing-acceptance.local.html | 16 ---------------- .../pages/ReturnInventoryManagementView.vue | 7 +++++-- 2 files changed, 5 insertions(+), 18 deletions(-) delete mode 100644 packing-acceptance.local.html diff --git a/packing-acceptance.local.html b/packing-acceptance.local.html deleted file mode 100644 index cbb4fece..00000000 --- a/packing-acceptance.local.html +++ /dev/null @@ -1,16 +0,0 @@ -本地合箱接口验收 -

本地合箱接口验收

复用当前平台会话,只调用正式业务接口。

- -

-
diff --git a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue
index 1b9e77db..9a2fc900 100644
--- a/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue
+++ b/src/modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue
@@ -182,9 +182,11 @@ const planCartonItemRows = computed(() => (planDetail.value?.completionCartons |
 ));
 const planSourceRows = computed(() => (planDetail.value?.sources || []).flatMap(source => {
   const original = source.originalItems?.length ? source.originalItems : source.items;
+  const existingTarget = planDetail.value?.status === 'COMPLETED' && planDetail.value.completionCartons?.some(carton => carton.cartonMark === source.cartonMark);
   return original.map(item => {
-    const take = source.items.find(line => line.skuCode === item.skuCode)?.quantity || 0;
+    const take = existingTarget ? 0 : source.items.find(line => line.skuCode === item.skuCode)?.quantity || 0;
     return { cartonMark: source.cartonMark, sourceReturnNo: source.sourceReturnNo, participation: source.participation,
+      role: existingTarget ? '目标原箱(保留)' : '取出来源',
       skuCode: item.skuCode, originalQuantity: source.originalItems?.length ? item.quantity : null, take,
       remaining: source.originalItems?.length ? item.quantity - take : null };
   });
@@ -872,6 +874,7 @@ const tableColumns = computed(() =>
         
           
           
+          
           
           
           
@@ -896,7 +899,7 @@ const tableColumns = computed(() =>
           
           
           
-          
+          
         
         
           
-- 
2.54.0


From e7411eb03f293a52cf89b99c487ad860aac5e307 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 14 Oct 2026 12:39:16 +0800
Subject: [PATCH 31/57] feat(self-operated): unify completed-day ranges and
 analytics sorting

---
 .../components/AnalyticsView.vue              | 174 +++++++++++++-----
 .../components/InventoryTurnoverView.test.ts  |   9 +-
 .../components/InventoryTurnoverView.vue      |  43 +++--
 .../self-operated/dashboardNavigation.ts      |   5 +-
 src/modules/self-operated/inventorySorting.ts |  16 ++
 src/modules/self-operated/operation-matrix.md |  10 +-
 .../self-operated/overviewRange.test.ts       |  23 +++
 src/modules/self-operated/overviewRange.ts    |  26 +++
 .../self-operated/pages/OverviewView.test.ts  |  66 +++++--
 .../self-operated/pages/OverviewView.vue      |  27 ++-
 .../self-operated/pages/workbench.test.ts     | 125 ++++++++++---
 src/modules/self-operated/state.test.ts       |   6 +-
 src/modules/self-operated/state.ts            |   4 +-
 .../self-operated/turnoverNavigation.test.ts  |   2 +-
 .../self-operated/turnoverNavigation.ts       |   4 +-
 src/modules/self-operated/turnoverTypes.ts    |   6 +-
 src/modules/self-operated/types.ts            |  15 +-
 17 files changed, 426 insertions(+), 135 deletions(-)
 create mode 100644 src/modules/self-operated/inventorySorting.ts
 create mode 100644 src/modules/self-operated/overviewRange.test.ts
 create mode 100644 src/modules/self-operated/overviewRange.ts

diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue
index 2fad1123..fc0e584b 100644
--- a/src/modules/self-operated/components/AnalyticsView.vue
+++ b/src/modules/self-operated/components/AnalyticsView.vue
@@ -17,6 +17,9 @@ import type { BusinessQuery, DailyResponse, InventoryAttention, InventoryMetric,
 import { RequestFence, RuntimePoller, contextParams, hasActiveTask, label, beijingTime, reasonsText, quantityText, sameContext, shownQuantity, validateCustomRange } from '../state'
 import SkuTrend from './SkuTrend.vue'
 import { expired, inventoryExpired, parseDashboardDrilldown, previousDate, type DashboardDrilldown } from '../dashboardNavigation'
+import { overviewRange, overviewRangeOptions } from '../overviewRange'
+import { inventorySortColumns, inventorySortOptions } from '../inventorySorting'
+import { turnoverNumber as dailyAverageText } from '../turnoverPresentation'
 
 const emit = defineEmits<{ warehouseSelected: [code: string] }>()
 const props = defineProps<{ mode: 'sales' | 'inventory' }>()
@@ -34,10 +37,10 @@ function readDrilldown() {
 }
 readDrilldown()
 function beijingToday() { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()) }
-function initialFilters(): QueryModel { return { warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: drilldown.value?.analysis ?? 'summary', rangePreset: drilldown.value?.rangePreset ?? 'TODAY', windowDays: 30, salesWindowDays: 30, salesWindowEndDate: previousDate(drilldown.value?.context.anchorDate ?? beijingToday()), attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customRange: drilldown.value?.startDate && drilldown.value?.endDate ? [drilldown.value.startDate, drilldown.value.endDate] : [] } }
+function initialFilters(): QueryModel { return { warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: drilldown.value?.analysis ?? 'summary', rangePreset: drilldown.value?.rangePreset ?? 'LAST_30_DAYS', windowDays: 30, salesWindowDays: 30, salesWindowEndDate: previousDate(drilldown.value?.context.anchorDate ?? beijingToday()), attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customRange: drilldown.value?.startDate && drilldown.value?.endDate ? [drilldown.value.startDate, drilldown.value.endDate] : [] } }
 const filters = ref(initialFilters())
 const applied = ref({ sku: String(filters.value.sku) })
-const appliedWindow = ref({ salesWindowDays: 30 as 10 | 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) })
+const appliedWindow = ref({ salesWindowDays: 30 as number, salesWindowEndDate: String(filters.value.salesWindowEndDate) })
 const displayedWindowDays = computed(() => context.value?.calculationWindowDays ?? appliedWindow.value.salesWindowDays)
 const overview = ref(null)
 const context = ref(drilldown.value?.context ?? null)
@@ -51,6 +54,7 @@ const attention = computed(() => (filters.value.attention || 'ALL') as Inventory
 const sortBy = computed(() => (filters.value.sortBy || 'AVAILABLE_DESC') as InventorySort)
 const thresholdInput = computed(() => String(filters.value.threshold ?? ''))
 const appliedInventory = ref<{ attention: InventoryAttention; sortBy: InventorySort; coverageDaysThreshold?: number }>({ attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' })
+const displayedInventorySort = ref(null)
 const loading = ref(false)
 const error = ref('')
 const errorDetails = ref(null)
@@ -58,14 +62,26 @@ const runtimeError = ref('')
 const newData = ref(false)
 const newDay = ref(false)
 const current = ref(1)
+let changingSortPage = false
+async function setSortPage(page: number) {
+  changingSortPage = true
+  current.value = page
+  await nextTick()
+  changingSortPage = false
+}
 const size = ref(20)
 const total = ref(0)
-const preset = computed(() => (filters.value.rangePreset || 'TODAY') as RangePreset)
+type SalesPreset = RangePreset | 'LAST_3_DAYS' | 'LAST_7_DAYS' | 'LAST_15_DAYS' | 'LAST_30_DAYS'
+const preset = computed(() => (filters.value.rangePreset || 'LAST_30_DAYS') as SalesPreset)
 const customRange = computed(() => Array.isArray(filters.value.customRange) ? filters.value.customRange : [])
 const customStart = computed(() => String(customRange.value[0] ?? ''))
 const customEnd = computed(() => String(customRange.value[1] ?? ''))
 type SalesRange = { rangePreset: RangePreset; startDate?: string; endDate?: string }
-function draftSalesRange(): SalesRange { return { rangePreset: preset.value, ...(preset.value === 'CUSTOM' ? { startDate: customStart.value, endDate: customEnd.value } : {}) } }
+function draftSalesRange(): SalesRange {
+  try { return overviewRange(preset.value, origin.value?.context.anchorDate ?? beijingToday(), customStart.value, customEnd.value) }
+  catch { return { rangePreset: 'CUSTOM', startDate: customStart.value, endDate: customEnd.value } }
+
+}
 const appliedSalesRange = ref(draftSalesRange())
 const salesRangePending = computed(() => props.mode === 'sales' && tab.value === 'summary' && JSON.stringify(draftSalesRange()) !== JSON.stringify(appliedSalesRange.value))
 const tab = computed(() => (filters.value.analysis || 'summary') as 'summary' | 'hot' | 'slow')
@@ -81,24 +97,26 @@ const fields = computed(() => {
     { key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
   ]
   if (props.mode === 'inventory') result.push(
-    ...(!drilldown.value ? [{ key: 'salesWindowDays', type: 'select' as const, label: '销量窗口', width: 150, props: { clearable: false }, options: [{ value: 10, label: '10个完整自然日' }, { value: 30, label: '30个完整自然日' }] },
-    { key: 'salesWindowEndDate', type: 'date' as const, label: '统计截止日(含)', width: 180, props: { clearable: false } }] : [{ key: 'customRange', type: 'daterange' as const, label: '原图统计区间', width: 300 }]),
     { key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] },
     { key: 'threshold', type: 'input', label: '可售天数阈值', width: 180, placeholder: '天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } },
-    { key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, { value: 'OUTBOUND_ASC', label: '区间出库从低到高' }] },
+    { key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: inventorySortOptions },
   )
   else {
     result.push({ key: 'analysis', type: 'select', label: '销量类别', width: 170, props: { clearable: false }, options: [{ value: 'summary', label: 'SKU销量' }, { value: 'hot', label: '热销Top100' }, { value: 'slow', label: '滞销Top100' }] })
-    if (tab.value === 'summary') {
-      result.push({ key: 'rangePreset', type: 'select', label: '销量日期', width: 150, props: { clearable: false }, options: [{ value: 'TODAY', label: '今日' }, { value: 'LAST_3_DAYS', label: '近3天' }, { value: 'THIS_WEEK', label: '本周' }, { value: 'LAST_15_DAYS', label: '近15天' }, { value: 'THIS_MONTH', label: '本月' }, { value: 'CUSTOM', label: '自定义' }] })
-      if (preset.value === 'CUSTOM') result.push({ key: 'customRange', type: 'daterange', label: '销量日期范围', width: 300, props: { 'start-placeholder': '开始日期', 'end-placeholder': '结束日期', 'range-separator': '至' } })
-    } else if (tab.value === 'hot') {
-      if (drilldown.value?.analysis === 'hot') result.push({ key: 'customRange', type: 'daterange', label: '热销日期范围', width: 300 })
-      else result.push({ key: 'windowDays', type: 'select', label: '热销窗口', width: 180, props: { clearable: false }, options: [{ value: 7, label: '近7个完整自然日' }, { value: 30, label: '近30个完整自然日' }] })
-    }
   }
+  result.splice(2, 0, { key: 'rangePreset', type: 'select', label: '统计时间范围', width: 180, props: { clearable: false }, options: overviewRangeOptions })
+  if (preset.value === 'CUSTOM') result.splice(3, 0, { key: 'customRange', type: 'daterange', label: '日期范围', width: 300, props: { disabledDate: (date: Date) => date.toLocaleDateString('en-CA') >= (origin.value?.context.anchorDate ?? beijingToday()) } })
+
   return drilldown.value ? result.map(field => field.key === 'warehouseCode' ? field : { ...field, props: { ...field.props, disabled: true } }) : result
 })
+const salesSort = ref<'QUANTITY_ASC' | 'QUANTITY_DESC'>('QUANTITY_DESC')
+async function sortSalesQuantity() {
+  if (loading.value || !summaryMode.value) return
+  const previous = salesSort.value, previousPage = current.value
+  salesSort.value = previous === 'QUANTITY_DESC' ? 'QUANTITY_ASC' : 'QUANTITY_DESC'
+  await setSortPage(1)
+  if (await load() === false) { salesSort.value = previous; await setSortPage(previousPage) }
+}
 const summaryMode = computed(() => props.mode === 'sales' && tab.value === 'summary')
 const columns = computed(() => [
   ...(summaryMode.value ? [{ key: 'actions', label: '操作', width: 110, slotName: 'actions', fixed: 'left' }] : []),
@@ -117,19 +135,51 @@ const columns = computed(() => [
     { key: 'inventoryCoverageDays', label: '可售天数', width: 110, slotName: 'inventoryCoverageDays' },
     { key: 'lastOutbound', label: '最近已观测出库', minWidth: 220, slotName: 'lastOutbound' },
     { key: 'attention', label: '关注原因', minWidth: 160, slotName: 'attention' },
-  ] : [{ key: 'quantity', label: summaryMode.value || tab.value === 'hot' ? '区间出库量 / 已采集量' : '完整窗口出库量', width: 200, slotName: 'quantity' }]),
+  ] : [{ key: 'quantity', label: summaryMode.value || tab.value === 'hot' ? '区间出库量 / 已采集量' : '完整窗口出库量', width: 200, slotName: 'quantity', ...(summaryMode.value ? { headerSlotName: 'salesSortHeader' } : {}) }]),
   { key: 'status', label: '数据状态', minWidth: 130, slotName: 'status' },
   { key: 'reason', label: '说明', minWidth: 220, slotName: 'reason' },
-].map(column => ['quantity', 'physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity', 'windowOutboundQuantity', 'averageDailyOutboundQuantity', 'inventoryCoverageDays'].includes(String(column.key)) ? { ...column, align: 'right' } : column))
+].map(column => ['quantity', ...Object.keys(inventorySortColumns)].includes(String(column.key)) ? {
+  ...column, align: 'right',
+  ...(props.mode === 'inventory' && inventorySortColumns[String(column.key)] ? { prop: String(column.key), headerSlotName: 'inventorySortHeader', width: Math.max(Number(column.width ?? 0), 140) } : {}),
+} : column))
+function inventorySortDirection(prop: string) {
+  const metric = inventorySortColumns[prop]
+  return displayedInventorySort.value === `${metric}_ASC` ? 'ASC' : displayedInventorySort.value === `${metric}_DESC` ? 'DESC' : undefined
+}
+async function sortInventoryColumn(prop: string) {
+  const metric = inventorySortColumns[prop]
+  if (!metric || props.mode !== 'inventory' || loading.value) return
+  const previousSort = appliedInventory.value.sortBy, previousPage = current.value
+  const nextSort: InventorySort = `${metric}_${inventorySortDirection(prop) === 'DESC' ? 'ASC' : 'DESC'}`
+  appliedInventory.value = { ...appliedInventory.value, sortBy: nextSort }
+  filters.value = { ...filters.value, sortBy: nextSort }
+  await setSortPage(1)
+  if (await load() === false) {
+    appliedInventory.value = { ...appliedInventory.value, sortBy: previousSort }
+    filters.value = { ...filters.value, sortBy: previousSort }
+    await setSortPage(previousPage)
+  }
+}
+function inventorySalesGap(row: InventoryMetric) {
+  const status = row.quantityStatuses?.windowOutboundQuantity
+  if (status === 'UNIT_UNKNOWN_OR_CONFLICT' || row.status === 'UNIT_UNKNOWN_OR_CONFLICT') return '数量单位待核验'
+  if (status === 'SALES_UNTRUSTED' || context.value?.rebuildRequired) return '新采集数据待发布'
+  if (status === 'COVERAGE_INSUFFICIENT' || row.salesCoverage === 'COVERAGE_INSUFFICIENT') return '销量窗口未采齐'
+  return '暂无完整窗口数据'
+}
+function inventoryReasonText(row: InventoryMetric) {
+  const reasons = row.reasonCodes?.filter(reason => reason !== 'OBSERVATION_INSUFFICIENT') ?? []
+  const observationNote = row.observationEligible === false ? '商品历史观察不足(仅提示,不影响完整窗口统计)' : ''
+  return [reasons.length ? reasonsText(reasons) : '', observationNote].filter(Boolean).join('、') || '—'
+}
+function inventoryMissingReason(row: InventoryMetric, coverage = false) {
+  if (coverage && row.status === 'NO_OUTBOUND_IN_WINDOW') return '所选时段无出库'
+  if (coverage && row.status?.startsWith('INVENTORY_')) return '当前可售库存待核验'
+  return inventorySalesGap(row)
+}
 const dataFence = new RequestFence()
 const dailyFence = new RequestFence()
 const runtimeFence = new RequestFence()
-function showRecentCompleteDays() {
-  const end = previousDate(context.value?.anchorDate ?? beijingToday())
-  const start = new Date(Date.parse(`${end}T00:00:00Z`) - 29 * 86400000).toISOString().slice(0, 10)
-  filters.value = { ...filters.value, rangePreset: 'CUSTOM', customRange: [start, end] }
-  void search()
-}
 function asSummary(row: unknown) { return row as SalesSummary }
 function asMetric(row: unknown) { return row as Metric }
 function asInventory(row: unknown) { return row as InventoryMetric }
@@ -137,13 +187,14 @@ function query(): InventoryQuery {
   const result: InventoryQuery = { sourceAccount, warehouseCode: warehouseCode.value, sku: applied.value.sku || undefined, current: current.value, size: size.value, ...contextParams(context.value) }
   if (drilldown.value?.customerCode) result.customerCode = drilldown.value.customerCode
   if (summaryMode.value) {
-    Object.assign(result, appliedSalesRange.value)
+    Object.assign(result, appliedSalesRange.value, { salesSort: salesSort.value })
   } else if (props.mode === 'sales' && tab.value === 'hot') {
-    if (drilldown.value?.analysis === 'hot') Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
-    else result.windowDays = windowDays.value
+    Object.assign(result, appliedSalesRange.value)
   }
+  if (props.mode === 'sales' && tab.value === 'slow') Object.assign(result, appliedSalesRange.value)
   if (props.mode === 'inventory') {
     Object.assign(result, appliedInventory.value, drilldown.value?.inventory)
+    result.sortBy = appliedInventory.value.sortBy
     if (drilldown.value) Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
     else Object.assign(result, appliedWindow.value)
   }
@@ -151,8 +202,8 @@ function query(): InventoryQuery {
 }
 async function load(reset = false, keepContext = false) {
   if (!permitted.value || !warehouseCode.value || routeProblem.value) return
-  if (origin.value && context.value && expired(context.value)) { error.value = '原图查询已过期,请返回经营总览整体刷新'; return }
-  if (drilldown.value && props.mode === 'inventory' && context.value && inventoryExpired(context.value)) { error.value = '原图库存已过期,请刷新总览'; return }
+  if (origin.value && context.value && expired(context.value)) { error.value = '原图查询已过期,请返回经营总览整体刷新'; return false }
+  if (drilldown.value && props.mode === 'inventory' && context.value && inventoryExpired(context.value)) { error.value = '原图库存已过期,请刷新总览'; return false }
   if (summaryMode.value && !appliedSalesRange.value) return
   if (reset) { if (!origin.value && !keepContext) context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
   trendOpen.value = false
@@ -172,7 +223,9 @@ async function load(reset = false, keepContext = false) {
     rows.value = result.records
     total.value = result.total
     daily.value = null
-  } catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '查询失败,请重新查询'); error.value = errorDetails.value.message } }
+    if (props.mode === 'inventory') displayedInventorySort.value = params.sortBy ?? 'AVAILABLE_DESC'
+    return true
+  } catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '查询失败,请重新查询'); error.value = errorDetails.value.message; return false } }
   finally { if (request.current()) loading.value = false }
 }
 async function showTrend(row: SalesSummary | Metric | InventoryMetric) {
@@ -216,26 +269,24 @@ watch(trendOpen, open => { if (!open) { dailyFence.cancel(); daily.value = null;
 function visibility() { poller.setVisible(!document.hidden) }
 function clearSalesResults() { dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; daily.value = null; rows.value = []; total.value = 0; loading.value = false; error.value = ''; errorDetails.value = null }
 function applySalesRange(anchor?: string) {
-  const problem = preset.value === 'CUSTOM' ? validateCustomRange(customStart.value, customEnd.value, anchor) : ''
-  if (problem) { clearSalesResults(); appliedSalesRange.value = null; error.value = problem; return false }
-  appliedSalesRange.value = draftSalesRange(); return true
+  try { appliedSalesRange.value = overviewRange(preset.value, anchor ?? beijingToday(), customStart.value, customEnd.value); return true }
+  catch (cause) { clearSalesResults(); appliedSalesRange.value = null; error.value = (cause as Error).message; return false }
 }
+
 function search() {
-  if (summaryMode.value && !applySalesRange(origin.value?.context.anchorDate ?? beijingToday())) return
+  if (!applySalesRange(origin.value?.context.anchorDate ?? beijingToday())) return
   let nextWindow = appliedWindow.value
   if (props.mode === 'inventory' && !drilldown.value) {
-    const end = String(filters.value.salesWindowEndDate ?? '')
-    const days = Number(filters.value.salesWindowDays)
-    const problem = validateCustomRange(end, end, previousDate(origin.value?.context.anchorDate ?? beijingToday()))
-    if (problem || ![10, 30].includes(days)) { error.value = problem || '请选择10天或30天销量窗口'; return }
-    nextWindow = { salesWindowDays: days as 10 | 30, salesWindowEndDate: end }
+    const range = appliedSalesRange.value!
+    nextWindow = { salesWindowDays: Math.round((Date.parse(range.endDate!) - Date.parse(range.startDate!)) / 86400000) + 1, salesWindowEndDate: range.endDate! }
   }
+
   const rawThreshold = thresholdInput.value.trim()
   if (props.mode === 'inventory' && ((rawThreshold && (!/^\d+$/.test(rawThreshold) || !Number.isSafeInteger(Number(rawThreshold)) || Number(rawThreshold) <= 0)) || (attention.value === 'COVERAGE_GT_THRESHOLD' && !rawThreshold))) {
     error.value = '可售天数阈值须为正整数;选择超过阈值关注时必须填写'
     return
   }
-  const keepContext = props.mode === 'inventory' && nextWindow.salesWindowDays === appliedWindow.value.salesWindowDays && nextWindow.salesWindowEndDate === appliedWindow.value.salesWindowEndDate && sortBy.value !== appliedInventory.value.sortBy
+  const keepContext = !newData.value && !newDay.value && props.mode === 'inventory' && nextWindow.salesWindowDays === appliedWindow.value.salesWindowDays && nextWindow.salesWindowEndDate === appliedWindow.value.salesWindowEndDate && sortBy.value !== appliedInventory.value.sortBy
   appliedWindow.value = nextWindow
   appliedInventory.value = { attention: attention.value, sortBy: sortBy.value, coverageDaysThreshold: rawThreshold ? Number(rawThreshold) : undefined }
   applied.value = { sku: String(filters.value.sku ?? '').trim() }
@@ -243,6 +294,7 @@ function search() {
   void load(true, keepContext)
 }
 async function reset() {
+  salesSort.value = 'QUANTITY_DESC'
   applyingRoute = true
   drilldown.value = null; origin.value = null
   filters.value = { ...initialFilters(), warehouseCode: warehouseCode.value || selectedWarehouse || defaultWarehouse(), sku: '' }
@@ -255,7 +307,7 @@ function returnOverview() {
 }
 async function clearDrilldown() {
   drilldown.value = null
-  filters.value = { ...filters.value, sku: '', analysis: 'summary', rangePreset: 'TODAY', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customRange: [] }
+  filters.value = { ...filters.value, sku: '', analysis: 'summary', rangePreset: 'LAST_30_DAYS', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customRange: [] }
   appliedSalesRange.value = draftSalesRange()
   applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }; current.value = 1
   await nextTick(); void load()
@@ -263,15 +315,16 @@ async function clearDrilldown() {
 function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, sku: applied.value.sku } }) }
 let applyingRoute = false
 watch(warehouseCode, () => { if (applyingRoute) return; drilldown.value = null; origin.value = null; trendOpen.value = false; dataFence.cancel(); dailyFence.cancel(); rows.value = []; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) })
-watch([() => filters.value.salesWindowDays, () => filters.value.salesWindowEndDate], () => { if (!applyingRoute && props.mode === 'inventory' && !drilldown.value) search() })
-watch([customStart, customEnd], () => { if (applyingRoute || !summaryMode.value) return; error.value = ''; errorDetails.value = null })
-watch([preset, tab, windowDays], () => {
+
+watch([customStart, customEnd], () => { if (applyingRoute) return; error.value = ''; errorDetails.value = null })
+watch([preset, tab], () => {
   if (applyingRoute) return
+  if (props.mode === 'inventory') { search(); return }
   clearSalesResults()
-  if (summaryMode.value && !applySalesRange(context.value?.anchorDate ?? beijingToday())) return
+  if (!applySalesRange(origin.value?.context.anchorDate ?? context.value?.anchorDate ?? beijingToday())) return
   current.value = 1; void load()
 })
-watch([current, size], () => { if (!applyingRoute) void load() })
+watch([current, size], () => { if (!applyingRoute && !changingSortPage) void load() })
 onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() })
 watch(() => route.query, async () => {
   if (route.path && route.path !== `/self-operated/${props.mode}`) return
@@ -292,13 +345,13 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
         
         
         
         
+        
+        
         
         
         
@@ -326,8 +389,15 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
         
         
         
-        
-        
+        
+        
+        
+        
       
@@ -342,6 +412,12 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu diff --git a/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue b/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue index f2bc133d..ace13fbf 100644 --- a/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue +++ b/src/modules/overseas-warehouse/pages/CargoDiscardsView.vue @@ -14,9 +14,9 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { overseasWarehousePermissions as permission } from '../permissions' import { useAccessPreviewStore } from '@/core/access/accessPreview' const access = useAccessPreviewStore(); const rows = ref([]); const customers = ref([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref(null); const filters = ref({ customerId: '', discardNo: '', inboundCode: '', status: '' }) -const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: 'PDA处理中', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }] +const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }] const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '总箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }] -const statusLabels: Record = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待仓库确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' } +const statusLabels: Record = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' } const photoUrls = reactive>({}); const photoLoading = reactive>({}); const photoErrors = reactive>({}) const photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean)) let photoGeneration = 0 diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts index d8a44f82..0b9c8225 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.test.ts @@ -3,9 +3,11 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getLastMileOrder } from '../api/lastMileOrders' import LastMileOrderDetailView from './LastMileOrderDetailView.vue' +import { getPlatformWarehouse } from '@/modules/warehouses' import { getWarehouseTransfer } from '../api/warehouseTransfers' const push = vi.hoisted(() => vi.fn()) +vi.mock('@/modules/warehouses', () => ({ getPlatformWarehouse: vi.fn() })) vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'internal-order-id' } }), useRouter: () => ({ push }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) })) vi.mock('../api/lastMileOrders', () => ({ getLastMileOrder: vi.fn(), createLastMileOrderFee: vi.fn() })) @@ -63,9 +65,34 @@ describe('LastMileOrderDetailView domestic detail alignment', () => { expect(wrapper.get('[aria-label="本票转运与换标履约"]').text()).toContain('不得提前出库') }) + it('fills the receiving warehouse from its ID when the order name is absent', async () => { + vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'tail', customerId: 'C1', orderNo: 'WT-TAIL', orderType: 'WAREHOUSE_TRANSFER', overseasWarehouseId: '193000000000200003' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] }) + vi.mocked(getPlatformWarehouse).mockResolvedValue({ warehouse: { id: '193000000000200003', warehouseName: '洛杉矶收货仓', warehouseCode: 'US-LAX-01', tenantId: '0', internalCode: null, warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }, addresses: [], capabilities: [] }) + const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises() + expect(getPlatformWarehouse).toHaveBeenCalledWith('193000000000200003') + expect(wrapper.get('[aria-label="尾程订单概览"]').text()).toContain('海外仓洛杉矶收货仓') + expect(wrapper.get('[aria-label="基本信息"]').text()).toContain('海外仓洛杉矶收货仓') + }) + + it('loads transfer declarations and actual cartons on the cargo tab using the source transfer', async () => { + vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'tail', customerId: 'customer-1', orderNo: 'TAIL-NO', sourceOrderNo: 'WT-SOURCE', orderType: 'WAREHOUSE_TRANSFER' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] }) + vi.mocked(getWarehouseTransfer).mockResolvedValue({ id: 'transfer', transferNo: 'WT-SOURCE', status: 'WAIT_PDA_RECEIVING', needRelabel: false, packageCount: 5, items: [], cartons: [], pallets: [], relabelBindings: [] }) + const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises() + expect(getWarehouseTransfer).not.toHaveBeenCalled() + await wrapper.get('#tab-cargo').trigger('click'); await flushPromises() + expect(getWarehouseTransfer).toHaveBeenCalledExactlyOnceWith('customer-1', 'WT-SOURCE') + const panel = wrapper.get('[aria-label="仓储转运预报与实际签收"]') + expect(wrapper.get('[aria-label="尾程订单概览"]').text()).toContain('预报箱数5 箱') + expect(wrapper.get('[aria-label="尾程订单概览"]').text()).toContain('实收箱数0 箱') + expect(panel.text()).toContain('预报与实际签收对比') + expect(panel.text()).toContain('签收箱号明细(0 箱)') + expect(panel.text()).toContain('查看箱号明细') + }) + beforeEach(() => { push.mockReset() vi.mocked(getWarehouseTransfer).mockReset() + vi.mocked(getPlatformWarehouse).mockReset() vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'FL-WH-001', currentStatus: 'PENDING_ASSIGNMENT', customerName: '示例客户', overseasWarehouseName: '洛杉矶仓', packageCount: 0, palletCount: 2, grossWeightKg: 30, netWeightKg: 22, volumeCbm: 6.374, inboundCode: 'IN-001', containerNo: 'CONT-001', customerRemark: '完整保留客户备注', updatedAt: '2026-09-14T10:00:00' }, cargo: [{ id: 'cargo-1', skuCode: 'SKU-001', productNameZh: '测试货物' }], fees: [], statusHistory: [], operationHistory: [], diff --git a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue index 7e3ecc76..05c23194 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrderDetailView.vue @@ -1,10 +1,11 @@ +