Compare commits

...
Author SHA1 Message Date
liuyihe bc27480e2c Merge origin/release/test and preserve pickup review photos 2026-10-21 18:13:56 +08:00
liuyihe 872d197531 优化 2026-10-21 18:11:48 +08:00
ouyangxq b835c1cdfc fix(admin): 补齐运输资源候选显隐事件类型 2026-10-21 18:08:06 +08:00
ouyangxq ba072611dc 优化 2026-10-21 18:05:02 +08:00
ouyangxq 98049c4f8c 优化 2026-10-21 17:57:02 +08:00
ouyangxq 64667af5c6 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-21 17:56:57 +08:00
ouyangxq 4f5cec1b94 优化 2026-10-21 17:49:04 +08:00
liuyihe c367fadf09 Merge release/test and preserve pickup execution and photo workflows 2026-10-21 17:35:43 +08:00
liuyihe fde16235ea 优化 2026-10-21 17:28:04 +08:00
ouyangxq 22e60248d2 优化 2026-10-21 17:26:49 +08:00
ouyangxq f8c9334e98 优化 2026-10-21 17:22:25 +08:00
ouyangxq 67acd02ea5 优化 2026-10-21 17:08:59 +08:00
ouyangxq c510cc4e2e 优化 2026-10-21 16:58:38 +08:00
ouyangxq e2f99b4f4b 优化 2026-10-21 16:40:10 +08:00
liuyihe 5e8daad5a0 Merge origin/release/test and preserve pickup workflow 2026-10-21 16:22:08 +08:00
liuyihe 215cb7c6d5 优化 2026-10-21 16:12:24 +08:00
ouyangxq 8356e98ce3 优化 2026-10-21 15:53:21 +08:00
ouyangxq 6ab3bbe289 Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test 2026-10-21 15:53:19 +08:00
ouyangxq a4c7f8edd5 优化 2026-10-21 15:49:35 +08:00
liuyihe a9c0285e22 优化 2026-10-21 15:20:24 +08:00
ouyangxq e6c864a2a6 优化 2026-10-21 15:10:54 +08:00
ouyangxq 457a95941d style(fleet): unify owned business row action buttons 2026-10-21 15:05:56 +08:00
ouyangxq fc4c14e420 fix(fleet): accept vehicle ownership for pool-based business lists 2026-10-21 14:57:17 +08:00
ouyangxq 8498798a9c 优化 2026-10-21 14:30:33 +08:00
ouyangxq 3953453090 fix(dispatch): type existing cargo selection event 2026-10-21 13:36:57 +08:00
ouyangxq 2333bd8bab 优化 2026-10-21 13:22:42 +08:00
187 changed files with 5129 additions and 1356 deletions
+5 -5
View File
@@ -3,9 +3,8 @@
## 快速读取顺序
1. 由“模块快速路由”选最小模块并读最近 `AGENTS.md`,按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不扫描全部 `src/`。
2. 视觉、布局/交互承载变更或真实页面验收先读 `../../docs/Admin-Client前端原型还原验收规则.md`;需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发。
样式优化另读 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”“新模块接入规则”,保留已确认密度、彩色页签、业务语义及承载。国内仓、客户管理为样板;新增菜单按本轮范围显式接入,货柜入口见 `../../docs/Admin货柜管理样式核对记录.md`。
蓝白样式接入见 [样板指南](../../docs/界面参考/蓝白卡片式工作台风格参考.md)。
2. Admin 样式唯一入口为 [Admin 界面规范](../../docs/Admin高密度界面规范.md#按任务阅读),按任务只读对应章节;提示小字、加载布局、蓝白风格及密度不再分散查多份规范。样板目录仅在需要视觉参考时查,模块核对记录仅在核对范围、业务例外或验收证据时查。
视觉、布局/交互承载变更或真实页面验收再读 [前端验收规则](../../docs/Admin-Client前端原型还原验收规则.md);需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发验收矩阵。
3. 标准列表/公共组件读 `src/shared/components/AGENTS.md`;HTTP、Token/刷新读 `src/shared/api/AGENTS.md`;Finance 读 `src/modules/finance/AGENTS.md`;海外仓全流程先读 `../../docs/海外仓业务/README.md`。
4. 平台/客户层级及权限读 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件按需读 `../../docs/统一认证与服务通信契约.md` 对应章节。
@@ -99,7 +98,7 @@
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
- 页面和交互必须服从对应原型及验收规则;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
- 页面外观遵循 Admin 界面规范,业务及操作承载核对当前契约、逐操作确认与原型证据;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
## 开发验收身份
@@ -121,7 +120,8 @@ pnpm typecheck
pnpm build
```
- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm test` 与 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。当前没有 lint 脚本,不声称 lint 通过。
- 按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”执行;命令表不是每轮清单。文案/纯展示核对差异,局部逻辑跑定向测试,不自动 typecheck、全量测试或构建。类型关系、模板绑定或模块引用可能受影响时,补最小类型检查;公共能力沿实际消费者验证。
- 构建和全量测试分别按实际影响触发,先说明扩大范围的原因;不因提交或对话收尾触发。仅复用同配置、同文件范围且相关输入未变的成功编译结果。纯文档/索引只检查路径、链接及索引合同。无 lint 脚本,不声称 lint 通过。
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
+1 -1
View File
@@ -47,7 +47,7 @@ describe('adminNavigation', () => {
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
{ title: '预约管理', children: ['预约列表'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配业务', '出车记录'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配任务', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
+1 -1
View File
@@ -135,7 +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-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' },
],
@@ -108,7 +108,8 @@ describe('four-type appointment calendar', () => {
expect(wrapper.text()).toContain('正在读取安排')
expect(wrapper.text()).not.toContain('当天暂无安排')
await wrapper.setProps({ loading: false, incomplete: true })
expect(wrapper.text()).toContain('部分数据不可用')
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('当前已加载 0 项安排')
expect(wrapper.text()).not.toContain('部分数据不可用')
expect(wrapper.text()).not.toContain('当天暂无安排')
})
})
@@ -70,9 +70,9 @@ function changeMode(next: CalendarMode) {
</div>
</div>
<aside class="calendar-detail workbench-panel is-flush" aria-label="当天安排">
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排</p></header>
<div class="detail-events" aria-live="polite">
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排,请先处理上方数据提示。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
<div class="event-heading"><span class="event-type" :class="event.type">{{ scheduleLabels[event.type] }}</span><span>{{ event.time || (event.date ? '日期计划' : '日期未定') }}</span></div>
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
@@ -161,7 +161,7 @@ describe('simplified appointment entry', () => {
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper.unmount()
})
it('retains a complete dispatch with its container search fields while naming a different unresolved dispatch and its reason', async () => {
it('retains a complete dispatch with its container search fields without showing an unresolved dispatch notice', async () => {
const good = dispatchEntry()
const unknown = dispatchEntry({ dispatchNo: 'PC-TIMEZONE-MISSING', containerNumbers: ['OTHER7654321'], originTimezone: null, incompleteReasons: ['ORIGIN_TIMEZONE_UNKNOWN'] })
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [good, good, unknown], unresolvedRecords: [] })
@@ -173,11 +173,7 @@ describe('simplified appointment entry', () => {
expect(dispatches[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
expect(dispatches[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
expect(panel.props('incomplete')).toBe(true)
const notice = wrapper.get('.data-notice').text()
expect(notice).toContain('1 条排车未能定位到日历日期')
expect(notice).toContain('PC-TIMEZONE-MISSING:起运仓时区待核实')
expect(notice).toContain('无法确定当地日期')
expect(notice).not.toContain('PC-CALENDAR-001')
expect(wrapper.find('.data-notice').exists()).toBe(false)
expect(dispatches.some(event => event.key === 'dispatch-PC-TIMEZONE-MISSING')).toBe(false)
expect(listTripCalendar).toHaveBeenCalledTimes(1)
wrapper.unmount()
@@ -16,7 +16,7 @@ import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
import { calendarRange, dateKey, type CalendarMode } from './calendar'
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
import { listTripCalendar, projectTripCalendar, tripIssues } from './tripCalendar'
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
import type { PlanningSourceLookup } from './planningApi'
import type { WorkAction } from './workApi'
@@ -37,7 +37,8 @@ const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mo
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
const hasUnresolvedTrips = ref(false)
const incomplete = computed(() => !!errors.value.length || !!notices.value.length || hasUnresolvedTrips.value)
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
let generation = 0, controller: AbortController | null = null
@@ -46,6 +47,7 @@ async function load(next = range.value) {
const request = ++generation; controller?.abort(); controller = new AbortController()
const signal = controller.signal
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
hasUnresolvedTrips.value = false
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
await Promise.allSettled([
(async () => {
@@ -71,10 +73,7 @@ async function load(next = range.value) {
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
if (request !== generation) return
trips.value = result.events
if (result.unresolved.length) {
const details = result.unresolved.slice(0, 3).map(row => `${row.dispatchNo?.trim() || '排车单号缺失'}:${tripIssues(row) || '资料待核实'}`).join(';')
notices.value.push(`${result.unresolved.length} 条排车未能定位到日历日期(${details}${result.unresolved.length > 3 ? ';其余请到排车列表核对' : ''})。`)
}
hasUnresolvedTrips.value = result.unresolved.length > 0
} catch (cause) { fail('排车读取失败', cause) }
})(),
])
@@ -0,0 +1,98 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
import AppointmentConfirmationCargo from './AppointmentConfirmationCargo.vue'
import { resolvePlanningOrder, resolvePlanningSource, type ResolvedPlanningSource } from './planningApi'
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
const access = vi.hoisted(() => ({ read: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.read }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('./planningApi', () => ({ resolvePlanningOrder: vi.fn(), resolvePlanningSource: vi.fn() }))
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
const source: ResolvedPlanningSource['source'] = { sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', orderId: '9007199254740993', inboundCode: 'IN-TEST', bookingNo: 'BK-TEST', containerNo: null, originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'BOOKING:1/ORDER:1', canFollowup: true, blockReasons: [] }
const resolved: ResolvedPlanningSource = { source, coverage: { status: 'PENDING', appointments: [], missingFields: ['FBA_SHIPMENT_REFERENCES'], changeReviewRequired: false } }
const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
const lookup = { orderId: source.orderId!, customerId: source.customerId, destinationKey: source.destinationKey }
function render(active = true, disabled = false) {
return mount(AppointmentConfirmationCargo, { props: { sources: [lookup], active, disabled }, global: { stubs: {
ElButton: { props: ['disabled', 'nativeType'], template: '<button :disabled="disabled" :type="nativeType || \'button\'"><slot /></button>' },
InlineHelp: { template: '<span />' }, ApiErrorMeta: true,
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<button @click="$emit(\'retry\')">重试核对</button></div>' },
} } })
}
beforeEach(() => {
vi.clearAllMocks(); access.read = true
vi.mocked(resolvePlanningOrder).mockResolvedValue(resolved)
vi.mocked(resolvePlanningSource).mockResolvedValue(resolved)
vi.mocked(getCargoVerification).mockResolvedValue(context)
vi.mocked(saveCargoVerification).mockResolvedValue({ version: 1, sourceVersion: source.sourceVersion, destinationKey: source.destinationKey, sourcePackages: 20, packageCount: 20, shipmentReferences: ['FBA-TEST'], remark: null, operatorName: '测试员', verifiedAt: '2026-10-21T03:00:00Z' })
})
it('names the missing material and supplements the canonical booking inside the appointment', async () => {
const wrapper = render(); await flushPromises()
expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup)
expect(wrapper.text()).toContain('IN-TEST'); expect(wrapper.text()).toContain('缺少本票 FBA 货件号或追踪号')
expect(wrapper.text()).toContain('不能代替货件号'); expect(wrapper.text()).not.toContain(source.orderId)
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).not.toHaveBeenCalled()
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ packageCount: 20, shipmentReferences: ['FBA-TEST'], expectedSourceVersion: source.sourceVersion }))
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
})
it('leaves pending arrangements lazy and reads only when formal confirmation is selected', async () => {
const wrapper = render(false); await flushPromises()
expect(resolvePlanningOrder).not.toHaveBeenCalled()
await wrapper.setProps({ active: true }); await flushPromises()
expect(resolvePlanningOrder).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('does not ask for known materials to be entered again', async () => {
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
})
it('retains unsaved inputs when the appointment mode changes and blocks leaving an unknown save outcome', async () => {
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-KEEP')
await wrapper.setProps({ active: false })
expect(wrapper.get('textarea[aria-label="本票货件号"]').isVisible()).toBe(true)
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce(new Error('keep editing'))
expect(await wrapper.vm.canLeave()).toBe(false)
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('connection lost'))
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(await wrapper.vm.canLeave()).toBe(false)
const payload = vi.mocked(saveCargoVerification).mock.calls[0]![1]
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(saveCargoVerification).toHaveBeenNthCalledWith(2, source, payload); wrapper.unmount()
})
it('reports unknown quantity and unmaterialized cargo without inventing values', async () => {
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, source: { ...source, orderId: null } })
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectivePackages: null })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('缺少本票箱数'); expect(wrapper.text()).toContain('原来货登记完成实收关联')
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
expect(wrapper.emitted('ready')?.at(-1)).toEqual([false]); wrapper.unmount()
})
it('keeps failed reads blocked, supports retry, and denies reads without permission', async () => {
vi.mocked(resolvePlanningOrder).mockRejectedValueOnce(new Error('network lost'))
const wrapper = render(); await flushPromises()
expect(wrapper.find('[role=alert]').exists()).toBe(true)
expect(wrapper.emitted('ready')?.at(-1)).toEqual([false])
await wrapper.get('[role=alert] button').trigger('click'); await flushPromises()
expect(resolvePlanningOrder).toHaveBeenCalledTimes(2); wrapper.unmount()
vi.clearAllMocks(); access.read = false
const denied = render(); await flushPromises()
expect(resolvePlanningOrder).not.toHaveBeenCalled(); expect(denied.text()).toContain('查看权限'); denied.unmount()
})
it('keeps the supplement action disabled for read-only appointment views', async () => {
const wrapper = render(true, true); await flushPromises()
expect(wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.attributes('disabled')).toBeDefined()
expect(saveCargoVerification).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, provide, ref, watch } from 'vue'
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 CargoVerificationForm from './CargoVerificationForm.vue'
import { getCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
import { resolvePlanningOrder, resolvePlanningSource, type PlanningSourceLookup, type ResolvedPlanningSource } from './planningApi'
import type { LeaveGuard } from './useWorkDraft'
const props = defineProps<{ sources: PlanningSourceLookup[]; active: boolean; disabled?: boolean }>()
const emit = defineEmits<{ ready: [value: boolean] }>()
const access = useAccessPreviewStore()
type Row = { resolved: ResolvedPlanningSource; cargo: CargoVerificationContext }
const rows = ref<Row[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const editing = ref<string | null>(null), editor = ref<InstanceType<typeof CargoVerificationForm> | null>(null)
const editingRow = computed(() => rows.value.find(row => key(row) === editing.value))
let generation = 0
const guards = new Set<LeaveGuard>()
const register = inject<((guard: LeaveGuard) => () => void) | undefined>('appointment-work-leave-guard', undefined)
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
async function canLeave() { for (const guard of guards) if (!await guard()) return false; return true }
const unregister = register?.(canLeave)
defineExpose({ canLeave })
function key(row: Row) { return `${row.resolved.source.customerId}:${row.resolved.source.sourceKey}` }
function issues(row: Row) {
const missing: string[] = []
if (!row.resolved.source.orderId) missing.push('尚未来货建单,请先在原来货登记完成实收关联')
if (row.cargo.effectivePackages == null || row.cargo.effectivePackages <= 0) missing.push('缺少本票箱数')
if (row.resolved.coverage.missingFields.includes('FBA_SHIPMENT_REFERENCES')) missing.push('缺少本票 FBA 货件号或追踪号(至少一种)')
if (row.cargo.sourceChanged) missing.push('原货物资料已变化,请重新核对箱数和货件号')
return missing
}
const ready = computed(() => rows.value.length === props.sources.length && rows.value.length > 0 && !loading.value && !error.value && !editing.value && rows.value.every(row => !issues(row).length))
watch(ready, value => emit('ready', value), { immediate: true })
async function load() {
const current = ++generation
loading.value = true; error.value = null
try {
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) {
error.value = { message: '无法读取本票核实资料,请联系有预约列表和尾程订单查看权限的同事补齐。' }; return
}
const result: Row[] = []
// 只核对本次明确选择的货物,并由服务端解析原 ORDER / BOOKING 身份。
for (const lookup of props.sources) {
const resolved = 'sourceKey' in lookup ? await resolvePlanningSource(lookup) : await resolvePlanningOrder(lookup)
if (current !== generation) return
const cargo = await getCargoVerification(resolved.source)
if (current !== generation) return
result.push({ resolved, cargo })
}
if (current === generation) rows.value = result
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '货物缺项读取失败,请重试核对') }
finally { if (current === generation) loading.value = false }
}
async function closeEditor() {
if (editor.value && !await editor.value.canLeave()) return
editing.value = null
}
async function saved() { editing.value = null; await load() }
watch(() => [props.active, JSON.stringify(props.sources)] as const, ([active]) => { if (active && !editing.value) void load() }, { immediate: true })
onBeforeUnmount(() => { ++generation; unregister?.() })
</script>
<template>
<section v-show="active || editing" class="density-dialog-section" data-tone="source" aria-label="正式确认前的货物资料" :aria-busy="loading">
<h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3>
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
<LoadErrorState v-if="error" title="货物资料未核对" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>需要补充的资料</th></tr></thead><tbody>
<tr v-for="row in rows" :key="key(row)">
<td><el-button v-if="issues(row).length && row.resolved.source.orderId" :disabled="disabled || loading || !!editing || !row.cargo.allowedActions.includes('VERIFY_CARGO')" @click="editing = key(row)">在此补齐资料</el-button><span v-else>—</span></td>
<td>{{ row.resolved.source.inboundCode || row.resolved.source.bookingNo || '未提供业务编号' }}</td>
<td>{{ row.cargo.effectivePackages ?? '待补充' }}</td>
<td>{{ issues(row).join(';') || '资料齐全,无需重复填写' }}</td>
</tr>
</tbody></table>
<div v-if="editingRow">
<CargoVerificationForm ref="editor" :source="editingRow.resolved.source" :disabled="disabled" confirmation @saved="saved" />
<el-button :disabled="disabled" @click="closeEditor">收起补充资料</el-button>
</div>
<p v-if="!loading && !error && !ready" role="status">请先在上表补齐并保存资料,再点击“保存预约安排”。暂时拿不到资料,可先选择“先保存安排,等待对方确认”。</p>
</section>
</template>
@@ -42,13 +42,13 @@ const columns: DataTableColumn[] = [
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140, slotName: 'origin' },
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
{ prop: 'packages', label: '箱数', width: 80 },
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
{ key: 'eligibility', label: '预约资格', minWidth: 260, singleLine: false, slotName: 'eligibility' },
]
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
function reason(row: PlanningCargo) {
if (row.detained) return '货物暂扣中'
if (row.mixedDestinations) return '多个目的地,需先核对'
if (row.hasActiveAppointment) return '已有有效预约'
if (row.hasActiveAppointment) return '已有预约安排,请到入库单编辑原预约'
const anchor = selectedRows.value[0]
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
@@ -10,15 +10,16 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext, type CargoVerificationSource, type SaveCargoVerification } from './cargoVerificationApi'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
const props = defineProps<{ source: CargoVerificationSource }>()
const props = defineProps<{ source: CargoVerificationSource; confirmation?: boolean; disabled?: boolean }>()
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const context = ref<CargoVerificationContext | null>(null), loading = ref(false), saving = ref(false)
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), pending = ref<SaveCargoVerification | null>(null)
const form = reactive({ packages: '', references: '', remark: '' }), baseline = ref(JSON.stringify(form))
const dirty = computed(() => JSON.stringify(form) !== baseline.value)
useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && context.value?.allowedActions.includes('VERIFY_CARGO'))
const canLeave = useWorkDraft(dirty, computed(() => saving.value || !!pending.value))
defineExpose({ canLeave })
const canWrite = computed(() => !props.disabled && access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && context.value?.allowedActions.includes('VERIFY_CARGO'))
async function load(preserve = false) {
loading.value = true; error.value = null
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) { error.value = { message: '当前账号没有该货源的读取权限,请联系预约负责人处理。' }; loading.value = false; return }
@@ -26,7 +27,7 @@ async function load(preserve = false) {
context.value = await getCargoVerification(props.source)
if (!preserve) {
const record = context.value.verification
Object.assign(form, { packages: record?.packageCount == null ? '' : String(record.packageCount), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
Object.assign(form, { packages: context.value.effectivePackages == null ? '' : String(context.value.effectivePackages), references: record?.shipmentReferences.join('\n') || '', remark: record?.remark || '' })
baseline.value = JSON.stringify(form)
}
} catch (cause) { error.value = getApiErrorDetails(cause, '核实资料读取失败') }
@@ -39,6 +40,7 @@ async function save() {
if (count !== null && (!Number.isSafeInteger(count) || count < 1)) { ElMessage.warning('核实箱数须为正整数,暂未核实可留空'); return }
if (count !== null && (count < context.value.allocatedPackages || (context.value.source.packages != null && count > context.value.source.packages))) { ElMessage.warning('核实箱数不能低于已分配量,也不能超过已知原箱数'); return }
const references = [...new Set(form.references.split(/\r?\n/).map(value => value.trim()).filter(Boolean))]
if (props.confirmation && (count === null || !references.length)) { ElMessage.warning('请填写本票箱数,以及至少一个 FBA 货件号或追踪号;外部约号不能代替货件号'); return }
if (references.length > 100 || references.some(value => value.length > 128)) { ElMessage.warning('最多填写100条货件号,每条不超过128字'); return }
pending.value = { destinationKey: context.value.source.destinationKey, expectedSourceVersion: context.value.source.sourceVersion, expectedVersion: Number(context.value.verification?.version || 0), packageCount: count, shipmentReferences: references, remark: form.remark.trim() || null, idempotencyKey: crypto.randomUUID() }
}
@@ -56,14 +58,15 @@ onMounted(() => load())
</script>
<template>
<section aria-label="货物核实资料">
<h3>核实货物资料<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
<h3>{{ confirmation ? '补齐本票送仓资料' : '核实货物资料' }}<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
<p v-if="confirmation">请向客户或货物负责人取得本票完整的 FBA 货件号或追踪号,每行一个;不要填写预约外部约号。下方保存只补充货物资料,完成后还需保存预约安排。</p>
<LoadErrorState v-if="error" title="核实资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在读取货物资料…</p>
<template v-if="context">
<dl class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></dl>
<dl class="cargo-facts"><dt>原票</dt><dd>{{ context.source.inboundCode || context.source.bookingNo || '—' }}</dd><dt>目的仓</dt><dd>{{ context.source.destinationWarehouseCode || '—' }}</dd><dt>原箱数</dt><dd>{{ context.source.packages ?? '待核实' }}</dd><template v-if="!confirmation"><dt>已分配</dt><dd>{{ context.allocatedPackages }} 箱<span v-if="context.unknownAllocationCount">;另有 {{ context.unknownAllocationCount }} 项数量未知</span></dd><dt>可分配</dt><dd>{{ context.availablePackages ?? '待核实' }}</dd></template></dl>
<p v-if="context.sourceChanged" role="alert">原货源已变化,请核对当前原票和已分配量后重新保存。</p><p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
<form class="density-form work-form appointment-native-form" @submit.prevent="save"><fieldset :disabled="!canWrite || loading || !!error || saving || !!pending">
<label>核实箱数(可暂不填)<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
<label>{{ confirmation ? '本票核实总箱数(必填)' : '核实箱数(可暂不填)' }}<input v-model="form.packages" type="number" min="1" step="1" aria-label="核实箱数" class="workbench-input"></label>
<label>本票货件号 / 追踪号(每行一个)<textarea v-model="form.references" rows="4" aria-label="本票货件号" class="workbench-input" /></label>
<label>核实说明<textarea v-model="form.remark" maxlength="1000" rows="2" class="workbench-input" /></label>
</fieldset><p v-if="!canWrite">当前仅可查看核实资料;需核实权限及有效货源资格才能保存。</p>
@@ -9,7 +9,7 @@ vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn()
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn() }))
vi.mock('./api', async original => ({ ...await original<typeof import('./api')>(), deleteAppointment: vi.fn() }))
const detail: FbaAppointmentDto = { id: '1', appointmentNo: 'AP-001', batchKey: 'FBA_APPOINTMENT:1', version: 3, originWarehouseId: 'warehouse-1', fbaWarehouseCode: 'ONT8', selectedCount: 1, items: [], appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-001', appointmentAt: '2026-10-15T23:30:00', appointmentEndAt: '2026-10-16T01:00:00', appointmentStartUtc: '2026-10-16T06:30:00Z', appointmentEndUtc: '2026-10-16T08:00:00Z', timezone: 'America/Los_Angeles' }
function render(cancel = false) { return mount(WorkAppointmentForm, { props: { appointmentNo: 'AP-001', cancel, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true } } }) }
function render(cancel = false) { return mount(WorkAppointmentForm, { props: { appointmentNo: 'AP-001', cancel, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, FbaAppointmentFields: { name: 'FbaAppointmentFields', mounted() { this.$emit('confirmationReady', true) }, props: ['model', 'disabled'], template: '<div />' }, LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true } } }) }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getFbaAppointment).mockResolvedValue(detail); vi.mocked(updateFbaAppointment).mockResolvedValue(detail) })
it('initializes confirmed facts after an initial read failure instead of showing an empty editable plan', async () => {
vi.mocked(getFbaAppointment).mockRejectedValueOnce(new Error('来源暂不可用'))
@@ -13,6 +13,8 @@ import { rejectedCommand, useWorkDraft } from './useWorkDraft'
const props = defineProps<{ appointmentNo: string; cancel: boolean; canWrite: boolean }>()
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto]; batch: [key: string]; receiving: [key: string] }>()
const access = useAccessPreviewStore(), detail = ref<FbaAppointmentDto | null>(null)
const confirmationReady = ref(false)
const appointmentFields = ref<InstanceType<typeof FbaAppointmentFields> | null>(null)
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(appointmentPayload(form)))
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
let initialized = false
@@ -44,6 +46,9 @@ async function save() {
pending.value = { kind: 'cancel', version: detail.value.version, reason: reason.value.trim() }
} else {
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
if (form.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料'); return }
if (appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
if (saving.value || !writable.value) return
pending.value = { kind: 'update', payload: { details: appointmentPayload(form), version: detail.value.version, idempotencyKey: crypto.randomUUID() } }
}
}
@@ -70,7 +75,7 @@ onMounted(() => load())
<el-button v-if="detail.batchKey && !saving && !pending" @click="emit('batch', detail.batchKey)">{{ replaced ? '查看替代批次' : '分批 / 合并与货物来源' }}</el-button>
<el-button v-if="detail.batchKey && !replaced && !saving && !pending" :disabled="loading || !!error" @click="emit('receiving', detail.batchKey)">客户调整 / 共同预约范围</el-button>
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" class="workbench-input" /></label></template>
<FbaAppointmentFields v-else :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
<FbaAppointmentFields v-else ref="appointmentFields" :sources="detail.items.map(item => ({ sourceKey: item.sourceKey, customerId: item.customerId, destinationKey: item.destinationKey || `FBA:${item.fbaWarehouseCode}` }))" @confirmation-ready="confirmationReady = $event" :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
<p v-if="!writable">当前预约仅可查看。</p>
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="!cancel && !rebooking && writable && !changed && !pending">预约内容未修改,无需重复保存。</p>
@@ -18,7 +18,7 @@ beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(previewPla
it('shows readable preview requirements without exposing missing-field machine codes', async () => {
vi.mocked(previewPlanningBatch).mockResolvedValue({ ...preview, missingFields: ['READY_WINDOW_UNVERIFIED', 'CARGO_QUANTITY_REVIEW'] })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('货物备妥窗口待核实')
expect(wrapper.text()).not.toContain('货物备妥窗口待核实')
expect(wrapper.text()).toContain('货量或重复关联需要核对。')
expect(wrapper.text()).not.toContain('READY_WINDOW_UNVERIFIED')
expect(wrapper.text()).not.toContain('CARGO_QUANTITY_REVIEW')
@@ -13,10 +13,13 @@ import { workReason } from './workPresentation'
const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>()
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto] }>()
const access = useAccessPreviewStore(), preview = ref<PlanningBatchPreview | null>(null)
const confirmationReady = ref(false)
const appointmentFields = ref<InstanceType<typeof FbaAppointmentFields> | null>(null)
const form = reactive(createAppointmentForm()), baseline = ref(JSON.stringify(form))
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const pending = ref<SourceAppointmentPayload | null>(null)
const writable = computed(() => props.canWrite && access.can(overseasWarehousePermissions.inboundOrderAppointment))
const visibleMissingFields = computed(() => preview.value?.missingFields.filter(field => field !== 'READY_WINDOW_UNVERIFIED') || [])
useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
async function load() {
if (!props.sources.length || props.sources.some(source => !source.customerId || !source.sourceKey || !source.destinationKey)) { error.value = { message: '缺少完整货源、客户或目的仓范围,无法新建预约。' }; return }
@@ -29,6 +32,9 @@ async function save() {
if (!writable.value || loading.value || saving.value || error.value || !preview.value) return
if (!pending.value) {
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
if (form.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料'); return }
if (appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
if (saving.value || !writable.value) return
pending.value = { originWarehouseId: preview.value.group.originWarehouseId, fbaWarehouseCode: preview.value.destinationWarehouseCode, sources: preview.value.sources.map(source => ({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })), details: appointmentPayload(form), idempotencyKey: crypto.randomUUID() }
}
saving.value = true; saveError.value = null
@@ -55,10 +61,9 @@ onMounted(load)
<tr><th scope="row">票数</th><td>{{ preview.group.sourceCount }}</td><th scope="row">箱数</th><td>{{ preview.group.packages ?? '箱数待核实' }}</td></tr>
<tr><th scope="row">货物业务号</th><td colspan="3"><template v-for="(source, index) in preview.sources" :key="`${source.customerId}:${source.sourceKey}`">{{ index ? '、' : '' }}{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}</template></td></tr>
</tbody></table>
<p v-for="field in preview.missingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
<p v-if="preview.group.readinessIncomplete" role="status" class="density-dialog-note">货物备妥窗口待核实。</p>
<p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
</section>
<FbaAppointmentFields continuous :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<FbaAppointmentFields continuous ref="appointmentFields" :sources="sources.map(source => ({ ...source, destinationKey: source.destinationKey || undefined }))" @confirmation-ready="confirmationReady = $event" :model="form" :timezone="preview.destinationTimezone" :disabled="!writable || loading || saving || !!pending" />
<LoadErrorState v-if="saveError" title="预约尚未创建" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<div class="density-actions density-actions--footer"><el-button type="primary" :disabled="!writable || loading" :loading="saving" @click="save">{{ pending ? '重试本次创建' : '保存预约安排' }}</el-button></div>
</template>
@@ -1,2 +1,4 @@
export { appointmentReturnTarget, appointmentReturnQuery } from './workNavigation'
export { default as TripAppointmentPanel } from './TripAppointmentPanel.vue'
export { default as AppointmentConfirmationCargo } from './AppointmentConfirmationCargo.vue'
export type { PlanningSourceLookup } from './planningApi'
+5 -5
View File
@@ -1,4 +1,4 @@
import { addDays } from './calendar'
import { projectWarehouseArrival } from '@/modules/container-loading'
import type { CalendarSources, ScheduleRange, WarehouseArrival } from './scheduleApi'
export const scheduleTypes = ['arrival', 'dispatch', 'return', 'warehouse'] as const
@@ -36,12 +36,12 @@ export function projectContainerSchedule(sources: CalendarSources, warehouses: W
if (warehouses === null) continue
const warehouse = warehouseMap.get(row.containerBookingId)
warehouseMap.delete(row.containerBookingId)
const canDefault = !warehouse || warehouse.version === 0 && warehouse.planCount === 0
const planned = warehouse?.plannedAt?.slice(0, 10) || (canDefault && date ? addDays(date, 2) : null)
const projected = projectWarehouseArrival(row.arrivalAt, warehouse)
const planned = projected.date
if (inRange(planned, range)) events.push({ key: `warehouse-${row.containerBookingId}`, type: 'warehouse', date: planned,
time: warehouse?.plannedAt?.slice(11, 16) || '', title: `${label} 计划到仓`, sourcePath, warehouse,
time: projected.time, title: `${label} 计划到仓`, sourcePath, warehouse,
canEdit: warehouse?.canEdit === true,
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : canDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
subtitle: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : projected.isDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: warehouse?.warehouseName || warehouse?.warehouseCode },
{ label: '计划到仓', value: warehouse?.plannedAt ? timestamp(warehouse.plannedAt) : planned },
{ label: '实际到仓', value: timestamp(warehouse?.actualAt || null) }] })
@@ -8,8 +8,8 @@ describe('container arrival-time overseas API', () => {
beforeEach(() => vi.clearAllMocks())
it('queries visible containers and updates the whole-container expected arrival', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] })
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never)
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }] })
vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1, planCount: 0 } } as never)
await listContainerArrivalTimes(['9001'])
await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 })
@@ -17,4 +17,14 @@ describe('container arrival-time overseas API', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] })
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } })
})
it('normalizes serialized plan counts and rejects unknown counts instead of showing a default', async () => {
const row = { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount: '1' }] })
expect(await listContainerArrivalTimes(['9001'])).toEqual([{ ...row, planCount: 1 }])
for (const planCount of [undefined, null, '', 'unknown', -1, 0.5]) {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ ...row, planCount }] })
await expect(listContainerArrivalTimes(['9001'])).rejects.toThrow('入库计划数量无效')
}
})
})
@@ -6,6 +6,14 @@ export interface ContainerArrivalTime {
estimatedWarehouseArrivalAt: string | null
actualWarehouseArrivalAt: string | null
version: number
planCount: number
}
function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime {
if (typeof item.planCount !== 'number' && (typeof item.planCount !== 'string' || !/^(0|[1-9]\d*)$/.test(item.planCount))) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
const count = Number(item.planCount)
if (item.planCount == null || !Number.isSafeInteger(count) || count < 0) throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
return { ...item, version: Number(item.version), planCount: count }
}
const queryPath = '/platform/v1/last-leg/orders/container-arrival-times/query'
@@ -20,7 +28,7 @@ export async function listContainerArrivalTimes(sourceContainerBookingIds: strin
queryPath,
{ sourceContainerBookingIds },
)
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, version: Number(item.version || 0) }))
return (unwrapApiResponse(response) || []).map(normalizeArrival)
}
export async function updateContainerExpectedArrival(
@@ -33,5 +41,5 @@ export async function updateContainerExpectedArrival(
{ headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } },
)
const result = unwrapApiResponse(response)
return { ...result, version: Number(result.version || 0) }
return normalizeArrival(result)
}
+1
View File
@@ -1,5 +1,6 @@
export * from './api/platformContainerLoading'
export * from './types'
export { projectWarehouseArrival } from './warehouseArrival'
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
export { default as ContainerSimulationCreate } from './components/ContainerSimulationCreate.vue'
@@ -57,6 +57,14 @@ function accessibleText(wrapper: { element: Element } | undefined) {
}
describe('ContainerDetailView empty return', () => {
it('shows the same default warehouse date as the calendar, with no fabricated midnight', async () => {
vi.mocked(getContainer).mockResolvedValue({ ...base, estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' } } as never)
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
const wrapper = await mountDetail()
const value = wrapper.findAll('dt').find(item => item.text() === '预计到仓')!.element.nextElementSibling!.textContent
expect(value).toBe('2026-10-23(默认推算)')
expect(value).not.toContain('00:00')
})
it('passes the original container to schedule maintenance and reloads its projection after save', async () => {
const wrapper = await mountDetail('DOMESTIC')
const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' })
@@ -83,7 +91,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
vi.mocked(getConfirmedLoadingList).mockReset().mockResolvedValue({ planNo: 'LP-1', summary: { status: 'COMPLETED' }, items: [] } as never)
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never)
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }])
})
it('records a real direct-delivery source with explicit local time and proof, retaining the request after an unknown outcome', async () => {
@@ -46,6 +46,7 @@ import {
} from '../api/platformContainerLoading'
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages'
import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes'
import { projectWarehouseArrival } from '../warehouseArrival'
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
@@ -91,6 +92,14 @@ async function rememberWorkbenchTab(tabName: string | number) {
}
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
const arrivalTime = ref<ContainerArrivalTime | null>(null)
const expectedArrivalText = computed(() => {
const row = workbenchDetail.value
const arrival = arrivalTime.value
const projected = projectWarehouseArrival(row?.actualFacts?.arrivedDestinationPortAt || row?.actualPortArrivalAt || row?.estimatedPortArrivalAt,
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
return arrival?.estimatedWarehouseArrivalAt ? formatTime(arrival.estimatedWarehouseArrivalAt)
: projected.date ? `${projected.date}(默认推算)` : '—'
})
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
@@ -726,7 +735,7 @@ onMounted(async () => {
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-overseas-receiving-heading">
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canReadDirect" @click="openDirect">直送卸货清单</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ formatTime(arrivalTime?.estimatedWarehouseArrivalAt) }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ expectedArrivalText }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
</section>
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section workbench-panel is-compact" aria-labelledby="container-loading-list-heading">
@@ -74,8 +74,8 @@ describe('ContainersView', () => {
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 })
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0, planCount: 0 }])
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1, planCount: 0 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
@@ -479,7 +479,9 @@ describe('ContainersView', () => {
expect(source).not.toContain('canSmartLoading && row.containerNo')
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
expect(source).toContain(':loading="loading" preference-key="container-loading-table"')
expect(source).toContain('appearance="integrated" :stripe="false"')
expect(source).toContain('appearance="integrated"')
expect(source).not.toContain(':stripe="false"')
expect(source).not.toContain(':stripe="false"')
})
it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => {
@@ -582,6 +584,30 @@ describe('ContainersView', () => {
})
})
it('shows the calendar default from actual port arrival without submitting an invented time', async () => {
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row,
estimatedPortArrivalAt: '2026-10-20T10:00:00', actualFacts: { arrivedDestinationPortAt: '2026-10-21T15:49:00' },
})) })
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0 }])
const wrapper = await mountOverseasRoute()
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '2026-10-23(默认)')!
expect(picker).toBeDefined()
expect(picker.props('modelValue')).toBe('')
expect(updateContainerExpectedArrival).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps a frozen missing arrival undated and prevents editing it', async () => {
const page = await listContainers({ page: 1, size: 20 }, 'OVERSEAS')
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map(row => ({ ...row, estimatedPortArrivalAt: '2026-10-20T10:00:00' })) })
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 1 }])
const wrapper = await mountOverseasRoute()
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '选择预计到仓')!
expect(picker.props('disabled')).toBe(true)
wrapper.unmount()
})
it.each(['EDITING', 'LOADING', 'COMPLETED'] as const)('keeps released-booking linking reachable alongside the %s process action', async (planStatus) => {
const page = await listContainers({ page: 1, size: 20 }, 'DOMESTIC')
vi.mocked(listContainers).mockResolvedValue({ ...page, records: page.records.map((row) => ({
@@ -40,6 +40,7 @@ import {
type TerminalDrayageView,
} from '../api/terminalDrayages'
import { listContainerArrivalTimes, updateContainerExpectedArrival, type ContainerArrivalTime } from '../api/containerArrivalTimes'
import { projectWarehouseArrival } from '../warehouseArrival'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
@@ -285,6 +286,13 @@ function optional(value: string | null) { return value?.trim() || null }
function statusText(status: ContainerPlanStatus) { return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] }
function documentStatusText(status: ContainerDocumentStatus | null | undefined) { return status ? ({ MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成' })[status] : '—' }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
function defaultArrivalDate(source: object) {
const row = source as ContainerWorkbenchRow
const arrival = arrivalTimes.value[row.id]
const projected = projectWarehouseArrival(row.actualFacts?.arrivedDestinationPortAt || row.actualPortArrivalAt || row.estimatedPortArrivalAt,
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
return projected.isDefault ? projected.date : null
}
function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] }
function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已设置预计到仓', PICKED_UP: '已实际到仓', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[status || ''] || status || '—' }
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '修改预计到仓', RECORD_EMPTY_RETURN: '确认还柜', SEND_PICKUP_EMAIL: '发送提柜邮件' } as Record<TerminalDrayageAction, string>)[action] }
@@ -577,7 +585,7 @@ async function saveDrayageAction() {
}
async function saveExpectedArrival(row: ContainerBookingResource, value: string | null) {
const current = arrivalTimes.value[row.id]
if (!value || !current || current.version < 0 || arrivalSavingIds.value.includes(row.id)) return
if (!value || !current || current.version < 0 || current.planCount > 0 || arrivalSavingIds.value.includes(row.id)) return
arrivalSavingIds.value.push(row.id)
try {
const updated = await updateContainerExpectedArrival(row.id, {
@@ -736,7 +744,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<ListPageLayout :ariaLabel="isDomestic ? '送柜报关工作台' : '清关提柜工作台'">
<section id="container-workbench-results" ><div class="workbench-table is-page-surface"><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" :total="total" :loading="loading" preference-key="container-loading-table" empty-text="暂无货柜">
<section id="container-workbench-results" ><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-start>
<div class="container-scope-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="货柜完结状态">
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
@@ -760,7 +768,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable remote reserve-keyword remote-show-suffix placeholder="输入车队名称或联系人搜索" :remote-method="searchTerminalFleets" :loading="terminalFleetLoading || drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" no-data-text="未找到合作中的码头车队" @visible-change="handleTerminalFleetVisible" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !terminalFleetOptions.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in terminalFleetOptions" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" placeholder="选择预计到仓" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
<template #pickupAppointmentAt="{ row }"><div class="arrival-time-cell" :title="defaultArrivalDate(row as ContainerWorkbenchRow) ? '到港日期+2个自然日(默认推算);可选择日期时间人工调整' : undefined"><el-date-picker v-model="arrivalInputs[row.id]" class="arrival-time-picker" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" :placeholder="defaultArrivalDate(row as ContainerWorkbenchRow) ? `${defaultArrivalDate(row as ContainerWorkbenchRow)}(默认)` : '选择预计到仓'" :disabled="!canExpectedArrivalEdit || !arrivalTimes[row.id] || arrivalTimes[row.id].version < 0 || arrivalTimes[row.id].planCount > 0 || arrivalSavingIds.includes(row.id)" @change="saveExpectedArrival(row as ContainerBookingResource, $event as string | null)" /></div></template>
<template #actualPickupAt="{ row }">{{ formatTime(arrivalTimes[row.id]?.actualWarehouseArrivalAt) }}</template>
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
<template #actualEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualEmptyReturnAt) }}</template>
@@ -772,7 +780,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.arrivedDestinationPortAt) }}</template>
</DataTable></div></section>
</ListPageLayout>
+1
View File
@@ -224,6 +224,7 @@ export interface ContainerWorkbenchStatusSummary {
}
export interface ContainerWorkbenchActualFacts {
arrivedDestinationPortAt?: string | null
actualSailingAt?: string | null
actualPortArrivalAt?: string | null
actualWarehouseArrivalAt?: string | null
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { projectWarehouseArrival } from './warehouseArrival'
describe('shared warehouse arrival projection', () => {
it.each([
['2026-10-21T15:49:00', '2026-10-23'],
['2026-10-30T23:30:00-07:00', '2026-11-01'],
['2026-12-31T23:30:00', '2027-01-02'],
['2026-03-07T23:30:00-08:00', '2026-03-09'],
])('adds two local calendar days to %s without manufacturing a time', (arrivalAt, date) => {
expect(projectWarehouseArrival(arrivalAt, { plannedAt: null, version: 0, planCount: 0 })).toEqual({ date, time: '', isDefault: true })
})
it('keeps manually maintained and frozen dates instead of following port arrival', () => {
for (const plan of [{ plannedAt: '2026-10-28T09:30:00', version: 1, planCount: 0 }, { plannedAt: '2026-10-28T09:30:00', version: 0, planCount: 1 }]) {
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false })
}
})
it('does not replace inconsistent or frozen missing dates with defaults', () => {
for (const plan of [{ plannedAt: null, version: -1, planCount: 0 }, { plannedAt: null, version: 0, planCount: 1 }, { plannedAt: null, version: 1, planCount: 0 }]) {
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan).date).toBeNull()
}
for (const arrival of [null, '', 'invalid', '2026-02-30T09:00:00']) expect(projectWarehouseArrival(arrival, undefined).date).toBeNull()
})
})
@@ -0,0 +1,20 @@
export interface WarehouseArrivalPlan {
plannedAt: string | null
version: number
planCount: number
}
/** 默认值只有当地日期,不能作为人工维护的日期时间提交。 */
export function projectWarehouseArrival(arrivalAt: string | null | undefined, plan: WarehouseArrivalPlan | undefined) {
const canDefault = !plan || plan.version === 0 && plan.planCount === 0
let date = plan?.plannedAt?.slice(0, 10) || null
if (!date && canDefault && arrivalAt) {
const day = arrivalAt.slice(0, 10)
const calendarDate = new Date(`${day}T12:00:00Z`)
if (/^\d{4}-\d{2}-\d{2}$/.test(day) && !Number.isNaN(calendarDate.getTime()) && calendarDate.toISOString().slice(0, 10) === day) {
calendarDate.setUTCDate(calendarDate.getUTCDate() + 2)
date = calendarDate.toISOString().slice(0, 10)
}
}
return { date, time: plan?.plannedAt?.slice(11, 16) || '', isDefault: !plan?.plannedAt && canDefault }
}
@@ -1065,7 +1065,7 @@ onMounted(() => { void loadCustomers() })
<ListPageLayout aria-label="客户管理">
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" border>
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" border>
<template #toolbar-start><h1>客户列表</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" /></template>
<template #notices><template v-if="loadError">
@@ -1086,7 +1086,7 @@ onMounted(() => { void loadCustomers() })
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog admin-workbench is-dialog" :loading="editorLoading" :confirm-loading="saving" fullscreenable destroy-on-close>
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">基础资料</h3>
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">基础资料</h3>
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td></tr>
<tr><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
@@ -1095,7 +1095,7 @@ onMounted(() => { void loadCustomers() })
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td></tr>
<tr><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item></td></tr>
@@ -1103,7 +1103,7 @@ onMounted(() => { void loadCustomers() })
</tbody></table>
</section>
<section class="density-dialog-section qualification-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp></h3><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<section class="density-dialog-section qualification-section workbench-evidence"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp></h3><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" label-width="144px" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
<div class="qualification-table" role="table" aria-label="客户资质文件">
@@ -1221,7 +1221,7 @@ onMounted(() => { void loadCustomers() })
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; }
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:var(--admin-space-5, 12px); } .qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
.qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--workbench-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--workbench-muted); line-height:1.5; }
.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--workbench-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--workbench-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:var(--workbench-surface-soft); color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; }
.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--workbench-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--workbench-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:var(--cc-list-table-header-bg); color:var(--cc-list-table-header-text); font-size:var(--admin-font-caption, 12px); font-weight:600; }
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
.qualification-optional { padding:2px 5px; border-radius:var(--admin-radius, 4px); background:var(--el-fill-color-light); color:var(--workbench-muted); font-size:var(--admin-font-caption, 10px); font-weight:500; line-height:1.4; }
.qualification-file { gap:var(--admin-space-5, 12px); } .qualification-file-control { display:flex; min-width:0; flex:1; flex-wrap:wrap; align-items:center; gap:var(--admin-space-3, 8px); } .file-picker { position:relative; display:inline-flex; flex:0 0 auto; align-items:center; gap:7px; overflow:hidden; padding:0 var(--admin-space-5, 12px); border:1px solid var(--workbench-border); border-radius:6px; background:var(--workbench-surface); color:var(--workbench-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--workbench-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; overflow:hidden; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); text-overflow:ellipsis; } .qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; }
@@ -72,7 +72,7 @@ onMounted(() => { void load() })
<div class="business-page overseas-fees-page admin-density customer-density">
<ListPageLayout ariaLabel="海外仓阶梯费用列表">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
<template #toolbar-start><h1>海外仓阶梯费用</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" /></template>
<template #notices><template v-if="error"><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template></template>
@@ -93,9 +93,9 @@ onMounted(() => { void load() })
</ListPageLayout>
<AppDialog class="admin-density-dialog customer-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="customerDialogTitle('编辑海外仓阶梯费用', { tenantName: editing?.customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
<el-form label-position="left" label-width="80px" class="density-form density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="work"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
<section class="density-dialog-section workbench-evidence"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
<div class="customer-fee-tier-groups">
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section" data-tone="work">
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section">
<div class="customer-fee-tier-heading"><h3 class="density-dialog-section__title">{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div>
<div class="customer-fee-tier-table-wrap workbench-evidence"><table class="density-table density-table--editable customer-fee-tier-table" :aria-label="groupIndex === 0 ? '仓租阶梯填写' : '退货仓储费阶梯填写'"><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody>
<tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></td></tr>
@@ -75,7 +75,7 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
<template><AdminPage as="div" class="domestic-containers-page admin-density admin-workbench">
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><div class="workbench-table is-page-surface"><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" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
<template #toolbar-start><div class="status-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)">{{ tab.label }}</button></div></template>
<template #filters><div class="business-query-panel integrated-query-group"><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
@@ -283,7 +283,7 @@ onBeforeUnmount(() => {
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="downloads-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无符合筛选条件的下载任务" aria-label="下载任务列表">
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="downloads-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无符合筛选条件的下载任务" aria-label="下载任务列表">
<template #toolbar-start><h1>下载中心</h1></template>
<template #filters>
<QueryToolbar v-model="filters" ariaLabel="下载任务筛选" :fields="queryFields" preference-key="downloads-query" @search="applyFilters" />
@@ -325,7 +325,7 @@ onMounted(() => {
<section aria-live="polite"><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
<section aria-live="polite"><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page"
v-model:page-size="pageSize"
:data="pagedRows"
@@ -129,7 +129,9 @@ describe('FeeItemsView public master data and access', () => {
expect(actions).not.toMatch(/<(?:el-dropdown|Pencil|Power|Eye|Trash2|Download|CircleX)\b/)
})
it('keeps the prototype table treatment and mobile form fallback without inventing a pricing mode', () => {
expect(source).toContain('appearance="integrated" :stripe="false"')
expect(source).toContain('appearance="integrated"')
expect(source).not.toContain(':stripe="false"')
expect(source).not.toContain(':stripe="false"')
expect(source).toContain(':page-sizes="[20, 50, 100]"')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('.fee-item-editor-table tr { display: grid; }')
+1 -1
View File
@@ -350,7 +350,7 @@ onMounted(() => {
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
@@ -446,7 +446,7 @@ onBeforeUnmount(clearVoucherPreview)
<ListPageLayout v-else-if="!detailOpen" aria-label="客户账户">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!stateError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!stateError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>客户账户</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="search" /></template>
<template #notices><template v-if="stateError"><LoadErrorState title="客户账户加载失败" :message="stateError.message" :loading="loading || customersLoading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template></template>
@@ -403,7 +403,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!customersError && !loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customersError && !loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>客户应收应付</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
<template #notices><template v-if="stateError"><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template></template>
@@ -680,7 +680,7 @@ watch(detailRouteId, (statementId) => {
<ListPageLayout v-else aria-label="客户对账单">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
<template #toolbar-start><h1>客户对账单</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template>
<template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
@@ -733,7 +733,7 @@ watch(detailRouteId, (statementId) => {
<section v-if="adjustStatement" class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前对账单"><h3 class="density-dialog-section__title">当前对账单</h3><table class="density-table statement-dialog-facts"><tbody><tr><th scope="row">对账单号</th><td>{{ adjustStatement.billNo || '—' }}</td><th scope="row">客户</th><td>{{ customerDisplayName(adjustStatement.customerId, adjustStatement.customerName) }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="对账单调整登记"><h3 class="density-dialog-section__title">调整登记</h3><div class="statement-adjust-editor">
<div class="workbench-evidence"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></td></tr></tbody></table></div>
<div class="workbench-table"><el-table :data="adjustLines" size="small" border :stripe="false" row-key="statementLineId"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" :aria-label="`选择${row.feeItemName || '费用明细'}`" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" show-overflow-tooltip /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table></div>
<div class="workbench-table"><el-table :data="adjustLines" size="small" border stripe row-key="statementLineId"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" :aria-label="`选择${row.feeItemName || '费用明细'}`" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" show-overflow-tooltip /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table></div>
</div></section>
</el-form>
</AppDialog>
@@ -606,7 +606,7 @@ onMounted(() => {
<ListPageLayout v-else :aria-label="title">
<section class="workbench-table is-page-surface"><DataTable :preference-key="`finance-settlement-${kind}-table`" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
<section class="workbench-table is-page-surface"><DataTable :preference-key="`finance-settlement-${kind}-table`" appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
<template #toolbar-start><h1>{{ title }}</h1></template>
<template #filters>
<QueryToolbar v-model="filters" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" :search-on-clear="false" @search="load" />
@@ -132,7 +132,7 @@ describe('finance presentation governance', () => {
it('keeps bordered statement details and lets the adjustment editor scroll with its dialog', () => {
expect(statements).toContain(':data="detail.lines" size="small" border stripe')
expect(statements).toContain(':data="detail.revisions" size="small" border stripe')
expect(statements).toContain(':data="adjustLines" size="small" border :stripe="false" row-key="statementLineId"')
expect(statements).toContain(':data="adjustLines" size="small" border stripe row-key="statementLineId"')
expect(statements).not.toContain('max-height="420"')
})
@@ -93,7 +93,7 @@ onMounted(() => { void loadCustomers(); void load() })
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
<template v-else>
<div class="workbench-table is-page-surface"><DataTable class="statistics-table" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
<div class="workbench-table is-page-surface"><DataTable class="statistics-table" appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>应收款统计表</h1></template>
<template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选">
<div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
+1 -1
View File
@@ -389,7 +389,7 @@ onMounted(loadInitialData)
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns" preference-key="internal-users-table"
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns" preference-key="internal-users-table"
:loading="loading" :total="total" aria-label="内部用户列表" row-key="membershipId" empty-text="没有符合筛选条件的内部用户">
<template #toolbar-start><h1>用户管理</h1></template>
<template #filters>
+1 -1
View File
@@ -279,7 +279,7 @@ onMounted(loadPermissions)
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<template #toolbar-start><h1>菜单权限</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" /></template>
<template #notices><template v-if="loadError">
+3 -1
View File
@@ -76,7 +76,9 @@ describe('RolesView', () => {
expect(rolesSource).toContain("(row as Role).status || '—'")
})
it('uses bordered striped tables and semantic role operation buttons', () => {
expect(rolesSource).toContain('appearance="integrated" :stripe="false"')
expect(rolesSource).toContain('appearance="integrated"')
expect(rolesSource).not.toContain(':stripe="false"')
expect(rolesSource).not.toContain(':stripe="false"')
expect(rolesSource).toContain('size="small" data-tone="neutral"')
expect(rolesSource).toContain('size="small" :data-tone=')
expect(rolesSource).toContain('size="small" data-tone="danger"')
+1 -1
View File
@@ -242,7 +242,7 @@ onMounted(loadRoles)
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" border aria-label="平台角色列表" empty-text="没有符合条件的角色">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" border aria-label="平台角色列表" empty-text="没有符合条件的角色">
<template #toolbar-start><h1>角色权限</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="角色筛选" :fields="queryFields" preference-key="roles-query" @search="applyFilters" /></template>
<template #notices><template v-if="loadError">
@@ -359,7 +359,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<section :aria-busy="loading"><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
<section :aria-busy="loading"><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page"
v-model:page-size="pageSize"
:data="result.records"
@@ -928,7 +928,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<style scoped>
.measurement-comparison { width: 100%; border-collapse: collapse; table-layout: fixed; background: var(--admin-surface, #fff); font-size: var(--admin-font-body, 13px); }
.measurement-comparison th, .measurement-comparison td { padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); border: 1px solid var(--workbench-border); text-align: left; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.measurement-comparison thead th { background: #f8fafc; }
.measurement-comparison thead th { background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); }
.measurement-comparison thead th:nth-child(2) { background: #f7f9ff; color: #465fc4; }
.measurement-comparison thead th:nth-child(3) { background: #f4fbf8; color: #18795d; }
.measurement-comparison__difference { color: var(--el-color-danger-dark-2); font-weight: 600; }
@@ -984,7 +984,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
.manual-entry-upload { width: 100%; }
.measurement-items-section { background: var(--admin-surface, #fff); }
.measurement-table { width: 100%; font-size: var(--admin-font-caption, 12px); }
.measurement-table :deep(.el-table__header th) { padding: var(--admin-space-3, 8px) 0; background: #f6f8fa; color: #475467; font-size: var(--admin-font-caption, 11px); font-weight: 700; }
.measurement-table :deep(.el-table__header th) { padding: var(--admin-space-3, 8px) 0; background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); font-size: var(--admin-font-caption, 11px); font-weight: 700; }
.measurement-table :deep(.el-table__cell) { padding: 7px 0; }
.measurement-table :deep(.cell) { line-height: 1.35; }
.measurement-table :deep(.measurement-table__row--error > td.el-table__cell) { background: var(--el-color-danger-light-9); }
@@ -4,8 +4,9 @@
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context { flex: 1 1 auto; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-context p > span { flex-shrink: 0; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-status { flex-wrap: wrap; }
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-work-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-work-bg); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert { background: var(--admin-surface, #fff); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-subheading { color: #18795d; font-size: var(--admin-font-section, 14px); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
@@ -744,7 +744,7 @@ defineExpose({ reload: loadWorkspace })
.weight-card__correct { width: 100%; margin: var(--admin-space-5, 12px) 0 0; }
.weight-table-wrap { overflow-x: auto; border: 1px solid var(--workbench-border); border-radius: 8px; background: #fff; }
.weight-data-table { width: 100%; min-width: 1180px; border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: var(--admin-font-caption, 12px); }
.weight-data-table th { height: 42px; padding: 0 8px; border-bottom: 1px solid var(--workbench-border); background: var(--workbench-surface-soft); color: var(--workbench-muted); font-weight: 700; white-space: nowrap; }
.weight-data-table th { height: 42px; padding: 0 8px; border-bottom: 1px solid var(--workbench-border); background: var(--cc-list-table-header-bg); color: var(--cc-list-table-header-text); font-weight: 700; white-space: nowrap; }
.weight-data-table td { height: 76px; padding: 6px 8px; border-top: 1px solid var(--workbench-border); color: var(--workbench-text); text-align: center; vertical-align: middle; white-space: nowrap; }
.weight-data-table th + th, .weight-data-table td + td { border-left: 1px solid var(--workbench-border); }
.weight-data-table tbody tr:nth-child(even) td { background: var(--workbench-surface-soft); }
@@ -145,7 +145,9 @@ describe('LoadingListsView', () => {
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('.row-actions { gap: 4px; }')
expect(source).toContain('row-actions density-actions admin-text-actions')
expect(source).toContain('appearance="integrated" :stripe="false"')
expect(source).toContain('appearance="integrated"')
expect(source).not.toContain(':stripe="false"')
expect(source).not.toContain(':stripe="false"')
expect(detailSource).toMatch(/:columns="itemColumns"\s+stripe/)
})
@@ -191,7 +191,7 @@ onMounted(() => {
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="canList && !error"
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="canList && !error"
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
@@ -378,7 +378,7 @@ onMounted(() => {
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
@@ -289,7 +289,7 @@ onMounted(() => {
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError"
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError"
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
@@ -22,7 +22,7 @@ function search(){page.value===1?void load():page.value=1}
async function action(row:NotificationTask,type:'cancel'|'retry'){try{await ElMessageBox.confirm(type==='cancel'?'确定取消该消息任务吗?':'确定重试该消息任务吗?',`${type==='cancel'?'取消任务':'重试任务'} · ${row.taskNo || '—'}`,{type:'warning',customClass: 'admin-density-confirm admin-workbench'});actionId.value=row.id;if(type==='cancel')await cancelTask(row.id);else await retryTask(row.id);ElMessage.success(type==='cancel'?'任务已取消':'任务已提交重试');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,type==='cancel'?'取消失败':'重试失败'))}finally{actionId.value=''}}
watch([page,pageSize],load);onMounted(()=>{void load();void loadTemplates()})
</script>
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="消息管理列表"><section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-messages-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息任务">
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="消息管理列表"><section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-messages-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息任务">
<template #toolbar-start><h1>消息管理</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="消息任务筛选" :fields="queryFields" preference-key="notifications-messages-query" @search="search" /></template>
<template #notices><template v-if="error"><LoadErrorState title="消息任务加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
@@ -12,7 +12,7 @@ function search(){page.value===1?void load():page.value=1}function create(){edit
async function changeStatus(row:NotificationTemplate){const target=row.status==='DRAFT'?'ACTIVE':'DISABLED';try{await ElMessageBox.confirm(target==='ACTIVE'?'激活后模板不可再编辑,确定继续吗?':'禁用后首期不可重新启用,确定继续吗?',`${target==='ACTIVE'?'激活模板':'禁用模板'} · ${row.templateName || row.templateCode || '—'}`,{type:'warning',customClass: 'admin-density-confirm admin-workbench'});actionId.value=row.id;await changeTemplateStatus(row.id,target,row.version);ElMessage.success(target==='ACTIVE'?'模板已激活':'模板已禁用');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,'模板状态变更失败'))}finally{actionId.value=''}}
watch([page,pageSize],load);onMounted(load)
</script>
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="消息模板列表"><section class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-templates-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息模板">
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="消息模板列表"><section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-templates-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息模板">
<template #toolbar-start><h1>模板管理</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="消息模板筛选" :fields="queryFields" preference-key="notifications-templates-query" @search="search"/></template>
<template #notices><template v-if="error"><LoadErrorState title="模板加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error"/></template></LoadErrorState></template></template>
@@ -93,7 +93,7 @@ onMounted(() => void load())
<ListPageLayout aria-label="我的消息列表">
<section class="workbench-table is-page-surface"><DataTable preference-key="notifications-platformnotificationsview" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息">
<section class="workbench-table is-page-surface"><DataTable preference-key="notifications-platformnotificationsview" appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息">
<template #toolbar-start><h1>我的消息</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-inbox-query" @search="search" /></template>
<template #notices><template v-if="error"><LoadErrorState title="我的消息加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
@@ -74,7 +74,7 @@ onActivated(() => void load())
<ListPageLayout aria-label="订舱列表">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
<template #toolbar-start><h1>订舱列表</h1></template>
<template #filters><QueryToolbar v-model="query" ariaLabel="订舱筛选" :fields="fields" @search="search" /></template>
<template #notices><template v-if="error"><LoadErrorState title="订舱列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
@@ -37,6 +37,13 @@
删除和文件预览在三类正式页面中均未定义,不自行补充承载方式;海外采购审核按当前对话确认的承载方式实现。
### 2026-10-21 本土卡车审核详情页头修复
| 页面 | 操作 | 确认依据与布局 | 当前实现 | 修复结论与验收 |
|---|---|---|---|---|
| 本土卡车详情 | 从列表点击审核、查看详情 | 用户截图 `codex-clipboard-515ba915-7b67-48de-9914-62cec7371ec5.png` 红框;当前详情密度规范要求返回、编号、状态及操作合并页头 | 标题、状态/刷新、审核/取消拆成三行,标题使用浏览器默认字号 | 接入公共详情页头与连续白底容器:左侧返回、订单号及单一状态徽标,右侧刷新与原业务操作;保留完整编号;待人工 1920×1080、100% 截图确认 |
| 本土卡车详情 | 审核、取消及后续命令 | 本表已确认的居中操作确认弹框 | `TruckOrderActions` 保留既有表单、权限及提交保护 | 只移动页头入口,弹框承载、尺寸和命令保持现状;按既有行为测试回归 |
## 本土卡车需求字段调整
依据本轮客户端截图及用户确认,同步平台订单字段;规则唯一见 `docs/海外仓业务/README.md`“美国本土卡独立下单”。
@@ -113,3 +120,13 @@
| ProcurementNodeActions | 采购详情中的节点命令居中弹框 | 自定义footer绕过AppDialog默认操作区容器;三项操作加入density-actions底部容器,允许换行并保持间距 | 定向回归通过;待人工1920×1080、100%截图确认 |
保持既有命令、草稿、未知结果核对、权限与提交条件;只新增操作区容器。公共AppDialog的直接按钮footer修复由公共样式写者维护,本域未修改公共组件。
## 2026-10-21 本土运输表单布局修复
| 页面 | 操作 | 依据与现有承载 | 当前问题与修复结论 | 验收 |
|---|---|---|---|---|
| 本土卡车 | 新增 | 用户截图 `codex-clipboard-1a93aa11-88f2-4e90-8130-030fa91185f5.png`;既有独立路由及上文统一运输表单矩阵 | 移除居中窄表单的宽度限制及重复分区内距,铺满内容区;接入公共密度表单与分区,保留运输双列、货物三列和字段顺序 | 自动检查通过;待人工 1920×1080、100% 截图确认 |
| 本土卡车 | 编辑 | 既有独立编辑路由,与新增复用运输表单 | 同步宽度、密度与分区;保留回显、状态限制及提交逻辑 | 同上 |
| 本土散板 | 新增、编辑 | 既有独立路由,与卡车复用运输表单 | 同步布局修复,保留运输双列、货物四列及散板专属字段 | 同上 |
本次仅调整 Admin 运输表单展示,不改采购编辑的宽度或 Client 表单。在 `apps/admin-web` 经 `local-check.ps1` 执行:`pnpm exec vitest run src/modules/overseas-business/pages/OverseasBusinessViews.test.ts src/modules/overseas-business/contract.test.ts src/styles/adminDensity.test.ts`,3 文件 115 项通过;`pnpm typecheck`、`pnpm build` 通过。共享完整 `pnpm test`(任务名 `Admin restored dispatch full regression`,13:55:33–14:05:50)退出码 0,覆盖本次未再变化的运输表单源码;其他在途排车变更由对应任务验证,不据此推定其最新快照通过。视觉仍待人工截图确认。
@@ -1,7 +1,9 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref } from 'vue'
import type { FormInstance } from 'element-plus'
import FormSection from '@/shared/components/FormSection.vue'
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
const props = withDefaults(defineProps<{
@@ -22,10 +24,9 @@ defineExpose({
</script>
<template>
<el-form ref="formRef" class="transport-demand-form admin-workbench" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
<el-form ref="formRef" class="transport-demand-form admin-density admin-workbench density-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
<slot name="customer" />
<section class="transport-demand-section">
<h3>运输信息</h3>
<FormSection class="density-form-section" title="运输信息">
<div class="transport-demand-grid">
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
@@ -39,9 +40,8 @@ defineExpose({
<el-form-item label="起运详细地址"><el-input v-model.trim="model.originAddress" maxlength="500" /></el-form-item>
<el-form-item label="目的详细地址"><el-input v-model.trim="model.destinationAddress" maxlength="500" /></el-form-item>
</div>
</section>
<section class="transport-demand-section">
<h3>货物信息</h3>
</FormSection>
<FormSection class="density-form-section" title="货物信息">
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
@@ -50,9 +50,8 @@ defineExpose({
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" /></el-form-item>
</div>
</section>
<section class="transport-demand-section">
<h3>费用与时效</h3>
</FormSection>
<FormSection class="density-form-section" title="费用与时效">
<div class="transport-demand-grid">
<el-form-item label="预算总价" prop="budgetAmount"><div class="transport-demand-budget">
<el-input-number v-model="model.budgetAmount" :min="0" :precision="2" controls-position="right" />
@@ -63,8 +62,8 @@ defineExpose({
<el-form-item label="送货时间" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</div>
</section>
<footer class="transport-demand-actions">
</FormSection>
<footer class="transport-demand-actions density-actions">
<el-button :disabled="submitting" @click="emit('cancel')">取消</el-button>
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="disabled" @click="emit('submit')">{{ editing ? '保存修改' : '提交订单' }}</el-button>
</footer>
@@ -72,17 +71,12 @@ defineExpose({
</template>
<style scoped>
.transport-demand-form { --el-component-size: 36px; width: 100%; max-width: 1180px; margin: 0; background: var(--workbench-surface); }
.transport-demand-section { padding: 20px 24px 0; border-bottom: 1px solid var(--workbench-border); }
.transport-demand-section h3 { margin: 0 0 16px; color: var(--workbench-text); font-size: 14px; font-weight: 600; }
.transport-demand-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
.transport-demand-form { width: 100%; min-width: 0; margin: 0; }
.transport-demand-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
.transport-demand-cargo-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.transport-demand-cargo-four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.transport-demand-wide { grid-column: 1 / -1; }
.transport-demand-form :deep(.el-form-item) { min-width: 0; margin-bottom: 18px; }
.transport-demand-form :deep(.el-form-item__label) { height: auto; padding-bottom: 6px; font-size: 13px; line-height: 20px; }
.transport-demand-form :deep(.el-input), .transport-demand-form :deep(.el-input-number), .transport-demand-form :deep(.el-select), .transport-demand-form :deep(.el-date-editor) { width: 100%; }
.transport-demand-budget { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 100px; gap: 10px; }
.transport-demand-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 24px; }
.transport-demand-actions :deep(.el-button + .el-button) { margin-left: 0; }
.transport-demand-form :deep(.el-form-item) { min-width: 0; }
.transport-demand-budget { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 100px; gap: var(--admin-space-3); }
.transport-demand-actions { justify-content: flex-end; flex-wrap: wrap; padding: var(--admin-space-5) 0; }
</style>
@@ -1,8 +1,10 @@
/* Shared business form layout for purchase and local transport editors. */
.admin-density.overseas-business-editor { width: 100%; max-width: 1120px; }
.admin-density.overseas-business-editor { width: 100%; }
.admin-density.overseas-business-editor:not(.overseas-transport-editor) { max-width: 1120px; }
.overseas-business-editor .overseas-business-form-layout { gap: 0; }
.overseas-business-editor .form-reference { min-width: 0; color: var(--cc-muted); font-size: var(--admin-font-body); overflow-wrap: anywhere; }
.overseas-business-editor .form-panel { min-width: 0; padding: var(--admin-space-5); }
.overseas-business-editor .form-panel { min-width: 0; }
.overseas-business-editor:not(.overseas-transport-editor) .form-panel { padding: var(--admin-space-5); }
.overseas-business-editor .form-grid { display: grid; min-width: 0; gap: 0 var(--admin-space-5); }
.overseas-business-editor .form-grid :is(.el-select, .el-date-editor, .el-input-number) { width: 100%; }
.overseas-business-editor .form-grid small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); font-size: var(--admin-font-caption); }
@@ -73,7 +73,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="海外采购订单列表">
<section><div class="workbench-table is-page-surface"><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-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" 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-business-purchases" row-key="id" empty-text="暂无符合筛选条件的海外采购订单">
<template #toolbar-start><h1>海外采购订单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="海外采购订单筛选" preference-key="overseas-business-purchases-query" @search="search" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
@@ -94,7 +94,7 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
</script>
<template>
<AdminPage class="admin-density overseas-business-editor admin-workbench"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout workbench-panel is-flush is-page-surface" :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
<AdminPage class="admin-density overseas-business-editor overseas-transport-editor admin-workbench"><OperationErrorToast :details="submitError" /><DetailPageLayout class="overseas-business-form-layout workbench-panel is-flush is-page-surface" :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h1><span v-if="editing" class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<FleetArrangementSummary v-else-if="fleetArrangement" :arrangement="fleetArrangement" />
@@ -102,14 +102,10 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
<TransportDemandForm ref="formRef" :model="form" :kind="isLooseboard ? 'looseboard' : 'truck'" :submitting="saving" :disabled="!editableStatus" :editing="editing" @submit="save" @cancel="back">
<template v-if="!editing" #customer>
<FormSection class="transport-customer-section" title="客户信息"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
<FormSection class="transport-customer-section density-form-section" title="客户信息"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
</template>
</TransportDemandForm>
<FormSection v-if="!isLooseboard" class="density-form-section" title="车辆与派车"><el-button @click="routeOpen = true">查看路线</el-button></FormSection>
</section>
</DetailPageLayout><TransportRouteDialog v-model="routeOpen" :origin="routeLocation(form.origin, form.originAddress)" :destinations="[routeLocation(form.destination, form.destinationAddress)]" /></AdminPage>
</template>
<style scoped>
.transport-customer-section { padding: 20px 24px 0; border-bottom: 1px solid #e7ebf1; }
</style>
@@ -120,7 +120,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
<AdminPage class="admin-density admin-workbench"><ListPageLayout :aria-label="`${domainLabel}订单列表`">
<section><div class="workbench-table is-page-surface"><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-business-${domain}`" row-key="id" :empty-text="`暂无符合筛选条件的${domainLabel}订单`">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" 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-business-${domain}`" row-key="id" :empty-text="`暂无符合筛选条件的${domainLabel}订单`">
<template #toolbar-start><el-button v-if="inquiryReturnTarget(route.query)" link @click="router.push(inquiryReturnTarget(route.query)!)">返回原拼车登记</el-button><h1>{{ domainLabel }}</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="search" /></template>
<template #notices><template v-if="loadError"><LoadErrorState :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
@@ -56,6 +56,20 @@ beforeEach(() => {
afterEach(() => vi.unstubAllGlobals())
describe('truck review and fulfillment', () => {
it('groups the business number, single status and actions in the shared detail header', async () => {
const wrapper = mountDetail(); await flushPromises()
const header = wrapper.find('.detail-page-layout__header')
expect(header.find('.density-detail-title').text()).toBe('本土卡车订单 TR-TEST')
expect(header.find('.status-badge').text()).toBe('待审核')
expect(wrapper.findAll('.status-badge')).toHaveLength(1)
expect(header.find('.truck-order-actions').text()).toContain('审核')
expect(header.find('.truck-order-actions').text()).toContain('取消订单')
await click(wrapper, '刷新订单')
expect(getUsTruckOrder).toHaveBeenCalledTimes(2)
await click(wrapper, '返回列表')
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-trucks' })
})
it('loads customer scoped documents and runs review through receipt, allocation and delivery completion', async () => {
let current = order()
vi.mocked(executeUsTruckCommand).mockImplementation(async (_no, _customer, operation) => {
@@ -119,6 +133,7 @@ describe('truck review and fulfillment', () => {
const wrapper = mountDetail(); await flushPromises(); await click(wrapper, '审核'); await confirm(wrapper); await confirm(wrapper)
expect(executeUsTruckCommand).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent(AppDialog).props('confirmLoading')).toBe(true)
for (const button of wrapper.find('.detail-page-layout__header').findAll('button')) expect(button.attributes('disabled')).toBeDefined()
finish(order({ status: 'WAITING_INBOUND', version: 2 })); await flushPromises()
expect(wrapper.text()).toContain('登记实收')
})
@@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -65,16 +66,25 @@ watch([orderNo, customerId], load, { immediate: true })
</script>
<template>
<AdminPage class="admin-density admin-workbench"><DetailPageLayout aria-label="本土卡车订单详情" class="workbench-panel is-flush is-page-surface">
<template #header><div class="truck-detail-header"><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-trucks' })"><ArrowLeft :size="16" />返回列表</el-button><h1>本土卡车订单 {{ detail?.orderNo || orderNo }}</h1></div></template>
<AdminPage class="admin-density admin-workbench"><DetailPageLayout aria-label="本土卡车订单详情" class="density-detail-layout workbench-panel is-flush is-page-surface">
<template #header>
<div class="density-detail-header truck-detail-header">
<div class="density-detail-heading">
<el-button link class="density-back" :disabled="saving" @click="router.push({ name: 'overseas-business-trucks' })"><ArrowLeft :size="16" />返回列表</el-button>
<h1 class="density-detail-title">本土卡车订单 {{ detail?.orderNo || orderNo }}</h1>
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
</div>
<div v-if="detail" class="density-actions truck-detail-actions">
<el-button :disabled="saving" @click="load">刷新订单</el-button>
<TruckOrderActions v-if="!detail.fleetArrangement" :detail="detail" @updated="detail = $event" @busy="saving = $event" />
</div>
</div>
</template>
<LoadErrorState v-if="error" title="本土卡车订单暂时无法加载" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="8" animated />
<template v-else-if="detail">
<section class="truck-detail-section"><div class="truck-detail-header"><h2>{{ statusLabel(detail.status) }}</h2><el-button :disabled="saving" @click="load">刷新订单</el-button></div>
<FleetArrangementSummary v-if="detail.fleetArrangement" :arrangement="detail.fleetArrangement" />
<template v-else><TruckOrderActions :detail="detail" @updated="detail = $event" @busy="saving = $event" /></template>
</section>
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section v-if="detail.fleetArrangement" class="truck-detail-section"><FleetArrangementSummary :arrangement="detail.fleetArrangement" /></section>
<section class="truck-detail-section"><h2>订单资料</h2><el-descriptions class="density-descriptions" :column="3" border><el-descriptions-item v-for="[label, value] in fields" :key="String(label)" :label="String(label)">{{ value ?? '—' }}</el-descriptions-item></el-descriptions></section>
<section class="truck-detail-section"><h2>托盘记录</h2><div class="workbench-table"><DataTable :data="detail.pallets" :columns="palletColumns" empty-text="暂无托盘记录,独立订单首次实收后生成托盘" /></div></section>
<section class="truck-detail-section"><h2>审核与操作记录</h2><div class="workbench-table"><DataTable :data="detail.histories" :columns="historyColumns" empty-text="暂无操作记录" /></div></section>
</template>
@@ -82,7 +92,9 @@ watch([orderNo, customerId], load, { immediate: true })
</template>
<style scoped>
.truck-detail-header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.truck-detail-header h1, .truck-detail-header h2 { margin: 0; }
.truck-detail-section { margin-bottom: 16px; }
.truck-detail-header { flex-wrap: wrap; }
.truck-detail-header .density-detail-heading { flex: 1; }
.truck-detail-header .status-badge { flex-shrink: 0; }
.truck-detail-actions { flex-wrap: wrap; }
.truck-detail-section { margin-bottom: var(--admin-space-6); }
</style>
+2 -2
View File
@@ -1,7 +1,7 @@
# 海外通用资料与车辆管理
- 自有业务:`pages/OwnedBusinessView.vue`、`OwnedBusinessDetailView.vue`及`api/ownedBusiness.ts`沿`fleet-owned-business-v1.yaml`,是原排车装车后的自有车辆运营视图,不按货源分类或另建履约流程;共同规则见`../../../../../docs/海外仓业务/自有业务首版开发契约.md`。详情三个页签,运营登记1080px居中、照片760px居中;金额/油耗单位、附加与排车版本分离,`owned-business:list/edit/upload`独立,统计沿完整计划发车日期筛选。
- 外配业务:`pages/OutsourcedBusinessView.vue`、`OutsourcedBusinessDetailView.vue`及`api/outsourcedBusiness.ts`沿`fleet-outsourced-business-v1.yaml`;共同规则见`../../../../../docs/海外仓业务/外配业务首版开发契约.md`。读取装车时DISPATCHED归属,包括临时EXTERNAL车辆,不按货源区分。一车一行、三页签详情、整车承运金额除实托数、逐目的仓到达和五个装车照片位;无里程/油耗/油耗照片、成本拆分和新增履约流程。三项`outsourced-business:list/edit/upload`与自有权限独立,缺承运商保留空,vehicleId可空;其余版本、原键、真实来源及完整筛选统计沿共同契约,不能静默把外配写入当成自有权限。
- 自有业务:`pages/OwnedBusinessView.vue`、`OwnedBusinessDetailView.vue`及`api/ownedBusiness.ts`沿`fleet-owned-business-v1.yaml`,是原排车装车后未实际接入拼车货物的运营视图,与车辆归属无关,不另建履约流程;共同规则见`../../../../../docs/海外仓业务/自有业务首版开发契约.md`。详情三个页签,运营登记1080px居中、照片760px居中;金额/油耗单位、附加与排车版本分离,`owned-business:list/edit/upload`独立,统计沿完整计划发车日期筛选。
- 外配任务:`pages/OutsourcedBusinessView.vue`、`OutsourcedBusinessDetailView.vue`及`api/outsourcedBusiness.ts`沿`fleet-outsourced-business-v1.yaml`;共同规则见`../../../../../docs/海外仓业务/外配业务首版开发契约.md`。按需求池实际接入本车的拼车货物分类,完整判定见外配契约;车辆归属独立,允许OWNED/DISPATCHED及临时EXTERNAL车辆。一车一行、三页签详情、整车承运金额除实托数、逐目的仓到达和五个装车照片位;无里程/油耗/油耗照片、成本拆分和新增履约流程。三项`outsourced-business:list/edit/upload`与自有权限独立,缺承运商保留空,vehicleId可空;其余版本、原键、真实来源及完整筛选统计沿共同契约,不能静默把外配写入当成自有权限。
- 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。
@@ -1,5 +1,14 @@
# 海外通用资料原型核对矩阵
## 散板需求只读展示(2026-10-21)
依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。
| 页面/操作 | 当前承载与调整 | 验收 |
|---|---|---|
| 散板需求列表/查询、分页、详情 | 保持独立列表与原余位来源,默认展示车次余位、线路、时间、联系信息;隐藏待匹配散板、我的登记、登记及待办入口,原代码保留 | 43项定向测试及页面、测试源码定向类型检查通过;待人工1920×1080、100%截图确认 |
| 散板需求详情/查看、重新核对 | 保持独立详情,仅展示运输摘要、联系方式和分段余位;隐藏登记、意向列表、来货及履约窗口,旧登记参数不再打开窗口 | 同上 |
## 2026-10-20 列表顶部紧凑收尾
依据为本轮用户整柜业务截图及“所有列表顶部紧凑”的确认;列表公共工具栏和筛选内距由 `adminWorkbench.css` 统一维护。
@@ -63,6 +72,26 @@
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
## 自有业务列表字段补齐(2026-10-21)
依据本轮用户字段截图 `codex-clipboard-9d532c45-a5f3-4f90-a8f2-f8a3cdfb8a3e.png`;用户确认派送仓库就是目的仓,最晚装车完成时间先空着。此前详情、运营登记、照片的承载与权限不变。
| 页面/操作 | 截图与当前确认 | 本次调整 | 验收 |
|---|---|---|---|
| 自有业务列表/读取字段 | 总价、单价、预约到达、实际到达各为独立列 | 拆开原合并列;一车一行,各到达时间保留目的仓标识,金额沿服务端原值 | 8项定向检查通过;待修改后人工截图 |
| 自有业务列表/仓库与时间 | 派送仓库等于目的仓;最晚装车完成时间暂无来源 | 派送仓库复用目的仓展示;最晚装车完成时间显示“—”,不拿计划发车替代;保留已有计划发车列 | 8项定向检查通过;待修改后人工截图 |
| 自有业务列表/字段名称 | 图中第一处“托数”装的是仓库名称 | 保留正确的“提货仓库”和唯一“实际托数”;“阶段”列改名“状态”,取值不变 | 8项定向检查通过;待修改后人工截图 |
## 自有业务行操作样式核对(2026-10-21)
依据本轮用户截图 `codex-clipboard-eb6aa95b-c8a2-4b85-9294-22f3127cf72c.png` 与 Admin 界面规范“列表”纯文字行操作规则;仅修正列表按钮外观。
| 页面 | 操作 | 已确认承载 | 当前问题与修正 | 验收 |
|---|---|---|---|---|
| 自有业务列表 | 查看明细 | 原独立详情路由 | 旧描边粗体按钮接入公共纯文字行操作类,保留路由和业务编号 | 77项定向检查及类型/构建通过;修改后待人工截图确认 |
| 自有业务列表 | 登记运营信息 | 原1080px居中弹框 | 同上;保留原权限、状态及处理中禁用 | 77项定向检查及类型/构建通过;修改后待人工截图确认 |
| 自有业务列表 | 上传/查看照片 | 原760px居中弹框 | 同上;保留原读写权限与按钮文案 | 77项定向检查及类型/构建通过;修改后待人工截图确认 |
## 自有业务实现核对(2026-10-20,主规则已确认)
用户当前要求逐页处理,先看自有业务;HTTP 扩展由既有排车后端对话独立负责。本节记录原型与已确认规则的映射,不授权外配页面改造。自有与外配均为原排车装车后的车辆归属视图,不另建任务或履约流程;自有车增加运营细项,外配管理细度后续确认。视觉沿当前 Admin 高密度规范,业务与操作承载依据用户提供的 `C:/Users/BY-OY/Documents/WXWork/1688854456873012/Cache/File/2026-10/悦/悦/新版.html` 及本轮截图。
@@ -12,6 +12,19 @@ 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.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified pooled %s vehicles in lists, details and write responses', async ownership => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle.ownership = ownership
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
expect((await listOutsourcedBusiness({})).records[0]!.vehicle).toEqual(detail.row.vehicle)
expect((await getOutsourcedBusiness(detail.row.dispatchNo)).row.vehicle.ownership).toBe(ownership)
vi.mocked(http.put).mockResolvedValue({ data: detail })
expect((await saveOutsourcedBusinessOperations(detail.row.dispatchNo, { recordVersion: detail.row.recordVersion, idempotencyKey: 'pool-ownership', operations: detail.row.operations, reason: null })).row.vehicle.ownership).toBe(ownership)
})
it.each(['UNKNOWN', null, undefined])('rejects invalid vehicle ownership %s', async ownership => {
const detail = outsourcedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership } } } })
await expect(getOutsourcedBusiness(detail.row.dispatchNo)).rejects.toThrow('来源或阶段')
})
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' } } })
@@ -69,10 +82,8 @@ describe('outsourced business HTTP contract', () => {
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 () => {
it('rejects 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('不支持里程或油耗')
@@ -18,9 +18,9 @@ function operations(value: OutsourcedBusinessOperations): OutsourcedBusinessOper
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 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('外配排车来源或阶段不完整,请重新读取')
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || !['OWNED','DISPATCHED'].includes(value.vehicle?.ownership)) 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) } }
@@ -12,6 +12,19 @@ beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/owned-business'
const photo: OwnedBusinessPhoto = { slot: 'FUEL_START', category: 'FUEL', fileId: '9007199254740993', fileName: 'fuel.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('owned business HTTP contract', () => {
it.each(['OWNED', 'DISPATCHED'] as const)('accepts server-classified non-pooled %s vehicles in lists, details and write responses', async ownership => {
const detail = ownedBusinessFixture(); detail.row.vehicle.ownership = ownership; detail.row.vehicle.vehicleId = null
vi.mocked(http.get).mockResolvedValueOnce({ data: { records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } } }).mockResolvedValue({ data: detail })
expect((await listOwnedBusiness({})).records[0]!.vehicle).toEqual(detail.row.vehicle)
expect((await getOwnedBusiness(detail.row.dispatchNo)).row.vehicle.ownership).toBe(ownership)
vi.mocked(http.put).mockResolvedValue({ data: detail })
expect((await saveOwnedBusinessOperations(detail.row.dispatchNo, { recordVersion: detail.row.recordVersion, idempotencyKey: 'non-pool-ownership', operations: detail.row.operations, reason: null })).row.vehicle.ownership).toBe(ownership)
})
it.each(['UNKNOWN', null, undefined])('rejects invalid vehicle ownership %s', async ownership => {
const detail = ownedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership } } } })
await expect(getOwnedBusiness(detail.row.dispatchNo)).rejects.toThrow('来源或阶段')
})
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
const detail = ownedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
@@ -16,7 +16,7 @@ function operations(value: OwnedBusinessOperations): OwnedBusinessOperations {
return { ...value, money: value.money ? { ...value.money, amount: decimal(value.money.amount, 2, 18)! } : null, actualDistanceMiles: decimal(value.actualDistanceMiles,3,12), fuelConsumptionLitresPer100Km: decimal(value.fuelConsumptionLitresPer100Km,3,12) }
}
function row(value: OwnedBusinessRow): OwnedBusinessRow {
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'OWNED') throw new Error('自有排车来源或阶段不完整,请重新读取')
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || !['OWNED','DISPATCHED'].includes(value.vehicle?.ownership)) throw new Error('自有排车来源或阶段不完整,请重新读取')
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: ownedBusinessVersion(value.recordVersion), dispatchRecordVersion: ownedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount), fuelCount: count(value.photoSummary.fuelCount) } }
}
function detail(value: OwnedBusinessDetail): OwnedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
@@ -2,11 +2,10 @@ import ElementPlus, { ElMessageBox } from 'element-plus'
import { createPinia } from 'pinia'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
import { defineComponent, ref } from 'vue'
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
import CapacityInquiryDialog from './CapacityInquiryDialog.vue'
import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue'
import CapacityOfferDetailView from '../pages/CapacityOfferDetailView.vue'
import type { CapacityOffer } from '../capacityOfferTypes'
import type { FleetTrip } from '../tripTypes'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
@@ -64,11 +63,13 @@ it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited i
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('blocks browser back with the real router, cached detail and native dialog, without duplicating the global prompt', async () => {
it('preserves route-leave protection in the retained native inquiry dialog without duplicating the global prompt', async () => {
const pinia = createPinia(), registry = useUnsavedWorkStore(pinia), confirms = vi.spyOn(ElMessageBox, 'confirm')
// 登记页面入口已隐藏;保留组件自身与真实路由的未保存保护回归。
const InquiryHost = defineComponent({ components: { CapacityInquiryDialog }, setup: () => ({ offer, open: ref(false) }), template: '<button @click="open = true">打开保留的登记组件</button><CapacityInquiryDialog v-model="open" :offer="offer" :inquiry="null" :readonly="false" />' })
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/pool', name: 'overseas-master-data-capacity-offers', component: { template: '<p data-testid="pool-list">运力列表</p>' }, meta: { requiresAuth: true } },
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: CapacityOfferDetailView, meta: { requiresAuth: true } },
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: InquiryHost, meta: { requiresAuth: true } },
{ path: '/outside', name: 'outside', component: { template: '<p>外部页面</p>' }, meta: { workspaceTab: false } },
] })
// Match the application's intentional policy: cross-workspace switching skips the global prompt.
@@ -81,7 +82,7 @@ it('blocks browser back with the real router, cached detail and native dialog, w
await router.push('/pool'); await router.isReady()
const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper)
await router.push('/pool/CO-CLOSE'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises()
await wrapper.get('details summary').trigger('click')
const input = wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]')
await input.setValue('UI-修复后保留输入'); await flushPromises()
@@ -92,15 +93,15 @@ it('blocks browser back with the real router, cached detail and native dialog, w
expect(confirms).toHaveBeenCalledOnce(); expect(wrapper.get('.app-dialog').isVisible()).toBe(true)
await continueEditing()
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail')
expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).not.toHaveBeenCalled()
confirms.mockClear(); router.back(); await flushPromises()
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
const leave = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '放弃并离开') as HTMLButtonElement
expect(leave).toBeTruthy(); leave.click(); await flushPromises()
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers')
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).not.toHaveBeenCalled()
await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises()
await wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]').setValue('另一次修改'); await flushPromises()
confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises()
expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认')
@@ -32,7 +32,7 @@ const columns: DataTableColumn[] = [
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
]
</script>
<template><div class="workbench-table"><DataTable :appearance="integrated ? 'integrated' : 'default'" :stripe="false" :results-visible="!failed" preference-key="fleet-capacity-inquiries" column-settings-mode="show" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="bookingNo" empty-text="当前范围暂无意向">
<template><div class="workbench-table"><DataTable :appearance="integrated ? 'integrated' : 'default'" :results-visible="!failed" preference-key="fleet-capacity-inquiries" column-settings-mode="show" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="bookingNo" empty-text="当前范围暂无意向">
<template v-if="$slots['toolbar-start']" #toolbar-start><slot name="toolbar-start" /></template>
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
@@ -11,11 +11,11 @@ const emit = defineEmits<{ back: [] }>()
<template>
<AdminPage class="admin-density admin-workbench" spacing="compact">
<DetailPageLayout aria-label="外配业务详情" class="workbench-panel is-flush is-page-surface">
<DetailPageLayout aria-label="外配任务详情" class="workbench-panel is-flush is-page-surface">
<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><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" />
@@ -10,7 +10,7 @@ export function outsourcedBusinessFixture(): OutsourcedBusinessDetail {
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: [] },
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: [], exceptions: [] }, 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: [],
}
}
@@ -8,7 +8,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
<template>
<AdminPage class="admin-density admin-workbench" spacing="compact">
<slot name="statistics" />
<ListPageLayout aria-label="外配业务任务列表">
<ListPageLayout aria-label="外配任务任务列表">
<template v-if="$slots.filters" #toolbar><slot name="filters" /></template>
<template v-if="$slots.state" #state><slot name="state" /></template>
<slot />
@@ -20,7 +20,7 @@ const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [valu
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) })
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))
@@ -20,7 +20,7 @@ const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [valu
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) })
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 = {} }
@@ -10,7 +10,7 @@ export function ownedBusinessFixture(): OwnedBusinessDetail {
actualPalletCount: 1, operations: { money: null, actualDistanceMiles: null, fuelConsumptionLitresPer100Km: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
photoSummary: { loadingCount: 0, fuelCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
},
dispatch: { dispatchNo: 'DS-OWN-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-OWN-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
dispatch: { dispatchNo: 'DS-OWN-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: [], exceptions: [] }, documents: { dispatchNo: 'DS-OWN-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: [],
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ export { listTripDispatchCandidates, prepareTrip, type TripPreparation, type Dis
export type { TripPreviousDispatch } from './tripTypes'
export { listVehicles } from './api/overseasMasterData'
export { listDrivers } from './api/drivers'
export { listDrivers, getDriver, downloadDriverLicense } from './api/drivers'
export type { DriverDetail } from './driverTypes'
export { normalizeTripDetail } from './api/trips'
export type { TripDetail, TripAssignment, TripResourceRelease } from './tripTypes'
@@ -10,7 +10,7 @@ export interface OutsourcedBusinessDestination { destinationKey: string; warehou
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 }
vehicle: { vehicleId: string | null; plate: string | null; ownership: 'OWNED' | '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[]
}
@@ -10,7 +10,7 @@ export interface OwnedBusinessDestination { destinationKey: string; warehouseCod
export interface OwnedBusinessRow {
dispatchNo: string; stage: OwnedBusinessStage; plannedDepartureAt: string; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
origin: { warehouseId: string; warehouseCode: string; warehouseName: string }
vehicle: { vehicleId: string; plate: string; ownership: 'OWNED' }; driver: { name: string | null; phone: string | null }
vehicle: { vehicleId: string | null; plate: string | null; ownership: 'OWNED' | 'DISPATCHED' }; driver: { name: string | null; phone: string | null }
destinations: OwnedBusinessDestination[]; actualPalletCount: number | null; operations: OwnedBusinessOperations; unitPrice: string | null; recordVersion: string; dispatchRecordVersion: string
photoSummary: { loadingCount: number; fuelCount: number }; actions: { edit: OwnedBusinessGate; upload: OwnedBusinessGate }; sourceIssues: string[]
}
@@ -53,11 +53,14 @@ async function load() {
try {
const value = await getCapacityOffer(String(route.params.offerNo))
let checked: CapacityInquiry | null = null
// 当前只读展示余位,旧登记深链不再打开登记窗口;原流程保留。
/*
if (typeof route.query?.inquiry === 'string' && /^[A-Z][A-Z0-9-]{1,63}$/.test(route.query.inquiry)) {
const returned = await getCapacityInquiry(route.query.inquiry)
if (returned.offerNo !== value.offer.offerNo) throw new Error('登记不属于当前发布')
if (current === sequence) { checked = returned; readonly.value = true; confirmMode.value = false; editorOpen.value = true }
} else if (editing.value && editorOpen.value) checked = value.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo)
*/
if (current === sequence) { resetting = true; detail.value = value; if (checked) editing.value = checked; page.value = value.inquiries.page; size.value = value.inquiries.size; queueMicrotask(() => { resetting = false }) }
}
catch (cause) { if (current === sequence) { detail.value = null; error.value = getApiErrorDetails(cause, '发布详情读取失败') } }
@@ -90,18 +93,29 @@ watch([page, size], loadInquiries)
onBeforeUnmount(() => { followupSequence++; sequence++; inquirySequence++ })
</script>
<template><AdminPage class="admin-density admin-workbench"><DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" aria-label="运力发布详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回散板需求</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回散板需求</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><!-- 当前暂不开放登记与拼车;原代码保留。
<el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button>
--></div></div></template>
<LoadErrorState v-if="error" title="发布详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-loading="loading" :aria-busy="loading"><template v-if="detail">
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>最晚送仓</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
<p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记客户货物:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记客户货物不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
<h2 class="capacity-section-title">分段运力</h2><div class="workbench-table"><DataTable :data="detail.offer.segments" :columns="columns" :stripe="false" row-key="fromIndex" empty-text="容量待调度核对"><template #planned="{ row }">{{ loadText(row.planned) }}</template><template #reserved="{ row }">{{ loadText(row.reserved) }}</template><template #remaining="{ row }">{{ detail.offer.capacityKnown ? loadText(row.remaining) : '待调度核对' }}</template></DataTable></div>
<h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的登记' }}</h2>
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" />
<!-- 当前暂不开放登记与拼车;原代码保留。
<template><p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记客户货物:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记客户货物不占位,调度确认后占位。' }}</p></template>
--><p v-if="detail.offer.blockReason" role="status">{{ detail.offer.blockReason }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
<h2 class="capacity-section-title">分段运力</h2><div class="workbench-table"><DataTable :data="detail.offer.segments" :columns="columns" row-key="fromIndex" empty-text="容量待调度核对"><template #planned="{ row }">{{ loadText(row.planned) }}</template><template #reserved="{ row }">{{ loadText(row.reserved) }}</template><template #remaining="{ row }">{{ detail.offer.capacityKnown ? loadText(row.remaining) : '待调度核对' }}</template></DataTable></div>
<!-- 当前暂不开放登记与拼车;原代码保留。
<template><h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的登记' }}</h2>
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template>
</LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" /></template>
-->
</template></div>
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
<!-- 当前暂不开放登记与拼车;原代码保留。
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
-->
<!-- 当前暂不开放登记与拼车;原代码保留。
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
-->
</DetailPageLayout></AdminPage></template>
<style scoped>.capacity-section-title{font-size:var(--admin-font-section);margin:var(--admin-space-6) 0 var(--admin-space-3)}</style>
@@ -26,7 +26,7 @@ function mount(component: object, props: Record<string, unknown> = {}): { wrappe
const pinia = createPinia()
const wrapper = shallowMount(component, { props, global: { plugins: [pinia], directives: { loading: () => undefined }, stubs: {
AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout,
DataTable: { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/><div v-for="(row, index) in data" :key="index"><slot name="loads" :row="row"/></div><slot/></div>' },
DataTable: { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total', 'columns'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="toolbar-end"/><slot name="filters"/><slot name="notices"/><div v-for="(row, index) in data" :key="index"><slot name="actions" :row="row"/><slot name="loads" :row="row"/><slot name="status" :row="row"/><slot name="contact" :row="row"/><slot name="destinations" :row="row"/></div><slot/></div>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], template: '<div/>' },
CapacityInquiryTable: { name: 'CapacityInquiryTable', props: ['rows', 'total', 'failed'], emits: ['action', 'update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/></div>' },
CapacityInquiryDialog: component === CapacityInquiryDialog ? false : true,
@@ -59,35 +59,46 @@ it.each(['DRAFT', 'CONFIRMED', 'RECEIVED', 'BOUND', 'CANCELLED'] as const)('labe
expect(detail.find('[label="已核实实托数"]').exists()).toBe(false)
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
})
it('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
it('keeps the pool read-only and sends one query after resetting from a later page', async () => {
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('待调度核对')
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).exists()).toBe(false)
for (const label of ['我的登记', '待匹配散板', '登记客户货物', '待我处理']) expect(button(wrapper, label)).toBeUndefined()
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3); expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
await button(wrapper, '我的登记').trigger('click'); await flushPromises()
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'MINE' })); expect(api.getCapacityOffer).not.toHaveBeenCalled()
const query = wrapper.findComponent({ name: 'QueryToolbar' }); query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
expect(api.listCapacityInquiries).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'MINE' }))
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3)
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
})
it('ignores an older page response after changing tabs and retains an explicit error state', async () => {
it('ignores an older pool query response and retains errors with retry', async () => {
let resolve!: (value: ReturnType<typeof pageOf<CapacityOffer>>) => void
vi.mocked(api.listCapacityOffers).mockReturnValue(new Promise(done => { resolve = done }))
const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
vi.mocked(api.listCapacityOffers).mockReturnValueOnce(new Promise(done => { resolve = done }))
const { wrapper } = mount(CapacityOffersView)
vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([offer], 7))
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
resolve(pageOf([offer], 900)); await flushPromises()
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('total')).toBe(1)
vi.mocked(api.listCapacityInquiries).mockRejectedValue(new Error('读取失败')); wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(7)
vi.mocked(api.listCapacityOffers).mockRejectedValueOnce(new Error('读取失败'))
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('failed')).toBe(true)
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
})
it('reads detail once, permits drafting while capacity is unknown, and never preloads customers or quotes', async () => {
it('reads only the offer detail and hides registration even with write permission', async () => {
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
expect(api.getCapacityOffer).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
await button(wrapper, '登记客户货物').trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(true)
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(button(wrapper, '登记客户货物')).toBeUndefined()
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).exists()).toBe(false)
expect(api.listOfferCustomers).not.toHaveBeenCalled(); expect(api.listOfferQuotes).not.toHaveBeenCalled()
})
it('preserves nullable estimates and retries the original immutable create request after an uncertain result', async () => {
vi.mocked(api.saveCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry, replayed: true })
const { wrapper, pinia } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises()
@@ -120,12 +131,13 @@ it('retains an uncertain request and route protection after closing and reopenin
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, inquiry.bookingNo, first)
})
it('keeps sales from confirming even if a stale row advertises the action', async () => {
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises()
expect(api.commandCapacityInquiry).not.toHaveBeenCalled(); expect(ElMessageBox.confirm).not.toHaveBeenCalled()
it('retains pool route details as the only cargo row action for sales', async () => {
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).exists()).toBe(false)
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
})
it('loads publishing settings only when explicitly opened, not with the trip page', async () => {
state.manage = true
const { wrapper } = mount(CapacityOfferSettingsDialog, { modelValue: false, trip: { tripNo: 'TR-1', recordVersion: 5 } as FleetTrip }); await flushPromises()
@@ -148,24 +160,22 @@ it('retries an uncertain combined save and confirmation with the original payloa
expect(wrapper.emitted('saved')).toHaveLength(1)
})
it('keeps the create button disabled with a reason after cutoff, while hiding it without write permission', async () => {
it('keeps capacity blockers visible without registration prompts after cutoff', async () => {
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: { ...offer, canCreateInquiry: false, blockReason: '交货截止已过或不符合当前发车时间' }, inquiries: pageOf([]) })
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
expect(button(wrapper, '登记客户货物').attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('无法登记客户货物:交货截止已过')
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
state.write = false
const readonly = mount(CapacityOfferDetailView); await flushPromises()
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记客户货物')).toBe(false)
expect(button(wrapper, '登记客户货物')).toBeUndefined()
expect(wrapper.text()).toContain('交货截止已过')
expect(wrapper.text()).not.toContain('无法登记客户货物')
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
})
it('defaults minimal managers to their TODO inquiries without loading offers, and explicitly switches to available offers', async () => {
it('defaults managers to the same read-only pool and preserves explicit historical queries', async () => {
state.manage = true
const { wrapper } = mount(CapacityOffersView); await flushPromises()
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'TODO' }))
expect(api.listCapacityOffers).not.toHaveBeenCalled()
expect(state.links.size).toBe(0)
await button(wrapper, '可拼车次').trigger('click'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
for (const label of ['我的登记', '待匹配散板', '待我处理', '登记客户货物']) expect(button(wrapper, label)).toBeUndefined()
const query = wrapper.findComponent({ name: 'QueryToolbar' })
query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'ALL' }))
@@ -188,21 +198,6 @@ it('requires an explicit choice for multiple destinations', async () => {
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
})
// Navigation must retain the exact bound business references and independent read permissions.
it.each([CapacityOffersView, CapacityOfferDetailView])('opens the bound trip separately from read-only dispatch detail', async component => {
const row = { ...inquiry, dispatchNo: 'DS-BOUND', status: 'BOUND' as const }
state.links = new Set(['platform:fleet:trip:list', 'platform:overseas-warehouse:dispatch:list'])
const { wrapper } = mount(component); await flushPromises()
const dialog = wrapper.findComponent(CapacityInquiryDialog)
dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'DS-BOUND' }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
dialog.vm.$emit('action', 'trip', row); await flushPromises()
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
state.links.clear(); state.push.mockClear()
dialog.vm.$emit('action', 'trip', row); dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
expect(state.push).not.toHaveBeenCalled()
})
it('shows follow-up destinations only with the matching read permission', async () => {
const row = { ...inquiry, dispatchNo: 'DS-BOUND', status: 'BOUND' as const }
state.links.add('platform:overseas-warehouse:dispatch:list')
@@ -318,69 +313,47 @@ it('shows the business identity and exposes the order link only with its list pe
await button(wrapper, 'ORDER-1').trigger('click')
expect(wrapper.emitted('action')?.[0]).toEqual(['business-order', row])
})
it('reopens a returned inquiry through an authorized fetch and refuses a different offer', async () => {
state.route.query = { inquiry: inquiry.bookingNo }
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
expect(editor.props('inquiry').bookingNo).toBe(inquiry.bookingNo)
wrapper.unmount()
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, offerNo: 'FO-OTHER' })
const rejected = mount(CapacityOfferDetailView).wrapper; await flushPromises()
expect(rejected.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
expect(rejected.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
})
it('opens refreshed server guidance after fulfillment succeeds without replaying a write', async () => {
it('ignores old inquiry deep links and renders only the offer summary', async () => {
state.manage = true
const { wrapper } = mount(CapacityOffersView); await flushPromises()
const updated = { ...inquiry, status: 'BOUND' as const, progress: { ...inquiry.progress, primaryAction: 'VIEW', message: '服务端下一待办', owner: '审核 / 仓库' } }
vi.mocked(api.getCapacityInquiry).mockResolvedValue(updated)
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'bind', inquiry); await flushPromises()
wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).vm.$emit('saved'); await flushPromises()
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
expect(editor.props('inquiry').progress.message).toBe('服务端下一待办')
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
it('fetches an off-page returned inquiry once per refresh and retries without falling back to stale data', async () => {
state.route.query = { inquiry: inquiry.bookingNo }
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer, inquiries: pageOf<CapacityInquiry>([], 30) })
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
expect(editor.props('modelValue')).toBe(true)
vi.mocked(api.getCapacityOffer).mockClear(); vi.mocked(api.getCapacityInquiry).mockClear()
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, recordVersion: 7 })
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
expect(api.getCapacityInquiry).toHaveBeenCalledExactlyOnceWith(inquiry.bookingNo)
expect(editor.props('inquiry').recordVersion).toBe(7)
vi.mocked(api.getCapacityInquiry).mockRejectedValueOnce(new Error('读取失败'))
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
const error = wrapper.findComponent({ name: 'LoadErrorState' })
expect(error.exists()).toBe(true)
vi.mocked(api.getCapacityOffer).mockClear(); vi.mocked(api.getCapacityInquiry).mockClear()
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, recordVersion: 8 })
error.vm.$emit('retry'); await flushPromises()
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
expect(api.getCapacityInquiry).toHaveBeenCalledExactlyOnceWith(inquiry.bookingNo)
expect(editor.props('inquiry').recordVersion).toBe(8)
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
expect(api.getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-1')
expect(api.getCapacityInquiry).not.toHaveBeenCalled()
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).exists()).toBe(false)
expect(wrapper.text()).toContain(offer.warehouseName)
expect(wrapper.text()).toContain(offer.contactPhone)
})
it('keeps transport, destination, remaining capacity and contact columns for manual follow-up', async () => {
const { wrapper } = mount(CapacityOffersView); await flushPromises()
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as { key: string }[]
for (const key of ['warehouseName', 'destinations', 'plannedDepartureAt', 'contactName']) expect(columns.some(column => column.key === key)).toBe(true)
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
it('shows customer looseboard demand in the same pool and passes the original to the selected trip inquiry', async () => {
const source = { orderNo: 'LB-SOURCE', customerId: '19', version: 2, origin: 'LA', destination: 'ONT8', productName: '货物', estimatedPalletCount: 3, packageCount: 12, totalWeightKg: 100, totalVolumeCbm: 2, currency: 'USD', originContactName: 'Alice', originContactPhone: '555', remark: null }
vi.mocked(api.listLooseboardDemands).mockResolvedValue(pageOf([source]))
it('refreshes and retries read-only offer detail without loading an old inquiry', async () => {
state.route.query = { inquiry: inquiry.bookingNo }
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
vi.mocked(api.getCapacityOffer).mockClear()
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
expect(api.getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-1')
expect(api.getCapacityInquiry).not.toHaveBeenCalled()
vi.mocked(api.getCapacityOffer).mockRejectedValueOnce(new Error('读取失败'))
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
const error = wrapper.findComponent({ name: 'LoadErrorState' })
expect(error.exists()).toBe(true)
error.vm.$emit('retry'); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
expect(api.getCapacityInquiry).not.toHaveBeenCalled()
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
})
it('does not load customer looseboard demands or expose matching from the pool', async () => {
state.manage = true
const { wrapper } = mount(CapacityOffersView); await flushPromises()
await button(wrapper, '待匹配散板').trigger('click'); await flushPromises()
expect(api.listLooseboardDemands).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined })
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([source])
expect(api.getCapacityOffer).not.toHaveBeenCalled()
// 用页面实际暴露的匹配动作选择来源,再沿已有登记入口选车。
const vm = wrapper.vm as unknown as { matchDemand: (row: typeof source) => void; create: (row: CapacityOffer) => Promise<void> }
vm.matchDemand(source); await flushPromises(); await vm.create(offer); await flushPromises()
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('sourceDemand')).toEqual(source)
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('offer').offerNo).toBe(offer.offerNo)
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
expect(button(wrapper, '匹配车次')).toBeUndefined()
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
})
@@ -20,8 +20,9 @@ import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
const router = useRouter(), access = useAccessPreviewStore()
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
const tab = ref<'offers' | 'inquiries' | 'demands'>(manage.value ? 'inquiries' : 'offers')
const filters = ref<QueryModel>(manage.value ? { scope: 'TODO' } : {}), applied = ref<QueryModel>({ ...filters.value })
// 当前只展示余位需求池;登记、原单匹配及履约模板直接隐藏,原实现保留。
const tab = ref<'offers' | 'inquiries' | 'demands'>('offers')
const filters = ref<QueryModel>({}), applied = ref<QueryModel>({ ...filters.value })
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([])
const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true)
@@ -56,8 +57,8 @@ let sequence = 0, editorSequence = 0
const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 },
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
{ key: 'canConfirm', label: '接单资格', type: 'select', placeholder: '全部接单资格', width: 150, options: [{ value: 'true', label: '可确认承运' }, { value: 'false', label: '暂不可确认' }] },
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '可登记意向', width: 180, options: [{ value: 'AVAILABLE', label: '可登记意向' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
{ key: 'canConfirm', label: '余位状态', type: 'select', placeholder: '全部状态', width: 150, options: [{ value: 'true', label: '余位已核对' }, { value: 'false', label: '待核对或不可承运' }] },
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '当前发布', width: 180, options: [{ value: 'AVAILABLE', label: '当前发布' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
] : tab.value === 'demands' ? [{ key: 'keyword', label: '需求关键词', type: 'input', placeholder: '原单号 / 起运地 / 目的地', width: 280 }] : [
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
@@ -68,7 +69,7 @@ const columns = computed<DataTableColumn[]>(() => [
{ key: 'warehouseName', label: '起运仓', minWidth: 155 }, { key: 'destinations', label: '目的站与可接余位', minWidth: 310, slotName: 'destinations', singleLine: false },
{ key: 'plannedDepartureAt', label: '发车 / 最晚送仓', minWidth: 195, slotName: 'times' },
...(manage.value ? [{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false }] : []),
{ key: 'status', label: '接单状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
{ key: 'status', label: '发布状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
])
const canCreate = computed(() => manage.value || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create'))
async function create(row: CapacityOffer) {
@@ -77,7 +78,7 @@ async function create(row: CapacityOffer) {
try { const value = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = value.offer; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '登记信息读取失败') }
}
function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (value !== 'offers' || tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try {
@@ -129,29 +130,40 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
</script>
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求">
<p v-if="selectedDemand && tab === 'offers'">正在匹配 {{ selectedDemand.orderNo }}:{{ selectedDemand.origin }} → {{ selectedDemand.destination }}。请选择合适车次,保存登记后尚不保位。<el-button link @click="selectedDemand = null">取消选择</el-button></p>
<div v-if="tab === 'demands'" class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" preference-key="fleet-looseboard-demands" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="demands" :columns="demandColumns" :loading="loading" :total="total" row-key="orderNo" empty-text="暂无待匹配散板需求">
<!-- 当前暂不开放登记与拼车;原代码保留。
<div v-if="tab === 'demands'" class="workbench-table is-page-surface"><DataTable appearance="integrated" preference-key="fleet-looseboard-demands" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="demands" :columns="demandColumns" :loading="loading" :total="total" row-key="orderNo" empty-text="暂无待匹配散板需求">
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="true" class="is-active" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="待匹配散板查询" :loading="loading" @search="search" /></template>
<template #notices><LoadErrorState v-if="error" title="需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" @click="matchDemand(row as LooseboardDemand)">匹配车次</el-button></div></template>
</DataTable></div>
<div v-if="tab === 'offers'" class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div></template><template #toolbar-end><el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
-->
<div v-if="tab === 'offers'" class="workbench-table is-page-surface"><DataTable appearance="integrated" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
<template #toolbar-start><h1>散板需求</h1><!-- 当前暂不开放登记与拼车;原代码保留。
<div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div>
--></template><template #toolbar-end><!-- 当前暂不开放登记与拼车;原代码保留。
<el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button>
--><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
<template #filters>
<div class="integrated-query-group">
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可拼车次查询" :loading="loading" @search="search" />
</div>
</template>
<template #notices><LoadErrorState v-if="error" title="运力列表读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" :disabled="!row.canCreateInquiry" @click="create(row as CapacityOffer)">登记客户货物</el-button><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><!-- 当前暂不开放登记与拼车;原代码保留。
<el-button class="density-action" v-if="canCreate" :disabled="!row.canCreateInquiry" @click="create(row as CapacityOffer)">登记客户货物</el-button>
--><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
<template #identity="{ row }"><div>{{ row.offerNo }}</div><div>{{ row.tripNo }}</div></template>
<template #destinations="{ row }"><div v-for="stop in (row as CapacityOffer).destinations" :key="stop.destinationKey">{{ stop.name }} · {{ row.capacityKnown ? loadText(stop.remaining) : '待调度核对' }}</div></template>
<template #times="{ row }"><div>发车 {{ offerTime(row.plannedDepartureAt) }}</div><div>最晚送仓 {{ offerTime(row.cutoffAt) }}</div></template>
<template #loads="{ row }"><span v-if="!row.segments.length">{{ row.capacityKnown ? '—' : '待调度核对' }}</span><div v-for="segment in (row as CapacityOffer).segments" :key="segment.fromIndex">{{ segment.fromName }} → {{ segment.toName }}<br>计划 {{ loadText(segment.planned) }}<br>预留 {{ loadText(segment.reserved) }}</div></template>
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}<div v-if="row.blockReason">{{ row.blockReason }}</div></template>
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }}<!-- 当前暂不开放登记与拼车;原代码保留。
<template> · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}</template>
--><div v-if="row.blockReason">{{ row.blockReason }}</div></template>
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
</DataTable></div>
<CapacityInquiryTable v-else-if="tab === 'inquiries'" integrated class="is-page-surface" v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
<!-- 当前暂不开放登记与拼车;原代码保留。
<CapacityInquiryTable v-if="tab === 'inquiries'" integrated class="is-page-surface" v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div><span v-if="applied.scope === 'TODO' || applied.scope === 'ALL'" class="workbench-badge">{{ applied.scope === 'TODO' ? '待我处理' : '全部登记' }}</span></template>
<template #filters>
<div class="integrated-query-group">
@@ -160,6 +172,11 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
</template>
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
</CapacityInquiryTable>
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :source-demand="editing ? null : selectedDemand" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
-->
<!-- 当前暂不开放登记与拼车;原代码保留。
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
-->
<!-- 当前暂不开放登记与拼车;原代码保留。
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :source-demand="editing ? null : selectedDemand" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
-->
</ListPageLayout></AdminPage></template>
@@ -113,7 +113,7 @@ onBeforeUnmount(() => { request++ })
<template>
<AdminPage class="admin-density admin-workbench">
<ListPageLayout aria-label="司机管理">
<div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="overseas-drivers-table" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="id" empty-text="暂无符合条件的司机">
<div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="true" preference-key="overseas-drivers-table" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="id" empty-text="暂无符合条件的司机">
<template #toolbar-start><h1>司机管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="司机查询" preference-key="overseas-drivers-query" :loading="loading" @search="search" /></template>
<template #notices>
@@ -50,7 +50,7 @@ watch([page, pageSize], load); onMounted(load)
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="month" preference-key="overseas-exchange-rates-table" empty-text="暂无月度汇率">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="month" preference-key="overseas-exchange-rates-table" empty-text="暂无月度汇率">
<template #toolbar-start><h1>汇率管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" :loading="loading" @search="search" /></template>
<template #notices><LoadErrorState v-if="error" title="月度汇率加载失败" :message="error.message" @retry="load" /></template>
@@ -36,7 +36,7 @@ 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, '外配业务详情读取失败') } }
catch (cause) { if (active && request === generation) { detail.value = null; error.value = getApiErrorDetails(cause, '外配任务详情读取失败') } }
finally { if (request === generation) loading.value = false }
}
async function loadHistory() {
@@ -66,7 +66,7 @@ onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previ
<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 #state><LoadErrorState v-if="error" title="外配任务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
<template #task><dl v-if="detail" class="density-fields outsourced-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="outsourced-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><div class="workbench-table"><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable></div><section class="outsourced-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><div class="workbench-table"><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ outsourcedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车承运金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></div></template>
@@ -62,10 +62,10 @@ async function load() {
const request = ++generation
loading.value = true; error.value = null
try {
if (!access.can(permission.outsourcedBusinessList)) throw new Error('没有外配业务查看权限')
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, '外配业务读取失败') } }
} catch (cause) { if (active && request === generation) { result.value = null; error.value = getApiErrorDetails(cause, '外配任务读取失败') } }
finally { if (request === generation) loading.value = false }
}
function search() {
@@ -102,11 +102,11 @@ onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
<template>
<OutsourcedBusinessListLayout>
<template #statistics><section class="outsourced-business-statistics workbench-panel is-flush" 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>
<div class="workbench-table is-page-surface"><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>外配业务<InlineHelp title="查询范围说明">日期和时间按浏览器本地时区显示;统计覆盖同一完整筛选范围。</InlineHelp></h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="外配业务筛选" preference-key="outsourced-business-query" :loading="loading" @search="search" /></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 #statistics><section class="outsourced-business-statistics workbench-panel is-flush" 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>
<div class="workbench-table is-page-surface"><DataTable appearance="integrated" 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>外配任务<InlineHelp title="查询范围说明">仅统计实际接入拼车货物的已装车排车,与车辆归属无关;日期和时间按浏览器本地时区显示,统计覆盖同一完整筛选范围。</InlineHelp></h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="外配任务筛选" preference-key="outsourced-business-query" :loading="loading" @search="search" /></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>
@@ -30,10 +30,21 @@ beforeEach(() => {
})
describe('outsourced business views', () => {
it('keeps an owned vehicle with pooled cargo in the outsourced task list', async () => {
const detail = outsourcedBusinessFixture()
detail.row.vehicle.ownership = 'OWNED'
calls.list.mockResolvedValue({ records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } })
const wrapper = mount(OutsourcedBusinessView, { global }); await flushPromises()
expect(wrapper.findComponent(Table).props('data')[0].vehicle.ownership).toBe('OWNED')
expect(wrapper.findComponent(Table).props('total')).toBe(1)
expect(wrapper.text()).toContain('外配任务')
expect(wrapper.text()).toContain('登记运营信息')
wrapper.unmount()
})
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.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)
@@ -46,7 +57,7 @@ describe('outsourced business views', () => {
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')
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 () => {
@@ -58,7 +58,7 @@ watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions(
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="海外仓报价列表">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-quotes-table" row-key="id" empty-text="暂无海外仓报价">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-quotes-table" row-key="id" empty-text="暂无海外仓报价">
<template #toolbar-start><h1>海外仓报价</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" :loading="loading" @search="search" /></template>
<template #notices><LoadErrorState v-if="error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></template>
@@ -37,11 +37,17 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
{ 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' },
{ key: 'destinations', label: '目的仓', minWidth: 150, slotName: 'destinations' },
{ key: 'deliveryWarehouses', label: '派送仓库', minWidth: 150, slotName: 'destinations' },
{ key: 'fuel', label: '油耗(L/100km)', minWidth: 145, slotName: 'fuel' }, { 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' },
{ key: 'money', label: '总价', minWidth: 170, align: 'right', slotName: 'money' },
{ key: 'unitPrice', label: '单价(/托)', minWidth: 160, align: 'right', slotName: 'unitPrice' },
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' },
{ key: 'latestLoadingCompletedAt', label: '最晚装车完成时间', minWidth: 180, slotName: 'latestLoaded' },
{ key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
{ key: 'scheduledArrival', label: '预约到达时间', minWidth: 190, slotName: 'scheduledArrival' },
{ key: 'actualArrival', label: '实际到达时间', minWidth: 190, slotName: 'actualArrival' },
{ 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') : '—' }
async function load() {
@@ -90,16 +96,20 @@ onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
<template>
<OwnedBusinessListLayout>
<template #statistics><section class="owned-business-statistics workbench-panel is-flush" 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>
<div class="workbench-table is-page-surface"><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="owned-business-table" empty-text="当前筛选范围暂无已装车的自有车辆排车单">
<template #toolbar-start><h1>自有业务<InlineHelp title="查询范围说明">日期和时间按浏览器本地时区显示;统计覆盖同一完整筛选范围。</InlineHelp></h1></template>
<div class="workbench-table is-page-surface"><DataTable appearance="integrated" 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="owned-business-table" empty-text="当前筛选范围暂无已装车且未接入拼车货物的排车单">
<template #toolbar-start><h1>自有业务<InlineHelp title="查询范围说明">仅统计未实际接入拼车货物的已装车排车,与车辆归属无关;日期和时间按浏览器本地时区显示,统计覆盖同一完整筛选范围。</InlineHelp></h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自有业务筛选" preference-key="owned-business-query" :loading="loading" @search="search" /></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 }"><div class="density-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></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="owned-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="owned-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 #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination">{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</div><span v-if="!row.destinations.length">—</span></template>
<template #scheduledArrival="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ time(appointment.scheduledAt) }}</div><span v-if="!destination.appointments.length">—</span></div><span v-if="!row.destinations.length">—</span></template>
<template #actualArrival="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div>{{ time(destination.actualArrivedAt) }}</div></div><span v-if="!row.destinations.length">—</span></template>
<template #fuel="{ row }">{{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }}</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 #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}</template><span v-else>未登记</span></template>
<template #unitPrice="{ row }"><template v-if="row.operations.money && row.unitPrice != null">{{ row.operations.money.currency }} {{ row.unitPrice }}</template><span v-else>{{ row.operations.money ? '—' : '未登记' }}</span></template>
<template #latestLoaded>—</template>
<template #plannedDeparture="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #loaded="{ row }">{{ time(row.loadingCompletedAt) }}</template><template #stage="{ row }">{{ ownedBusinessStages[row.stage as OwnedBusinessStage] }}</template>
</DataTable></div>
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
@@ -17,7 +17,7 @@ const Button = defineComponent({ props: ['disabled'], template: '<button :disabl
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 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(([name,slot]) => h('section', { 'data-column': name }, slot?.({row})))))]) } })
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: () => {} } }
@@ -30,6 +30,37 @@ beforeEach(() => {
})
describe('owned business views', () => {
it('keeps split prices authoritative and arrival times associated with each destination', async () => {
const detail = ownedBusinessFixture()
detail.row.operations.money = { currency: 'USD', amount: '0.00' }; detail.row.unitPrice = '0.00'
detail.row.destinations[0]!.appointments[0]!.scheduledAt = '2026-10-21T09:00:00Z'
detail.row.destinations.push({ destinationKey: 'destination-2', warehouseCode: 'FBA-2', warehouseName: null, address: null, appointments: [], actualArrivedAt: '2026-10-21T10:00:00Z' })
calls.list.mockResolvedValue({ records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } })
const wrapper = mount(OwnedBusinessView, { global }); await flushPromises()
const columns = wrapper.findComponent(Table).props('columns') as { key?: string; label: string; slotName?: string }[]
expect(columns.map(column => column.label)).toEqual(expect.arrayContaining(['目的仓', '派送仓库', '总价', '单价(/托)', '最晚装车完成时间', '预约到达时间', '实际到达时间', '状态']))
expect(columns.find(column => column.key === 'deliveryWarehouses')!.slotName).toBe('destinations')
expect(wrapper.get('[data-column="money"]').text()).toBe('USD 0.00')
expect(wrapper.get('[data-column="unitPrice"]').text()).toBe('USD 0.00')
expect(wrapper.get('[data-column="latestLoaded"]').text()).toBe('—')
const scheduled = wrapper.findAll('[data-column="scheduledArrival"] .owned-business-destination')
expect(scheduled.map(cell => cell.text())).toEqual(['FBA-1' + dayjs('2026-10-21T09:00:00Z').format('YYYY-MM-DD HH:mm'), 'FBA-2—'])
const actual = wrapper.findAll('[data-column="actualArrival"] .owned-business-destination')
expect(actual.map(cell => cell.text())).toEqual(['FBA-1—', 'FBA-2' + dayjs('2026-10-21T10:00:00Z').format('YYYY-MM-DD HH:mm')])
expect(calls.detail).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps a non-pool external vehicle returned by the server without filtering or requiring a vehicle id', async () => {
const detail = ownedBusinessFixture()
detail.row.vehicle = { vehicleId: null, plate: 'EXTERNAL-NO-POOL', ownership: 'DISPATCHED' }
calls.list.mockResolvedValue({ records: [detail.row], page: 1, size: 20, total: 1, summary: { dispatchCount: 1, afterLoadingCount: 1, deliveringCount: 0, deliveredCount: 0 } })
const wrapper = mount(OwnedBusinessView, { global }); await flushPromises()
expect(wrapper.findComponent(Table).props('data')[0].vehicle).toEqual(detail.row.vehicle)
expect(wrapper.findComponent(Table).props('total')).toBe(1)
expect(wrapper.text()).toContain('登记运营信息')
wrapper.unmount()
})
it('uses one list request for all statistics and never hydrates each row', async () => {
const wrapper = mount(OwnedBusinessView,{global}); await flushPromises()
expect(calls.list).toHaveBeenCalledTimes(1); expect(calls.detail).not.toHaveBeenCalled(); expect(calls.content).not.toHaveBeenCalled()
@@ -93,7 +93,7 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
<template #state>
<LoadErrorState v-if="error" title="查询或导出失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</template>
<div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-operations-table" v-model:page="page" v-model:page-size="size" :data="result?.page.records || []" :columns="columns" :total="result?.page.total || 0" :loading="loading" row-key="tripNo" empty-text="该范围暂无实际出车记录">
<div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="true" preference-key="fleet-operations-table" v-model:page="page" v-model:page-size="size" :data="result?.page.records || []" :columns="columns" :total="result?.page.total || 0" :loading="loading" row-key="tripNo" empty-text="该范围暂无实际出车记录">
<template #toolbar-start><h1>出车记录</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="运营记录查询" preference-key="fleet-operations-query" :loading="loading" @search="search" @reset="resetFilters" /></template>
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:operations:export') && access.can('platform:download:list')" :loading="exporting" @click="download">导出</el-button></template>
@@ -52,7 +52,7 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="车次管理">
<el-tabs v-model="workTab" class="density-element-tabs"><el-tab-pane label="车次管理" name="trips" /><el-tab-pane label="待取货" name="pickups" /></el-tabs>
<PickupDemandPanel v-if="pickupVisited" v-show="workTab === 'pickups'" />
<div class="workbench-table is-page-surface"><DataTable v-show="workTab === 'trips'" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
<div class="workbench-table is-page-surface"><DataTable v-show="workTab === 'trips'" appearance="integrated" column-settings-mode="show" :results-visible="true" preference-key="fleet-trips-table" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>车次管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车次查询" preference-key="fleet-trips-query" :loading="loading" @search="search" @reset="search" /></template>
<template #notices><LoadErrorState v-if="error" title="车次加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
@@ -39,7 +39,7 @@ function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=ro
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();await submitExport('VEHICLE_LIST', '车辆', q)}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
watch([page,pageSize],load);onMounted(load)
</script>
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="车辆列表"><section class="vehicle-panel"><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆">
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="车辆列表"><section class="vehicle-panel"><div class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆">
<template #toolbar-start><div class="status-tabs density-status-tabs workbench-list-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])"><span class="density-tab-icon" aria-hidden="true">{{ tab[1] === 'OWNED' ? '🚚' : '🚛' }}</span>{{tab[0]}}</button></div></template>
<template #filters><div class="query-panel integrated-query-group"><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template>
<template #notices><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template>
+5 -5
View File
@@ -6,13 +6,13 @@ export const overseasMasterDataRoutes = [
path: 'overseas-master-data/capacity-offers',
name: 'overseas-master-data-capacity-offers',
component: () => import('./pages/CapacityOffersView.vue'),
meta: { title: '散板需求', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
meta: { title: '散板需求', description: '查看车次余位与调度联系方式', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
},
{
path: 'overseas-master-data/capacity-offers/:offerNo',
name: 'overseas-master-data-capacity-offer-detail',
component: () => import('./pages/CapacityOfferDetailView.vue'),
meta: { title: '散板需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
meta: { title: '散板需求详情', description: '查看车次分段余位与调度联系方式', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
},
{
path: 'overseas-master-data/fleet-quotes',
@@ -30,7 +30,7 @@ export const overseasMasterDataRoutes = [
path: 'overseas-master-data/owned-business',
name: 'overseas-master-data-owned-business',
component: () => import('./pages/OwnedBusinessView.vue'),
meta: { title: '自有业务', description: '查看自有车辆装车后的排车与运营记录', permission: permission.ownedBusinessList, showPageHeading: false },
meta: { title: '自有业务', description: '查看未接入拼车货物的已装车排车与运营记录', permission: permission.ownedBusinessList, showPageHeading: false },
},
{
path: 'overseas-master-data/owned-business/:dispatchNo',
@@ -42,13 +42,13 @@ export const overseasMasterDataRoutes = [
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 },
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 },
meta: { title: '外配任务详情', description: '查看原排车及外配承运登记', permission: permission.outsourcedBusinessList, showPageHeading: false },
},
{
path: 'overseas-master-data/trips',
+27 -15
View File
@@ -22,11 +22,12 @@
| 退货预报审核、退货驳回 | `pages/LastMileOrdersView.vue`、`api/lastMileOrders.ts` 的 `reviewReturnOrder` | 尾程订单内 `RETURN_RELABEL + PENDING_REVIEW` 行进入审核,来源退货号取 `sourceOrderNo`;请求 `/platform/v1/last-leg/return-orders/{returnNo}/review` 携带 `X-Customer-Id`,结论 `APPROVE/REJECT`,驳回必填原因。按钮权限入口为 `permissions.ts` 的 `returnOrderReview`;服务端归 `returnreceipt/`,通过后才能签收,不是仓储转运审核或换标完工确认 |
| 换标申请审核、待审核按钮与驳回原因 | `pages/RelabelOrdersView.vue`、`pages/RelabelOrderDetailView.vue`、`api/relabelOrders.ts` | 列表审核动作仅在 `PENDING_REVIEW` 显示,动作选择为审核通过/已驳回,驳回原因必填;审核后不再显示审核按钮。回归见 `pages/RelabelOrdersView.test.ts`、`pages/RelabelOrderDetailView.test.ts`;PDA 完工、客户确认与申请审核分别定位,不混用状态入口 |
| 排车资源、草稿容器与实际容量 | `components/DispatchResourceFields.vue`、`components/SmartDispatchDialog.vue`、`dispatchContracts.ts` | 车辆/司机分别选择已有档案或临时外配,容量取真实档案或本趟填写的托位、千克及立方米,不按车型名称推算。草稿容器容量不代替车辆实测或装载校验;取证、审核前补齐规则见当前排车正式契约 |
| 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写。新增暂扣只在 `INBOUND_COMPLETED` 开放,解除暂扣保持可用;暂扣不阻断入库计划,只限制后续排车和出库。人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
| 仓储转运审核、审核通过 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `permissions.ts` 的 `warehouseTransferApprove`;仅 `WAREHOUSE_TRANSFER + PENDING_REVIEW` 行开放。当前弹框仅提交 `APPROVE`,携带该行客户和转运单号;无驳回按钮 |
| 转运驳回、REJECT 接口能力 | `api/warehouseTransfers.ts` 的 `reviewWarehouseTransfer` | API 支持 `APPROVE/REJECT` 与 `remark`;不据此推定当前弹框提供驳回操作,界面能力沿上一行组件核对 |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | `api/lastMileOrders.ts`;[入仓计划页面约束](../../../../../docs/业务实现边界.md#admin-入仓计划页面约束),仅计划/状态/请求行为变化时查 |
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询、整车草稿、审核及派送安排 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型,状态须覆盖 `WAIT_PICK/待拣货`、`PICKING/拣货中`、`PICKED/已拣货`、`WAIT_OUTBOUND/待出库`、`OUTBOUNDED/已出库` 和 `CANCELLED/已取消`;仅未开始的 `WAIT_PICK/WAIT_OUTBOUND` 允许取消。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
| 排车查询、整车草稿、审核与装车资料 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/SmartDispatchExistingCargo.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;[出库与派送页面约束](../../../../../docs/业务实现边界.md#admin-出库与派送页面约束),候选选择另查下表 |
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
| 海外库位维护与状态 | `pages/LocationsView.vue`、`api/locations.ts` | 按海外仓分页或多仓查询库位,支持库区/库位编码、类型和状态筛选;`NORMAL` 库位可编辑、停用或删除,`A0/SHARED_OVERFLOW`、`VIRTUAL` 由服务端事实保护,不能按普通库位修改。接口经 `/platform/v1/warehouse-admin/{warehouseId}/locations`,权限沿 `permissions.ts` 的库位维护/删除入口 |
@@ -34,22 +35,33 @@
## 履约硬边界
- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。五阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车与实际发车分别确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、原幂等键和完整载荷;未知结果保留原请求核对,成功后另读当前事实。
- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。五个作业阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车中可同步上传五张装车照片,确认装车完成且资料齐全自动生成派送单并进入派送中,资料未齐时留在装车后,无独立安排派送或发车确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、原幂等键和完整载荷;未知结果保留原请求核对,成功后另读当前事实。
- 智能排车来源支持 `RELABEL`(待出库换标单),按服务端候选的`selection.relabelNo/sourceVersion`提交,不用展示订单号或原入仓号替代换标身份;沿用真实库存、托盘与箱唛,不生成新入库单。客户和仓库归属由服务端核验,不在前端拼接客户子排车。
- 托盘管理单张打印:`pages/PalletsView.vue` 每行“打印”经 `api/pallets.ts` 的 `getPalletLabelPdf` 下载当前托盘一页 PDF;使用该行仓库 ID 与托盘 ID,权限复用托盘列表。Backend `warehouse-overseas-service` 的 `PalletLabelPrintService` 读取公共托盘;有 FBA 仓码的托盘与国内统一使用 Print `FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm,FBA 仓码、完整托盘号 Code 128、托盘号文字),其他托盘使用 `INBOUND_MARK_V1`。不在前端生成标签、改码或维护第二套模板。
- 托盘标签从 `pages/PalletsView.vue` → `api/pallets.ts` 的 `getPalletLabelPdf` 下载服务端 PDF,传该行仓库与托盘 ID,复用列表权限;不在前端生成标签或维护模板、尺寸、编码。模板/接口变化才查 [Print 指引](../../../../../baiyue-firstleg/print/AGENTS.md)的托盘入口及[标签维护](../../../../../docs/打印标签维护/README.md),纯按钮文案不追加打印资料。
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。整车派送安排在装车完成后、实际发车前进行;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。出库/派送建单时机按[排车现行契约](../../../../../docs/海外仓业务/排车列表统一作业改造契约.md)执行;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
## 作业细则按需入口
仅接口、状态、权限或业务行为变化时读命中段;纯文案、样式沿当前代码,不逐条读取。
| 任务 | 规则正文 |
|---|---|
| 排车审核附件 | [对应条款](../../../../../docs/业务实现边界.md#admin-排车审核附件) |
| 一件代发出库候选 | [对应条款](../../../../../docs/业务实现边界.md#admin-一件代发出库候选) |
| 货物计划与装车命令 | [对应条款](../../../../../docs/业务实现边界.md#admin-货物计划与装车命令) |
| 取消与费用展示 | [对应条款](../../../../../docs/业务实现边界.md#admin-取消与费用展示) |
| 业务导出 | [对应条款](../../../../../docs/业务实现边界.md#admin-业务导出) |
| 装车照片与资料入口 | [对应条款](../../../../../docs/业务实现边界.md#admin-装车照片与资料入口) |
## 验证
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
- 派送安排弹框通过`getDispatchArrangement`读取整车分组、实际货物、外接报价及原费用依据,`DispatchArrangeFields.vue`按真实客户目的地组提交;不逐行查费用,不按金额推断覆盖,不把原费用再次记入新费用行。移货必须使用`GET items`返回的真实dispatchItemId及逐项remove Gate;路线仅重排`GET route`返回的既有全部目的地,包含外接点,不能新增或猜测目的地。附件沿`DispatchEvidenceFiles.vue`的本车鉴权上传/读取,上传与主单引用保存分开。
- 出库取消提示先解除车次关联,取消后仍须在排车列表释放原排车占用;不提示库存已自动回滚。派送详情费用读取reviewStatus/sourceStatus/voidStatus,同一详情响应展示待财务确认、待财务处理、确认作废,不逐行查询,也不把取消成功视为费用作废。货柜费用面板直接消费同一费用来源接口,待确认时同样禁止调整和重复作废,沿用原页面/弹框承载。
- 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
- 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。
- 排车调度/理货审核分区仅显示审核、详情;车辆图片、司机照片和驾驶证照片在维护车辆司机时通过既有本车附件接口上传,随资源编辑命令保存,司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。
- 尾程订单、库存余额与进销存记录导出统一使用 `../downloads/` 的 `useExportTask` 创建 XLSX 任务;保留当前筛选和业务导出权限,并要求 `platform:download:list`。Lastleg 内部 Provider 契约为 `lastleg-exports-v1.yaml`,业务页面不再请求同步 CSV。
- 纯文案/纯展示只核对差异;业务逻辑变化选择直接对应测试,不因文档引用某测试就自动执行。
- 转运审核组件/请求逻辑变化:定向选择 `components/WarehouseTransferReviewDialog.test.ts`、`api/warehouseTransfers.test.ts`;入口、权限或状态变化再加 `pages/prototypeContract.test.ts`。
- 出库候选/选择变化选 `pages/OutboundsView.selection.test.ts`;状态/取消变化选 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。排车选择受影响组件或详情页测试,入口变化再加原型合同。
- 命令从应用根按 `pnpm test src/modules/overseas-warehouse/<目标测试路径>` 运行,并经工作区限流入口;类型关系、模板绑定或模块引用受影响时按应用根选择最小类型检查,不自动升级全量。
- 指引/索引修改后从后端根运行一次 `./scripts/agents-index-contract.ps1`;不以离线结果代替真实 HTTP 或人工截图验收。
@@ -1,5 +1,146 @@
# 海外仓原型核对矩阵
## 2026-10-21 智能排车区域入口替代仓库搜索
用户明确删除查询区海外仓选择,仅保留“全部/洛杉矶/纽约”。界面移除顶部仓库框,区域筛选直接提交服务端;候选 warehouseId 不传时跨仓数据库分页,首托确定草稿仓并继续同仓安排,已有排车/预约仍显式指定仓库。空单模式在排车设置中保留起运仓,避免无来源时猜测仓库。正式业务边界维护于《排车列表统一作业改造契约》的区域入口确认段。
自动检查:前端7文件134项中旧区域断言调整后,相关3文件57项复验通过,其余77项结果复用;排车样式合同通过,应用类型检查通过。后端3个精确测试类10项通过,覆盖区域HTTP参数、服务校验、Mapper统计/分页范围及OpenAPI;未运行全量回归。未部署、未访问测试环境数据;需Admin与Lastleg同步发布,视觉与真实HTTP待验收。
## 2026-10-21 智能排车全屏空间利用
依据用户截图 `codex-clipboard-df3ac2ac-0fa1-4e30-98e6-e508074c0d66.png` 与 `codex-clipboard-4833c2b8-d6a3-4e34-bb5c-0099a3fc8fcf.png`:区域按钮并入待排订单标题行,移除中栏订单号 65% 宽度上限;全屏新建选货模式沿弹框可用空间分配三栏高度,待排表格取消原 390px/40vh 上限,分页位于下方,三栏各自按内容滚动。退出全屏、空单与已有排车沿用各自布局。计划时间及备注迁移至列表尚处于用户要求的梳理阶段,本轮未删除其填写区;现有列表“维护车辆信息”已支持三字段,计划发车审核前补齐。
自动检查:受影响布局、选货及排车样式合同共 41 项通过,69 项无关样式用例未执行,未跑全量;先前样式合同整文件运行另发现 DispatchesView 的取货车辆弹框缺少密度类,两项失败与本轮改动无关,未扩散修改。差异检查通过;全屏实际高度及滚动待人工截图确认。
## 2026-10-21 智能排车查询与三栏统一对齐
用户确认保留蓝白配色,按最初第二张原型恢复区块与控件位置;最新截图 `codex-clipboard-40f3fcdc-8093-4fd1-8b0e-bd1abb147b3e.png` 确认已有排车查询散落、三栏错位,上轮不构成视觉验收通过。
| 页面/操作 | 依据与问题 | 修复目标 | 验收 |
|---|---|---|---|
| 新建排车/查询与设置 | 原型 `modal-dispatch-schedule` 查询在三栏之前,现状设置占据顶部 | 起运仓进入首个查询面板;建单方式、草稿恢复按需展开;保留区域按钮与现行真实字段 | 定向179项通过;待人工截图确认 |
| 两模式/选订单、托盘与装车 | 截图中中栏固定偏移、已有货物栏替换整个布局 | 三栏共享标题行和内容行,右栏插槽只更换标题与内容,不再自行定位;长标题换行一起撑高标题行 | 定向179项通过;待人工截图确认 |
| 已有排车/查询、加货与移除 | 两行裸露查询,货物查询被重复单号推偏 | 同一个查询面板、统一标签和输入宽度;单号及仓库移入右栏标题;保留即时保存与移除确认 | 定向179项通过;待人工截图确认 |
| 新建排车/搜索标签与分页 | 最新截图 `codex-clipboard-06b92e79-d28a-4eec-8d63-f0f856d1f380.png` 三栏已对齐,但起运仓换行、分页提示多余;用户要求仅做样式定向检查 | 按最新确认删除起运仓可见标签并默认首个可用仓;删除分页提示及对应说明入口;分页与订单表左边缘对齐 | 后续合并定向161项通过;待截图确认 |
本轮追加:用户要求打开时默认第一个起运仓并删除标签;无已有选择、草稿或订单指定仓时采用目录首项并读取候选,空目录保持空值。已有排车按新截图 `codex-clipboard-43654ee8-6c76-4141-bca5-4d108d45b828.png` 合并为可搜索单号的选择器,分页进入下拉底部、货物查询同排,删除重复标签和介绍文字。沿用当前查询API、明确选择后读取清单,不自动选择已有排车。6个文件161项定向回归通过(18.32秒),覆盖默认仓自动加载、空目录、草稿仓保留、下拉搜索分页及原有加货/移除;本轮不跑全量测试。应用类型检查与差异检查通过,真实样式待截图确认。
加入货物反馈修复(截图 `codex-clipboard-6ef9c642-96b3-4c97-a234-dbfefc5821f8.png`):正常请求暂存也触发待核对警告、重试按钮,候选刷新清空列表且触发整窗遮罩。目标为仅在请求空闲且仍待确认时展示恢复提示;同仓刷新保留不可操作的旧列表直至原子替换,失败或换仓清空,刷新期间拒绝使用旧来源;整窗遮罩只用于初始化。共享候选读取还有预约排车消费者,追加该消费者定向验证,不改其业务逻辑。6个文件110项定向检查通过,包含慢加入、刷新期间列表保留且禁止操作、刷新失败清空、原请求重试和空单创建;未运行全量测试。应用类型检查、差异检查通过;视觉效果待人工截图确认。
托盘移动与删除反馈(用户截图 `codex-clipboard-3317655c-a74e-4775-b0ff-65aa46ccc4be.png`、`codex-clipboard-2459ecf1-8447-4835-a571-486fd540289a.png`,参考原型 `app.js` 的 `renderDispatchPalletDetails/renderDispatchTrucks`):中栏保留托盘号与箱数,拖拽或点击编号移入选中车辆;按用户“直接移过去”要求,服务端确认加入后从中栏移走,右栏“×”移回,删除每行加入/已加入和目的仓文字。整托关联来源仍可展开,阻断保留“暂不可排车”及可展开原因,候选与整组重复原因去重。非空车辆隐藏删除入口且命令层拦截;首次明确拒绝解除待核对锁,历史已标记明确拒绝的浏览器记录重开时清除锁并恢复原草稿;未知结果及未知后再次拒绝继续保留原请求。自动检查:6文件107项定向测试通过;补充恢复断言后3文件102项(含样式合同)通过,未运行全量。应用类型检查被其他在途模块阻断:ContainersView.vue 行类型、OwnedBusinessFixtures.ts/OutsourcedBusinessFixtures.ts 缺少 exceptions;本轮文件未报告类型错误。差异检查通过,未部署;视觉效果待人工截图确认。换标单轮次/实托/库存来源校验仅确认代码来源,未核实该单据实际数据,不宣称业务阻断已解决。
自动检查:7个文件179项定向回归通过;`pnpm build`(含应用类型检查)通过。标题共享网格行,三栏内容共同起始,不再使用中栏固定顶部偏移;已有排车自定义内容仍留在同一网格列内。本轮修改的4个测试文件及其组件依赖定向类型检查通过;新增查询断言修正后已有排车23项再次通过。额外全仓`typecheck:all`触及统一入口2GB堆上限,未提高资源限制;扩大到未改动的规划测试时存在既有`VueWrapper`类型报错,未据此宣称全仓测试代码类型检查通过。全量`pnpm test`通过:470文件、4336项(14:34启动,591.74秒)。验证期间三个生产组件哈希未变;测试断言查询方法的最后修正已另跑23项通过。差异空白检查通过。只调整本模块模板和布局,保留蓝白配色及现行排车契约;未部署,视觉验收待1920×1080、100%人工截图确认。
## 2026-10-21 智能排车截图复核与样式纠偏
依据用户截图 `codex-clipboard-03c13f2c-5e0d-46da-9f0c-10f2be9fcdad.png`:上轮仅通过自动检查,真实截图确认仍未还原,不能据上轮结论标记视觉完成。对照原型 `overlays.html:1247-1300` 的完整样式以及10月18日组件末尾覆盖规则,本轮纠正标题留白、固定空白高度和托盘明细缺表头。
| 操作 | 截图问题 | 本轮目标 | 验收 |
|---|---|---|---|
| 打开/空态/切换区域 | 92vh外框、74px标题区和固定520px表区叠加,空数据也出现滚动;区域仍为下划线 | 外框随内容增长且保留全屏;三栏标题按控件高度对齐;订单区只限制最大高度;区域恢复独立按钮 | 排车定向108项通过;人工截图待确认 |
| 选择订单/托盘明细 | 标题浮在边框外,缺少托盘号和箱数表头 | 恢复完整明细表格及表内空态,保留整托说明、按钮加货与拖放;中栏卡片与订单表顶部对齐 | 排车定向108项通过;人工截图待确认 |
| 装车分组 | 右栏过窄,标题、两个说明图标、容量与按钮挤在一行 | 按原型44/22/34三栏比例,容量与添加操作独立成组,只保留一处说明入口;当前蓝白主题和业务契约继续保留 | 排车定向108项通过;人工截图待确认 |
本轮定向7文件178项:排车6文件108项全部通过;共享样式70项中68项通过,2项失败为预约消费者的`<FbaAppointmentFields continuous`属性顺序断言,未改动这些预约实现。`pnpm build`在预约模块`WorkCreateAppointmentForm.vue:63`被`SourceReference.destinationKey`的`null`与`PlanningSourceLookup`不兼容阻断;不据此宣称构建通过,也不扩大范围修复其他在途业务。本轮全量`pnpm test`完成:468文件中465通过、3失败;4324项中4305通过、19失败,另3个未处理错误。失败集中于预约属性顺序断言、预约组件Mock缺少`AppointmentConfirmationCargo`/`canLeave`及其卸载连带错误,未修改对应在途预约代码。排车组件输入在检查期间未变,差异空白检查通过。未部署,视觉仍待1920×1080、100%人工截图确认。
## 2026-10-21 装车后资料与自动派送
依据用户最新提供的排车五阶段截图和“管理资料/装车资料”五张照片截图,替代同日此前装完即派送的确认。规则以排车正式契约为准。
| 页面/操作 | 截图承载与当前实现 | 验证 |
|---|---|---|
| 排车列表/装车后页签 | 在装车中与派送中之间显示;计数与分页按服务端AFTER_LOADING查询,行内管理资料进入原居中弹框 | 定向自动检查;待1920×1080、100%人工截图确认 |
| 管理资料/装车资料 | 原居中弹框内两列共五个装车照片框;装车后默认打开,Admin与PDA共享整车照片;第五张保存后自动进入派送中 | 上传恢复、权限和服务端事务定向检查;待人工截图及PDA真机确认 |
| 装车完成/原请求恢复 | 装车结果为AFTER_LOADING;已确认结果重读失败不重复POST,当前派送状态另读 | 定向恢复检查 |
## 2026-10-21 智能排车恢复10月18日布局
依据:本轮用户明确确认恢复10月18日版本;Admin Git `a5a19769` 的 `SmartDispatchDialog.vue:496` 起模板与 `.dispatch-workbench` 三栏样式,结合原型 `modal-dispatch-schedule`。只恢复页面布局,当前接口、整托资格、草稿与幂等恢复、15/30托位及已有排车调整继续保留,不回退旧协议。
| 页面/操作 | 10月18日布局 | 本轮恢复范围 | 验收 |
|---|---|---|---|
| 智能排车/新建与查询 | 顶部紧凑查询、区域按钮,之后直接三栏 | 必要起运仓、建单方式、草稿合并一条工具栏;删除两块步骤表单;查询独立一行;区域恢复active选中态 | 定向177项通过;1920×1080人工截图待确认 |
| 选订单/拖托盘/分车 | 左订单、中托盘、右装车,三栏标题对齐;托盘可拖入各车 | 恢复1.5/0.85/1三栏比例及订单、托盘工作区高度;计划托位移到右侧添加车辆旁;保留当前真实字段、分页和完整组提示,不补造旧字段 | 定向177项通过;1920×1080人工截图待确认 |
| 空单/调整已有/生成 | 旧版不包含全部当前能力 | 保留现行入口与权限、独立时间备注、原请求恢复和提交门禁;共用三栏的已有排车同步验证 | 定向177项通过 |
本条覆盖此前“三栏交互”中的顶部步骤区和布局结论;不表示旧版所有字段、旧协议或紫色主题一并恢复。
自动验证:通过统一 `local-check.ps1` 在 `apps/admin-web` 执行7文件定向回归177项全部通过;最终 `pnpm build`(含应用类型检查)通过;`pnpm test` 全量469文件/4332项全部通过(13:55启动,614.49秒)。差异空白检查通过。本轮只改两处组件模板/局部样式、对应布局和样式合同及本段记录;保留其他对话改动,未部署。真实页面1920×1080、100%截图待人工确认。
## 2026-10-21 排车列表操作列收紧
依据本轮用户两张截图,第二张为当前操作展示参考;仓库拆分原型 `04-overseas-delivery.html:1101` 的 `page-outbound-vehicle` 仍为旧版列表,不据此覆盖现行五阶段和审核链。
| 页面/操作 | 原型/截图布局 | 当前调整 | 验收 |
|---|---|---|---|
| 排车列表/维护车辆、详情 | 主要动作在前、详情在后 | 直接展示当前主要动作及详情,操作列左固定220px;提交审核、取消、卸货顺序及BOL沿已有更多菜单可达 | 定向49项、类型检查及构建通过;1920×1080人工截图待确认 |
| 排车列表/调度、理货审核 | 对应分区显示“审核、详情” | 优先当前分区审核,按钮简称“审核”,可访问名称保留完整环节;原居中弹框、权限与服务端动作资格不变 | 定向49项、类型检查及构建通过;1920×1080人工截图待确认 |
本轮Admin完整回归4328/4329通过,唯一失败为布局合同仍要求旧260px操作列;已同步为220px及两个直接动作,随后页面、原型合同和布局合同49项全部通过。修正后未重复完整回归;未部署,最终页面待人工截图确认。
## 2026-10-21 审核驳回统一返回客服处理
依据用户本轮确认及会话原型截图1(装车前三分区)、截图2/3(居中审核弹框及必填驳回意见)。保留现有列表、详情和居中弹框承载;首分区仍称“车辆信息”,即客服最初处理位置。
| 页面/操作 | 目标 | 验收 |
|---|---|---|
| 排车列表/审核分区 | 客服处理、调度待审、理货待审按服务端流转;任一驳回回车辆信息,显示环节与原因 | 定向回归通过;1920×1080人工截图待确认 |
| 详情/维护车辆信息 | 展示当前退回原因,客服修改期间保留;重新提交后开启完整新审核轮次 | 两级退回原因与重提清除回归通过;人工截图待确认 |
| 调度/理货审核弹框 | 驳回必填原因,成功提示退回客服;理货员双端审核共用服务端资格 | 必填校验、原命令恢复及成功提示回归通过;人工截图待确认 |
## 2026-10-21 货物调整统一在智能排车
依据用户排车列表截图及本轮确认:去掉车辆信息中的添加货物入口,安排后的货物只在智能排车修改,保留理货员现场真实拣货。
| 操作 | 本轮目标 | 验收 |
|---|---|---|
| 排车列表、详情 | 删除独立添加/移除货物动作及旧弹框,列表统一通过“智能排车”进入;原货物变更未知请求也进入智能排车恢复 | 定向检查通过;1920×1080待人工截图 |
| 智能排车 | 同一弹框区分新建与调整已有排车;已有单读取真实排车及货物清单、来源候选,使用当前版本及既有资格增减货物,不转回草稿、不重建排车;跨页补齐同托各来源的预约分配,资料缺失或组成变化阻断提交 | 交互、权限、版本冲突及原请求恢复通过;类型检查、构建通过 |
| 现场作业 | PDA真实拣货及装车规则保持;已扫码不得改计划,理货通过未扫码仍须显式退回重审才能修改 | 服务端原门禁不变 |
本次完整Admin回归4319/4320通过,唯一失败为旧样式断言仍要求已删除的货物弹框保留`LineClampText`;已改为核对智能排车内的统一货物面板,并定向复核`adminDensity.test.ts`全部69项通过。类型检查、构建及1229项AGENTS索引检查通过;完整回归与修正后的定向结果分别记录,未再次运行完整回归。未部署、未执行真实HTTP;真实页面待人工截图确认。
## 2026-10-21 新建排车先安排货物
依据本轮用户确认:先排车和货物,车辆、司机留到建单后由调度或客服在后台补充;不涉及客户自助填写。此项覆盖下方新建弹框内提前填写车辆与司机的说明。
| 操作 | 本轮目标 | 验收 |
|---|---|---|
| 智能排车/选货建单、空单 | 保留三栏选货、多车分组、计划托位与每组时间/备注;移除新建环节的车辆、司机和证照表单,创建时两项均为空,后续从车辆信息维护进入 | 定向97项、类型检查和构建通过;1920×1080真实页面待人工截图确认 |
当前完整验证结果以上方“货物调整统一在智能排车”为准。
## 2026-10-21 智能排车三栏交互(当前确认)
依据:用户最新截图 `codex-clipboard-136583c3-e776-440b-980b-eed26a228c13.png`、原型 `prototype-src/overlays.html:25-91` 的 `modal-dispatch-schedule` 及 `app.js:6170-6240`。用户明确“本次仍用15/30,只还原三栏交互”。本条覆盖下方先前两栏最小方案,容量和后端业务接口保持当前合同。
| 操作 | 原型承载/布局 | 当前目标/边界 | 验收 |
|---|---|---|---|
| 入库单智能排车/排车列表新增排车 | 共用同一智能排车弹窗 | 两处均使用 `SmartDispatchDialog.vue` 及内部三栏组件;入库单入口传入已选入库单范围,排车列表入口不预选。选货、分车、容量与生成逻辑共用 | 入口合同通过 |
| 选择订单/查看托盘 | 大居中弹窗;左订单、中选中订单托盘、右多辆车装载区 | 默认不选订单,点击后中间才出现对应托盘。复用候选服务端分页,明确本页订单/本页货物关联托盘,同一订单可能跨页;关联托数含必须同车的完整组,不以本页汇总冒充全单统计 | 定向回归通过;待人工截图 |
| 加货/分车 | 中间托盘拖入右侧指定车辆;各车同时可见 | 拖放沿用现有完整选择组LOCK及幂等恢复;支持按钮作为键盘替代,拖到非当前车以目标ID提交,同实托关联货物整组加入;不把外部拖放文本当可信货物 | 定向回归通过 |
| 添加车辆/删除车辆/移除货物 | 右侧添加及逐车移除 | 计划容量15/30;各车保持独立时间/备注,跨页保留;已配货不能直接车间拖移,先使用原移除接口再重新选择,避免拆成不原子的隐式两次写 | 定向回归通过 |
| 查询/生成排车 | 顶部查询与仓库页签,底部统一生成 | 仅保留真实订单/入仓码、目的仓、来源阶段及仓库筛选;不把预约批次号冒充预约号,不补造渠道/订单类型/配送状态。每辆有货的车生成一单 | 定向回归通过 |
样式使用当前公共密度和浅蓝表头,按最新截图还原三栏比例和右侧装载区;保留起运仓、草稿恢复、空单及时间/备注。车辆司机按上方最新确认在建单后维护。详情/审核/文件预览承载不受本次调整。真实页面仍需1920×1080、100%人工截图确认;不以自动DOM测试认定视觉已验收。
本轮自动检查:三栏与先货后车组合定向5文件81项通过(2026-10-21 11:57启动),`pnpm build`(含应用类型检查)通过。当前最终源码:`SmartDispatchDialog.vue` SHA256 `D5399FA4B5909A21E4C089F9F8267607DB31C87ED1412855C1E9E6E272EC639C`;`SmartDispatchCargoBoard.vue` SHA256 `3A9195B76D40D5C0F618978E0DDFA90FFBB15C8FF2E28EE29D5760C81DF66958`。复用11:56启动的完整回归结果:4294/4295通过,唯一旧车辆表单样式断言已修正,对应共享布局测试69项复核通过;完整与修正后定向分别记证。未发布,不代表真实页面已验收。
## 2026-10-21 智能排车最小交互优化(已被上方三栏确认覆盖)
依据:用户截图 `codex-clipboard-5f71bb39-dc08-4048-a5c7-31f4c0893bc2.png`,原型 `prototype-src/overlays.html:25-91` 的 `modal-dispatch-schedule`,以及用户随后确认的最小方案:保留两栏,移除“开始安排第一辆车”,默认空车、直接加货、多辆车常驻,不改后端。本条覆盖下方旧“先开始选货”的安排;完整三栏、拖拽、车间移货暂不实施。
| 页面/操作 | 原型承载与布局 | 原实现问题 | 本次调整/验收 |
|---|---|---|---|
| 智能排车/选货与分车 | 原型多辆车同时展示;最新确认保留当前两栏居中弹框 | 须先建草稿才能选货;仅改名称仍无法看清步骤 | 默认显示空车辆框;首次点击“加入这辆车”时内部顺序创建草稿、创建车辆区、锁货,准备期间禁用交互,任一步失败即停止。已有分配跨页保留;待自动检查及1920×1080人工截图 |
| 智能排车/新增车辆与生成排车单 | 右侧各车货物分别展示,统一提交 | “开始安排第一辆车/再安排一辆车”易被理解为串行离开前车 | “添加车辆”保留前车并选中新车,按服务端ID差集识别新车,按车独立车辆/司机/时间。空区不生成单,有货各车一次原子转换。容量仍使用现有服务端15/30托位,截图26为参考示例,不自行扩展容量合同 |
既有空单、详情、审核、文件预览与删除业务不改承载。来源资格、同实托完整组、跨客户归属、版本与未知请求恢复继续沿现行服务端合同;不复制原型示例数据、前端估算重量或未经证实的预约/状态筛选。
自动验证:原组件及原型合同52项、新增真实组件交互12项通过;覆盖首加货、两车配置独立、切回加货、乱序新车回执、准备链冻结、明确拒绝、已执行但响应丢失的原键恢复、跨页恢复和同托整组一次写。只读源码审查通过;`pnpm build`(含应用类型检查)通过,Admin完整回归467个文件/4268项通过。本轮未修改后端、未执行环境HTTP、未发布;真实页面1920×1080、100%截图待人工确认。
## 尾程订单汇总文字隐藏(2026-10-20)
按用户截图`codex-clipboard-168f9aa5-5478-4085-a9b4-d59e7b08ca1f.png`及明确要求,移除查询区下方“仓储转运·全筛选”和“当前筛选中的退货”两段长汇总,不再挂载仅提供转运汇总的组件。订单明细、退货行维度、筛选、分页和错误反馈保持;不删交易数据或改变统计口径。原列表顶部矩阵中的“数量汇总”不再指这两段常驻文字。本页行为回归75项通过,差异检查通过;待人工截图确认。
@@ -259,3 +400,41 @@
本批未修改SmartDispatchDialog及其候选分页、入库计划、换标与退货出库业务逻辑。源码复查这些入口的既有承载与表单/表格布局,未凭推测批量改宽度或删除必要横向滚动。
公共缺口复核:此前AppDialog后代选择器可能连带命中二级弹框;当前公共写者已将header/body/footer改为直接子级选择器,代码层已收口,本域未修改公共样式;真实嵌套预览仍待人工截图确认。
### 2026-10-21 FBA 正式确认缺项补充
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|---|---|---|---|---|
| 入库单 | 新增 / 编辑 FBA 预约 | 用户本轮截图 Image 1:既有居中弹框;保持原尺寸与底部保存按钮 | 正式确认前按入仓码展示实际缺项;弹框内补齐箱数和货件资料,分别反馈资料保存与预约确认 | 定向回归通过;待人工 1920×1080、100% 截图确认 |
| 入库单 | 查看预约 | 同一弹框只读 | 展示缺项及用途,禁用资料补充 | 定向回归通过;待人工截图确认 |
| 预约列表 | 新增 / 编辑 FBA 预约 | 用户截图 Image 2 与现有 WorkCreateAppointmentForm / WorkAppointmentForm 居中弹框 | 共用逐票资料补充;已有安排明确指向原预约编辑,保留原占用规则 | 定向回归通过;待人工截图确认 |
自动验证:本轮 Admin 全量初跑 469 文件、4333 项,4315 项通过;2 个关联文件的 18 项失败在修复后,由包含这两文件的 45 文件、426 项定向回归全部通过。最终补充文案与表单另行复测 11 项通过;最终 `pnpm build`(含应用类型检查)通过。未触发部署、未写测试环境数据;真实页面仍待人工截图确认。
### 2026-10-21 排车审核入口与附件
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|---|---|---|---|---|
| 排车列表:调度审核、理货员审核 | 行操作 | 用户本轮确认只保留审核、详情;沿现有列表 | 去掉维护车辆信息及更多菜单,仅显示本环节审核与详情 | 本轮定向回归通过;待人工截图确认 |
| 排车审核弹框 | 通过/驳回、预览照片 | 用户附件 codex-clipboard-7867e8a0-a3ae-4a16-bcd5-35291cf12e63.png、codex-clipboard-6baaaa9b-89bd-49fa-ac11-a26b20be7501.png;沿既有弹框 | 三类照片等宽等高区域并排、下方名称、点击预览;底部直接驳回/审核通过,驳回原因必填,去掉结果单选框;未知结果仅重试原请求 | 定向回归通过;待人工 1920×1080、100% 截图确认 |
| 排车维护车辆信息 | 上传车辆、司机和驾驶证照片 | 用户确认上传入口属于维护车辆司机 | 三类照片通过资源编辑保存,司机照片独立字段;审核只读,保留历史审核证据 | 定向回归通过;待人工截图确认 |
| 排车详情:车辆信息 | 查看审核附件 | 复用既有附件预览 | 汇总原资源与两级审核已保存证据,去重并只读预览 | 本轮定向回归通过;待人工截图确认 |
本轮自动验证:Admin 5 个定向文件 96 项通过,`pnpm typecheck` 通过;Lastleg/File 8 个定向类 63 项通过,API 路由合同 5 项通过,AGENTS 索引 1232 项通过。未部署,未进行真实页面操作;上线本次变更须包含 Admin、lastleg-service 和 file(新增司机照片文件分类)。
- 2026-10-21 排车“派送中 → 标记异常”:按用户提供的三类异常、两类退货去向截图补齐选项,沿用居中作业弹框,说明与图片选填,保存后仍派送中;现有排车信息页签回看。自动回归单独报告,1920×1080/100%真实页面待人工截图确认。
- 2026-10-21 照片维护与审核按钮调整:Admin 4 个业务定向文件共 78 项通过,公共密度样式 70 项通过,类型检查通过;前两次回归发现的提示渲染时序与局部背景样式问题已修正并定向复测。指引索引检查因本地缺少 `D:/project/docs/业务快速索引.md` 无法完成。未部署,真实页面待人工截图确认。
- 2026-10-21 取货详情:按用户截图在转运单号后展示对应取货地址、联系人和联系电话,缺失值显示“—”;沿现有取货明细接口读取,不跨行复用联系人。`TransportExecutionPanel.test.ts` 6 项通过,含两张转运单地址和联系人分别展示的回归;真实页面待人工截图确认。
- 2026-10-21 取货装货留痕:列表及执行面板统一使用“上传装货照片”,沿居中弹框展示预报箱数并要求手填实际取货箱数(1—5000),未填数量或未上传照片不能确认;到仓仅展示已保存取货数量,不改写取货事实。数量与照片通过既有版本化命令同次确认,复用服务端操作人和时间历史。执行面板 7 项自动回归通过;真实页面待人工截图确认。
## 排车管理资料统一入口(2026-10-21)
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|---|---|---|---|---|
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;1920×1080、100%待人工截图确认 |
| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 |
@@ -0,0 +1,27 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { DispatchStage } from '../dispatchContracts'
export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'] as const
export type LoadingPhotoSlot = typeof loadingPhotoSlots[number]
export interface LoadingPhoto { slot: LoadingPhotoSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] }
export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string }
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents`
export async function getLoadingDocuments(no: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(path(no)))
}
export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string) {
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
return unwrapApiResponse(await http.post<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/${slot}/uploads`, form))
}
export async function recoverLoadingPhoto(no: string, key: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/uploads/${encodeURIComponent(key)}`))
}
export async function bindLoadingPhoto(no: string, slot: LoadingPhotoSlot, body: BindLoadingPhoto) {
return unwrapApiResponse(await http.put<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(`${path(no)}/${slot}`, body))
}
export async function readLoadingPhoto(no: string, photo: LoadingPhoto) {
const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对')
return blob
}
@@ -0,0 +1,25 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type FuelSlot = 'FUEL_START' | 'FUEL_END'
export interface VehiclePhoto { slot: FuelSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
export interface VehicleDocuments { dispatchNo: string; recordVersion: string; editable: boolean; photos: VehiclePhoto[] }
export interface BindVehiclePhoto { recordVersion: string; idempotencyKey: string; fileId: string }
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/vehicle-documents`
export async function getVehicleDocuments(no: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<VehicleDocuments>, ApiEnvelope<VehicleDocuments>>(path(no)))
}
export async function uploadVehiclePhoto(no: string, slot: FuelSlot, file: File, key: string) {
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
return unwrapApiResponse(await http.post<ApiEnvelope<VehiclePhoto>, ApiEnvelope<VehiclePhoto>>(`${path(no)}/${slot}/uploads`, form))
}
export async function recoverVehiclePhoto(no: string, key: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<VehiclePhoto>, ApiEnvelope<VehiclePhoto>>(`${path(no)}/uploads/${encodeURIComponent(key)}`))
}
export async function bindVehiclePhoto(no: string, slot: FuelSlot, body: BindVehiclePhoto) {
return unwrapApiResponse(await http.put<ApiEnvelope<VehicleDocuments>, ApiEnvelope<VehicleDocuments>>(`${path(no)}/${slot}`, body))
}
export async function readVehiclePhoto(no: string, photo: VehiclePhoto) {
const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对')
return blob
}

Some files were not shown because too many files have changed in this diff Show More