Compare commits

...
Author SHA1 Message Date
ouyangxq 39224e5682 fix(appointments): retain latest TEST admin with dispatch detail navigation 2026-10-20 17:09:57 +08:00
ouyangxq a781db819a fix(admin): clarify billing inputs and correct location and filter display
Scoped TEST release of three reviewed display fixes; no API, field or workflow changes.
2026-10-20 16:53:42 +08:00
13 changed files with 149 additions and 32 deletions
@@ -88,10 +88,10 @@ describe('simplified appointment entry', () => {
wrapper.unmount() wrapper.unmount()
}) })
it('does not request unreadable domains and describes the incomplete scope', async () => { 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() const wrapper = renderSchedule(); await flushPromises()
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled() expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少车次读取权限') expect(wrapper.text()).toContain('缺少排车读取权限')
expect(wrapper.text()).toContain('缺少尾程订单读取权限') expect(wrapper.text()).toContain('缺少尾程订单读取权限')
wrapper.unmount() 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' } }) expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper.unmount() 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()
})
}) })
@@ -64,12 +64,12 @@ async function load(next = range.value) {
} catch (cause) { fail('计划到仓读取失败', cause) } } catch (cause) { fail('计划到仓读取失败', cause) }
})(), })(),
(async () => { (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 { try {
const result = projectTripCalendar(await listTripCalendar(next, signal), next) const result = projectTripCalendar(await listTripCalendar(next, signal), next)
if (request !== generation) return if (request !== generation) return
trips.value = result.events 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) } } 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 四类日历恢复(当前结论) ## 2026-10-19 四类日历恢复(当前结论)
依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。 依据:用户本轮确认四类与日期规则,以及截图 `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' import { http } from '@/shared/api/http'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data })) 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' } const range = { from: '2026-10-01', to: '2026-11-01' }
function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry { function trip(dispatchNo: 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: [] } 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', () => { 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', () => { it('counts a shared trip once and excludes cancelled arrangements', () => {
const shared = trip('TR-SHARED', '2026-10-10T12:00:00Z') const shared = trip('DS-SHARED', '2026-10-10T12:00:00Z')
const cancelled = { ...trip('TR-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' } const cancelled = { ...trip('DS-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range) const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range)
expect(result.events).toHaveLength(1) 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 () => { 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'} 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', () => { 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 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) 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', () => { 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 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) 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', () => { 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 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('TR-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events 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.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:'计划出发(UTC)',value:'2026-11-01T05:30:00Z'})
expect(events[0]!.fields).toContainEqual({label:'路线',value:'Stop 0 → Stop 1 → Stop 2 等20站'}) 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', () => { 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([]) expect(projectTripCalendar({records:[executed],unresolvedRecords:[]}, range).events).toEqual([])
const november = {from:'2026-11-01',to:'2026-12-01'} const november = {from:'2026-11-01',to:'2026-12-01'}
const event = projectTripCalendar({records:[executed],unresolvedRecords:[]}, november).events[0] 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}) 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', () => { 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) const result = projectTripCalendar({records:[invalid],unresolvedRecords:[]},range)
expect(result.events).toEqual([]) 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' import type { ScheduleEvent } from './schedule'
export interface TripCalendarEntry { export interface TripCalendarEntry {
sourceType: 'TRIP_PLAN' sourceType: 'DISPATCH'
tripNo: string dispatchNo: string
status: string status: string
originWarehouseId: string | null originWarehouseId: string | null
originWarehouseCode: string | null originWarehouseCode: string | null
@@ -23,6 +23,7 @@ export const tripIssueLabels: Record<string, string> = {
ORIGIN_WAREHOUSE_UNKNOWN: '起运仓资料缺失', ORIGIN_COUNTRY_UNKNOWN: '起运国家待核实', ORIGIN_WAREHOUSE_UNKNOWN: '起运仓资料缺失', ORIGIN_COUNTRY_UNKNOWN: '起运国家待核实',
ORIGIN_TIMEZONE_UNKNOWN: '起运仓时区待核实', ROUTE_INCOMPLETE: '路线资料不完整', ORIGIN_TIMEZONE_UNKNOWN: '起运仓时区待核实', ROUTE_INCOMPLETE: '路线资料不完整',
LOCAL_DATE_UNRESOLVED: '无法确定当地日期', LOCAL_DATE_UNRESOLVED: '无法确定当地日期',
DISPATCH_REFERENCE_UNKNOWN: '排车单号缺失',
} }
export function tripIssues(row: TripCalendarEntry) { export function tripIssues(row: TripCalendarEntry) {
return row.incompleteReasons.map(reason => tripIssueLabels[reason] || '资料待核实').join(';') 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 }) { export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords] const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords]
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' } 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 if (row.status === 'CANCELLED') continue
// Executed work stays on its actual local date even if the plan changes later. // Executed work stays on its actual local date even if the plan changes later.
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone) 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 if (occurrence.date < range.from || occurrence.date >= range.to) continue
const actual = localStamp(row.departedAt, row.originTimezone) const actual = localStamp(row.departedAt, row.originTimezone)
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone) const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
events.push({ key: `trip-${row.tripNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time, events.push({ key: `dispatch-${row.dispatchNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排', title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`, sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`,
fields: [ fields: [
{ label: '车次状态', value: labels[row.status] || '待核实' }, { label: '运输状态', value: labels[row.status] || '待核实' },
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode }, { label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
{ label: '地点时区', value: row.originTimezone }, { label: '地点时区', value: row.originTimezone },
{ label: '计划出发(UTC)', value: row.plannedDepartureAt }, { label: '计划出发(UTC)', value: row.plannedDepartureAt },
@@ -821,7 +821,6 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<style scoped> <style scoped>
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.container-editor-table { table-layout: fixed; }.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); } .container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.container-editor-table { table-layout: fixed; }.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; } .container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; } .container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
@media(max-width:720px){.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }} @media(max-width:720px){.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
@media(max-width:720px){.container-editor-table :is(tbody,tr,td){display:block}} @media(max-width:720px){.container-editor-table :is(tbody,tr,td){display:block}}
@@ -19,6 +19,21 @@ vi.mock('@/modules/overseas-master-data', async () => ({
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true } } } 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]) }) 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', () => { 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 () => { 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 }] 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) vi.mocked(getDispatch).mockResolvedValueOnce(value)
@@ -3,6 +3,7 @@ import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { inquiryReturnTarget, vehicleStatusLabels } from '@/modules/overseas-master-data' import { inquiryReturnTarget, vehicleStatusLabels } from '@/modules/overseas-master-data'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -65,7 +66,9 @@ async function load() {
catch (error) { if (current === generation) loadError.value = getApiErrorDetails(error, '排车详情加载失败,请重试') } catch (error) { if (current === generation) loadError.value = getApiErrorDetails(error, '排车详情加载失败,请重试') }
finally { if (current === generation) loading.value = false } 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 } 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 handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
async function handleSaved() { rejectionError.value = null; await load() } async function handleSaved() { rejectionError.value = null; await load() }
@@ -74,7 +77,7 @@ onBeforeUnmount(() => { generation++ })
</script> </script>
<template> <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="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> <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"> <div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
@@ -2403,15 +2403,15 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
</tr> </tr>
<tr> <tr>
<th scope="row"><label for="fee-confirm-weight">计费毛重</label></th> <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> <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>
<tr> <tr>
<th scope="row">价格表参考单价</th> <th scope="row">价格表参考单价</th>
<td>{{ feeConfirmPriceMissing ? '未命中' : feeConfirmReference?.unitPrice == null ? '正在读取参考单价' : `${formatNumber(Number(feeConfirmReference.unitPrice), 4)} ${feeConfirmPriceUnit}` }}</td> <td>{{ feeConfirmPriceMissing ? '未命中' : feeConfirmReference?.unitPrice == null ? '正在读取参考单价' : `${formatNumber(Number(feeConfirmReference.unitPrice), 4)} ${feeConfirmPriceUnit}` }}</td>
<th scope="row"><label for="fee-confirm-unit-price">本次对客单价</label></th> <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>
<tr v-if="feeConfirmReference"> <tr v-if="feeConfirmReference">
<th scope="row">参考计费量</th><td>{{ formatNumber(Number(feeConfirmReference.chargeableQuantity), feeConfirmReference.roundingScale) }} {{ feeConfirmReference.chargeUnit }}</td> <th scope="row">参考计费量</th><td>{{ formatNumber(Number(feeConfirmReference.chargeableQuantity), feeConfirmReference.roundingScale) }} {{ feeConfirmReference.chargeUnit }}</td>
@@ -15,6 +15,7 @@ import {
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses' import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue' import DataTable from '@/shared/components/DataTable.vue'
import LayoutPreferenceList from '@/shared/components/LayoutPreferenceList.vue'
import LocationsView from './LocationsView.vue' import LocationsView from './LocationsView.vue'
const locationsSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouse-resources/locations/pages/LocationsView.vue'), 'utf8') const locationsSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouse-resources/locations/pages/LocationsView.vue'), 'utf8')
@@ -179,6 +180,27 @@ describe('LocationsView', () => {
vi.mocked(createPlatformLocation).mockReset() vi.mocked(createPlatformLocation).mockReset()
}) })
it('renders missing maximum weights as a dash and preserves zero and populated weights', async () => {
const missingWeight = { ...availableLocation }
Reflect.deleteProperty(missingWeight, 'maxWeightKg')
const records = [missingWeight, ...[null, '', 0, 2000, '10000.125'].map((maxWeightKg, index) => ({
...availableLocation, id: `weight-${index}`, locationCode: `WEIGHT-${index}`, maxWeightKg,
}))]
vi.mocked(listPlatformLocations).mockResolvedValue({ records, page: 1, size: 20, total: records.length, pages: 1 })
const wrapper = await mountView()
await wrapper.get('button[aria-label="列设置"]').trigger('click')
await flushPromises()
const preferences = wrapper.findComponent(DataTable).findComponent(LayoutPreferenceList)
preferences.vm.$emit('visibility', 'maxWeightKg', true)
await flushPromises()
const column = wrapper.findAll('thead th').findIndex(header => header.text().includes('最大承重'))
expect(column).toBeGreaterThan(-1)
expect(wrapper.findAll('tbody tr').map(row => row.findAll('td')[column]!.text())).toEqual([
'—', '—', '—', '0 kg', '2,000 kg', '10,000.125 kg',
])
expect(wrapper.text()).not.toContain('undefined kg')
})
it('loads only domestic warehouse candidates for querying and writes', async () => { it('loads only domestic warehouse candidates for querying and writes', async () => {
const wrapper = await mountView() const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar) const toolbar = wrapper.findComponent(QueryToolbar)
@@ -188,8 +188,8 @@ function optionalText(value: string) {
return value.trim() || null return value.trim() || null
} }
function formatWeight(value: number | string | null) { function formatWeight(value: number | string | null | undefined) {
if (value === null || value === '') return '—' if (value == null || value === '') return '—'
const numeric = Number(value) const numeric = Number(value)
return Number.isFinite(numeric) ? `${numeric.toLocaleString('zh-CN', { maximumFractionDigits: 3 })} kg` : `${value} kg` return Number.isFinite(numeric) ? `${numeric.toLocaleString('zh-CN', { maximumFractionDigits: 3 })} kg` : `${value} kg`
} }
+5
View File
@@ -252,6 +252,11 @@
--el-input-hover-border-color: var(--admin-table-border); --el-input-hover-border-color: var(--admin-table-border);
--el-input-bg-color: transparent; --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 { 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-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; } :is(.admin-density, .admin-density-dialog) .density-table--editable .el-input__inner { text-align: left; }
+39
View File
@@ -178,6 +178,45 @@ const cssRoot = (css: string) => {
} }
describe('approved Admin density ownership', () => { 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) {
cssRoot(style.content).walkRules(rule => {
if (!rule.selector.includes('.query-toolbar__tail')) return
rule.walkDecls(decl => {
if (decl.prop === 'width') expect(decl.value).not.toBe('100%')
if (decl.prop === 'flex') expect(decl.value).not.toBe('0 0 100%')
})
})
}
})
it('keeps declaration order fields on the continuous cell-editor baseline', () => { it('keeps declaration order fields on the continuous cell-editor baseline', () => {
const source = read('modules/unified-orders/components/OrderDeclarationEditorDialog.vue') const source = read('modules/unified-orders/components/OrderDeclarationEditorDialog.vue')
const orderFields = source.slice(source.indexOf('<el-form label-position="left"'), source.indexOf('</el-form>') + '</el-form>'.length) const orderFields = source.slice(source.indexOf('<el-form label-position="left"'), source.indexOf('</el-form>') + '</el-form>'.length)