Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bfce44988 | ||
|
|
00b1fefecb | ||
|
|
88632e6e4f | ||
|
|
01ecd63991 | ||
|
|
3598310237 | ||
|
|
6b3286fa5a | ||
|
|
f155976f52 |
@@ -8,6 +8,13 @@ import {
|
||||
} from './adminNavigation'
|
||||
|
||||
describe('adminNavigation', () => {
|
||||
it('keeps outsourced read access separate from owned access and write-only permissions', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:list']))
|
||||
const vehicle = read.find(node => node.key === 'vehicle-management')
|
||||
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/outsourced-business'])
|
||||
const write = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:edit', 'platform:fleet:outsourced-business:upload']))
|
||||
expect(getNavigationLeaves(write).some(node => node.path === '/overseas-master-data/outsourced-business')).toBe(false)
|
||||
})
|
||||
it('places owned-business under vehicle management and keeps edit/upload from granting page access', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:owned-business:list']))
|
||||
const vehicle = read.find(node => node.key === 'vehicle-management')
|
||||
@@ -40,7 +47,7 @@ describe('adminNavigation', () => {
|
||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
||||
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
|
||||
{ title: '预约管理', children: ['预约列表'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '出车记录'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配业务', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
|
||||
@@ -135,6 +135,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
|
||||
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
|
||||
{ key: 'fleet-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' },
|
||||
{ key: 'fleet-outsourced-business', title: '外配业务', icon: Truck, path: '/overseas-master-data/outsourced-business', permission: 'platform:fleet:outsourced-business:list' },
|
||||
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true },
|
||||
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
|
||||
],
|
||||
|
||||
@@ -88,10 +88,10 @@ describe('simplified appointment entry', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not request unreadable domains and describes the incomplete scope', async () => {
|
||||
routing.denied.add('platform:fleet:trip:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
|
||||
routing.denied.add('platform:overseas-warehouse:dispatch:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('缺少车次读取权限')
|
||||
expect(wrapper.text()).toContain('缺少排车读取权限')
|
||||
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -143,4 +143,17 @@ describe('simplified appointment entry', () => {
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => {
|
||||
routing.denied.add('platform:fleet:trip:list')
|
||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [{ sourceType: 'DISPATCH', dispatchNo: 'DS/1', status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-21T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [], incompleteReasons: [] }], unresolvedRecords: [] })
|
||||
const wrapper = renderSchedule(); await flushPromises()
|
||||
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||
const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch')
|
||||
expect(listTripCalendar).toHaveBeenCalledOnce()
|
||||
expect(event.sourcePath).toBe('/overseas-warehouse/dispatches/DS%2F1')
|
||||
panel.vm.$emit('select', '2026-10-21', 'week')
|
||||
panel.vm.$emit('open', event); await flushPromises()
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,12 +65,12 @@ async function load(next = range.value) {
|
||||
} catch (cause) { fail('计划到仓读取失败', cause) }
|
||||
})(),
|
||||
(async () => {
|
||||
if (!access.can('platform:fleet:trip:list')) { notices.value.push('缺少车次读取权限,当前未显示排车安排。'); return }
|
||||
if (!access.can('platform:overseas-warehouse:dispatch:list')) { notices.value.push('缺少排车读取权限,当前未显示排车安排。'); return }
|
||||
try {
|
||||
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
|
||||
if (request !== generation) return
|
||||
trips.value = result.events
|
||||
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 个车次缺少起运仓或时区资料,请到车辆管理核对。`)
|
||||
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 条排车资料不完整,请到排车列表核对。`)
|
||||
} catch (cause) { fail('排车读取失败', cause) }
|
||||
})(),
|
||||
])
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 预约工作日历逐操作矩阵
|
||||
|
||||
## 2026-10-20 排车入口收敛
|
||||
|
||||
用户指出预约日历仍跳旧车次详情。排车标签改用服务端关联真实整车主单返回的排车单号,打开 `/overseas-warehouse/dispatches/:dispatchNo` 独立排车详情;返回按钮显示“返回预约日历”并保留原日期与月/周视图。无合法日历返回参数时继续走原列表或拼车登记返回逻辑,外部返回地址拒绝。读取权限与排车列表一致,不要求旧车次列表权限;独立旧车次、取消排车主单不进入活动日历,不按TR号猜DS号。原四类、地点日期和实际发车归属规则保持。自动检查与发布/真实HTTP分别记录,1920×1080、100%人工操作截图待确认。
|
||||
|
||||
自动验证:相关3文件42项、Admin全量453文件4086项、类型检查与生产构建通过;后端最终常规Maven流程完成生产/测试源码编译,`FleetTripCalendarServiceTest`及真实H2查询的`FleetTripCalendarMapperTest`共9项通过,覆盖真实排车号与内部车次号不同、独立旧车次/取消主单/取货排除、跨日期和查询上限。Gateway路由合同5项通过。均经共享`local-check.ps1`执行;早先其他自有业务在途编译错误及失效生成物尝试不作为通过证据,以最终常规Maven回归为准。接口路径复用、无网关配置或数据库结构变更,匹配发布单元为Admin与Lastleg。
|
||||
|
||||
## 2026-10-19 四类日历恢复(当前结论)
|
||||
|
||||
依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。
|
||||
|
||||
@@ -3,16 +3,24 @@ import { listTripCalendar, projectTripCalendar, tripQueryWindow, type TripCalend
|
||||
import { http } from '@/shared/api/http'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
|
||||
const range = { from: '2026-10-01', to: '2026-11-01' }
|
||||
function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
|
||||
return { sourceType: 'TRIP_PLAN', tripNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
|
||||
return { sourceType: 'DISPATCH', dispatchNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
|
||||
}
|
||||
describe('location-based trip calendar', () => {
|
||||
it('routes the canonical dispatch number to dispatch detail and does not invent a link without it', () => {
|
||||
const row = trip('DS/REAL', '2026-10-10T12:00:00Z')
|
||||
const result = projectTripCalendar({ records: [row], unresolvedRecords: [] }, range)
|
||||
expect(result.events[0]).toMatchObject({ key: 'dispatch-DS/REAL', title: 'DS/REAL 排车', sourcePath: '/overseas-warehouse/dispatches/DS%2FREAL' })
|
||||
const missing = projectTripCalendar({ records: [{ ...row, dispatchNo: '' }], unresolvedRecords: [] }, range)
|
||||
expect(missing.events).toEqual([])
|
||||
expect(missing.unresolved[0]!.incompleteReasons).toContain('DISPATCH_REFERENCE_UNKNOWN')
|
||||
})
|
||||
it('counts a shared trip once and excludes cancelled arrangements', () => {
|
||||
const shared = trip('TR-SHARED', '2026-10-10T12:00:00Z')
|
||||
const cancelled = { ...trip('TR-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
|
||||
const shared = trip('DS-SHARED', '2026-10-10T12:00:00Z')
|
||||
const cancelled = { ...trip('DS-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
|
||||
const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range)
|
||||
expect(result.events).toHaveLength(1)
|
||||
expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'TR-SHARED 排车' })
|
||||
expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'DS-SHARED 排车' })
|
||||
})
|
||||
it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => {
|
||||
const localRange = {from:'2026-09-28',to:'2026-11-09'}
|
||||
@@ -25,23 +33,23 @@ describe('location-based trip calendar', () => {
|
||||
it('includes the first and last local day and removes padded rows outside each origin date range', () => {
|
||||
const records=[trip('LA-before','2026-10-01T06:30:00Z'),trip('NY-start','2026-10-01T06:30:00Z','America/New_York'),trip('LA-end','2026-11-01T06:30:00Z'),trip('NY-after','2026-11-01T06:30:00Z','America/New_York')]
|
||||
const projected=projectTripCalendar({records,unresolvedRecords:[]},range)
|
||||
expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['trip-NY-start','2026-10-01','02:30'],['trip-LA-end','2026-10-31','23:30']])
|
||||
expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['dispatch-NY-start','2026-10-01','02:30'],['dispatch-LA-end','2026-10-31','23:30']])
|
||||
})
|
||||
it('does not guess a slot for absent, fixed-offset, invalid or browser-unsupported time zones', () => {
|
||||
const unknown={...trip('unknown','2026-10-10T12:00:00Z'),originTimezone:null,incompleteReasons:['ORIGIN_TIMEZONE_UNKNOWN']}
|
||||
const result=projectTripCalendar({records:[trip('offset','2026-10-10T12:00:00Z','UTC-5'),trip('bad','2026-10-10T12:00:00Z','Mars/Base'),trip('naive','2026-10-10T12:00:00')],unresolvedRecords:[unknown]},range)
|
||||
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.tripNo)).toEqual(['unknown','offset','bad','naive'])
|
||||
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['unknown','offset','bad','naive'])
|
||||
})
|
||||
it('uses actual local departure through DST changes while retaining the original plan for comparison', () => {
|
||||
const row={...trip('TR-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
|
||||
const events=projectTripCalendar({records:[row,trip('TR-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
|
||||
const row={...trip('DS-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
|
||||
const events=projectTripCalendar({records:[row,trip('DS-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
|
||||
expect(events.map(event=>event.time)).toEqual(['00:00','01:30'])
|
||||
expect(events[0]).toMatchObject({date:'2026-11-02',title:'TR-DST 实际发车'})
|
||||
expect(events[0]).toMatchObject({date:'2026-11-02',title:'DS-DST 实际发车'})
|
||||
expect(events[0]!.fields).toContainEqual({label:'计划出发(UTC)',value:'2026-11-01T05:30:00Z'})
|
||||
expect(events[0]!.fields).toContainEqual({label:'路线',value:'Stop 0 → Stop 1 → Stop 2 等20站'})
|
||||
})
|
||||
it('keeps executed trips in the actual month after replanning and excludes their old plan month', () => {
|
||||
const executed = {...trip('TR-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
|
||||
const executed = {...trip('DS-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
|
||||
expect(projectTripCalendar({records:[executed],unresolvedRecords:[]}, range).events).toEqual([])
|
||||
const november = {from:'2026-11-01',to:'2026-12-01'}
|
||||
const event = projectTripCalendar({records:[executed],unresolvedRecords:[]}, november).events[0]
|
||||
@@ -50,9 +58,9 @@ describe('location-based trip calendar', () => {
|
||||
expect(projectTripCalendar({records:[changed],unresolvedRecords:[]},november).events[0]).toMatchObject({date:event!.date,time:event!.time})
|
||||
})
|
||||
it('does not fall back to the plan when an actual departure cannot be resolved', () => {
|
||||
const invalid = {...trip('TR-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
|
||||
const invalid = {...trip('DS-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
|
||||
const result = projectTripCalendar({records:[invalid],unresolvedRecords:[]},range)
|
||||
expect(result.events).toEqual([])
|
||||
expect(result.unresolved.map(row=>row.tripNo)).toEqual(['TR-INVALID'])
|
||||
expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['DS-INVALID'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { ScheduleEvent } from './schedule'
|
||||
|
||||
export interface TripCalendarEntry {
|
||||
sourceType: 'TRIP_PLAN'
|
||||
tripNo: string
|
||||
sourceType: 'DISPATCH'
|
||||
dispatchNo: string
|
||||
status: string
|
||||
originWarehouseId: string | null
|
||||
originWarehouseCode: string | null
|
||||
@@ -23,6 +23,7 @@ export const tripIssueLabels: Record<string, string> = {
|
||||
ORIGIN_WAREHOUSE_UNKNOWN: '起运仓资料缺失', ORIGIN_COUNTRY_UNKNOWN: '起运国家待核实',
|
||||
ORIGIN_TIMEZONE_UNKNOWN: '起运仓时区待核实', ROUTE_INCOMPLETE: '路线资料不完整',
|
||||
LOCAL_DATE_UNRESOLVED: '无法确定当地日期',
|
||||
DISPATCH_REFERENCE_UNKNOWN: '排车单号缺失',
|
||||
}
|
||||
export function tripIssues(row: TripCalendarEntry) {
|
||||
return row.incompleteReasons.map(reason => tripIssueLabels[reason] || '资料待核实').join(';')
|
||||
@@ -56,7 +57,13 @@ function localStamp(value: string | null, zone: string | null) {
|
||||
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
|
||||
const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords]
|
||||
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
|
||||
for (const row of new Map(result.records.map(row => [row.tripNo, row])).values()) {
|
||||
const seen = new Set<string>()
|
||||
for (const row of result.records) {
|
||||
if (!row.dispatchNo?.trim()) {
|
||||
unresolved.push({ ...row, incompleteReasons: [...row.incompleteReasons, 'DISPATCH_REFERENCE_UNKNOWN'] }); continue
|
||||
}
|
||||
if (seen.has(row.dispatchNo)) continue
|
||||
seen.add(row.dispatchNo)
|
||||
if (row.status === 'CANCELLED') continue
|
||||
// Executed work stays on its actual local date even if the plan changes later.
|
||||
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone)
|
||||
@@ -66,11 +73,11 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
|
||||
if (occurrence.date < range.from || occurrence.date >= range.to) continue
|
||||
const actual = localStamp(row.departedAt, row.originTimezone)
|
||||
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
|
||||
events.push({ key: `trip-${row.tripNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
|
||||
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
|
||||
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
|
||||
events.push({ key: `dispatch-${row.dispatchNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
|
||||
title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
|
||||
sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`,
|
||||
fields: [
|
||||
{ label: '车次状态', value: labels[row.status] || '待核实' },
|
||||
{ label: '运输状态', value: labels[row.status] || '待核实' },
|
||||
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
|
||||
{ label: '地点时区', value: row.originTimezone },
|
||||
{ label: '计划出发(UTC)', value: row.plannedDepartureAt },
|
||||
|
||||
@@ -27,14 +27,12 @@ describe('ContainerSimulationCreate opted-in form', () => {
|
||||
await flushPromises()
|
||||
expect(createContainerSimulation).not.toHaveBeenCalled()
|
||||
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
|
||||
expect(fields).toHaveLength(4)
|
||||
expect(fields).toHaveLength(3)
|
||||
fields.find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '8')
|
||||
await flushPromises()
|
||||
expect(listWarehouseLogisticsChannels).toHaveBeenCalledExactlyOnceWith('8')
|
||||
fields.find(field => field.props('prop') === 'channelGroupCode')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'MATSON_CLX')
|
||||
await flushPromises()
|
||||
const specification = fields.find(field => field.props('label') === '统一规格')!
|
||||
expect(specification.find('input').exists()).toBe(!dense)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(createContainerSimulation).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { Container, Plus } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listWarehouseLogisticsChannels, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
|
||||
@@ -9,7 +9,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { createContainerSimulation } from '../api/platformContainerLoading'
|
||||
import type { ContainerSimulationCreateRequest } from '../types'
|
||||
import { SMART_LOADING_CHANNEL_GROUPS } from '../smartLoadingChannelGroups'
|
||||
@@ -95,14 +95,54 @@ async function saveSimulation() {
|
||||
|
||||
<template>
|
||||
<el-button type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button>
|
||||
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
|
||||
|
||||
<section :class="{ 'density-dialog-section': dense }" data-tone="work"> <el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" :label-position="dense ? 'left' : 'top'" :label-width="dense ? '120px' : undefined" class="simulation-form" :class="{ 'density-form': dense }"><template v-if="dense"><table :class="{ 'density-table': dense, 'density-table--editable': dense }" aria-label="模拟柜资料"><tbody><tr><td><el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item></td><td><el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item></td><td><el-form-item label="统一规格"><span>{{ `${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg` }}</span><small :class="{ 'density-dialog-note': dense }">{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item></td></tr></tbody></table></template><template v-else><el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item><el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item><el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item><el-form-item label="统一规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /><small>{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item></template></el-form></section>
|
||||
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
v-model="simulationOpen"
|
||||
class="simulation-create-dialog"
|
||||
:class="{ 'admin-density-dialog': dense, 'admin-density-dialog--comfortable': dense }"
|
||||
title="新增模拟柜"
|
||||
width="min(800px, calc(100vw - 32px))"
|
||||
confirm-text="创建模拟柜"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="simulationSaving"
|
||||
:confirm-disabled="warehouseLoading || simulationChannelsLoading"
|
||||
@confirm="saveSimulation"
|
||||
>
|
||||
<template #header>
|
||||
<div class="simulation-heading"><span class="simulation-heading__icon"><Container :size="20" aria-hidden="true" /></span><span>新增模拟柜</span></div>
|
||||
</template>
|
||||
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
|
||||
<div class="simulation-fields">
|
||||
<el-form-item label="装柜仓库" prop="warehouseId">
|
||||
<el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse">
|
||||
<el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="物流渠道" prop="channelGroupCode">
|
||||
<el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道">
|
||||
<el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="柜型" prop="containerType" class="simulation-type">
|
||||
<el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))">
|
||||
<el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.simulation-form :deep(.el-select), .simulation-form :deep(.el-input-number) { width: 100%; }
|
||||
.simulation-heading { display: flex; align-items: center; gap: 10px; font-size: var(--admin-font-title, 16px); }
|
||||
.simulation-heading__icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.simulation-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; row-gap: 8px; }
|
||||
.simulation-fields > * { min-width: 0; }
|
||||
.simulation-form :deep(.el-select) { width: 100%; }
|
||||
.simulation-type { grid-column: 1 / -1; }
|
||||
.simulation-type :deep(.el-select) { max-width: calc(50% - 12px); }
|
||||
@media (max-width: 600px) {
|
||||
.simulation-fields { grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
||||
.simulation-type :deep(.el-select) { max-width: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,7 @@ import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { firstLegBusinessTypeLabel } from '@/shared/utils/firstLegBusinessLabels'
|
||||
import { listPlatformInbounds } from '../core/api'
|
||||
import type { InboundOrderSummary } from '../core/types'
|
||||
import type { InboundOrderSummary, InboundPageResult } from '../core/types'
|
||||
import InboundMeasurementDialog from '../measurement/InboundMeasurementDialog.vue'
|
||||
import MeasurementReviewPanel from '../review/MeasurementReviewPanel.vue'
|
||||
import { approveMeasurement, getMeasurementReviewDetail } from '../review/api'
|
||||
@@ -33,7 +33,11 @@ type FilterModel = QueryModel & {
|
||||
expectedArrivalRange: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ mode: InboundTaskMode; title?: string }>()
|
||||
const props = defineProps<{
|
||||
mode: InboundTaskMode
|
||||
title?: string
|
||||
taskLoader?: (pagination: { page: number; size: number }) => Promise<InboundPageResult>
|
||||
}>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -143,6 +147,7 @@ function dateParam(value: string | undefined, end = false) {
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
if (props.taskLoader) return
|
||||
const serial = ++optionsRequestSerial
|
||||
customerLoadError.value = null
|
||||
try {
|
||||
@@ -169,7 +174,7 @@ async function loadTasks() {
|
||||
loadError.value = null
|
||||
const applied = appliedFilters.value
|
||||
try {
|
||||
const response = await listPlatformInbounds(applied.customerId || undefined, buildInboundTaskQuery(props.mode, {
|
||||
const response = props.taskLoader ? await props.taskLoader({ page: page.value, size: pageSize.value }) : await listPlatformInbounds(applied.customerId || undefined, buildInboundTaskQuery(props.mode, {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
warehouseId: applied.warehouseId || undefined,
|
||||
@@ -328,6 +333,12 @@ function clearSelectedInbound() {
|
||||
reviewNavigationLoading.value = false
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (page.value === 1) return loadTasks()
|
||||
page.value = 1
|
||||
}
|
||||
defineExpose({ reload })
|
||||
|
||||
watch([page, pageSize], () => { void loadTasks() })
|
||||
onMounted(() => { void loadOptions(); void loadTasks() })
|
||||
onActivated(() => {
|
||||
@@ -363,6 +374,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
>
|
||||
<template #toolbar-start><h1>{{ title || (isReview ? '测量数据审核' : '订单测量') }}</h1></template>
|
||||
<template #filters>
|
||||
<slot name="filters" :loading="loading">
|
||||
<slot name="status-tabs" />
|
||||
<QueryToolbar
|
||||
v-model="filters"
|
||||
@@ -373,6 +385,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
@search="applyFilters"
|
||||
@reset="resetFilters"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<template #notices><template v-if="customerLoadError || loadError">
|
||||
<div class="state-stack">
|
||||
|
||||
@@ -20,3 +20,5 @@ export type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './fee
|
||||
export { downloadWeightImage } from './weight/api/weightOcr'
|
||||
export type { InboundMeasurementDetail } from './measurement/types'
|
||||
export type { InboundHoldCommand, InboundHoldDetail, InboundHoldEvidenceUpload, InboundHoldHistoryEntry } from './core/types'
|
||||
|
||||
export type { InboundOrderSummary, InboundPageResult } from './core/types'
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
const toolbar = wrapper.get('.review-toolbar')
|
||||
expect(toolbar.text()).toContain('待审核')
|
||||
expect(toolbar.text()).toContain('修改重量')
|
||||
expect(toolbar.text()).toContain('通过并进入待分柜')
|
||||
expect(toolbar.findAll('button').map(button => button.text())).toContain('通过')
|
||||
expect(toolbar.text()).toContain('刷新')
|
||||
expect(toolbar.text()).toContain('客户:柏悦客户')
|
||||
expect(toolbar.text()).toContain('渠道:海运')
|
||||
@@ -99,10 +99,10 @@ describe('MeasurementReviewPanel', () => {
|
||||
const warning = vi.spyOn(ElMessage, 'warning')
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001', fulfillmentType: 'ONE_CLICK_DROPSHIP' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('通过并进入待分柜')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待一件代发组托'))!.trigger('click')
|
||||
expect(wrapper.text()).not.toContain('通过并进入')
|
||||
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('进入待一件代发组托'), '确认测量审核通过', expect.objectContaining({ confirmButtonText: '通过并进入待一件代发组托' }))
|
||||
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('进入待一件代发组托'), '确认测量审核通过', expect.objectContaining({ confirmButtonText: '通过' }))
|
||||
expect(initialPricingSucceeded ? success : warning).toHaveBeenCalledWith(expect.stringContaining('入库单已进入待一件代发组托'))
|
||||
expect(approveMeasurement).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
@@ -209,7 +209,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
|
||||
await wrapper.get('.mock-weight-change').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text() === '通过')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getMeasurementReviewDetail).toHaveBeenCalledTimes(2)
|
||||
@@ -227,7 +227,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.review-toolbar__actions').exists()).toBe(false)
|
||||
expect(wrapper.get('.review-toolbar__context').text()).toContain('客户:柏悦客户')
|
||||
expect(wrapper.vm.approvalButtonLabel).toBe('通过并进入待分柜')
|
||||
expect(wrapper.vm.approvalButtonLabel).toBe('通过')
|
||||
wrapper.vm.enableWeightCorrection()
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'WeightOcrPanel' }).props('correctionMode')).toBe(true)
|
||||
@@ -245,7 +245,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const approveButton = wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!
|
||||
const approveButton = wrapper.findAll('button').find((button) => button.text() === '通过')!
|
||||
await approveButton.trigger('click')
|
||||
await approveButton.trigger('click')
|
||||
|
||||
@@ -263,12 +263,12 @@ describe('MeasurementReviewPanel', () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
expect(wrapper.find('.review-panel').exists()).toBe(true)
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('通过并进入待分柜'))).toBe(true)
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '通过')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks approval while the order detail navigation is loading', async () => {
|
||||
@@ -279,7 +279,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
|
||||
|
||||
expect(approveMeasurement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@ const canReview = computed(() => access.can('firstleg:inbound:measurement-review
|
||||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
|
||||
const approvalTargetLabel = computed(() => props.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '待一件代发组托' : '待分柜')
|
||||
const approvalButtonLabel = computed(() => `通过并进入${approvalTargetLabel.value}`)
|
||||
const approvalButtonLabel = computed(() => '通过')
|
||||
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
|
||||
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
|
||||
const displayCustomerName = computed(() => detail.value?.companyName?.trim() || props.customerName?.trim() || '客户未命名')
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 海外通用资料与车辆管理
|
||||
|
||||
- 自有业务:`pages/OwnedBusinessView.vue`、`OwnedBusinessDetailView.vue`及`api/ownedBusiness.ts`沿`fleet-owned-business-v1.yaml`,是原排车装车后的自有车辆运营视图,不按货源分类或另建履约流程;共同规则见`../../../../../docs/海外仓业务/自有业务首版开发契约.md`。详情三个页签,运营登记1080px居中、照片760px居中;金额/油耗单位、附加与排车版本分离,`owned-business:list/edit/upload`独立,统计沿完整计划发车日期筛选。外配页面本批不实现。
|
||||
|
||||
- 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。
|
||||
|
||||
- 内部拼车需求池:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getOutsourcedBusiness, listOutsourcedBusiness, listOutsourcedBusinessHistory, saveOutsourcedBusinessOperations, uploadOutsourcedBusinessPhoto, bindOutsourcedBusinessPhoto, getOutsourcedBusinessPhotoContent } from './outsourcedBusiness'
|
||||
import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures'
|
||||
import type { OutsourcedBusinessPhoto, OutsourcedBusinessPhotoSlot, OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
|
||||
|
||||
vi.mock('@/shared/api/http', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@/shared/api/http')>()
|
||||
return { ...actual, http: { get: vi.fn(), put: vi.fn(), post: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }
|
||||
})
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
const base = '/platform/v1/last-leg/fleet/outsourced-business'
|
||||
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
|
||||
describe('outsourced business HTTP contract', () => {
|
||||
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
|
||||
const detail = outsourcedBusinessFixture()
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
|
||||
const query = { page: 1, size: 20, plannedDepartureFrom: '2026-10-20T00:00:00Z', plannedDepartureTo: '2026-10-21T00:00:00Z', keyword: 'FBA-1' }
|
||||
const value = await listOutsourcedBusiness(query)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query })
|
||||
expect(value.summary.dispatchCount).toBe(41); expect(value.records).toHaveLength(1); expect(value.records[0]!.actualPalletCount).toBe(1)
|
||||
})
|
||||
it('encodes the dispatch business number and preserves auxiliary/main versions independently', async () => {
|
||||
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = '0'; detail.row.dispatchRecordVersion = '9007199254740993'
|
||||
detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
|
||||
vi.mocked(http.get).mockResolvedValue({ data: detail })
|
||||
expect((await getOutsourcedBusiness('DS/1')).row.dispatchRecordVersion).toBe('9007199254740993')
|
||||
expect(http.get).toHaveBeenCalledWith(`${base}/DS%2F1`, { params: undefined })
|
||||
detail.dispatch.dispatchNo = 'another'; await expect(getOutsourcedBusiness('DS/1')).rejects.toThrow('不一致')
|
||||
})
|
||||
it('does not convert money text, calculate a client unit price or submit a dispatch version', async () => {
|
||||
const body: OutsourcedBusinessSave = { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null }
|
||||
const detail = outsourcedBusinessFixture(); detail.row.operations.money = body.operations.money
|
||||
vi.mocked(http.put).mockResolvedValue({ data: detail })
|
||||
const saved = await saveOutsourcedBusinessOperations('DS-OUT-1', body)
|
||||
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS-OUT-1/operations`, body)
|
||||
expect(body.operations.money!.amount).toBe('9999999999999999.99'); expect('dispatchRecordVersion' in body).toBe(false)
|
||||
expect(saved.row.operations.money!.amount).toBe('9999999999999999.99')
|
||||
})
|
||||
it('keeps upload and versioned file binding separate with the same prepared file identity', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: photo })
|
||||
const file = new File(['image'], 'loading.png', { type: 'image/png' })
|
||||
await uploadOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', file, 'upload-key')
|
||||
const [url, form] = vi.mocked(http.post).mock.calls[0]!
|
||||
expect(url).toBe(`${base}/DS%2F1/photos/LOADING_SCENE/uploads`); expect((form as FormData).get('file')).toBe(file); expect((form as FormData).get('idempotencyKey')).toBe('upload-key')
|
||||
const detail = outsourcedBusinessFixture(); detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
|
||||
vi.mocked(http.put).mockResolvedValue({ data: detail })
|
||||
await bindOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
|
||||
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS%2F1/photos/LOADING_SCENE`, { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
|
||||
})
|
||||
it('retains the two history streams and reads historical files only at their explicit revision', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ eventSource: 'DISPATCH', recordVersion: null, dispatchRecordVersion: '9', operations: null, photos: [] }, { eventSource: 'OPERATIONS', recordVersion: '1', dispatchRecordVersion: null, operations: outsourcedBusinessFixture().row.operations, photos: [] }], page: '1', size: '20', total: '2' } })
|
||||
const value = await listOutsourcedBusinessHistory('DS/1', 1, 20)
|
||||
expect(value.records.map(record => [record.eventSource, record.recordVersion, record.dispatchRecordVersion])).toEqual([['DISPATCH',null,'9'],['OPERATIONS','1',null]])
|
||||
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||
await getOutsourcedBusinessPhotoContent('DS/1', photo, '1')
|
||||
expect(http.get).toHaveBeenLastCalledWith(`${base}/DS%2F1/photos/LOADING_SCENE/files/${photo.fileId}/content`, { responseType: 'blob', params: { revisionVersion: '1' } })
|
||||
})
|
||||
it('never renders JSON errors or empty bytes as a photo', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({ code: 'A0403', message: '拒绝读取', requestId: 'request', traceId: 'trace' })], { type: 'application/json' }))
|
||||
await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toMatchObject({ code: 'A0403', requestId: 'request', traceId: 'trace' })
|
||||
vi.mocked(http.get).mockResolvedValue(new Blob([], { type: 'image/png' })); await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toThrow('为空')
|
||||
await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1','LOADING_SCENE',new File(['text'],'bad.txt',{type:'text/plain'}),'key')).rejects.toThrow('JPG')
|
||||
})
|
||||
it.each(['2', null])('accepts authoritative external vehicle identity %s without looking up the current master', async vehicleId => {
|
||||
const detail = outsourcedBusinessFixture(); detail.row.vehicle.vehicleId = vehicleId; detail.row.vehicle.carrierName = null
|
||||
vi.mocked(http.get).mockResolvedValue({ data: detail })
|
||||
const value = await getOutsourcedBusiness('DS-OUT-1')
|
||||
expect(value.row.vehicle).toMatchObject({ vehicleId, ownership: 'DISPATCHED', carrierName: null })
|
||||
expect(http.get).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('rejects owned vehicle facts and unsupported fuel or mileage writes before any request', async () => {
|
||||
const detail = outsourcedBusinessFixture()
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership: 'OWNED' } } } })
|
||||
await expect(getOutsourcedBusiness('DS-OUT-1')).rejects.toThrow('来源或阶段')
|
||||
for (const field of ['actualDistanceMiles','fuelConsumptionLitresPer100Km','actualFuelLitres']) {
|
||||
const body = { recordVersion:'0', idempotencyKey:'original-key', operations:{ ...detail.row.operations, [field]:'1' }, reason:null }
|
||||
await expect(saveOutsourcedBusinessOperations('DS-OUT-1', body)).rejects.toThrow('不支持里程或油耗')
|
||||
}
|
||||
expect(http.put).not.toHaveBeenCalled()
|
||||
for (const slot of ['FUEL_START','FUEL_END']) {
|
||||
await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,new File(['image'],'loading.png',{type:'image/png'}),'upload-key')).rejects.toThrow('不支持该照片分类')
|
||||
await expect(bindOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})).rejects.toThrow('不支持该照片分类')
|
||||
}
|
||||
expect(http.post).not.toHaveBeenCalled(); expect(http.put).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import axios from 'axios'
|
||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessPhotoRoles, type OutsourcedBusinessOperations, type OutsourcedBusinessRow, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessSave, type OutsourcedBusinessBind, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessCommand, type OutsourcedBusinessHistory, type OutsourcedBusinessPage } from '../outsourcedBusinessTypes'
|
||||
|
||||
const base = '/platform/v1/last-leg/fleet/outsourced-business'
|
||||
const path = (no: string) => `${base}/${encodeURIComponent(no)}`
|
||||
async function get<T>(url: string, params?: object): Promise<T> { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
|
||||
function count(value: unknown): number { const text = outsourcedBusinessVersion(value); const result = Number(text); if (!Number.isSafeInteger(result)) throw new Error('运营记录数量超出精确范围'); return result }
|
||||
function decimal(value: string | null, scale: number, precision: number): string | null {
|
||||
if (value == null) return null
|
||||
if (typeof value !== 'string' || outsourcedBusinessDecimal(value, scale, precision) !== value) throw new Error('运营金额或测量必须使用精确十进制字符串')
|
||||
return value
|
||||
}
|
||||
function operations(value: OutsourcedBusinessOperations): OutsourcedBusinessOperations {
|
||||
if ('actualDistanceMiles' in value || 'fuelConsumptionLitresPer100Km' in value || 'actualFuelLitres' in value) throw new Error('外配运营信息不支持里程或油耗')
|
||||
if (value.money && !['CNY','USD'].includes(value.money.currency)) throw new Error('运营金额币种无效')
|
||||
const amount = value.money ? decimal(value.money.amount, 2, 18) : null
|
||||
if (value.money && amount == null) throw new Error('承运金额与币种必须同时登记')
|
||||
return { money: value.money ? { currency: value.money.currency, amount: amount! } : null, arrivals: value.arrivals }
|
||||
}
|
||||
function requireSlot(slot: OutsourcedBusinessPhotoSlot) { if (!outsourcedBusinessPhotoRoles.some(role => role.slot === slot)) throw new Error('外配业务不支持该照片分类') }
|
||||
function row(value: OutsourcedBusinessRow): OutsourcedBusinessRow {
|
||||
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'DISPATCHED') throw new Error('外配排车来源或阶段不完整,请重新读取')
|
||||
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: outsourcedBusinessVersion(value.recordVersion), dispatchRecordVersion: outsourcedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount) } }
|
||||
}
|
||||
function detail(value: OutsourcedBusinessDetail): OutsourcedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
|
||||
function command(value: OutsourcedBusinessCommand): OutsourcedBusinessCommand { return { ...value, recordVersion: value.recordVersion == null ? null : outsourcedBusinessVersion(value.recordVersion) } }
|
||||
export async function listOutsourcedBusiness(query: OutsourcedBusinessQuery): Promise<OutsourcedBusinessList> {
|
||||
const value = await get<OutsourcedBusinessList>(base, query)
|
||||
return { ...value, records: value.records.map(row), page: count(value.page), size: count(value.size), total: count(value.total), summary: { dispatchCount: count(value.summary.dispatchCount), afterLoadingCount: count(value.summary.afterLoadingCount), deliveringCount: count(value.summary.deliveringCount), deliveredCount: count(value.summary.deliveredCount) } }
|
||||
}
|
||||
export async function getOutsourcedBusiness(no: string) { const value = detail(await get<OutsourcedBusinessDetail>(path(no))); if (value.row.dispatchNo !== no) throw new Error('运营资料与当前排车业务号不一致'); return value }
|
||||
export async function listOutsourcedBusinessHistory(no: string, page: number, size: number): Promise<OutsourcedBusinessPage<OutsourcedBusinessHistory>> {
|
||||
const value = await get<OutsourcedBusinessPage<OutsourcedBusinessHistory>>(`${path(no)}/history`, { page, size })
|
||||
return { ...value, records: value.records.map(record => ({ ...record, operations: record.operations ? operations(record.operations) : null, recordVersion: record.recordVersion == null ? null : outsourcedBusinessVersion(record.recordVersion), dispatchRecordVersion: record.dispatchRecordVersion == null ? null : outsourcedBusinessVersion(record.dispatchRecordVersion) })), page: count(value.page), size: count(value.size), total: count(value.total) }
|
||||
}
|
||||
export const getOutsourcedBusinessCommand = (no: string, key: string) => get<OutsourcedBusinessCommand>(`${path(no)}/commands/${encodeURIComponent(key)}`).then(command)
|
||||
export async function saveOutsourcedBusinessOperations(no: string, body: OutsourcedBusinessSave) {
|
||||
outsourcedBusinessVersion(body.recordVersion)
|
||||
operations(body.operations)
|
||||
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OutsourcedBusinessDetail>, ApiEnvelope<OutsourcedBusinessDetail>>(`${path(no)}/operations`, body)))
|
||||
if (value.row.dispatchNo !== no) throw new Error('运营保存结果与原排车单不一致')
|
||||
return value
|
||||
}
|
||||
export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) {
|
||||
requireSlot(slot)
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
|
||||
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
|
||||
const value = unwrapApiResponse(await http.post<ApiEnvelope<OutsourcedBusinessPhoto>, ApiEnvelope<OutsourcedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
|
||||
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
|
||||
return value
|
||||
}
|
||||
export async function bindOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, body: OutsourcedBusinessBind) {
|
||||
requireSlot(slot)
|
||||
outsourcedBusinessVersion(body.recordVersion)
|
||||
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OutsourcedBusinessDetail>, ApiEnvelope<OutsourcedBusinessDetail>>(`${path(no)}/photos/${slot}`, body)))
|
||||
if (value.row.dispatchNo !== no) throw new Error('照片绑定结果与原排车单不一致')
|
||||
return value
|
||||
}
|
||||
async function downloadError(payload: unknown, status: number) {
|
||||
let value = payload
|
||||
if (payload instanceof Blob && payload.type.includes('json')) { try { value = JSON.parse(await payload.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') } }
|
||||
if (value && typeof value === 'object' && 'code' in value) { const error = value as ApiEnvelope<unknown>; throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId) }
|
||||
}
|
||||
export async function getOutsourcedBusinessPhotoContent(no: string, photo: OutsourcedBusinessPhoto, revisionVersion?: string): Promise<Blob> {
|
||||
requireSlot(photo.slot)
|
||||
if (revisionVersion != null && BigInt(outsourcedBusinessVersion(revisionVersion)) < 1n) throw new Error('历史图片版本必须大于零')
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } })
|
||||
await downloadError(blob, 200)
|
||||
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
|
||||
return blob
|
||||
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import * as api from '../api/outsourcedBusiness'
|
||||
import { outsourcedBusinessFixture, outsourcedBusinessCommandFixture } from './OutsourcedBusinessFixtures'
|
||||
import { acceptOutsourcedBusinessCommand, newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, outsourcedBusinessPendingKey } from './OutsourcedBusinessCommandRecovery'
|
||||
import type { OutsourcedBusinessPhoto, OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
|
||||
|
||||
beforeEach(() => sessionStorage.clear())
|
||||
const no = 'DS-OUT-1'
|
||||
function operation() : OutsourcedBusinessSave { return { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null } }
|
||||
function client() {
|
||||
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
|
||||
const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null }
|
||||
return { ...api, getOutsourcedBusiness: vi.fn(async () => current), getOutsourcedBusinessCommand: vi.fn(async () => outsourcedBusinessCommandFixture()), saveOutsourcedBusinessOperations: vi.fn(async () => current), bindOutsourcedBusinessPhoto: vi.fn(async () => current), uploadOutsourcedBusinessPhoto: vi.fn(async () => photo) }
|
||||
}
|
||||
describe('outsourced business exact command recovery', () => {
|
||||
it('persists the complete original payload, actor and zero auxiliary version', () => {
|
||||
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation())
|
||||
persistOutsourcedBusinessPending('actor', no, 'operations', pending)
|
||||
expect(restoreOutsourcedBusinessPending('actor', no, 'operations')).toEqual(pending)
|
||||
expect(restoreOutsourcedBusinessPending('another', no, 'operations')).toBeNull()
|
||||
const raw = JSON.parse(sessionStorage.getItem(outsourcedBusinessPendingKey('actor',no,'operations'))!)
|
||||
raw.actor = 'another'; sessionStorage.setItem(outsourcedBusinessPendingKey('actor',no,'operations'),JSON.stringify(raw))
|
||||
expect(() => restoreOutsourcedBusinessPending('actor',no,'operations')).toThrow('损坏')
|
||||
})
|
||||
it('looks up an unknown write first and retries only the unchanged original body on explicit not-found', async () => {
|
||||
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()); pending.attempted = true
|
||||
const service = client(), persist = vi.fn()
|
||||
service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
|
||||
await recoverOutsourcedBusinessCommand(pending,persist,() => {},service)
|
||||
expect(service.getOutsourcedBusinessCommand).toHaveBeenCalledWith(no,'original-key')
|
||||
expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledExactlyOnceWith(no,operation())
|
||||
expect(pending.body).toEqual(operation()); expect(pending.body?.recordVersion).toBe('0')
|
||||
})
|
||||
it('retains a proven success through failed fresh reads without writing it a second time', async () => {
|
||||
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()), service = client()
|
||||
service.getOutsourcedBusiness.mockRejectedValueOnce(new Error('read offline'))
|
||||
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('read offline')
|
||||
expect(pending.confirmed?.recordVersion).toBe('1')
|
||||
await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)
|
||||
expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledTimes(1); expect(service.getOutsourcedBusinessCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
it('never retransmits a file for an unknown upload, including command not-found', async () => {
|
||||
const pending = newOutsourcedBusinessPending('actor',no,'UPLOAD_PHOTO',null,'LOADING_SCENE'); pending.attempted = true
|
||||
const service = client(); service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
|
||||
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('禁止重复上传')
|
||||
expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(pending.key).toBeTruthy()
|
||||
})
|
||||
it('keeps the prepared file and exact binding key through unknown binding and later replacement', async () => {
|
||||
const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:'2026-10-20T10:01:00Z' }
|
||||
const pending = newOutsourcedBusinessPending('actor',no,'BIND_PHOTO',{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId},photo.slot)
|
||||
pending.attempted = true
|
||||
const service = client(); const bound = outsourcedBusinessFixture(); bound.row.recordVersion='1'; bound.photos=[photo]
|
||||
const original = outsourcedBusinessCommandFixture({action:'BIND_PHOTO',idempotencyKey:'bind-key',result:bound})
|
||||
service.getOutsourcedBusinessCommand.mockResolvedValue(original)
|
||||
const current = outsourcedBusinessFixture();current.row.recordVersion='2';current.photos=[{...photo,fileId:'9007199254740994'}]
|
||||
service.getOutsourcedBusiness.mockResolvedValue(current)
|
||||
const result = await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)
|
||||
expect(result.detail?.photos[0]?.fileId).toBe('9007199254740994')
|
||||
expect(pending.body).toEqual({recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})
|
||||
expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled();expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled()
|
||||
})
|
||||
it('refuses wrong command identities, stale reads, definite rejection retries and scope changes', async () => {
|
||||
const pending = newOutsourcedBusinessPending('actor',no,'SAVE_OPERATIONS',operation()), service=client()
|
||||
expect(() => acceptOutsourcedBusinessCommand(pending,outsourcedBusinessCommandFixture({dispatchNo:'another'}))).toThrow('不一致')
|
||||
pending.command=outsourcedBusinessCommandFixture({recordVersion:'9007199254740993'})
|
||||
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('早于')
|
||||
pending.rejected=true;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('明确拒绝')
|
||||
pending.rejected=false;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {throw new Error('scope changed')},service)).rejects.toThrow('scope changed')
|
||||
expect(service.saveOutsourcedBusinessOperations).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import * as api from '../api/outsourcedBusiness'
|
||||
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessAction, type OutsourcedBusinessBind, type OutsourcedBusinessCommand, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
|
||||
|
||||
export interface OutsourcedBusinessPending {
|
||||
actor: string; dispatchNo: string; action: OutsourcedBusinessAction; key: string; slot?: OutsourcedBusinessPhotoSlot
|
||||
body: OutsourcedBusinessSave | OutsourcedBusinessBind | null; attempted: boolean; command: OutsourcedBusinessCommand | null; confirmed: { recordVersion: string | null; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto } | null; rejected: boolean
|
||||
}
|
||||
export const outsourcedBusinessPendingKey = (actor: string, no: string, area: string) => `outsourced-business:${encodeURIComponent(actor)}:${encodeURIComponent(no)}:${area}`
|
||||
export function restoreOutsourcedBusinessPending(actor: string, no: string, area: string): OutsourcedBusinessPending | null {
|
||||
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, no, area))
|
||||
if (!raw) return null
|
||||
const pending = JSON.parse(raw) as OutsourcedBusinessPending
|
||||
if (pending.actor !== actor || pending.dispatchNo !== no || !pending.key || !['SAVE_OPERATIONS','UPLOAD_PHOTO','BIND_PHOTO'].includes(pending.action)) throw new Error('原运营请求记录损坏,请保留现场核对')
|
||||
if (pending.action !== 'SAVE_OPERATIONS' && !outsourcedBusinessPhotoRoles.some(role => role.slot === pending.slot) || pending.action !== 'UPLOAD_PHOTO' && (!pending.body || pending.body.idempotencyKey !== pending.key)) throw new Error('原运营请求内容不完整,请保留现场核对')
|
||||
return pending
|
||||
}
|
||||
export function persistOutsourcedBusinessPending(actor: string, no: string, area: string, pending: OutsourcedBusinessPending | null) {
|
||||
if (!actor || !no || pending && (pending.actor !== actor || pending.dispatchNo !== no)) throw new Error('运营请求身份已变化')
|
||||
const key = outsourcedBusinessPendingKey(actor, no, area)
|
||||
if (pending) sessionStorage.setItem(key, JSON.stringify(pending)); else sessionStorage.removeItem(key)
|
||||
}
|
||||
export function newOutsourcedBusinessPending(actor: string, no: string, action: OutsourcedBusinessAction, body: OutsourcedBusinessPending['body'], slot?: OutsourcedBusinessPhotoSlot): OutsourcedBusinessPending {
|
||||
if (!actor || !no) throw new Error('当前运营作业身份缺失')
|
||||
const key = body?.idempotencyKey || crypto.randomUUID()
|
||||
return { actor, dispatchNo: no, action, key, slot, body: body ? structuredClone(body) : null, attempted: false, command: null, confirmed: null, rejected: false }
|
||||
}
|
||||
export function acceptOutsourcedBusinessResult(pending: OutsourcedBusinessPending, result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto) {
|
||||
if (pending.action === 'UPLOAD_PHOTO') {
|
||||
if (!('fileId' in result) || result.slot !== pending.slot || result.category !== 'LOADING' || typeof result.fileId !== 'string' || !/^[1-9]\d*$/.test(result.fileId) || !['image/jpeg','image/png'].includes(result.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
|
||||
pending.confirmed = { recordVersion: null, result }
|
||||
} else {
|
||||
if (!('row' in result) || result.row.dispatchNo !== pending.dispatchNo) throw new Error('运营保存结果与原排车单不一致')
|
||||
pending.confirmed = { recordVersion: outsourcedBusinessVersion(result.row.recordVersion), result }
|
||||
}
|
||||
}
|
||||
export function acceptOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, command: OutsourcedBusinessCommand) {
|
||||
if (command.dispatchNo !== pending.dispatchNo || command.action !== pending.action || command.idempotencyKey !== pending.key) throw new Error('返回结果与原运营请求不一致,请保留原请求核对')
|
||||
pending.command = command
|
||||
if (command.state === 'REJECTED') pending.rejected = true
|
||||
if (command.state !== 'SUCCEEDED') throw new Error(command.state === 'REJECTED' ? '原请求已明确拒绝,请核对原因后重新登记' : '原请求结果尚未确定,请保留原请求继续核对')
|
||||
if (!command.result) throw new Error('原运营命令的成功结果不完整,请继续核对')
|
||||
acceptOutsourcedBusinessResult(pending, command.result)
|
||||
if (pending.action !== 'UPLOAD_PHOTO' && (command.recordVersion == null || command.recordVersion !== pending.confirmed!.recordVersion)) throw new Error('原运营命令版本与结果不一致')
|
||||
}
|
||||
export async function recoverOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, persist: () => void, ensureScope: () => void, client = api): Promise<{ detail?: OutsourcedBusinessDetail; photo?: OutsourcedBusinessPhoto }> {
|
||||
ensureScope()
|
||||
if (pending.rejected) throw new Error('原请求已明确拒绝,请先按明确拒绝结果重新核对')
|
||||
if (pending.command?.state === 'SUCCEEDED') acceptOutsourcedBusinessCommand(pending, pending.command)
|
||||
const previous = pending.attempted
|
||||
if (!pending.confirmed) {
|
||||
let original: OutsourcedBusinessCommand | null = null
|
||||
if (previous) {
|
||||
try { original = await client.getOutsourcedBusinessCommand(pending.dispatchNo, pending.key) }
|
||||
catch (cause) { if (!(cause instanceof ApiResponseError) || !['404','A0404','HTTP_404'].includes(cause.code)) throw cause }
|
||||
ensureScope()
|
||||
}
|
||||
if (original) { try { acceptOutsourcedBusinessCommand(pending, original) } finally { persist() } }
|
||||
else {
|
||||
if (pending.action === 'UPLOAD_PHOTO') throw new Error('图片上传结果未知,禁止重复上传;请继续核对原上传请求')
|
||||
pending.attempted = true; persist()
|
||||
try {
|
||||
const result = pending.action === 'SAVE_OPERATIONS'
|
||||
? await client.saveOutsourcedBusinessOperations(pending.dispatchNo, pending.body as OutsourcedBusinessSave)
|
||||
: await client.bindOutsourcedBusinessPhoto(pending.dispatchNo, pending.slot!, pending.body as OutsourcedBusinessBind)
|
||||
ensureScope(); acceptOutsourcedBusinessResult(pending, result); persist()
|
||||
} catch (cause) {
|
||||
ensureScope()
|
||||
if (!previous && cause instanceof ApiResponseError && ['A0400','A0403','A0404','A0409','400','403','404','409','422'].includes(cause.code)) { pending.rejected = true; persist() }
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
}
|
||||
const confirmed = pending.confirmed!
|
||||
if (pending.action === 'UPLOAD_PHOTO') {
|
||||
const photo = confirmed.result as OutsourcedBusinessPhoto | null
|
||||
if (!photo || photo.slot !== pending.slot || photo.category !== 'LOADING' || typeof photo.fileId !== 'string' || !/^[1-9]\d*$/.test(photo.fileId) || !['image/jpeg','image/png'].includes(photo.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
|
||||
return { photo }
|
||||
}
|
||||
const current = await client.getOutsourcedBusiness(pending.dispatchNo); ensureScope()
|
||||
if (current.row.dispatchNo !== pending.dispatchNo || confirmed.recordVersion == null || BigInt(outsourcedBusinessVersion(current.row.recordVersion)) < BigInt(outsourcedBusinessVersion(confirmed.recordVersion))) throw new Error('当前运营版本早于原操作结果,请继续核对')
|
||||
if (pending.action === 'BIND_PHOTO') {
|
||||
const matches = (photo: OutsourcedBusinessPhoto) => photo.slot === pending.slot && photo.fileId === (pending.body as OutsourcedBusinessBind).fileId && Boolean(photo.savedAt)
|
||||
const original = confirmed.result
|
||||
const originalPhotos = original && 'row' in original ? original.photos : original && 'fileId' in original ? [original] : []
|
||||
if (!current.photos.some(matches) && !originalPhotos.some(matches)) throw new Error('图片绑定结果尚未确认,请保留原文件和请求')
|
||||
}
|
||||
return { detail: current }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
|
||||
defineProps<{ dispatchNo?: string; statusText?: string; loading?: boolean }>()
|
||||
const activeTab = defineModel<'task' | 'route' | 'history'>('activeTab', { default: 'task' })
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density" spacing="compact">
|
||||
<DetailPageLayout aria-label="外配业务详情">
|
||||
<template #header>
|
||||
<div class="outsourced-business-detail-heading">
|
||||
<div><strong>外配业务</strong><span class="outsourced-business-reference">{{ dispatchNo || '—' }}</span><span v-if="statusText">{{ statusText }}</span></div>
|
||||
<div class="density-actions"><slot name="actions" /><el-button class="density-action" @click="emit('back')">返回外配业务</el-button></div>
|
||||
</div>
|
||||
</template>
|
||||
<slot name="state" />
|
||||
<div v-loading="loading" :aria-busy="loading">
|
||||
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||||
<el-tab-pane name="task" label="任务与车辆信息"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span>任务与车辆信息</template><slot v-if="activeTab === 'task'" name="task" /></el-tab-pane>
|
||||
<el-tab-pane name="route" label="路线货物与凭证"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span>路线货物与凭证</template><slot v-if="activeTab === 'route'" name="route" /></el-tab-pane>
|
||||
<el-tab-pane name="history" label="操作记录"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span>操作记录</template><slot v-if="activeTab === 'history'" name="history" /></el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
|
||||
.outsourced-business-detail-heading > div { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
|
||||
.outsourced-business-reference { overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { OutsourcedBusinessCommand, OutsourcedBusinessDetail } from '../outsourcedBusinessTypes'
|
||||
|
||||
export function outsourcedBusinessFixture(): OutsourcedBusinessDetail {
|
||||
const review = { status: 'APPROVED' as const, reason: null, reviewerId: null, reviewedAt: '2026-10-20T10:00:00Z', reviewRound: '1', basisHash: null, invalidatedReason: null }
|
||||
return {
|
||||
row: {
|
||||
dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', plannedDepartureAt: '2026-10-20T10:00:00Z', loadingCompletedAt: '2026-10-20T09:00:00Z', departedAt: null, deliveryCompletedAt: null,
|
||||
origin: { warehouseId: '1', warehouseCode: 'WH-1', warehouseName: '起运仓' }, vehicle: { vehicleId: '2', plate: 'OUT-1', ownership: 'DISPATCHED', carrierName: '测试外配承运商' }, driver: { name: null, phone: null },
|
||||
destinations: [{ destinationKey: 'destination-1', warehouseCode: 'FBA-1', warehouseName: '目的仓', address: null, appointments: [{ appointmentNo: 'AP-1', confirmationNo: null, scheduledAt: null, allocatedPackages: '2' }], actualArrivedAt: null }],
|
||||
actualPalletCount: 1, operations: { money: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
|
||||
photoSummary: { loadingCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
|
||||
},
|
||||
dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
|
||||
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
|
||||
}
|
||||
}
|
||||
export function outsourcedBusinessCommandFixture(overrides: Partial<OutsourcedBusinessCommand> = {}): OutsourcedBusinessCommand {
|
||||
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = overrides.recordVersion ?? '1'
|
||||
return { dispatchNo: 'DS-OUT-1', action: 'SAVE_OPERATIONS', idempotencyKey: 'original-key', state: 'SUCCEEDED', recordVersion: '1', replayed: false, result: detail, errorCode: null, retryAllowed: false, ...overrides }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import OutsourcedBusinessDetailLayout from './OutsourcedBusinessDetailLayout.vue'
|
||||
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
|
||||
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
|
||||
|
||||
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
|
||||
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
|
||||
const Pane = defineComponent({ props: ['name', 'label'], template: '<article><slot /></article>' })
|
||||
const Dialog = defineComponent({ props: ['width', 'showConfirm', 'beforeClose'], template: '<div><slot /></div>' })
|
||||
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, AppDialog: Dialog, ElTabs: Tabs, ElTabPane: Pane, ElButton: Surface }, directives: { loading: () => {} } }
|
||||
|
||||
describe('OutsourcedBusiness presentation boundaries', () => {
|
||||
it('mounts only the selected one of the three independent detail regions', async () => {
|
||||
const wrapper = mount(OutsourcedBusinessDetailLayout, { global, props: { dispatchNo: 'DS-OUT-1' }, slots: { task: '<p>task-facts</p>', route: '<p>route-facts</p>', history: '<p>history-facts</p>' } })
|
||||
expect(wrapper.findAllComponents(Pane).map(pane => pane.props('label'))).toEqual(['任务与车辆信息', '路线货物与凭证', '操作记录'])
|
||||
expect(wrapper.text()).toContain('task-facts'); expect(wrapper.text()).not.toContain('history-facts')
|
||||
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'history'); await wrapper.vm.$nextTick()
|
||||
expect(wrapper.text()).toContain('history-facts'); expect(wrapper.text()).not.toContain('task-facts')
|
||||
})
|
||||
|
||||
it('preserves only five approved photo roles without demo images or uploaded claims', () => {
|
||||
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
|
||||
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
|
||||
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
|
||||
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证'])
|
||||
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
|
||||
})
|
||||
|
||||
it('blocks a close while busy and delegates unsaved-result decisions to the owner', async () => {
|
||||
const guard = vi.fn(), done = vi.fn()
|
||||
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true, busy: true, beforeClose: guard } })
|
||||
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
|
||||
close(done); expect(guard).not.toHaveBeenCalled(); expect(done).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ busy: false }); close(done)
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
|
||||
const done = vi.fn(), guard = vi.fn()
|
||||
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
|
||||
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
|
||||
close(done); expect(done).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ beforeClose: guard }); close(done)
|
||||
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density" spacing="compact">
|
||||
<slot name="statistics" />
|
||||
<ListPageLayout aria-label="外配业务任务列表">
|
||||
<template v-if="$slots.filters" #toolbar><slot name="filters" /></template>
|
||||
<template v-if="$slots.state" #state><slot name="state" /></template>
|
||||
<slot />
|
||||
</ListPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
dispatchNo?: string
|
||||
busy?: boolean
|
||||
saveDisabled?: boolean
|
||||
pending?: boolean
|
||||
beforeClose?: (done: () => void) => void | Promise<void>
|
||||
}>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const emit = defineEmits<{ save: [] }>()
|
||||
function close(done: () => void) {
|
||||
if (props.busy) return
|
||||
if (props.beforeClose) return props.beforeClose(done)
|
||||
if (!props.pending) done()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" :confirm-text="pending ? '核对并重试原请求' : '保存登记'" :before-close="close" :close-on-press-escape="!busy" @confirm="emit('save')">
|
||||
<p class="outsourced-business-operation-reference">排车单号 {{ dispatchNo || '—' }}</p>
|
||||
<slot name="state" />
|
||||
<fieldset class="outsourced-business-operation-section"><legend>整车承运金额</legend><slot name="amount" /><p>整车只登记一次;单价采用系统返回的承运金额与实际物理托数计算结果。</p></fieldset>
|
||||
<fieldset class="outsourced-business-operation-section"><legend>逐目的地实际到达时间</legend><slot name="arrivals" /></fieldset>
|
||||
<p>登记运营信息不推进排车或派送状态;照片在独立上传照片窗口中处理。</p>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-operation-reference { overflow-wrap: anywhere; }
|
||||
.outsourced-business-operation-section { min-width: 0; margin: 0 0 var(--admin-space-4); padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
|
||||
.outsourced-business-operation-section legend { padding: 0 var(--admin-space-2); font-weight: 600; }
|
||||
</style>
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import OutsourcedBusinessOperationsEditor from './OutsourcedBusinessOperationsEditor.vue'
|
||||
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
|
||||
|
||||
const calls = vi.hoisted(() => ({ save: vi.fn(), detail: vi.fn(), command: vi.fn(), canEdit: true }))
|
||||
vi.mock('../api/outsourcedBusiness', () => ({ saveOutsourcedBusinessOperations: calls.save, getOutsourcedBusiness: calls.detail, getOutsourcedBusinessCommand: calls.command }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'actor' } }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => calls.canEdit }) }))
|
||||
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
const Frame = defineComponent({ props: ['modelValue','busy','saveDisabled','pending'], emits: ['save','update:modelValue'], setup(props,{ slots,emit }) { return () => h('div', [...Object.values(slots).map(slot => slot?.()), h('button',{ 'data-save':'',disabled: props.busy || props.saveDisabled,onClick: () => emit('save') },'save')]) } })
|
||||
const Control = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' })
|
||||
const Field = defineComponent({ props: ['label'], template: '<label>{{label}}<slot /></label>' })
|
||||
const global = { stubs: { OutsourcedBusinessOperationsDialog: Frame, ElForm: { template:'<div><slot /></div>' }, ElFormItem: Field, ElInput: Control, ElSelect: Control, ElOption: true, ElDatePicker: Control, LoadErrorState: { props:['message'],template:'<div>{{message}}</div>' }, ApiErrorMeta: true } }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); sessionStorage.clear(); calls.canEdit = true
|
||||
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
|
||||
calls.save.mockResolvedValue(current); calls.detail.mockResolvedValue(current)
|
||||
calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
|
||||
})
|
||||
|
||||
describe('outsourced operations registration', () => {
|
||||
it('sends explicit zero and destination arrival with auxiliary zero, without fuel or mileage fields', async () => {
|
||||
const wrapper = mount(OutsourcedBusinessOperationsEditor,{ global,props:{modelValue:true,detail:outsourcedBusinessFixture()} }); await flushPromises()
|
||||
const currency = wrapper.findAll('label').find(field => field.text() === '币种')!.find('input')
|
||||
const amount = wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input')
|
||||
await currency.setValue('USD'); await amount.setValue('0.00')
|
||||
await wrapper.findAll('input').at(-1)!.setValue('2026-10-20T12:00:00+08:00')
|
||||
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
|
||||
const body = calls.save.mock.calls[0]![1]
|
||||
expect(body).toEqual({ recordVersion:'0',idempotencyKey:expect.any(String),operations:{money:{currency:'USD',amount:'0.00'},arrivals:[{destinationKey:'destination-1',actualArrivedAt:'2026-10-20T12:00:00+08:00'}]},reason:null })
|
||||
expect(calls.save).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('里程'); wrapper.unmount()
|
||||
})
|
||||
it('clears money as null and retains exactly the original request after an unknown save', async () => {
|
||||
const detail = outsourcedBusinessFixture(); detail.row.operations.money = {currency:'CNY',amount:'123.45'}
|
||||
calls.save.mockRejectedValueOnce(new TypeError('network interrupted'))
|
||||
const wrapper = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail}}); await flushPromises()
|
||||
await wrapper.findAll('label').find(field => field.text() === '币种')!.find('input').setValue('')
|
||||
await wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input').setValue('')
|
||||
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
|
||||
const original = JSON.parse(JSON.stringify(calls.save.mock.calls[0]![1]))
|
||||
expect(original.operations.money).toBeNull(); expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(calls.command).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',original.idempotencyKey)
|
||||
expect(calls.save).toHaveBeenNthCalledWith(2,'DS-OUT-1',original); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('does not write for an unchanged form or a user without edit permission', async () => {
|
||||
const first = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
|
||||
await first.find('[data-save]').trigger('click'); await flushPromises(); expect(calls.save).not.toHaveBeenCalled(); first.unmount()
|
||||
calls.canEdit = false
|
||||
const readonly = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
|
||||
expect(readonly.find('[data-save]').attributes('disabled')).toBeDefined(); expect(calls.save).not.toHaveBeenCalled(); readonly.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
|
||||
import { newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
|
||||
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessMoney, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { getOutsourcedBusiness } from '../api/outsourcedBusiness'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
|
||||
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), pending = ref<OutsourcedBusinessPending | null>(null), error = ref<ApiErrorDetails | null>(null), corrupted = ref(false)
|
||||
const form = reactive({ currency: '', amount: '', reason: '', arrivals: {} as Record<string,string> })
|
||||
const active = computed(() => open.value || Boolean(pending.value))
|
||||
const registration = useUnsavedWorkRegistration({ key: `outsourced-business-operations:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务运营登记'), isOpen: active, model: reactive({ form, pending }), requiredPermission: computed(() => permission.outsourcedBusinessEdit) })
|
||||
let actor = '', number = '', baseline = ''
|
||||
const writable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessEdit) && props.detail?.row.actions.edit.allowed === true)
|
||||
const locked = computed(() => busy.value || Boolean(pending.value))
|
||||
function persist() { persistOutsourcedBusinessPending(actor, number, 'operations', pending.value) }
|
||||
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('操作身份或排车单已变化,请重新打开') }
|
||||
watch(busy, value => emit('busy', value))
|
||||
watch(open, async value => {
|
||||
if (!value || !props.detail) return
|
||||
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo; error.value = null; corrupted.value = false
|
||||
try {
|
||||
pending.value = restoreOutsourcedBusinessPending(actor, number, 'operations')
|
||||
const operations = pending.value?.action === 'SAVE_OPERATIONS' ? (pending.value.body as OutsourcedBusinessSave).operations : props.detail.row.operations
|
||||
Object.assign(form, { currency: operations.money?.currency || '', amount: operations.money?.amount ?? '', reason: pending.value ? (pending.value.body as OutsourcedBusinessSave).reason || '' : '', arrivals: Object.fromEntries(props.detail.row.destinations.map(destination => [destination.destinationKey, operations.arrivals.find(arrival => arrival.destinationKey === destination.destinationKey)?.actualArrivedAt || ''])) })
|
||||
baseline = JSON.stringify(form); await nextTick(); if (!pending.value) registration.captureBaseline()
|
||||
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '运营资料恢复失败') }
|
||||
}, { immediate: true })
|
||||
function payload(): OutsourcedBusinessSave {
|
||||
const amount = outsourcedBusinessDecimal(form.amount, 2, 18)
|
||||
let money: OutsourcedBusinessMoney | null = null
|
||||
if (amount != null) { if (!['CNY','USD'].includes(form.currency)) throw new Error('请选择金额币种'); money = { currency: form.currency as OutsourcedBusinessMoney['currency'], amount } }
|
||||
else if (form.currency) throw new Error('金额和币种请成对填写,清空时同时清空两项')
|
||||
const arrivals = props.detail!.row.destinations.map(destination => {
|
||||
const value = form.arrivals[destination.destinationKey] || null
|
||||
if (value && (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))) throw new Error('实际到达时间必须包含有效时区')
|
||||
return { destinationKey: destination.destinationKey, actualArrivedAt: value }
|
||||
})
|
||||
return { recordVersion: outsourcedBusinessVersion(props.detail!.row.recordVersion), idempotencyKey: crypto.randomUUID(), operations: { money, arrivals }, reason: form.reason.trim() || null }
|
||||
}
|
||||
async function save() {
|
||||
if (busy.value || !writable.value || !props.detail) return
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope()
|
||||
if (!pending.value) { if (JSON.stringify(form) === baseline) { open.value = false; return }; pending.value = newOutsourcedBusinessPending(actor, number, 'SAVE_OPERATIONS', payload()); persist() }
|
||||
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
|
||||
if (!result.detail) throw new Error('运营登记结果未确认')
|
||||
pending.value = null; persist(); registration.captureBaseline(); emit('saved', result.detail); open.value = false; ElMessage.success('运营信息已保存')
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '运营登记结果尚未确认') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function beforeClose(done: () => void) {
|
||||
if (busy.value) return
|
||||
if (pending.value || JSON.stringify(form) !== baseline) {
|
||||
try { await ElMessageBox.confirm(pending.value ? '原请求将保留,重新打开后可核对原结果。是否关闭?' : '放弃尚未保存的运营信息?', '关闭运营登记', { customClass: 'admin-density-confirm', confirmButtonText: pending.value ? '保留请求并关闭' : '放弃修改', cancelButtonText: '继续编辑' }) } catch { return }
|
||||
}
|
||||
done()
|
||||
}
|
||||
async function discardRejected() {
|
||||
if (!pending.value?.rejected || busy.value) return
|
||||
busy.value = true
|
||||
try { const current = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', current); error.value = null }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '当前运营版本核对失败') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OutsourcedBusinessOperationsDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :pending="Boolean(pending)" :save-disabled="!writable" :before-close="beforeClose" @save="save">
|
||||
<template #state><LoadErrorState v-if="error" title="运营登记未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原请求已保留,字段锁定;核对结果前不会建立新请求。</p><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝结果重新编辑</el-button><p v-if="!writable">{{ detail?.row.actions.edit.blockReason || '没有运营登记权限' }}</p></template>
|
||||
<template #amount><el-form label-position="top" class="outsourced-business-form"><el-form-item label="币种"><el-select v-model="form.currency" clearable :disabled="locked"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select></el-form-item><el-form-item label="整车承运金额"><el-input v-model="form.amount" :disabled="locked" placeholder="未登记;允许明确填写0" /></el-form-item><el-form-item label="更正说明"><el-input v-model="form.reason" :disabled="locked" maxlength="1000" /></el-form-item></el-form></template>
|
||||
<template #arrivals><div v-for="destination in detail?.row.destinations || []" :key="destination.destinationKey" class="outsourced-business-arrival"><span>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}<small v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}</small></span><el-date-picker v-model="form.arrivals[destination.destinationKey]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" :disabled="locked" placeholder="实际到达时间,未登记留空" /></div><p>时间按浏览器本地时区输入,提交时保留时区。</p></template>
|
||||
</OutsourcedBusinessOperationsDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-4); }
|
||||
.outsourced-business-arrival { display: grid; grid-template-columns: minmax(0,1fr) 280px; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-3); }
|
||||
.outsourced-business-arrival small { display: block; }
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { outsourcedBusinessPhotoRoles } from '../outsourcedBusinessTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
dispatchNo?: string
|
||||
busy?: boolean
|
||||
beforeClose?: (done: () => void) => void | Promise<void>
|
||||
}>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
function close(done: () => void) {
|
||||
if (props.busy) return
|
||||
if (props.beforeClose) return props.beforeClose(done)
|
||||
done()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
|
||||
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}</p>
|
||||
<slot name="state" />
|
||||
<p>每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</p>
|
||||
<div class="outsourced-business-photo-grid">
|
||||
<section v-for="photo in outsourcedBusinessPhotoRoles" :key="photo.slot" class="outsourced-business-photo-slot" :aria-label="photo.label">
|
||||
<h3>{{ photo.label }}</h3><slot :name="photo.viewSlot"><p>暂无照片</p></slot>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-photo-reference { overflow-wrap: anywhere; }
|
||||
.outsourced-business-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.outsourced-business-photo-slot { min-width: 0; padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
|
||||
.outsourced-business-photo-slot h3 { margin: 0 0 var(--admin-space-3); font-size: inherit; }
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import OutsourcedBusinessPhotosEditor from './OutsourcedBusinessPhotosEditor.vue'
|
||||
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
|
||||
import type { OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes'
|
||||
|
||||
const calls=vi.hoisted(()=>({upload:vi.fn(),bind:vi.fn(),command:vi.fn(),detail:vi.fn(),content:vi.fn(),actor:{user:{id:'actor'}},canUpload:true}))
|
||||
vi.mock('../api/outsourcedBusiness',()=>({uploadOutsourcedBusinessPhoto:calls.upload,bindOutsourcedBusinessPhoto:calls.bind,getOutsourcedBusinessCommand:calls.command,getOutsourcedBusiness:calls.detail,getOutsourcedBusinessPhotoContent:calls.content}))
|
||||
vi.mock('@/core/auth/auth',()=>({useAuthStore:()=>calls.actor}))
|
||||
vi.mock('@/core/access/accessPreview',()=>({useAccessPreviewStore:()=>({can:()=>calls.canUpload})}))
|
||||
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration',()=>({useUnsavedWorkRegistration:()=>({captureBaseline:vi.fn()})}))
|
||||
vi.mock('element-plus',()=>({ElMessage:{success:vi.fn()},ElMessageBox:{confirm:vi.fn()}}))
|
||||
const Frame=defineComponent({props:['modelValue','busy','beforeClose'],setup(_props,{slots}){return()=>h('div',Object.values(slots).map(slot=>slot?.()))}})
|
||||
const Button=defineComponent({props:['disabled'],template:'<button :disabled="disabled"><slot /></button>'})
|
||||
const global={stubs:{OutsourcedBusinessPhotosDialog:Frame,ElButton:Button,LoadErrorState:{props:['message'],template:'<div>{{message}}<slot name="details" /></div>'},ApiErrorMeta:true}}
|
||||
const photo:OutsourcedBusinessPhoto={slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null}
|
||||
async function choose(wrapper:ReturnType<typeof mount>){const input=wrapper.find('input[type="file"]');Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['image'],'loading.png',{type:'image/png'})]});await input.trigger('change');await flushPromises()}
|
||||
beforeEach(()=>{
|
||||
vi.clearAllMocks();sessionStorage.clear();calls.actor.user.id='actor';calls.canUpload=true
|
||||
calls.upload.mockResolvedValue(photo);calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
|
||||
const current=outsourcedBusinessFixture();current.row.recordVersion='1';current.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
|
||||
calls.bind.mockResolvedValue(current);calls.detail.mockResolvedValue(current)
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
describe('outsourced business per-slot photo save',()=>{
|
||||
it('retains prepared file and original binding request through a failed bind without retransmitting the image',async()=>{
|
||||
calls.bind.mockRejectedValueOnce(new TypeError('network interrupted'))
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
|
||||
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('尚未确认绑定保存');expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
const original={...calls.bind.mock.calls[0]![2]}
|
||||
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
|
||||
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(2)
|
||||
expect(calls.bind.mock.calls[1]![2]).toEqual(original);expect(original.fileId).toBe(photo.fileId);expect(original.recordVersion).toBe('0')
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1);wrapper.unmount()
|
||||
})
|
||||
it('does not offer a new upload after unknown upload or fabricate a saved photo',async()=>{
|
||||
calls.upload.mockRejectedValue(new TypeError('network interrupted'))
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
|
||||
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
|
||||
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).not.toHaveBeenCalled();expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined();expect(wrapper.text()).toContain('禁止重复上传');wrapper.unmount()
|
||||
})
|
||||
it('keeps saved metadata readable without upload permission and fetches image bytes only on explicit preview',async()=>{
|
||||
calls.canUpload=false
|
||||
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
|
||||
expect(wrapper.find('input[type="file"]').exists()).toBe(false);expect(wrapper.text()).toContain('已保存');expect(calls.content).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('reads a saved loading photo only on click and releases its URL on closing',async()=>{
|
||||
const create=vi.fn(()=> 'blob:outsourced-loading'),revoke=vi.fn()
|
||||
vi.stubGlobal('URL',class extends URL { static createObjectURL=create; static revokeObjectURL=revoke })
|
||||
calls.canUpload=false;calls.content.mockResolvedValue(new Blob(['image'],{type:'image/png'}))
|
||||
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
|
||||
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
|
||||
expect(calls.content).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises()
|
||||
expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading')
|
||||
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
|
||||
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot } from '../outsourcedBusinessTypes'
|
||||
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, uploadOutsourcedBusinessPhoto } from '../api/outsourcedBusiness'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
import { acceptOutsourcedBusinessResult, newOutsourcedBusinessPending, outsourcedBusinessPendingKey, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
|
||||
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
|
||||
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null), pending = ref<OutsourcedBusinessPending | null>(null), prepared = ref<OutsourcedBusinessPhoto | null>(null), corrupted = ref(false)
|
||||
const urls = ref<Partial<Record<OutsourcedBusinessPhotoSlot,string>>>({}), reading = ref<OutsourcedBusinessPhotoSlot | null>(null)
|
||||
const uploadable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
|
||||
useUnsavedWorkRegistration({ key: `outsourced-business-photos:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务照片作业'), isOpen: computed(() => open.value || Boolean(pending.value) || Boolean(prepared.value)), model: reactive({ pending, prepared }), requiredPermission: computed(() => permission.outsourcedBusinessUpload) })
|
||||
let actor = '', number = '', generation = 0
|
||||
function savedPhoto(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
|
||||
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
|
||||
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
|
||||
function persist() { persistOutsourcedBusinessPending(actor, number, 'photos', pending.value) }
|
||||
function persistPrepared() {
|
||||
const key = outsourcedBusinessPendingKey(actor, number, 'prepared-photo')
|
||||
if (prepared.value) sessionStorage.setItem(key, JSON.stringify(prepared.value)); else sessionStorage.removeItem(key)
|
||||
}
|
||||
watch(busy, value => emit('busy', value))
|
||||
watch(open, value => {
|
||||
generation++; release(); reading.value = null; error.value = null; corrupted.value = false
|
||||
if (!value || !props.detail) return
|
||||
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo
|
||||
try {
|
||||
pending.value = restoreOutsourcedBusinessPending(actor, number, 'photos')
|
||||
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, number, 'prepared-photo'))
|
||||
prepared.value = raw ? JSON.parse(raw) as OutsourcedBusinessPhoto : null
|
||||
if (prepared.value && (!prepared.value.fileId || !outsourcedBusinessPhotoRoles.some(role => role.slot === prepared.value!.slot))) throw new Error('原上传文件资料损坏,请保留现场核对')
|
||||
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '照片作业恢复失败') }
|
||||
}, { immediate: true })
|
||||
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
|
||||
onBeforeUnmount(() => { generation++; release() })
|
||||
async function preview(slot: OutsourcedBusinessPhotoSlot) {
|
||||
const photo = savedPhoto(slot), current = generation
|
||||
if (!photo || reading.value) return
|
||||
reading.value = slot; error.value = null
|
||||
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
|
||||
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
|
||||
finally { if (current === generation) reading.value = null }
|
||||
}
|
||||
async function bindPrepared() {
|
||||
if ((!prepared.value && !pending.value) || !props.detail || !uploadable.value) return
|
||||
if (!pending.value) {
|
||||
const body = { recordVersion: outsourcedBusinessVersion(props.detail.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: prepared.value!.fileId }
|
||||
pending.value = newOutsourcedBusinessPending(actor, number, 'BIND_PHOTO', body, prepared.value!.slot); persist()
|
||||
}
|
||||
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
|
||||
if (!result.detail) throw new Error('照片绑定尚未确认')
|
||||
pending.value = null; persist(); prepared.value = null; persistPrepared(); emit('saved', result.detail); ElMessage.success('照片已绑定保存')
|
||||
}
|
||||
async function recover() {
|
||||
if (busy.value || !uploadable.value) return
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope()
|
||||
if (pending.value?.action === 'UPLOAD_PHOTO') {
|
||||
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
|
||||
if (!result.photo) throw new Error('原上传文件未确认')
|
||||
prepared.value = result.photo; persistPrepared(); pending.value = null; persist()
|
||||
}
|
||||
await bindPrepared()
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '照片结果尚未确认') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
|
||||
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
|
||||
if (!file || busy.value || pending.value || prepared.value || !uploadable.value) return
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
|
||||
busy.value = true; error.value = null
|
||||
try {
|
||||
ensureScope(); pending.value = newOutsourcedBusinessPending(actor, number, 'UPLOAD_PHOTO', null, slot); pending.value.attempted = true; persist()
|
||||
const uploaded = await uploadOutsourcedBusinessPhoto(number, slot, file, pending.value.key)
|
||||
ensureScope(); acceptOutsourcedBusinessResult(pending.value, uploaded); persist()
|
||||
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
|
||||
if (!result.photo) throw new Error('真实上传文件未确认')
|
||||
prepared.value = result.photo; persistPrepared(); pending.value = null; persist(); await bindPrepared()
|
||||
} catch (cause) {
|
||||
if (pending.value?.action === 'UPLOAD_PHOTO' && cause instanceof ApiResponseError && ['A0400','A0403','A0404','400','403','404','422'].includes(cause.code)) { pending.value.rejected = true; persist() }
|
||||
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '图片上传或绑定结果尚未确认')
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
async function discardRejected() {
|
||||
if (busy.value || !pending.value?.rejected) return
|
||||
busy.value = true
|
||||
try { const latest = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', latest); error.value = null }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '当前照片版本读取失败') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function beforeClose(done: () => void) {
|
||||
if (busy.value) return
|
||||
if (pending.value || prepared.value) {
|
||||
try { await ElMessageBox.confirm('未完成的原上传或绑定记录将保留,重新打开后继续核对;关闭不会保存尚未绑定的照片。是否关闭?', '关闭照片窗口', { customClass: 'admin-density-confirm', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
|
||||
}
|
||||
done()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OutsourcedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
|
||||
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原{{ pending.action === 'UPLOAD_PHOTO' ? '上传' : '绑定' }}请求已保留,不会重新上传文件。</p><p v-if="prepared">{{ prepared.fileName }} 已上传,尚未确认绑定保存。</p><el-button v-if="pending || prepared" :loading="busy" :disabled="!uploadable" @click="recover">核对原结果并继续绑定</el-button><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝重新核对,保留已上传文件</el-button><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
|
||||
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
|
||||
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="outsourced-business-photo" />
|
||||
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
|
||||
<p v-else>暂无已保存照片</p>
|
||||
<label v-if="uploadable" class="outsourced-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy || Boolean(pending) || Boolean(prepared)" @change="upload(role.slot, $event)" /></label>
|
||||
</template>
|
||||
</OutsourcedBusinessPhotosDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-photo { display: block; width: 100%; max-height: 160px; object-fit: contain; }
|
||||
.outsourced-business-upload { display: block; margin-top: var(--admin-space-3); }
|
||||
.outsourced-business-upload input { display: block; width: 100%; margin-top: var(--admin-space-2); }
|
||||
</style>
|
||||
@@ -29,7 +29,7 @@ describe('owned business per-slot photo save',()=>{
|
||||
const wrapper=mount(OwnedBusinessPhotosEditor,{global,props:{modelValue:true,detail:ownedBusinessFixture()}});await choose(wrapper)
|
||||
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('尚未确认绑定保存');expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
const original=structuredClone(calls.bind.mock.calls[0]![2])
|
||||
const original={...calls.bind.mock.calls[0]![2]}
|
||||
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
|
||||
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(2)
|
||||
expect(calls.bind.mock.calls[1]![2]).toEqual(original);expect(original.fileId).toBe(photo.fileId);expect(original.recordVersion).toBe('0')
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessText, outsourcedBusinessPhotoRoles } from './outsourcedBusinessTypes'
|
||||
|
||||
describe('outsourced business decimal and version boundary', () => {
|
||||
it('supports exactly the five approved loading photo slots', () => {
|
||||
expect(outsourcedBusinessPhotoRoles.map(role => role.slot)).toEqual(['LOADING_SCENE','LOADING_LIST','PRE_DEPARTURE_CHECK','DRIVER_LICENSE','RECEIPT'])
|
||||
expect(outsourcedBusinessPhotoRoles.every(role => role.category === 'LOADING')).toBe(true)
|
||||
})
|
||||
it('preserves the maximum decimal money text and distinguishes empty from explicit zero', () => {
|
||||
expect(outsourcedBusinessDecimal('9999999999999999.99', 2, 18)).toBe('9999999999999999.99')
|
||||
expect(outsourcedBusinessDecimal('0.00', 2, 18)).toBe('0.00'); expect(outsourcedBusinessDecimal(' ', 2, 18)).toBeNull()
|
||||
expect(() => outsourcedBusinessDecimal('10000000000000000.00', 2, 18)).toThrow()
|
||||
for (const value of ['-1','1e3','1.001','01','NaN','Infinity']) expect(() => outsourcedBusinessDecimal(value, 2, 18)).toThrow()
|
||||
expect(outsourcedBusinessDecimal('123.456', 3, 12)).toBe('123.456')
|
||||
})
|
||||
it('keeps version zero and large string identities without accepting unsafe numeric versions', () => {
|
||||
expect(outsourcedBusinessVersion(0)).toBe('0'); expect(outsourcedBusinessVersion('9007199254740993')).toBe('9007199254740993')
|
||||
for (const value of [undefined,null,-1,0.5,Number.MAX_SAFE_INTEGER + 1,'','1e3']) expect(() => outsourcedBusinessVersion(value)).toThrow()
|
||||
expect(outsourcedBusinessText(null)).toBe('—'); expect(outsourcedBusinessText(0)).toBe('0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
|
||||
export type OutsourcedBusinessStage = 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
|
||||
export type OutsourcedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEPARTURE_CHECK' | 'DRIVER_LICENSE' | 'RECEIPT'
|
||||
export interface OutsourcedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
|
||||
export interface OutsourcedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
|
||||
export interface OutsourcedBusinessOperations { money: OutsourcedBusinessMoney | null; arrivals: OutsourcedBusinessArrival[] }
|
||||
export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
|
||||
export interface OutsourcedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
|
||||
export interface OutsourcedBusinessRow {
|
||||
dispatchNo: string; stage: OutsourcedBusinessStage; plannedDepartureAt: string | null; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
|
||||
origin: { warehouseId: string; warehouseCode: string; warehouseName: string }
|
||||
vehicle: { vehicleId: string | null; plate: string | null; ownership: 'DISPATCHED'; carrierName: string | null }; driver: { name: string | null; phone: string | null }
|
||||
destinations: OutsourcedBusinessDestination[]; actualPalletCount: number | null; operations: OutsourcedBusinessOperations; unitPrice: string | null; recordVersion: string; dispatchRecordVersion: string
|
||||
photoSummary: { loadingCount: number }; actions: { edit: OutsourcedBusinessGate; upload: OutsourcedBusinessGate }; sourceIssues: string[]
|
||||
}
|
||||
export interface OutsourcedBusinessGate { allowed: boolean; blockReason: string | null }
|
||||
export interface OutsourcedBusinessQuery { page?: number; size?: number; plannedDepartureFrom?: string; plannedDepartureTo?: string; keyword?: string; stage?: OutsourcedBusinessStage; warehouseId?: string }
|
||||
export interface OutsourcedBusinessSummary { dispatchCount: number; afterLoadingCount: number; deliveringCount: number; deliveredCount: number }
|
||||
export interface OutsourcedBusinessPage<T> { records: T[]; page: number; size: number; total: number }
|
||||
export interface OutsourcedBusinessList extends OutsourcedBusinessPage<OutsourcedBusinessRow> { summary: OutsourcedBusinessSummary }
|
||||
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] }
|
||||
export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null }
|
||||
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
|
||||
export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
|
||||
export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
|
||||
export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] }
|
||||
export const outsourcedBusinessStages: Record<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
|
||||
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
|
||||
export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [
|
||||
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' },
|
||||
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' },
|
||||
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' },
|
||||
]
|
||||
export function outsourcedBusinessVersion(value: unknown): string {
|
||||
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
|
||||
return String(value)
|
||||
}
|
||||
export function outsourcedBusinessDecimal(value: string, scale: number, precision: number): string | null {
|
||||
const text = value.trim()
|
||||
if (!text) return null
|
||||
if (!new RegExp(`^(?:0|[1-9]\\d*)(?:\\.\\d{1,${scale}})?$`).test(text) || text.split('.')[0]!.length > precision - scale) throw new Error(`请输入不超过${scale}位小数的有效非负数`)
|
||||
return text
|
||||
}
|
||||
export function outsourcedBusinessText(value: unknown): string { return value == null || value === '' ? '—' : String(value) }
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import OutsourcedBusinessDetailLayout from '../components/OutsourcedBusinessDetailLayout.vue'
|
||||
import OutsourcedBusinessOperationsEditor from '../components/OutsourcedBusinessOperationsEditor.vue'
|
||||
import OutsourcedBusinessPhotosEditor from '../components/OutsourcedBusinessPhotosEditor.vue'
|
||||
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, listOutsourcedBusinessHistory } from '../api/outsourcedBusiness'
|
||||
import { outsourcedBusinessActionLabels, outsourcedBusinessStages, outsourcedBusinessText as text, outsourcedBusinessPhotoRoles, type OutsourcedBusinessDetail, type OutsourcedBusinessHistory, type OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const dispatchNo = computed(() => typeof route.params.dispatchNo === 'string' ? route.params.dispatchNo : '')
|
||||
const detail = ref<OutsourcedBusinessDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), activeTab = ref<'task'|'route'|'history'>('task')
|
||||
const editorOpen = ref(false), photosOpen = ref(false), busy = ref(false)
|
||||
const history = ref<OutsourcedBusinessHistory[]>([]), historyPage = ref(1), historySize = ref(20), historyTotal = ref(0), historyLoading = ref(false), historyError = ref<ApiErrorDetails | null>(null), previewError = ref<ApiErrorDetails | null>(null)
|
||||
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
|
||||
const previewUrls = new Set<string>()
|
||||
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
|
||||
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
|
||||
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
|
||||
const taskFields = computed(() => {
|
||||
const row = detail.value?.row
|
||||
return [['计划发车', time(row?.plannedDepartureAt)], ['装车完成', time(row?.loadingCompletedAt)], ['实际发车', time(row?.departedAt)], ['派送完成', time(row?.deliveryCompletedAt)], ['车牌', text(row?.vehicle.plate)], ['司机', text(row?.driver.name)], ['司机电话', text(row?.driver.phone)], ['提货仓库', text(row?.origin.warehouseName)], ['实际物理托数', text(row?.actualPalletCount)], ['整车承运金额', row?.operations.money ? `${row.operations.money.currency} ${row.operations.money.amount}` : '未登记'], ['单价', row?.unitPrice == null ? '—' : `${row.operations.money?.currency || ''} ${row.unitPrice}`], ['承运商', text(row?.vehicle.carrierName)]]
|
||||
})
|
||||
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
|
||||
function destinationLabel(key: string) { const value = detail.value?.row.destinations.find(destination => destination.destinationKey === key); return value?.warehouseCode || value?.warehouseName || value?.address || '—' }
|
||||
function photoLabel(photo: OutsourcedBusinessPhoto) { return outsourcedBusinessPhotoRoles.find(role => role.slot === photo.slot)?.label || '照片' }
|
||||
async function load() {
|
||||
if (!active) return
|
||||
const request = ++generation, no = dispatchNo.value; loading.value = true; error.value = null
|
||||
try { if (!no || !access.can(permission.outsourcedBusinessList)) throw new Error('缺少排车业务号或查看权限'); const value = await getOutsourcedBusiness(no); if (active && request === generation) { if (value.row.dispatchNo !== no) throw new Error('当前运营资料与排车业务号不一致'); detail.value = value; if (activeTab.value === 'history') void loadHistory() } }
|
||||
catch (cause) { if (active && request === generation) { detail.value = null; error.value = getApiErrorDetails(cause, '外配业务详情读取失败') } }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
async function loadHistory() {
|
||||
if (!active || activeTab.value !== 'history' || !detail.value) return
|
||||
const request = ++historyGeneration, no = dispatchNo.value; historyLoading.value = true; historyError.value = null
|
||||
try { const value = await listOutsourcedBusinessHistory(no, historyPage.value, historySize.value); if (active && request === historyGeneration && no === dispatchNo.value) { history.value = value.records; historyTotal.value = value.total } }
|
||||
catch (cause) { if (request === historyGeneration) historyError.value = getApiErrorDetails(cause, '操作记录读取失败') }
|
||||
finally { if (request === historyGeneration) historyLoading.value = false }
|
||||
}
|
||||
async function preview(photo: OutsourcedBusinessPhoto, revision: string | null) {
|
||||
const no = dispatchNo.value, request = generation; previewError.value = null
|
||||
if (revision == null) { previewError.value = { message: '历史照片缺少运营资料版本,请重新读取' }; return }
|
||||
const windowRef = window.open('', '_blank')
|
||||
if (!windowRef) { previewError.value = { message: '请允许本站打开图片预览窗口' }; return }
|
||||
try { const blob = await getOutsourcedBusinessPhotoContent(no, photo, revision); if (!active || request !== generation || no !== dispatchNo.value) { windowRef.close(); return }; const url = URL.createObjectURL(blob); previewUrls.add(url); windowRef.location.href = url }
|
||||
catch (cause) { windowRef.close(); previewError.value = getApiErrorDetails(cause, '历史照片读取失败') }
|
||||
}
|
||||
function saved(value: OutsourcedBusinessDetail) { if (value.row.dispatchNo !== dispatchNo.value) return; detail.value = value; if (activeTab.value === 'history') void loadHistory() }
|
||||
watch([activeTab,historyPage,historySize], () => { if (active) void loadHistory() })
|
||||
watch(dispatchNo, () => { generation++; historyGeneration++; detail.value = null; history.value = []; historyTotal.value = 0; activeTab.value = 'task'; editorOpen.value = false; photosOpen.value = false; if (active) void load() })
|
||||
onMounted(load)
|
||||
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
|
||||
onDeactivated(() => { active = false; generation++; historyGeneration++; loading.value = false; historyLoading.value = false; editorOpen.value = false; photosOpen.value = false })
|
||||
onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previewUrls.forEach(url => URL.revokeObjectURL(url)) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OutsourcedBusinessDetailLayout v-model:active-tab="activeTab" :dispatch-no="detail?.row.dispatchNo || dispatchNo" :status-text="detail ? outsourcedBusinessStages[detail.row.stage] : ''" :loading="loading" @back="router.push('/overseas-master-data/outsourced-business')">
|
||||
<template #actions><el-button v-if="detail && access.can(permission.outsourcedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.outsourcedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
|
||||
<template #state><LoadErrorState v-if="error" title="外配业务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
|
||||
<template #task><dl v-if="detail" class="density-fields outsourced-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
|
||||
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="outsourced-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable><section class="outsourced-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
|
||||
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ outsourcedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车承运金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></template>
|
||||
</OutsourcedBusinessDetailLayout>
|
||||
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
|
||||
<OutsourcedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-fields { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); padding: var(--admin-space-4); }
|
||||
.outsourced-business-fields > div { min-width: 0; overflow-wrap: anywhere; }
|
||||
.outsourced-business-route { padding: var(--admin-space-4); border-bottom: 1px solid var(--admin-table-border); }
|
||||
.outsourced-business-route h3 { font-size: inherit; margin: 0 0 var(--admin-space-3); }
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import QueryToolbar, { type QueryField, type QueryModel, type QueryOption } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OutsourcedBusinessListLayout from '../components/OutsourcedBusinessListLayout.vue'
|
||||
import OutsourcedBusinessOperationsEditor from '../components/OutsourcedBusinessOperationsEditor.vue'
|
||||
import OutsourcedBusinessPhotosEditor from '../components/OutsourcedBusinessPhotosEditor.vue'
|
||||
import { getOutsourcedBusiness, listOutsourcedBusiness } from '../api/outsourcedBusiness'
|
||||
import { outsourcedBusinessStages, outsourcedBusinessText as text, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessRow, type OutsourcedBusinessStage } from '../outsourcedBusinessTypes'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const result = ref<OutsourcedBusinessList | null>(null), page = ref(1), size = ref(20), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ plannedRange: [], keyword: '', stage: '', warehouseId: '' })
|
||||
const warehouseOptions = ref<QueryOption[]>([]), warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<OutsourcedBusinessDetail | null>(null), editorOpen = ref(false), photosOpen = ref(false), opening = ref(false), mutating = ref(false)
|
||||
let applied: Omit<OutsourcedBusinessQuery,'page'|'size'> = {}, generation = 0, openGeneration = 0, active = true, initialActivation = true
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'plannedRange', type: 'daterange', label: '计划发车日期', width: 270, props: { valueFormat: 'YYYY-MM-DD', 'aria-label': '计划发车日期范围' } },
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 230, placeholder: '排车单号 / 车牌 / 目的仓', props: { maxlength: 128 } },
|
||||
{ key: 'stage', type: 'select', label: '阶段', width: 140, options: Object.entries(outsourcedBusinessStages).map(([value,label]) => ({ value,label })) },
|
||||
{ key: 'warehouseId', type: 'select', label: '提货仓库', width: 180, options: warehouseOptions.value, props: { disabled: !access.can('platform:warehouse:list') } },
|
||||
])
|
||||
const summary = computed(() => [
|
||||
['排车数', result.value?.summary.dispatchCount], ['装车完成', result.value?.summary.afterLoadingCount], ['派送中', result.value?.summary.deliveringCount], ['派送完成', result.value?.summary.deliveredCount],
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
|
||||
{ key: 'carrier', label: '承运商', minWidth: 150, slotName: 'carrier' },
|
||||
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
|
||||
{ key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 285, slotName: 'destinations' },
|
||||
{ prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
|
||||
{ key: 'money', label: '整车承运金额 / 单价', minWidth: 170, slotName: 'money' },
|
||||
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
|
||||
{ key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' },
|
||||
]
|
||||
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
|
||||
function rowActions(row: OutsourcedBusinessRow): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'detail', label: '查看明细' },
|
||||
{ key: 'operations', label: '登记运营信息', visible: access.can(permission.outsourcedBusinessEdit), disabled: opening.value || mutating.value || !row.actions.edit.allowed },
|
||||
{ key: 'photos', label: access.can(permission.outsourcedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片', disabled: opening.value || mutating.value },
|
||||
]
|
||||
}
|
||||
function act(row: OutsourcedBusinessRow, key: string) {
|
||||
if (key === 'detail') void router.push(`/overseas-master-data/outsourced-business/${encodeURIComponent(row.dispatchNo)}`)
|
||||
else if (key === 'operations' || key === 'photos') void openAction(row, key)
|
||||
}
|
||||
async function load() {
|
||||
if (!active) return
|
||||
const request = ++generation
|
||||
loading.value = true; error.value = null
|
||||
try {
|
||||
if (!access.can(permission.outsourcedBusinessList)) throw new Error('没有外配业务查看权限')
|
||||
const value = await listOutsourcedBusiness({ ...applied, page: page.value, size: size.value })
|
||||
if (active && request === generation) result.value = value
|
||||
} catch (cause) { if (active && request === generation) { result.value = null; error.value = getApiErrorDetails(cause, '外配业务读取失败') } }
|
||||
finally { if (request === generation) loading.value = false }
|
||||
}
|
||||
function search() {
|
||||
try {
|
||||
const range = filters.value.plannedRange
|
||||
const dates = Array.isArray(range) ? range : []
|
||||
if (dates.length && (dates.length !== 2 || !dayjs(String(dates[0])).isValid() || !dayjs(String(dates[1])).isValid() || dayjs(String(dates[1])).isBefore(dayjs(String(dates[0]))))) throw new Error('请选择有效的计划发车日期范围')
|
||||
applied = { keyword: String(filters.value.keyword || '').trim() || undefined, stage: (filters.value.stage || undefined) as OutsourcedBusinessStage | undefined, warehouseId: String(filters.value.warehouseId || '') || undefined,
|
||||
plannedDepartureFrom: dates.length ? dayjs(String(dates[0])).startOf('day').toISOString() : undefined, plannedDepartureTo: dates.length ? dayjs(String(dates[1])).add(1,'day').startOf('day').toISOString() : undefined }
|
||||
if (page.value !== 1) page.value = 1; else void load()
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '筛选条件无效') }
|
||||
}
|
||||
async function loadWarehouses() {
|
||||
if (!access.can('platform:warehouse:list')) return
|
||||
try { const values = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }); warehouseOptions.value = values.map(value => ({ value: String(value.id), label: value.warehouseName })) }
|
||||
catch (cause) { warehouseError.value = getApiErrorDetails(cause, '仓库目录读取失败,可继续使用其他筛选') }
|
||||
}
|
||||
async function openAction(row: OutsourcedBusinessRow, kind: 'operations' | 'photos') {
|
||||
if (opening.value || mutating.value) return
|
||||
const code = kind === 'operations' ? permission.outsourcedBusinessEdit : permission.outsourcedBusinessList
|
||||
if (!access.can(code) || kind === 'operations' && !row.actions.edit.allowed) return
|
||||
const request = ++openGeneration; opening.value = true; error.value = null
|
||||
try { const value = await getOutsourcedBusiness(row.dispatchNo); if (!active || request !== openGeneration) return; detail.value = value; if (kind === 'operations') editorOpen.value = true; else photosOpen.value = true }
|
||||
catch (cause) { if (request === openGeneration) error.value = getApiErrorDetails(cause, '当前运营资料读取失败') }
|
||||
finally { if (request === openGeneration) opening.value = false }
|
||||
}
|
||||
function saved(value: OutsourcedBusinessDetail) { detail.value = value; void load() }
|
||||
watch([page,size], () => { if (active) void load() })
|
||||
onMounted(() => { void load(); void loadWarehouses() })
|
||||
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
|
||||
onDeactivated(() => { active = false; generation++; openGeneration++; loading.value = false; opening.value = false; editorOpen.value = false; photosOpen.value = false })
|
||||
onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OutsourcedBusinessListLayout>
|
||||
<template #statistics><section class="outsourced-business-statistics" aria-label="完整筛选范围外配业务统计" :aria-busy="loading"><div v-for="item in summary" :key="String(item[0])"><span>{{ item[0] }}</span><strong>{{ item[1] ?? '—' }}</strong></div></section></template>
|
||||
<DataTable appearance="integrated" :stripe="false" v-model:page="page" v-model:page-size="size" :data="result?.records || []" :columns="columns" :total="result?.total || 0" :loading="loading" row-key="dispatchNo" preference-key="outsourced-business-table" empty-text="当前筛选范围暂无已装车的外配车辆排车单">
|
||||
<template #toolbar-start><h1>外配业务</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="外配业务筛选" preference-key="outsourced-business-query" :loading="loading" @search="search" /><small>日期和时间按浏览器本地时区显示;统计覆盖同一完整筛选范围。</small></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="外配业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
|
||||
<template #actions="{ row }"><RowActions :actions="rowActions(row as OutsourcedBusinessRow)" text-actions @action="act(row as OutsourcedBusinessRow, $event)" /></template>
|
||||
<template #carrier="{ row }">{{ text(row.vehicle.carrierName) }}</template>
|
||||
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
|
||||
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="outsourced-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
|
||||
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="outsourced-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约 {{ time(appointment.scheduledAt) }}</div><div>实到 {{ time(destination.actualArrivedAt) }}</div></div></template>
|
||||
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}<br>单价 {{ row.unitPrice ?? '—' }}</template><span v-else>未登记</span></template>
|
||||
<template #plannedDeparture="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #loaded="{ row }">{{ time(row.loadingCompletedAt) }}</template><template #stage="{ row }">{{ outsourcedBusinessStages[row.stage as OutsourcedBusinessStage] }}</template>
|
||||
</DataTable>
|
||||
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
|
||||
<OutsourcedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
|
||||
</OutsourcedBusinessListLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.outsourced-business-statistics { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); background: white; border: 1px solid var(--admin-table-border); }
|
||||
.outsourced-business-statistics > div { padding: var(--admin-space-4) var(--admin-space-5); border-right: 1px solid var(--admin-table-border); }
|
||||
.outsourced-business-statistics > div:last-child { border-right: 0; }
|
||||
.outsourced-business-statistics strong { display: block; margin-top: var(--admin-space-2); font-size: var(--admin-font-title); }
|
||||
.outsourced-business-destination + .outsourced-business-destination { margin-top: var(--admin-space-3); border-top: 1px solid var(--admin-table-border); padding-top: var(--admin-space-3); }
|
||||
.outsourced-business-destination small { display: block; }
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import dayjs from 'dayjs'
|
||||
import OutsourcedBusinessView from './OutsourcedBusinessView.vue'
|
||||
import OutsourcedBusinessDetailView from './OutsourcedBusinessDetailView.vue'
|
||||
import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
|
||||
const calls = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), history: vi.fn(), content: vi.fn(), warehouses: vi.fn(), push: vi.fn(), allowed: new Set<string>() }))
|
||||
vi.mock('../api/outsourcedBusiness', () => ({ listOutsourcedBusiness: calls.list, getOutsourcedBusiness: calls.detail, listOutsourcedBusinessHistory: calls.history, getOutsourcedBusinessPhotoContent: calls.content }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: calls.warehouses }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => calls.allowed.has(code) }) }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: calls.push }), useRoute: () => ({ params: { dispatchNo: 'DS-OUT-1' } }) }))
|
||||
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
|
||||
const Button = defineComponent({ props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' })
|
||||
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
|
||||
const Pane = defineComponent({ props: ['name','label'], template: '<article><slot /></article>' })
|
||||
const Query = defineComponent({ props: ['modelValue','fields'], emits: ['update:modelValue','search'], template: '<div />' })
|
||||
const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([,slot]) => slot?.({row}))))]) } })
|
||||
const OperationsEditor = defineComponent({ name: 'OutsourcedBusinessOperationsEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
|
||||
const PhotosEditor = defineComponent({ name: 'OutsourcedBusinessPhotosEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
|
||||
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, DataTable: Table, QueryToolbar: Query, ElTabs: Tabs, ElTabPane: Pane, ElButton: Button, OutsourcedBusinessOperationsEditor: OperationsEditor, OutsourcedBusinessPhotosEditor: PhotosEditor, LoadErrorState: { props:['message'], template:'<div>{{message}}<slot name="details" /></div>' }, ApiErrorMeta: true }, directives: { loading: () => {} } }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); calls.allowed = new Set(Object.values(permission)); calls.allowed.add('platform:warehouse:list')
|
||||
const detail = outsourcedBusinessFixture()
|
||||
calls.list.mockResolvedValue({ records:[detail.row],page:1,size:20,total:41,summary:{dispatchCount:41,afterLoadingCount:30,deliveringCount:10,deliveredCount:1} })
|
||||
calls.detail.mockResolvedValue(detail); calls.history.mockResolvedValue({records:[],page:1,size:20,total:0}); calls.warehouses.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('outsourced business views', () => {
|
||||
it('uses one list request for all statistics and never hydrates each row', async () => {
|
||||
const wrapper = mount(OutsourcedBusinessView,{global}); await flushPromises()
|
||||
expect(calls.list).toHaveBeenCalledTimes(1); expect(calls.detail).not.toHaveBeenCalled(); expect(calls.content).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[aria-label="完整筛选范围外配业务统计"]').text()).toContain('排车数41')
|
||||
expect(wrapper.text()).not.toContain('今日排车')
|
||||
expect(wrapper.findComponent(Table).props('columns')[0].key).toBe('actions')
|
||||
expect(wrapper.findComponent(Table).props('total')).toBe(41)
|
||||
expect(wrapper.text()).toContain('测试外配承运商'); expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('自有成本')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('applies planned date half-open boundaries only on query and keeps all stage counts from the response', async () => {
|
||||
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises();const query=wrapper.findComponent(Query)
|
||||
query.vm.$emit('update:modelValue',{plannedRange:['2026-10-20','2026-10-21'],keyword:' FBA-1 ',stage:'AFTER_LOADING',warehouseId:'1'});await wrapper.vm.$nextTick()
|
||||
expect(calls.list).toHaveBeenCalledTimes(1)
|
||||
query.vm.$emit('search');await flushPromises()
|
||||
expect(calls.list).toHaveBeenLastCalledWith({page:1,size:20,keyword:'FBA-1',stage:'AFTER_LOADING',warehouseId:'1',plannedDepartureFrom:dayjs('2026-10-20').startOf('day').toISOString(),plannedDepartureTo:dayjs('2026-10-21').add(1,'day').startOf('day').toISOString()})
|
||||
expect(wrapper.find('[aria-label="完整筛选范围外配业务统计"]').text()).toContain('装车完成30')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('opens the operations modal on demand with auxiliary zero version instead of the dispatch version', async () => {
|
||||
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises()
|
||||
expect(calls.detail).not.toHaveBeenCalled();expect(wrapper.findComponent(OperationsEditor).props('modelValue')).toBe(false)
|
||||
await wrapper.findAll('button').find(button=>button.text()==='登记运营信息')!.trigger('click');await flushPromises()
|
||||
expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OUT-1')
|
||||
const editor=wrapper.findComponent(OperationsEditor)
|
||||
expect(editor.props('modelValue')).toBe(true);expect(wrapper.findComponent(PhotosEditor).props('modelValue')).toBe(false)
|
||||
expect(editor.props('detail').row.recordVersion).toBe('0');expect(editor.props('detail').dispatch.recordVersion).toBe('8')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps a list-only user read-only while retaining detail and authorized photo reads', async () => {
|
||||
calls.allowed=new Set([permission.outsourcedBusinessList])
|
||||
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('登记运营信息');expect(wrapper.text()).toContain('查看照片');expect(calls.warehouses).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows a temporary external vehicle and a missing carrier without requiring a master ID', async () => {
|
||||
const detail = outsourcedBusinessFixture(); detail.row.vehicle = { vehicleId:null,plate:'TEMP-1',ownership:'DISPATCHED',carrierName:null }
|
||||
calls.detail.mockResolvedValue(detail)
|
||||
const wrapper = mount(OutsourcedBusinessDetailView,{global}); await flushPromises()
|
||||
expect(wrapper.text()).toContain('TEMP-1'); expect(wrapper.text()).toContain('承运商'); expect(wrapper.text()).toContain('—')
|
||||
expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OUT-1'); expect(calls.content).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('loads aggregated history only on its tab, preserving null operations on dispatch events', async () => {
|
||||
calls.history.mockResolvedValue({records:[{eventSource:'DISPATCH',recordVersion:null,dispatchRecordVersion:'8',action:'LOADING_COMPLETE',operatorName:null,occurredAt:'2026-10-20T09:00:00Z',reason:null,operations:null,photos:[]},{eventSource:'OPERATIONS',recordVersion:'1',dispatchRecordVersion:null,action:'SAVE_OPERATIONS',operatorName:'登记员',occurredAt:'2026-10-20T10:00:00Z',reason:null,operations:outsourcedBusinessFixture().row.operations,photos:[]}],page:1,size:20,total:2})
|
||||
const wrapper=mount(OutsourcedBusinessDetailView,{global});await flushPromises()
|
||||
expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.history).not.toHaveBeenCalled()
|
||||
wrapper.findComponent(Tabs).vm.$emit('update:modelValue','history');await flushPromises()
|
||||
expect(calls.history).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',1,20)
|
||||
expect(wrapper.text()).toContain('原排车');expect(wrapper.text()).toContain('运营登记')
|
||||
const table=wrapper.findComponent(Table), records=table.props('data')
|
||||
expect(records[0].eventKey).not.toBe(records[1].eventKey);expect(records[0].operations).toBeNull()
|
||||
table.vm.$emit('update:page',2);await flushPromises();expect(calls.history).toHaveBeenLastCalledWith('DS-OUT-1',2,20)
|
||||
expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.content).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('shows authoritative large money text and unit price without rounding or recomputing from two cargo rows', async () => {
|
||||
const detail=outsourcedBusinessFixture();detail.row.operations.money={currency:'USD',amount:'9999999999999999.99'};detail.row.unitPrice='9999999999999999.99'
|
||||
calls.detail.mockResolvedValue(detail)
|
||||
const wrapper=mount(OutsourcedBusinessDetailView,{global});await flushPromises()
|
||||
expect(wrapper.text()).toContain('USD 9999999999999999.99');expect(wrapper.text()).not.toContain('10000000000000000')
|
||||
expect(wrapper.text()).not.toContain('4999999999999999')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -18,8 +18,9 @@ const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue
|
||||
const Pane = defineComponent({ props: ['name','label'], template: '<article><slot /></article>' })
|
||||
const Query = defineComponent({ props: ['modelValue','fields'], emits: ['update:modelValue','search'], template: '<div />' })
|
||||
const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([,slot]) => slot?.({row}))))]) } })
|
||||
const Editor = defineComponent({ props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
|
||||
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, DataTable: Table, QueryToolbar: Query, ElTabs: Tabs, ElTabPane: Pane, ElButton: Button, OwnedBusinessOperationsEditor: Editor, OwnedBusinessPhotosEditor: Editor, LoadErrorState: { props:['message'], template:'<div>{{message}}<slot name="details" /></div>' }, ApiErrorMeta: true }, directives: { loading: () => {} } }
|
||||
const OperationsEditor = defineComponent({ name: 'OwnedBusinessOperationsEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
|
||||
const PhotosEditor = defineComponent({ name: 'OwnedBusinessPhotosEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
|
||||
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, DataTable: Table, QueryToolbar: Query, ElTabs: Tabs, ElTabPane: Pane, ElButton: Button, OwnedBusinessOperationsEditor: OperationsEditor, OwnedBusinessPhotosEditor: PhotosEditor, LoadErrorState: { props:['message'], template:'<div>{{message}}<slot name="details" /></div>' }, ApiErrorMeta: true }, directives: { loading: () => {} } }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); calls.allowed = new Set(Object.values(permission)); calls.allowed.add('platform:warehouse:list')
|
||||
@@ -49,9 +50,11 @@ describe('owned business views', () => {
|
||||
})
|
||||
it('opens the operations modal on demand with auxiliary zero version instead of the dispatch version', async () => {
|
||||
const wrapper=mount(OwnedBusinessView,{global});await flushPromises()
|
||||
expect(calls.detail).not.toHaveBeenCalled();expect(wrapper.findComponent(OperationsEditor).props('modelValue')).toBe(false)
|
||||
await wrapper.findAll('button').find(button=>button.text()==='登记运营信息')!.trigger('click');await flushPromises()
|
||||
expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OWN-1')
|
||||
const editor=wrapper.findAllComponents(Editor).find(child=>child.props('modelValue'))!
|
||||
const editor=wrapper.findComponent(OperationsEditor)
|
||||
expect(editor.props('modelValue')).toBe(true);expect(wrapper.findComponent(PhotosEditor).props('modelValue')).toBe(false)
|
||||
expect(editor.props('detail').row.recordVersion).toBe('0');expect(editor.props('detail').dispatch.recordVersion).toBe('8')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -17,6 +17,9 @@ export const overseasMasterDataPermissions = {
|
||||
ownedBusinessList: 'platform:fleet:owned-business:list',
|
||||
ownedBusinessEdit: 'platform:fleet:owned-business:edit',
|
||||
ownedBusinessUpload: 'platform:fleet:owned-business:upload',
|
||||
outsourcedBusinessList: 'platform:fleet:outsourced-business:list',
|
||||
outsourcedBusinessEdit: 'platform:fleet:outsourced-business:edit',
|
||||
outsourcedBusinessUpload: 'platform:fleet:outsourced-business:upload',
|
||||
tripBookingCreate: 'platform:fleet:trip:booking-create',
|
||||
tripBookingEdit: 'platform:fleet:trip:booking-edit',
|
||||
tripBookingConfirm: 'platform:fleet:trip:booking-confirm',
|
||||
|
||||
@@ -13,6 +13,8 @@ describe('overseas master data routes', () => {
|
||||
['overseas-master-data/trip-operations', overseasMasterDataPermissions.operationsList],
|
||||
['overseas-master-data/owned-business', overseasMasterDataPermissions.ownedBusinessList],
|
||||
['overseas-master-data/owned-business/:dispatchNo', overseasMasterDataPermissions.ownedBusinessList],
|
||||
['overseas-master-data/outsourced-business', overseasMasterDataPermissions.outsourcedBusinessList],
|
||||
['overseas-master-data/outsourced-business/:dispatchNo', overseasMasterDataPermissions.outsourcedBusinessList],
|
||||
['overseas-master-data/trips', overseasMasterDataPermissions.tripList],
|
||||
['overseas-master-data/trips/:tripNo', overseasMasterDataPermissions.tripList],
|
||||
['overseas-master-data/drivers', overseasMasterDataPermissions.driverList],
|
||||
@@ -45,6 +47,17 @@ describe('overseas master data routes', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses external list permission for both routes independently of owned and write permissions', () => {
|
||||
const external = overseasMasterDataRoutes.filter(route => route.path.includes('/outsourced-business'))
|
||||
expect(external).toHaveLength(2)
|
||||
for (const route of external) {
|
||||
expect(route.meta?.permission).toBe(overseasMasterDataPermissions.outsourcedBusinessList)
|
||||
expect(route.meta?.permissionAnyOf).toBeUndefined()
|
||||
expect(route.meta?.permission).not.toBe(overseasMasterDataPermissions.ownedBusinessList)
|
||||
expect(route.meta?.permission).not.toBe(overseasMasterDataPermissions.outsourcedBusinessEdit)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps vehicle detail as a route page and does not add a duplicate partner warehouse route', () => {
|
||||
expect(overseasMasterDataRoutes.at(-1)?.meta?.showPageHeading).toBe(false)
|
||||
expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false)
|
||||
|
||||
@@ -38,6 +38,18 @@ export const overseasMasterDataRoutes = [
|
||||
component: () => import('./pages/OwnedBusinessDetailView.vue'),
|
||||
meta: { title: '自有业务详情', description: '查看原排车事实与附加运营信息', permission: permission.ownedBusinessList, showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/outsourced-business',
|
||||
name: 'overseas-master-data-outsourced-business',
|
||||
component: () => import('./pages/OutsourcedBusinessView.vue'),
|
||||
meta: { title: '外配业务', description: '按原排车查询外配车辆装车后的履约与承运信息', permission: permission.outsourcedBusinessList, showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/outsourced-business/:dispatchNo',
|
||||
name: 'overseas-master-data-outsourced-business-detail',
|
||||
component: () => import('./pages/OutsourcedBusinessDetailView.vue'),
|
||||
meta: { title: '外配业务详情', description: '查看原排车及外配承运登记', permission: permission.outsourcedBusinessList, showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/trips',
|
||||
name: 'overseas-master-data-trips',
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TransferQuantitySummary from './TransferQuantitySummary.vue'
|
||||
import { getTransferQuantityTotals } from '../api/transferQuantityFacts'
|
||||
vi.mock('../api/transferQuantityFacts', () => ({ getTransferQuantityTotals: vi.fn() }))
|
||||
|
||||
describe('转运完整筛选数量', () => {
|
||||
it('keeps unknown pieces explicit and renders overlapping pallet facts after paging', async () => {
|
||||
vi.mocked(getTransferQuantityTotals).mockResolvedValue({ orderCount: '2', declaredPieceCount: null, knownDeclaredPieceCount: '16', unknownDeclaredOrderCount: '1', receivedPieceCount: '20', knownReceivedPieceCount: '20', unknownReceivedOrderCount: '0', declaredCartonCount: '4', receivedCartonCount: '4', receivedPalletCount: '3', availableCartonCount: '2', shippedCartonCount: '2', remainingPalletCount: '2', shippedPalletCount: '2' })
|
||||
const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1, orderType: 'WAREHOUSE_TRANSFER' } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('未记录(已知 16 件,1 票未知)')
|
||||
expect(wrapper.text()).toContain('20 件 / 4 箱 / 3 托')
|
||||
expect(wrapper.text()).toContain('剩余 2 托')
|
||||
expect(wrapper.text()).toContain('2 箱 / 2 托(托盘可重叠)')
|
||||
await wrapper.setProps({ query: { page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('全筛选 2 票')
|
||||
expect(getTransferQuantityTotals).toHaveBeenLastCalledWith({ page: 2, size: 1, orderType: 'WAREHOUSE_TRANSFER' })
|
||||
})
|
||||
it('shows a recoverable error instead of zero totals', async () => {
|
||||
vi.mocked(getTransferQuantityTotals).mockRejectedValue(new Error('offline'))
|
||||
const wrapper = mount(TransferQuantitySummary, { props: { query: { page: 1, size: 1 } } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('加载失败')
|
||||
expect(wrapper.text()).not.toContain('全筛选 0')
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { getTransferQuantityTotals, type TransferQuantityTotals } from '../api/transferQuantityFacts'
|
||||
import type { LastMileOrderQuery } from '../api/lastMileOrders'
|
||||
const props = defineProps<{ query: LastMileOrderQuery }>()
|
||||
const summary = ref<TransferQuantityTotals>()
|
||||
const error = ref('')
|
||||
let sequence = 0
|
||||
watch(() => JSON.stringify(props.query), async () => {
|
||||
const current = ++sequence
|
||||
summary.value = undefined; error.value = ''
|
||||
try { const value = await getTransferQuantityTotals(props.query); if (current === sequence) summary.value = value }
|
||||
catch { if (current === sequence) error.value = '转运全筛选数量加载失败,请重新查询' }
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="summary" role="status" aria-label="仓储转运全筛选汇总">
|
||||
<strong>仓储转运 · 全筛选 {{ summary.orderCount }} 票</strong> ·
|
||||
预报 {{ summary.declaredPieceCount ?? `未记录(已知 ${summary.knownDeclaredPieceCount} 件,${summary.unknownDeclaredOrderCount} 票未知)` }}{{ summary.declaredPieceCount == null ? '' : ' 件' }} / {{ summary.declaredCartonCount }} 箱 ·
|
||||
历史实收 {{ summary.receivedPieceCount ?? `未记录(已知 ${summary.knownReceivedPieceCount} 件,${summary.unknownReceivedOrderCount} 票未知)` }}{{ summary.receivedPieceCount == null ? '' : ' 件' }} / {{ summary.receivedCartonCount }} 箱 / {{ summary.receivedPalletCount }} 托 ·
|
||||
当前已清点 {{ summary.availableCartonCount }} 箱 · 剩余 {{ summary.remainingPalletCount }} 托 · 已出库 {{ summary.shippedCartonCount }} 箱 / {{ summary.shippedPalletCount }} 托(托盘可重叠)
|
||||
</div>
|
||||
<p v-else-if="error" role="alert">{{ error }}</p>
|
||||
</template>
|
||||
@@ -41,7 +41,10 @@ async function retry(state: VueWrapper) { await state.get('.load-error-state__ac
|
||||
|
||||
describe('native cargo discard detail and approval', () => {
|
||||
it('preserves all readonly facts, full item values and history through fullscreen', async () => {
|
||||
const { dialog } = await open()
|
||||
const { wrapper, dialog } = await open()
|
||||
expect(api.statistics).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('全筛选')
|
||||
expect(wrapper.text()).not.toContain('统计未加载')
|
||||
expect(dialog.props('title')).toBe('废弃单详情 · CD-001'); expect(api.detail).toHaveBeenCalledExactlyOnceWith('CD-001', '9007199254740994')
|
||||
const facts = dialog.get('[aria-label="废弃单基础资料"]')
|
||||
for (const value of ['CD-001', 'OUT-1', '洛杉矶仓', 'INBOUND-1', '待审核', '废弃原因与备注']) expect(facts.text()).toContain(value)
|
||||
|
||||
@@ -12,18 +12,17 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, getCargoDiscardStatistics, type CargoDiscardStatistics, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
|
||||
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
|
||||
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<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
|
||||
const statistics = ref<CargoDiscardStatistics | null>(null)
|
||||
/** 数量事实缺失时明确提示,不用零代替未记录。 */
|
||||
function quantity(value: number | null | undefined, unit: string) { return value == null ? '未记录 / 不适用' : `${value} ${unit}` }
|
||||
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: 'requestedPackages', 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<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
const customerError = ref<ApiErrorDetails | null>(null); const listError = ref<ApiErrorDetails | null>(null); const statisticsError = ref<ApiErrorDetails | null>(null)
|
||||
const customerError = ref<ApiErrorDetails | null>(null); const listError = ref<ApiErrorDetails | null>(null)
|
||||
const detailError = ref<ApiErrorDetails | null>(null); const reviewError = ref<ApiErrorDetails | null>(null)
|
||||
const detailLoading = ref(false); const reviewing = ref(''); const detailTarget = ref<CargoDiscardSummary | null>(null)
|
||||
const photoUrls = reactive<Record<string, string>>({}); const photoLoading = reactive<Record<string, boolean>>({}); const photoErrors = reactive<Record<string, ApiErrorDetails | null>>({})
|
||||
@@ -42,17 +41,20 @@ async function loadPhoto(fileId: string) {
|
||||
watch(detailOpen, value => { if (!value) { detailGeneration++; clearPhotos() } })
|
||||
onBeforeUnmount(() => { disposed = true; loadGeneration++; detailGeneration++; clearPhotos() })
|
||||
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
|
||||
/** 分页与全筛选统计独立保留诊断,过期响应不得覆盖新的查询。 */
|
||||
/** 过期响应不得覆盖新的查询。 */
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; listError.value = null; statisticsError.value = null; statistics.value = null
|
||||
const generation = ++loadGeneration; Object.assign(filters.value, model); loading.value = true; listError.value = null
|
||||
const query = { page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }
|
||||
const [list, stat] = await Promise.allSettled([listCargoDiscards(query), getCargoDiscardStatistics(query)])
|
||||
if (disposed || generation !== loadGeneration) return
|
||||
if (list.status === 'fulfilled') { rows.value = list.value.records; total.value = list.value.total }
|
||||
else { rows.value = []; total.value = 0; listError.value = getApiErrorDetails(list.reason, '废弃单列表加载失败') }
|
||||
if (stat.status === 'fulfilled') statistics.value = stat.value
|
||||
else statisticsError.value = getApiErrorDetails(stat.reason, '废弃统计加载失败')
|
||||
loading.value = false
|
||||
try {
|
||||
const result = await listCargoDiscards(query)
|
||||
if (disposed || generation !== loadGeneration) return
|
||||
rows.value = result.records; total.value = result.total
|
||||
} catch (error) {
|
||||
if (disposed || generation !== loadGeneration) return
|
||||
rows.value = []; total.value = 0; listError.value = getApiErrorDetails(error, '废弃单列表加载失败')
|
||||
} finally {
|
||||
if (!disposed && generation === loadGeneration) loading.value = false
|
||||
}
|
||||
}
|
||||
async function loadCustomers() {
|
||||
customerError.value = null
|
||||
@@ -77,7 +79,7 @@ async function audit(row: CargoDiscardSummary) {
|
||||
onMounted(() => { void loadCustomers(); void load() })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="statisticsError" title="废弃统计加载失败" :message="statisticsError.message" @retry="load()"><template #details><ApiErrorMeta :details="statisticsError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template><template #pagination-left><span v-if="statistics">全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuCount }} 种 SKU · 申请 {{ statistics.requestedPackages }} 箱 · 已确认实际 {{ quantity(statistics.actualPackages, '箱') }} · 商品 {{ quantity(statistics.requestedPieces, '个') }} · 托盘 {{ quantity(statistics.palletCount, '托盘') }}</span><span v-else>统计未加载</span></template></DataTable></section></ListPageLayout></AdminPage>
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template></DataTable></section></ListPageLayout></AdminPage>
|
||||
<AppDialog v-model="detailOpen" class="admin-density-dialog" :title="'废弃单详情 · ' + (detailTarget?.discardNo || '')" :confirm-loading="detailLoading" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))" fullscreenable>
|
||||
<p v-if="detailLoading" role="status">正在读取废弃单…</p>
|
||||
<LoadErrorState v-else-if="detailError" title="废弃单详情加载失败" :message="detailError.message" @retry="readDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
|
||||
@@ -19,6 +19,21 @@ vi.mock('@/modules/overseas-master-data', async () => ({
|
||||
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true } } }
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
|
||||
describe('whole-dispatch four-tab detail', () => {
|
||||
it('returns to the original appointment date and view from the dispatch detail', async () => {
|
||||
query.appointmentReturn = '/appointment-calendar?date=2026-10-21&mode=week'
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
expect(wrapper.get('button.density-back').text()).toContain('返回预约日历')
|
||||
await wrapper.get('button.density-back').trigger('click')
|
||||
expect(push).toHaveBeenCalledWith('/appointment-calendar?date=2026-10-21&mode=week')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores an external calendar return URL and keeps the normal dispatch-list return', async () => {
|
||||
query.appointmentReturn = 'https://outside.invalid/appointment-calendar?date=2026-10-21'
|
||||
const wrapper = mount(DispatchDetailView, options); await flushPromises()
|
||||
await wrapper.get('button.density-back').trigger('click')
|
||||
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatches' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('uses only four accessible colored tabs and switches without another network read', async () => {
|
||||
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
|
||||
vi.mocked(getDispatch).mockResolvedValueOnce(value)
|
||||
|
||||
@@ -3,6 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { inquiryReturnTarget, vehicleStatusLabels } from '@/modules/overseas-master-data'
|
||||
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -65,7 +66,9 @@ async function load() {
|
||||
catch (error) { if (current === generation) loadError.value = getApiErrorDetails(error, '排车详情加载失败,请重试') }
|
||||
finally { if (current === generation) loading.value = false }
|
||||
}
|
||||
function back() { void router.push(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
|
||||
const appointmentReturn = computed(() => appointmentReturnTarget(route.query))
|
||||
const backLabel = computed(() => appointmentReturn.value ? '返回预约日历' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表')
|
||||
function back() { void router.push(appointmentReturn.value || inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
|
||||
function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (!canDispatchAction(detail.value?.actions, value, access.can)) return; rejectionError.value = null; operation.value = requestedOperation; action.value = value; actionOpen.value = true }
|
||||
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
|
||||
async function handleSaved() { rejectionError.value = null; await load() }
|
||||
@@ -74,7 +77,7 @@ onBeforeUnmount(() => { generation++ })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="inquiryReturnTarget(route.query) ? '返回原拼车登记' : undefined" :back-label="inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'" domain="排车详情" :pending="false" :status-text="detail ? detail.status === 'CANCELLED' ? '已取消' : stageLabel(detail.stage) : '加载中'" @back="back">
|
||||
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="appointmentReturn || inquiryReturnTarget(route.query) ? backLabel : undefined" :back-label="backLabel" domain="排车详情" :pending="false" :status-text="detail ? detail.status === 'CANCELLED' ? '已取消' : stageLabel(detail.stage) : '加载中'" @back="back">
|
||||
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
|
||||
|
||||
@@ -89,7 +89,8 @@ describe('inventory customer-name snapshots', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('华远贸易')
|
||||
expect(wrapper.text()).toContain('SKU-1 × 5 件;SKU-SECOND × 7 件')
|
||||
expect(wrapper.text()).toContain('可用 12 件 / 2 箱 / 1 托')
|
||||
expect(wrapper.find('[aria-label="可用库存件箱托汇总"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[aria-label="库存汇总"]').text()).toContain('可用库存(箱)10')
|
||||
})
|
||||
|
||||
it('combines the available references and then shows an explicit empty business reference without exposing the inventory id', async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { getInventorySummary, getInventoryPieceProjection, listInventoryBalances, type InventoryDashboardDto, type InventoryPieceProjection, type InventoryPieceGroup } from '../api/inventory'
|
||||
import { getInventorySummary, getInventoryPieceProjection, listInventoryBalances, type InventoryDashboardDto, type InventoryPieceGroup } from '../api/inventory'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { toInventoryListItem } from '../inventoryPresentation'
|
||||
@@ -25,7 +25,6 @@ const access = useAccessPreviewStore()
|
||||
const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
|
||||
const rows = ref<InventoryListItem[]>([])
|
||||
const summary = ref<InventoryDashboardDto | null>(null)
|
||||
const pieces = ref<InventoryPieceProjection | null>(null)
|
||||
const rowPieces = ref<Record<string, InventoryPieceGroup | undefined>>({})
|
||||
const pieceError = ref('')
|
||||
const loading = ref(false)
|
||||
@@ -79,7 +78,7 @@ function query() {
|
||||
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
loading.value = true; loadError.value = null; pieces.value = null; rowPieces.value = {}; pieceError.value = ''
|
||||
loading.value = true; loadError.value = null; rowPieces.value = {}; pieceError.value = ''
|
||||
try {
|
||||
const currentQuery = query()
|
||||
const [pageResult, summaryResult] = await Promise.all([listInventoryBalances(currentQuery), getInventorySummary(currentQuery)])
|
||||
@@ -87,10 +86,9 @@ async function load(model: QueryModel = filters.value) {
|
||||
try {
|
||||
const pieceResult = await getInventoryPieceProjection(currentQuery)
|
||||
rowPieces.value = Object.fromEntries(pageResult.records.map(dto => [String(dto.id), pieceResult.groups.find(group => String(group.customerId) === String(dto.customerId) && String(group.warehouseId) === String(dto.overseasWarehouseId) && (group.destinationCode ?? '') === (dto.destinationWarehouseCode ?? '') && (group.anchorSkuCode ?? '') === (dto.skuCode ?? ''))]))
|
||||
pieces.value = pieceResult
|
||||
} catch { pieceError.value = '可用件数及箱内SKU加载失败,请重新查询;箱数列表仍可查看。' }
|
||||
}
|
||||
catch (error) { rows.value = []; total.value = 0; summary.value = null; pieces.value = null; rowPieces.value = {}; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
|
||||
catch (error) { rows.value = []; total.value = 0; summary.value = null; rowPieces.value = {}; loadError.value = getApiErrorDetails(error, '库存数据加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
@@ -109,7 +107,7 @@ onMounted(() => { void loadWarehouses(); void load() })
|
||||
<template #toolbar-start><h1>库存管理</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading || contextLoading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="page = 1; rows = []; total = 0; summary = null" /></template>
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><p v-if="pieceError" role="alert">{{ pieceError }}</p></template>
|
||||
<template #before-results><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}(箱)</span><strong>{{ metric[1] }}</strong></div></section><p v-if="pieces" role="status" aria-label="可用库存件箱托汇总">全部筛选结果 · 可用 {{ pieces.availablePieceCount ?? `未知(已知 ${pieces.knownAvailablePieceCount} 件,${pieces.unknownCartonCount} 箱件数未记录)` }}{{ pieces.availablePieceCount == null ? '' : ' 件' }} / {{ pieces.availableCartonCount }} 箱 / {{ pieces.availablePalletCount }} 托</p></template>
|
||||
<template #before-results><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}(箱)</span><strong>{{ metric[1] }}</strong></div></section></template>
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button></div></template>
|
||||
|
||||
@@ -194,23 +194,22 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
||||
}
|
||||
|
||||
describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
it('keeps server-wide return totals across pages and clears them on a statistics failure', async () => {
|
||||
it('hides filter totals while retaining return row dimensions', async () => {
|
||||
const totals = { orderCount: 3, skuTypes: 3, expectedQuantity: 19, receivedQuantity: 14, remainingQuantity: 5, receivedBoxCount: 3, receivedParcelCount: 2, receivedPalletCount: 1 }
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 30, pages: 3 })
|
||||
vi.mocked(getReturnOrderStatistics).mockResolvedValue({ totals, pageDimensions: [] })
|
||||
const wrapper = await mountView()
|
||||
const summary = () => wrapper.find('[aria-label="退货全筛选汇总"]')
|
||||
expect(summary().text()).toContain('预报 19 件')
|
||||
expect(summary().text()).toContain('3 种 SKU')
|
||||
expect(wrapper.find('[aria-label="退货全筛选汇总"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="仓储转运全筛选汇总"]').exists()).toBe(false)
|
||||
const table = wrapper.findComponent(DataTable)
|
||||
table.vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
expect(getReturnOrderStatistics).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
|
||||
expect(summary().text()).toContain('实收 14 件')
|
||||
expect(wrapper.text()).not.toContain('当前筛选中的退货')
|
||||
vi.mocked(getReturnOrderStatistics).mockRejectedValueOnce(new Error('统计暂不可用'))
|
||||
table.vm.$emit('update:page', 3)
|
||||
await flushPromises()
|
||||
expect(summary().exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="退货全筛选汇总"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('尾程订单加载失败')
|
||||
expect(table.props('data')).toEqual([])
|
||||
})
|
||||
|
||||
@@ -14,7 +14,6 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import TransferQuantitySummary from '../components/TransferQuantitySummary.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
@@ -1109,10 +1108,6 @@ onMounted(() => { void load() })
|
||||
</el-button>
|
||||
</template>
|
||||
</QueryToolbar>
|
||||
<TransferQuantitySummary v-if="!loading && statusTab !== '柜子'" :query="orderQuery()" />
|
||||
<div v-if="returnStatistics && !loading" class="return-filter-summary" role="status" aria-label="退货全筛选汇总">
|
||||
当前筛选中的退货:{{ returnStatistics.totals.orderCount }} 票 · {{ returnStatistics.totals.skuTypes }} 种 SKU · 预报 {{ returnStatistics.totals.expectedQuantity }} 件 · 实收 {{ returnStatistics.totals.receivedQuantity }} 件 · 待收 {{ returnStatistics.totals.remainingQuantity }} 件 · 签收 {{ returnStatistics.totals.receivedBoxCount }} 箱 / {{ returnStatistics.totals.receivedParcelCount }} 包裹 / {{ returnStatistics.totals.receivedPalletCount }} 托 · 预报载体数未提供
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<template #notices><template v-if="loadError">
|
||||
@@ -1311,8 +1306,6 @@ onMounted(() => { void load() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.return-filter-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); color: var(--cc-text); background: var(--cc-surface); font-size: var(--admin-font-body); }
|
||||
.return-filter-summary > span { color: var(--cc-muted); }
|
||||
.last-mile-dialog-facts, .last-mile-command-table, .manual-nodes-editor { table-layout: fixed; }
|
||||
.last-mile-dialog-facts th { width: var(--admin-label-width); }
|
||||
.last-mile-command-table td:not([colspan]) { width: 50%; }
|
||||
|
||||
@@ -46,23 +46,16 @@ enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('RelabelOrdersView', () => {
|
||||
it('uses full-filter totals instead of the visible page and preserves unknown boxes', async () => {
|
||||
it('hides filter totals and preserves row quantities and unknown boxes', async () => {
|
||||
vi.mocked(listRelabelOrders).mockResolvedValue({ records: [{ id: '1', relabelNo: 'RL-PIECE', skuCodes: 'A', totalPackageCount: 100, status: 'PROCESSING' }], page: 1, size: 10, total: 45, pages: 5 })
|
||||
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 45, skuCount: 12, knownLabelPieceCount: 300, unknownPieceOrders: 1, knownCartonCount: 8, unknownCartonOrders: 2, orders: { 'RL-PIECE': { labelPieceCount: 100, cartonCount: null, skuCount: 1, duplicateCarton: false, quantityUnit: 'PIECE', sourcePalletCount: null, targetPalletCount: null } } })
|
||||
const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const summary = wrapper.get('[aria-label="全部筛选结果数量汇总"]')
|
||||
expect(summary.text()).toContain('45 票')
|
||||
expect(summary.text()).toContain('300 件')
|
||||
expect(summary.text()).toContain('8 箱')
|
||||
expect(wrapper.findAll('tbody tr').some(row => row.text().includes('RL-PIECE') && row.text().includes('未提供') && row.text().includes('100'))).toBe(true)
|
||||
})
|
||||
it('does not display all-unknown carton quantities as zero boxes', async () => {
|
||||
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 1, skuCount: 1, knownLabelPieceCount: 100, unknownPieceOrders: 0, knownCartonCount: 0, unknownCartonOrders: 1, orders: {} })
|
||||
const wrapper = mount(RelabelOrdersView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[aria-label="全部筛选结果数量汇总"]').text()).toContain('箱数未提供(1票)')
|
||||
expect(wrapper.get('[aria-label="全部筛选结果数量汇总"]').text()).not.toContain('0 箱')
|
||||
expect(wrapper.find('[aria-label="全部筛选结果数量汇总"]').exists()).toBe(false)
|
||||
const cells = wrapper.findAll('tbody tr').find(row => row.text().includes('RL-PIECE'))!.findAll('td')
|
||||
expect(cells[5]!.text()).toBe('—')
|
||||
expect(cells[5]!.text()).toBe(cells[8]!.text())
|
||||
expect(cells[6]!.text()).toBe('100')
|
||||
})
|
||||
it('shows every SKU and quantity in the review dialog while keeping one box', async () => {
|
||||
vi.mocked(getRelabelQuantities).mockResolvedValue({ orderCount: 1, skuCount: 2, knownLabelPieceCount: 15, unknownPieceOrders: 0, knownCartonCount: 1, unknownCartonOrders: 0, orders: { 'RL-MULTI': { labelPieceCount: 15, cartonCount: 1, skuCount: 2, duplicateCarton: false, quantityUnit: 'CARTON', sourcePalletCount: null, targetPalletCount: null } } })
|
||||
|
||||
@@ -37,9 +37,7 @@ const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const quantities = ref<RelabelQuantityTotals | null>(null)
|
||||
const quantityError = ref('')
|
||||
function dimension(value: number | string | null | undefined) { return value == null ? '未提供' : String(value) }
|
||||
function knownDimension(value: number | string, unknown: number | string, unit: string) { return Number(value) === 0 && Number(unknown) > 0 ? `${unit === '箱' ? '箱数' : unit === '托' ? '托盘数' : '贴标件数'}未提供(${unknown}票)` : `已知 ${value} ${unit}${Number(unknown) > 0 ? `(${unknown}票未提供)` : ''}` }
|
||||
function dimension(value: number | string | null | undefined) { return value == null ? '—' : String(value) }
|
||||
const filters = ref<QueryModel>({ relabelNo: '', sku: '', status: '' })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'relabelNo', type: 'input', label: '换标单号', placeholder: '请输入换标单号', width: 220 },
|
||||
@@ -68,7 +66,7 @@ const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const submitting = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const editSourceStatus = ref('')
|
||||
const auditLabelPieces = ref('未提供')
|
||||
const auditLabelPieces = ref('—')
|
||||
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||
const createItems = ref<CreateRelabelItemPayload[]>([])
|
||||
const createOperation = ref<CreateRelabelItemPayload['operationType']>('CARTON_LABEL')
|
||||
@@ -253,12 +251,11 @@ async function load(model: QueryModel = filters.value) {
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const query = { page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }
|
||||
quantities.value = null; quantityError.value = ''
|
||||
quantities.value = null
|
||||
const result = await listRelabelOrders(query)
|
||||
const measured = await getRelabelQuantities(query).catch(() => null)
|
||||
if (current !== loadGeneration) return
|
||||
quantities.value = measured
|
||||
if (!measured) quantityError.value = '数量汇总加载失败,请重试;未将当前页作为全部合计'
|
||||
rows.value = result.records.map((item) => ({
|
||||
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
|
||||
productName: item.productNameZh?.trim() || '—', cartons: dimension(quantities.value?.orders[item.relabelNo]?.cartonCount), labelPieces: dimension(quantities.value?.orders[item.relabelNo]?.labelPieceCount), skuCount: dimension(quantities.value?.orders[item.relabelNo]?.skuCount), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
|
||||
@@ -280,7 +277,6 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||
<AdminPage class="admin-density"><ListPageLayout aria-label="换标单列表">
|
||||
|
||||
|
||||
<section class="quantity-summary" aria-label="全部筛选结果数量汇总"><template v-if="quantities"><strong>全部筛选结果</strong><span>{{ quantities.orderCount }} 票</span><span>{{ quantities.skuCount }} 种 SKU</span><span>{{ knownDimension(quantities.knownLabelPieceCount, quantities.unknownPieceOrders, '件') }}</span><span>{{ knownDimension(quantities.knownCartonCount, quantities.unknownCartonOrders, '箱') }}</span><span>原托盘:{{ knownDimension(quantities.knownSourcePalletCount ?? 0, quantities.unknownSourcePalletOrders ?? quantities.orderCount, '托') }}</span><span>本轮目标托盘:{{ knownDimension(quantities.knownTargetPalletCount ?? 0, quantities.unknownTargetPalletOrders ?? quantities.orderCount, '托') }}</span></template><span v-else-if="quantityError" role="alert">{{ quantityError }}</span></section>
|
||||
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @selection-change="selectedRows = $event" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-start><h1>换标单</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
@@ -326,7 +322,6 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quantity-summary { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 12px 16px; border: 1px solid var(--cc-border); border-radius: 8px; margin-bottom: 12px; }
|
||||
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number), .el-form :deep(.el-date-editor) { width: 100%; }
|
||||
.relabel-editor-table { table-layout: fixed; }
|
||||
|
||||
@@ -105,7 +105,10 @@ describe('return inventory operation dialogs', () => {
|
||||
const wrapper = await openPlan(); await clickPlan(wrapper); const view = dialog(wrapper, '合箱计划详情')
|
||||
expect(view.get('[aria-label="计划来源箱明细"]').text()).toContain('取出来源'); expect(view.get('[aria-label="计划来源箱明细"]').text()).toContain('预计剩余')
|
||||
expect(view.get('[aria-label="实际合箱流向"]').text()).toContain('BOX-B')
|
||||
const results = view.get('[aria-label="合箱结果"]'); expect(results.text()).toContain('完成时快照'); expect(results.text()).toContain('30 × 20 × 10 cm / 2 kg')
|
||||
const results = view.get('[aria-label="合箱结果"]'); expect(results.text()).not.toContain('结果数量为完成时快照');
|
||||
await results.get('[aria-label="查看合箱结果说明"]').trigger('focus')
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain('结果数量为完成时快照'));
|
||||
expect(results.text()).toContain('30 × 20 × 10 cm / 2 kg')
|
||||
expect(results.findAllComponents({ name: 'ElTableColumn' })[0]!.props('label')).toBe('操作')
|
||||
const oldBox = results.findAll('.el-table__body tr').filter(item => item.text().includes('OLD-BOX')).at(-1)!
|
||||
expect(oldBox.get('button').attributes('disabled')).toBeDefined(); await oldBox.get('button').trigger('click'); expect(api.label).not.toHaveBeenCalled()
|
||||
|
||||
@@ -335,7 +335,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(detail).toContain('单据信息')
|
||||
expect(detail).toContain('关联订单明细')
|
||||
expect(detail).toContain(':data="relatedOrders"')
|
||||
expect(detail).toContain("router.push(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' })")
|
||||
expect(detail).toContain("inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }")
|
||||
expect(detail).toContain("inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'")
|
||||
expect(detail).not.toContain('ElMessage.success')
|
||||
})
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('supplier formal pages', () => {
|
||||
it('does not simulate supplier data or license upload and retains API errors', () => {
|
||||
expect(source).not.toContain('supplierData')
|
||||
expect(formSource).not.toContain('type="file"')
|
||||
expect(formSource).toContain('平台公共供应商资质上传接口尚未提供')
|
||||
expect(formSource).toContain('暂不支持上传或替换资质文件。')
|
||||
expect(formSource).toContain('<ApiErrorMeta')
|
||||
expect(detailSource).toContain('<ApiErrorMeta')
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ export const unifiedOrderStatusOptions: Array<{ value: UnifiedOrderMainStatus; l
|
||||
{ value: 'WEIGHT_CONFIRMED', label: '已确认数据' },
|
||||
{ value: 'WAIT_CUSTOMS_DOCS', label: '待上传资料' },
|
||||
{ value: 'WAIT_PALLETIZE', label: '一件代发打托' },
|
||||
{ value: 'WAIT_SPLIT', label: '待分柜' },
|
||||
{ value: 'WAIT_SPLIT', label: '待装柜' },
|
||||
{ value: 'LOADING', label: '装柜中' },
|
||||
{ value: 'LOADED', label: '装柜完成' },
|
||||
{ value: 'EXPORT_CUSTOMS', label: '出口报关' },
|
||||
@@ -302,7 +302,7 @@ export interface UnifiedOrderQuery {
|
||||
tenantId?: string
|
||||
keyword?: string
|
||||
orderNo?: string
|
||||
mainStatus?: UnifiedOrderMainStatus
|
||||
mainStatus?: UnifiedOrderMainStatus | 'MEASUREMENT_REVIEW'
|
||||
customerId?: string
|
||||
receivingWarehouseId?: string
|
||||
destinationWarehouseId?: string
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { UnifiedOrderResource } from './api/unifiedOrders'
|
||||
import { toMeasurementReviewTask } from './measurementReviewTasks'
|
||||
|
||||
describe('measurement review task identity', () => {
|
||||
it('keeps original tenant, inbound identity and optimistic version for approval', () => {
|
||||
const task = toMeasurementReviewTask({
|
||||
orderId: '9007199254740995', tenantId: '9007199254740993', inboundId: '9007199254740994', inboundVersion: 0,
|
||||
inboundCode: 'IN-1', customerId: '9007199254740993', fulfillmentType: 'ONE_CLICK_DROPSHIP',
|
||||
measurement: { reviewStatus: 'PENDING', actualGrossWeightKg: 12, actualVolumeCbm: 0.2, chargeableWeightKg: 13, completedAt: '2026-10-20T08:00:00' },
|
||||
} as UnifiedOrderResource)
|
||||
expect(task).toMatchObject({ id: '9007199254740994', tenantId: '9007199254740993', orderId: '9007199254740995', version: 0, inboundCode: 'IN-1', measureReviewStatus: 'PENDING', fulfillmentType: 'ONE_CLICK_DROPSHIP', actualGrossWeightKg: 12, actualVolumeCbm: 0.2 })
|
||||
})
|
||||
it('rejects an order without an inbound identity instead of approving a different resource', () => {
|
||||
expect(() => toMeasurementReviewTask({ orderId: '1', inboundId: null } as UnifiedOrderResource)).toThrow('缺少入库单标识')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { InboundOrderSummary } from '@/modules/inbounds'
|
||||
import type { UnifiedOrderResource } from './api/unifiedOrders'
|
||||
|
||||
/** 将统一订单查询的审核待办映射到现有测量审核操作,保留原始客户、入库单与版本。 */
|
||||
export function toMeasurementReviewTask(row: UnifiedOrderResource): InboundOrderSummary {
|
||||
if (row.inboundId == null) throw new Error('测量审核订单缺少入库单标识,请刷新后重试')
|
||||
return {
|
||||
id: String(row.inboundId),
|
||||
tenantId: String(row.tenantId),
|
||||
orderId: String(row.orderId),
|
||||
orderNo: row.orderNo,
|
||||
inboundCode: row.inboundCode,
|
||||
processStatus: 'MEASURE_PENDING',
|
||||
orderStatusSnapshot: row.internalOrderStatus,
|
||||
measureReviewStatus: row.measurement?.reviewStatus ?? null,
|
||||
feeStatus: null,
|
||||
feeReviewStatus: row.feeReviewStatus,
|
||||
notificationStatus: null,
|
||||
version: row.inboundVersion,
|
||||
expectedArrivalAt: row.expectedArrivalAt,
|
||||
customerId: row.customerId == null ? null : String(row.customerId),
|
||||
customerName: row.customerName,
|
||||
companyId: null,
|
||||
companyName: row.companyName ?? null,
|
||||
warehouseId: row.receivingWarehouseId == null ? null : String(row.receivingWarehouseId),
|
||||
warehouseName: row.receivingWarehouseName,
|
||||
deliveryWarehouseId: row.destinationWarehouseId == null ? null : String(row.destinationWarehouseId),
|
||||
deliveryWarehouseName: row.destinationWarehouseName,
|
||||
businessType: row.businessType ?? null,
|
||||
fulfillmentType: row.fulfillmentType ?? null,
|
||||
channelCode: row.channelCode,
|
||||
channelName: row.channelName,
|
||||
taxMode: row.taxMode ?? null,
|
||||
containerMode: null,
|
||||
customsInspection: row.customsInspection ?? null,
|
||||
destinationType: row.destinationType ?? null,
|
||||
destinationWarehouseId: row.destinationWarehouseId == null ? null : String(row.destinationWarehouseId),
|
||||
destinationCode: null,
|
||||
destinationName: row.destinationWarehouseName,
|
||||
holdFlag: row.holdFlag,
|
||||
currentHoldReason: row.currentHoldReason,
|
||||
totalPackageCount: row.totalPackageCount,
|
||||
actualGrossWeightKg: row.measurement?.actualGrossWeightKg ?? row.actualGrossWeightKg,
|
||||
actualNetWeightKg: row.totalNetWeightKg,
|
||||
actualVolumeCbm: row.measurement?.actualVolumeCbm ?? row.actualVolumeCbm,
|
||||
volumeWeightKg: row.volumetricWeightKg,
|
||||
chargeableWeightKg: row.measurement?.chargeableWeightKg ?? null,
|
||||
chargeableVolumeCbm: row.chargeableVolumeCbm,
|
||||
palletCount: row.totalPalletCount,
|
||||
locationCodes: row.locationCodes,
|
||||
measureCompletedAt: row.measurement?.completedAt ?? row.measurementCompletedAt,
|
||||
inboundedAt: row.inboundCompletedAt,
|
||||
expressCompanyCode: null,
|
||||
expressCompanyName: row.expressCompany,
|
||||
expressTrackingNo: row.expressTrackingNo,
|
||||
loadingStatus: row.transport?.loadingStatus ?? null,
|
||||
containerNo: row.transport?.containerNo ?? null,
|
||||
cabinetNo: row.transport?.cabinetNo ?? null,
|
||||
vesselName: row.transport?.vesselName ?? null,
|
||||
voyageNo: row.transport?.voyageNo ?? null,
|
||||
sailingAt: row.transport?.effectiveSailingAt ?? null,
|
||||
portArrivalAt: row.transport?.effectivePortArrivalAt ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,12 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
|
||||
vi.mock('@/modules/inbounds', () => ({
|
||||
downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn(),
|
||||
InboundTaskList: { name: 'InboundTaskList', props: ['mode', 'title'], template: '<section><slot name="status-tabs" /></section>' },
|
||||
InboundTaskList: {
|
||||
name: 'InboundTaskList', props: ['mode', 'title', 'taskLoader'],
|
||||
methods: { reload() { return this.taskLoader({ page: 1, size: 20 }) } },
|
||||
mounted() { void this.reload() },
|
||||
template: '<section><slot name="filters" :loading="false" /><slot name="status-tabs" /></section>',
|
||||
},
|
||||
}))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
@@ -36,7 +41,7 @@ vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
|
||||
unifiedOrderStatusOptions: [
|
||||
{ value: 'PENDING_REVIEW', label: '待审核' },
|
||||
{ value: 'WAIT_MEASURE', label: '待测量数据' },
|
||||
{ value: 'WAIT_SPLIT', label: '待分柜' },
|
||||
{ value: 'WAIT_SPLIT', label: '待装柜' },
|
||||
{ value: 'LOADING', label: '装柜中' },
|
||||
{ value: 'LOADED', label: '装柜完成' },
|
||||
{ value: 'DELIVERING', label: '派送中' },
|
||||
@@ -76,6 +81,37 @@ describe('UnifiedOrdersView list request coordination', () => {
|
||||
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
|
||||
})
|
||||
|
||||
it('shares applied filters, advanced fields and reset across measurement review and other tabs', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const model = { receivingWarehouseId: '9007199254740993', destinationWarehouseId: '200', inboundCode: ' IN-9 ', tenantId: '300', channelName: '海派', containerNo: 'CONT-1', cabinetNo: 'CAB-1', loaded: 'false', fullContainer: 'true', expectedArrivalDate: '2026-10-20', sailingDate: '2026-11-01', feeReviewStatus: 'APPROVED', measurementReviewStatus: 'PENDING', fbaCode: 'SKU-1', createdRange: ['2026-10-01', '2026-10-20'] }
|
||||
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
|
||||
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...model })
|
||||
await flushPromises()
|
||||
toolbar.vm.$emit('search')
|
||||
await flushPromises()
|
||||
const ordinaryQuery = vi.mocked(listPlatformUnifiedOrders).mock.lastCall![0]
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text().startsWith('测量数据审核'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith({ ...ordinaryQuery, mainStatus: 'MEASUREMENT_REVIEW' })
|
||||
const reviewToolbar = wrapper.getComponent({ name: 'QueryToolbar' })
|
||||
expect(reviewToolbar.props('modelValue')).toMatchObject(model)
|
||||
expect(reviewToolbar.attributes('preference-key')).toBe('unified-orders-query-v2')
|
||||
expect(reviewToolbar.attributes('available-fields')).toBeDefined()
|
||||
reviewToolbar.vm.$emit('reset')
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ mainStatus: 'MEASUREMENT_REVIEW', receivingWarehouseId: undefined, inboundCode: undefined, channelName: undefined, tenantId: undefined }))
|
||||
reviewToolbar.vm.$emit('update:modelValue', { ...reviewToolbar.props('modelValue'), inboundCode: 'IN-REVIEW' })
|
||||
await flushPromises()
|
||||
reviewToolbar.vm.$emit('search')
|
||||
await flushPromises()
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ mainStatus: 'MEASUREMENT_REVIEW', inboundCode: 'IN-REVIEW' }))
|
||||
await wrapper.findAll('[role="tab"]').find(tab => tab.text().startsWith('待审核'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'QueryToolbar' }).props('modelValue')).toMatchObject({ inboundCode: 'IN-REVIEW' })
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ mainStatus: 'PENDING_REVIEW', inboundCode: 'IN-REVIEW' }))
|
||||
})
|
||||
|
||||
it('shows only supported counts, keeps all text-only and never scopes counts by status or pagination', async () => {
|
||||
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockResolvedValue({ counts: { PENDING_REVIEW: 12, WAIT_MEASURE: 0, SIGNED: 3 } })
|
||||
const wrapper = mountView()
|
||||
@@ -166,13 +202,13 @@ describe('UnifiedOrdersView list request coordination', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.getComponent({ name: 'InboundTaskList' }).props()).toMatchObject({ mode: 'review', title: '订单列表' })
|
||||
expect(wrapper.get('[aria-selected="true"]').text()).toBe('测量数据审核')
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1)
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(2)
|
||||
|
||||
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '待测量数据')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'InboundTaskList' }).exists()).toBe(false)
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ mainStatus: 'WAIT_MEASURE' }))
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(2)
|
||||
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('does not apply draft filters to pagination until query is submitted', async () => {
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
|
||||
it('exposes the product main statuses including dropship palletizing', () => {
|
||||
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
|
||||
'待审核', '待测量数据', '待客户确认', '已确认数据', '待上传资料', '一件代发打托', '待分柜', '装柜中', '装柜完成', '出口报关', '已开船/待清关', '待拆柜', '待排车', '出库单', '派送中', '签收完成',
|
||||
'待审核', '待测量数据', '待客户确认', '已确认数据', '待上传资料', '一件代发打托', '待装柜', '装柜中', '装柜完成', '出口报关', '已开船/待清关', '待拆柜', '待排车', '出库单', '派送中', '签收完成',
|
||||
])
|
||||
for (const legacyLabel of ['差异处理中', '进入分柜']) {
|
||||
expect(source).not.toContain(legacyLabel)
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type LegacyOrderImportResult,
|
||||
} from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import { cancelPlatformCoLoadGroup, createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode, type CoLoadBusinessSummary, type CoLoadGroup } from '@/modules/unified-orders/api/coLoadGroups'
|
||||
import { toMeasurementReviewTask } from '../measurementReviewTasks'
|
||||
import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue'
|
||||
import InboundFeeDetailManager from '@/modules/unified-orders/components/InboundFeeDetailManager.vue'
|
||||
import OrderDeclarationEditorDialog from '@/modules/unified-orders/components/OrderDeclarationEditorDialog.vue'
|
||||
@@ -330,6 +331,7 @@ const showAdvancedFilters = ref(false)
|
||||
const filters = ref<FilterModel>(emptyFilters())
|
||||
const appliedFilters = ref<FilterModel>(emptyFilters())
|
||||
const selectedStatus = ref<UnifiedOrderMainStatus | ''>('')
|
||||
const measurementReviewList = ref<InstanceType<typeof InboundTaskList> | null>(null)
|
||||
const measurementReviewSelected = ref(false)
|
||||
const selectedTab = computed(() => measurementReviewSelected.value ? 'MEASUREMENT_REVIEW' : selectedStatus.value)
|
||||
const statusTabList = ref<HTMLElement | null>(null)
|
||||
@@ -1641,9 +1643,14 @@ function tabCount(value: UnifiedOrderMainStatus | '' | 'MEASUREMENT_REVIEW') {
|
||||
return value ? statusCounts.value[value] : undefined
|
||||
}
|
||||
|
||||
async function loadMeasurementReviewTasks(pagination: { page: number; size: number }) {
|
||||
const result = await listPlatformUnifiedOrders({ ...orderCountFilters(), mainStatus: 'MEASUREMENT_REVIEW', ...pagination })
|
||||
return { ...result, records: result.records.map(toMeasurementReviewTask) }
|
||||
}
|
||||
|
||||
async function loadOrders(refreshCounts = true) {
|
||||
if (refreshCounts) void loadStatusCounts()
|
||||
if (measurementReviewSelected.value) return
|
||||
if (measurementReviewSelected.value) { await measurementReviewList.value?.reload(); return }
|
||||
selectedApprovalOrders.value = []
|
||||
selectedOrders.value = []
|
||||
loading.value = true
|
||||
@@ -2403,15 +2410,15 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><label for="fee-confirm-weight">计费毛重</label></th>
|
||||
<td><div class="density-table-editor"><el-input-number id="fee-confirm-weight" v-model="feeConfirmWeight" :min="0" :precision="3" :controls="false" placeholder="请输入计费毛重" /><span>kg</span></div></td>
|
||||
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-weight" v-model="feeConfirmWeight" :min="0" :precision="3" :controls="false" placeholder="请输入计费毛重" /><span>kg</span></div></td>
|
||||
<th scope="row"><label for="fee-confirm-volume">计费体积</label></th>
|
||||
<td><div class="density-table-editor"><el-input-number id="fee-confirm-volume" v-model="feeConfirmVolume" :min="0" :precision="6" :controls="false" placeholder="请输入计费体积" /><span>m³</span></div></td>
|
||||
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-volume" v-model="feeConfirmVolume" :min="0" :precision="6" :controls="false" placeholder="请输入计费体积" /><span>m³</span></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">价格表参考单价</th>
|
||||
<td>{{ feeConfirmPriceMissing ? '未命中' : feeConfirmReference?.unitPrice == null ? '正在读取参考单价' : `${formatNumber(Number(feeConfirmReference.unitPrice), 4)} ${feeConfirmPriceUnit}` }}</td>
|
||||
<th scope="row"><label for="fee-confirm-unit-price">本次对客单价</label></th>
|
||||
<td><div class="density-table-editor"><el-input-number id="fee-confirm-unit-price" v-model="feeConfirmUnitPrice" :min="0" :precision="4" :controls="false" :disabled="feeConfirmReferenceLoading" placeholder="请输入对客单价" /><span>{{ feeConfirmPriceUnit }}</span></div></td>
|
||||
<td><div class="density-table-editor density-table-editor--outlined"><el-input-number id="fee-confirm-unit-price" v-model="feeConfirmUnitPrice" :min="0" :precision="4" :controls="false" :disabled="feeConfirmReferenceLoading" placeholder="请输入对客单价" /><span>{{ feeConfirmPriceUnit }}</span></div></td>
|
||||
</tr>
|
||||
<tr v-if="feeConfirmReference">
|
||||
<th scope="row">参考计费量</th><td>{{ formatNumber(Number(feeConfirmReference.chargeableQuantity), feeConfirmReference.roundingScale) }} {{ feeConfirmReference.chargeUnit }}</td>
|
||||
@@ -2777,8 +2784,9 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
|
||||
<template v-else>
|
||||
<div v-if="measurementReviewSelected" id="unified-orders-results" role="tabpanel">
|
||||
<InboundTaskList mode="review" title="订单列表" @changed="loadStatusCounts">
|
||||
<template #status-tabs>
|
||||
<InboundTaskList ref="measurementReviewList" mode="review" title="订单列表" :task-loader="loadMeasurementReviewTasks" @changed="loadStatusCounts">
|
||||
<template #filters="{ loading: reviewLoading }">
|
||||
<div class="orders-query-panel integrated-query-group">
|
||||
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态" :aria-busy="statusCountsLoading">
|
||||
<button
|
||||
v-for="(tab, index) in statusTabs"
|
||||
@@ -2799,6 +2807,15 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
|
||||
<el-button link type="primary" @click="loadStatusCounts">重试数量</el-button>
|
||||
<ApiErrorMeta :details="statusCountsError" />
|
||||
</div>
|
||||
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :loading="reviewLoading" :search-on-reset="false" @search="search" @reset="reset">
|
||||
<template #actions>
|
||||
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
|
||||
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
|
||||
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
|
||||
</el-button>
|
||||
</template>
|
||||
</QueryToolbar>
|
||||
</div>
|
||||
</template>
|
||||
</InboundTaskList>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,18 @@
|
||||
.admin-density-dialog.el-dialog .el-empty__image { display: none; }
|
||||
.admin-density-dialog.el-dialog .el-empty__description { margin-top: 0; }
|
||||
|
||||
/* Opt-in comfortable editor frame for short forms that need room to breathe. */
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog { --el-component-size: 40px; border-radius: 12px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog:not(.is-fullscreen) { margin-top: 10vh; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-dialog__header { padding: 18px 48px 18px 24px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-dialog__headerbtn { top: 6px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-dialog__body { padding: 24px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-dialog__footer { padding: 16px 24px; background: var(--admin-label-bg); }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-form-item { margin-bottom: 20px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-form-item__label { margin-bottom: 8px; font-weight: 500; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-select__wrapper { min-height: 40px; }
|
||||
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .app-dialog__footer .el-button { min-width: 88px; height: 38px; }
|
||||
|
||||
/* Opted-in detail and editor content reuse the existing compact drawer frame. */
|
||||
:is(.admin-density-dialog, .admin-density-drawer:where(.admin-density-drawer--detail, .admin-density-drawer--editor)) .density-code-block { margin:0; padding:var(--admin-space-3); border:1px solid var(--admin-table-border); border-radius:var(--admin-radius); background:var(--admin-surface); color:var(--cc-text); font:var(--admin-font-caption)/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
|
||||
@@ -252,6 +264,11 @@
|
||||
--el-input-hover-border-color: var(--admin-table-border);
|
||||
--el-input-bg-color: transparent;
|
||||
}
|
||||
:is(.admin-density, .admin-density-dialog) .density-table--editable .density-table-editor--outlined .el-input {
|
||||
--el-input-border-color: var(--el-color-primary-light-5);
|
||||
--el-input-hover-border-color: var(--el-color-primary);
|
||||
--el-input-bg-color: var(--admin-surface);
|
||||
}
|
||||
:is(.admin-density, .admin-density-dialog) .density-table-editor { display: flex; align-items: center; gap: var(--admin-space-3); min-width: 0; }
|
||||
:is(.admin-density, .admin-density-dialog) .density-table-editor .el-input-number { flex: 1; width: 100%; min-width: 0; }
|
||||
:is(.admin-density, .admin-density-dialog) .density-table--editable .el-input__inner { text-align: left; }
|
||||
|
||||
@@ -178,6 +178,33 @@ const cssRoot = (css: string) => {
|
||||
}
|
||||
|
||||
describe('approved Admin density ownership', () => {
|
||||
it('outlines only the three opted-in customer billing editors without overriding native focus or disabled styles', () => {
|
||||
const rules = new Map<string, Record<string, string>>()
|
||||
cssRoot(shared).walkRules(rule => {
|
||||
const values: Record<string, string> = {}
|
||||
rule.walkDecls(decl => { values[decl.prop] = decl.value })
|
||||
rules.set(rule.selector, values)
|
||||
})
|
||||
expect(rules.get(':is(.admin-density, .admin-density-dialog) .density-table--editable .density-table-editor--outlined .el-input')).toEqual({
|
||||
'--el-input-border-color': 'var(--el-color-primary-light-5)',
|
||||
'--el-input-hover-border-color': 'var(--el-color-primary)',
|
||||
'--el-input-bg-color': 'var(--admin-surface)',
|
||||
})
|
||||
const ast = parse(read('modules/unified-orders/pages/UnifiedOrdersView.vue')).descriptor.template!.ast!
|
||||
const inputIds: string[] = []
|
||||
const visit = (nodes: typeof ast.children) => { for (const node of nodes) {
|
||||
if (node.type !== 1) continue
|
||||
if (classesOf(node).includes('density-table-editor--outlined')) {
|
||||
const input = node.children.find(child => child.type === 1 && child.tag === 'el-input-number') as ElementNode | undefined
|
||||
expect(input).toBeDefined()
|
||||
const id = input!.props.find(prop => prop.type === 6 && prop.name === 'id')
|
||||
inputIds.push(id?.type === 6 ? id.value?.content || '' : '')
|
||||
}
|
||||
visit(node.children)
|
||||
} }
|
||||
visit(ast.children)
|
||||
expect(inputIds).toEqual(['fee-confirm-weight', 'fee-confirm-volume', 'fee-confirm-unit-price'])
|
||||
})
|
||||
it('allows container filters and their final action group to wrap by available width', () => {
|
||||
const source = read('modules/container-loading/pages/ContainersView.vue')
|
||||
for (const style of parse(source).descriptor.styles) {
|
||||
@@ -1007,6 +1034,14 @@ describe('approved Admin density ownership', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('gives simulation creation an opt-in comfortable editor frame', () => {
|
||||
const source = read('modules/container-loading/components/ContainerSimulationCreate.vue')
|
||||
expect(source).toContain("'admin-density-dialog--comfortable': dense")
|
||||
expect(source).toContain('width="min(800px, calc(100vw - 32px))"')
|
||||
expect(shared).toContain('.admin-density-dialog.admin-density-dialog--comfortable.el-dialog { --el-component-size: 40px; border-radius: 12px; }')
|
||||
expect(shared).toContain('.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-select__wrapper { min-height: 40px; }')
|
||||
})
|
||||
|
||||
it('keeps conditional shared consumers opt-in instead of enabling other menus', () => {
|
||||
const actionWeights = new Map<string, string>()
|
||||
cssRoot(read('styles/adminTextActions.css')).walkRules(rule => {
|
||||
|
||||
Reference in New Issue
Block a user