Compare commits

..
338 changed files with 3729 additions and 10536 deletions
+1 -2
View File
@@ -120,8 +120,7 @@ pnpm typecheck
pnpm build
```
- 按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”执行;命令表不是每轮清单。文案/纯展示核对差异,局部逻辑跑定向测试,不自动 typecheck、全量测试或构建。类型关系、模板绑定或模块引用可能受影响时,补最小类型检查;公共能力沿实际消费者验证。
- 构建和全量测试分别按实际影响触发,先说明扩大范围的原因;不因提交或对话收尾触发。仅复用同配置、同文件范围且相关输入未变的成功编译结果。纯文档/索引只检查路径、链接及索引合同。无 lint 脚本,不声称 lint 通过。
- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm test` 与 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。当前没有 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
@@ -37,7 +37,7 @@
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 规则见下文「货柜工作台规则」 |
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后预计到港经 `/estimated-arrival` 按编辑权限和版本单独维护,其余运输/预报/关系方/柜型/附件只读;客服实货量经 `/actual-cargo` 按编辑权限、版本、幂等键和原因补录;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,托运单取委值,提单确认件取手工实值,未填显示“—”且不以委值回退。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
-1
View File
@@ -146,7 +146,6 @@ export const adminNavigation: AdminNavigationNode[] = [
icon: PrototypeOverseasBusinessIcon,
children: [
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
{ key: 'overseas-fees', title: '海外费用管理', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' },
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
{ key: 'fleet-capacity-offers', title: '散板需求', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
@@ -8,11 +8,11 @@ const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key:
const render = (events: ScheduleEvent[] = [], initialDate = '2026-10-19') => mount(AppointmentCalendarPanel, { props: { initialDate, events },
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
function reportedDispatch() {
const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: ['D1821', 'D1822'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] }
const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] }
return projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, { from: '2026-10-01', to: '2026-11-01' }).events
}
describe('four-type appointment calendar', () => {
it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 ', 'D1821', ' d1821 ', 'D1822', '1821'])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => {
it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 '])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => {
const wrapper = render(reportedDispatch(), '2026-10-23')
const search = wrapper.get('[aria-label="搜索当前日历"]')
expect(search.attributes('placeholder')).toBe('柜号、排车单号、仓库')
@@ -26,7 +26,6 @@ describe('four-type appointment calendar', () => {
expect(wrapper.get('.detail-event').text()).toContain('PC-CALENDAR-001')
expect(wrapper.get('.detail-event').text()).toContain('09:00')
expect(wrapper.get('.detail-event').text()).toContain('TSTU1234567、TSTU7654321')
expect(wrapper.get('.detail-event').text()).toContain('D1821、D1822')
expect(wrapper.get('.detail-event').text()).toContain('2026-10-22 09:00')
expect(wrapper.emitted('range')).toBeUndefined()
wrapper.unmount()
@@ -109,8 +108,7 @@ describe('four-type appointment calendar', () => {
expect(wrapper.text()).toContain('正在读取安排')
expect(wrapper.text()).not.toContain('当天暂无安排')
await wrapper.setProps({ loading: false, incomplete: true })
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('当前已加载 0 项安排')
expect(wrapper.text()).not.toContain('部分数据不可用')
expect(wrapper.text()).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> 项安排</p></header>
<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>
<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>
@@ -14,10 +14,10 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, plannedDate: '2026-10-21', isDefault: true, actualAt: null, version: 0, planCount: 0, canEdit: true }
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
function dispatchEntry(overrides: Partial<TripCalendarEntry> = {}): TripCalendarEntry {
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
}
export function renderSchedule() {
return mount(AppointmentCalendarView, { global: { stubs: {
@@ -34,7 +34,7 @@ export function renderSchedule() {
} } })
}
beforeEach(() => {
vi.resetAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
vi.clearAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
@@ -110,24 +110,29 @@ describe('simplified appointment entry', () => {
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
wrapper.unmount()
})
it('releases an unknown save and sends the edited date with a fresh key', async () => {
it('retries an unknown save with the same payload and key, then only reloads reads', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-23T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises()
const calls = vi.mocked(saveWarehouseArrival).mock.calls
expect(calls[1]![2]).not.toBe(calls[0]![2]); expect(calls[1]![1].estimatedWarehouseArrivalAt).toBe('2026-10-23T09:30:00')
expect(wrapper.find('[data-dialog]').exists()).toBe(false); wrapper.unmount()
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('提交结果尚未确认')
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
const guard = vi.mocked(onBeforeRouteLeave).mock.calls.at(-1)![0] as () => Promise<boolean>
expect(await guard()).toBe(false)
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
expect(saveWarehouseArrival).toHaveBeenCalledWith('1', { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 0 }, expect.any(String))
expect(wrapper.find('[data-dialog]').exists()).toBe(false)
wrapper.unmount()
})
it('allows editing after a conflict and preserves input across an optional manual refresh', async () => {
it('requires an explicit reread after a conflict and retains the entered date', async () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.get('[data-save]').attributes('disabled')).toBeUndefined()
expect(wrapper.get('[data-save]').attributes('disabled')).toBeDefined()
vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
@@ -156,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 without showing an unresolved dispatch notice', async () => {
it('retains a complete dispatch with its container search fields while naming a different unresolved dispatch and its reason', 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: [] })
@@ -168,7 +173,11 @@ 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)
expect(wrapper.find('.data-notice').exists()).toBe(false)
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(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 } from './tripCalendar'
import { listTripCalendar, projectTripCalendar, tripIssues } from './tripCalendar'
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
import type { PlanningSourceLookup } from './planningApi'
import type { WorkAction } from './workApi'
@@ -37,8 +37,7 @@ 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 hasUnresolvedTrips = ref(false)
const incomplete = computed(() => !!errors.value.length || !!notices.value.length || hasUnresolvedTrips.value)
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
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
@@ -47,7 +46,6 @@ 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 () => {
@@ -73,7 +71,10 @@ async function load(next = range.value) {
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
if (request !== generation) return
trips.value = result.events
hasUnresolvedTrips.value = result.unresolved.length > 0
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 ? ';其余请到排车列表核对' : ''})。`)
}
} catch (cause) { fail('排车读取失败', cause) }
})(),
])
@@ -87,27 +88,30 @@ async function openSource(event: ScheduleEvent) {
await router.push(appointmentExternalTarget(event.sourcePath, context))
}
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
const saveError = ref<ApiErrorDetails | null>(null), conflict = ref(false)
const saveError = ref<ApiErrorDetails | null>(null), unknownResult = ref(false), conflict = ref(false)
let pending: { id: string; body: WarehouseArrivalChange; key: string } | null = null
function edit(event: ScheduleEvent) {
if (!event.canEdit || !event.warehouse) return
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
saveError.value = null; conflict.value = false
pending = null; saveError.value = null; unknownResult.value = false; conflict.value = false
}
async function save() {
const row = editor.value?.warehouse
if (!row || saving.value || !plannedAt.value) return
const request = { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
if (!row || saving.value || conflict.value || !plannedAt.value) return
pending ||= { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
key: `calendar-arrival-${crypto.randomUUID()}` }
saving.value = true; saveError.value = null
try {
await saveWarehouseArrival(request.id, request.body, request.key)
editor.value = null
await saveWarehouseArrival(pending.id, pending.body, pending.key)
pending = null; unknownResult.value = false; editor.value = null
ElMessage.success('计划到仓已保存')
await load()
} catch (cause) {
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
unknownResult.value = !rejectedCommand(cause)
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
if (!unknownResult.value) pending = null
} finally { saving.value = false }
}
async function rereadEditor() {
@@ -125,7 +129,7 @@ async function rereadEditor() {
const guards = new Set<LeaveGuard>()
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
async function canLeave() {
if (saving.value) return false
if (saving.value || unknownResult.value) return false
for (const guard of guards) if (!await guard()) return false
if (editor.value && plannedAt.value !== originalAt.value) {
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm admin-workbench is-dialog' }) }
@@ -176,17 +180,18 @@ onBeforeUnmount(() => { ++generation; controller?.abort() })
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog admin-workbench is-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
</AppDialog>
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog admin-workbench is-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt" :before-close="closeEditor" confirm-text="保存" @confirm="save">
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog admin-workbench is-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt || conflict" :before-close="closeEditor" :confirm-text="unknownResult ? '重试确认保存结果' : '保存'" @confirm="save">
<div v-if="editor" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
<el-form class="density-form" label-position="left" label-width="150px" :disabled="saving">
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></el-form-item></td></tr></tbody></table>
<el-form class="density-form" label-position="left" label-width="150px" :disabled="saving || unknownResult">
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></el-form-item></td></tr></tbody></table>
</el-form>
</section>
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
</div>
@@ -82,9 +82,9 @@ async function saveArrangement() {
pendingCreate.value = null; batchPreview.value = null; selected.value = {}
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); ++externalRevision.value; emit('created'); await load()
} catch (cause) {
createError.value = getApiErrorDetails(cause, '保存失败,请重新操作')
createError.value = getApiErrorDetails(cause, '保存结果待核实,请重试原请求')
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(createError.value.code || '')) pendingCreate.value = null
} finally { pendingCreate.value = null; creating.value = false }
} finally { creating.value = false }
}
let followupRequest = 0
const query = ref<QueryModel>({ keyword: '' })
@@ -202,7 +202,7 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
</template>
</DataTable>
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" @arrange="arrangeFollowing" @close="following = null" />
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="'保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
@@ -63,7 +63,7 @@ watch(() => [props.active, JSON.stringify(props.sources)] as const, ([active]) =
onBeforeUnmount(() => { ++generation; unregister?.() })
</script>
<template>
<section v-show="active || editing" class="density-dialog-section" aria-label="正式确认前的货物资料" :aria-busy="loading">
<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>
@@ -80,6 +80,6 @@ onBeforeUnmount(() => { ++generation; unregister?.() })
<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>
<p v-if="!loading && !error && !ready" role="status">请先在上表补齐并保存资料,再点击“保存预约安排”。暂时拿不到资料,可先选择“先保存安排,等待对方确认”。</p>
</section>
</template>
@@ -30,12 +30,6 @@ async function selectAndPrepare(wrapper: ReturnType<typeof render>) {
await wrapper.get('input[type="checkbox"]').setValue(true)
await wrapper.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click')
await flushPromises()
const fields = wrapper.findComponent({ name: 'FbaAppointmentFields' })
if (fields.exists()) {
Object.assign(fields.props('model'), { confirmationNo: 'EXT-CONFIRMED', appointmentAt: '2026-10-25T09:30:00' })
fields.vm.$emit('confirmationReady', true)
}
await flushPromises()
}
beforeEach(() => {
vi.clearAllMocks(); guards.clear()
@@ -78,17 +78,24 @@ describe('appointment impact handling', () => {
expect(wrapper.text()).toContain('认领跟进')
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
})
it('unlocks close and navigation after uncertainty and permits a fresh click', async () => {
it('freezes an uncertain command and retries exactly once with original version and key', async () => {
vi.mocked(handleImpact).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...detail.impact, version: 2 })
vi.mocked(getImpact).mockResolvedValueOnce({...detail,transport:{trips:[{tripNo:'TRIP-1',status:'PLANNED',plannedDepartureAt:null}],sameTripBookings:[]}})
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
const original = vi.mocked(handleImpact).mock.calls[0]![1]
expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' })
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toBeUndefined()
const done=vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).not.toHaveBeenCalled()
expect(guards.register.mock.calls[0]![0]()).toBe(false)
await wrapper.findAll('button').find(b=>b.text()==='TRIP-1')!.trigger('click');expect(guards.push).not.toHaveBeenCalled()
for (const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
await wrapper.findAll('button').find(b => b.text() === '重试本次处理')!.trigger('click'); await flushPromises()
expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original)
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(getImpact).toHaveBeenCalledTimes(2)
expect(guards.register.mock.calls[0]![0]()).toBe(true)
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toHaveLength(1)
const done = vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).toHaveBeenCalledOnce()
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
expect(vi.mocked(handleImpact).mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey)
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
wrapper.unmount(); expect(guards.unregister).toHaveBeenCalledOnce()
})
it('does not resolve without an actual handling result', async () => {
const wrapper = render(); await flushPromises()
@@ -26,8 +26,8 @@ async function save(action: ImpactCommand['action']) {
pending.value ||= { expectedVersion: detail.value.impact.version, forecastToken: detail.value.impact.forecastToken, idempotencyKey: crypto.randomUUID(), action, result: form.result.trim() || null, nextAction: form.nextAction.trim() || null, followupDate: form.followupDate || null }
saving.value = true; error.value = null
try { await handleImpact(props.containerId, pending.value); pending.value = null; emit('saved'); historyPage.value = 1; await load() }
catch (cause) { error.value = getApiErrorDetails(cause, '操作失败,请重新操作'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
finally { pending.value = null; saving.value = false }
catch (cause) { error.value = getApiErrorDetails(cause, '处理结果待核实,请重试原请求'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
finally { saving.value = false }
}
function canLeave() { if (saving.value || pending.value) { ElMessage.warning('请先核实本次处理结果,再关闭或切换'); return false } return true }
function close() { if (canLeave()) emit('close') }
@@ -65,7 +65,7 @@ onMounted(load)
<el-button v-if="!pending" :disabled="saving || loading" @click="save('CLAIM')">认领跟进</el-button>
<el-button v-if="!pending" :disabled="saving || loading" @click="save('RECORD')">记录处理</el-button>
<el-button v-if="!pending" type="primary" :disabled="saving || loading" @click="save('RESOLVE')">完成本次核对</el-button>
<el-button v-else type="primary" :loading="saving" @click="save(pending.action)">重试本次处理</el-button>
</div>
<details><summary>观察及处理历史({{ detail.history.total }} 条)</summary><div class="impact-list"><p v-for="row in detail.history.records" :key="row.version">{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC<br>{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}</p></div><el-pagination v-model:current-page="historyPage" :page-size="20" :total="detail.history.total" layout="prev, pager, next" :disabled="loading || saving || !!pending" @current-change="load" /></details>
</template>
@@ -49,16 +49,14 @@ it('does not edit or cancel replaced snapshots and offers the stable batch conte
expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']]); wrapper.unmount()
})
const cancelled: FbaAppointmentDto = { ...detail, version: 4, appointmentStatus: 'CANCELLED', confirmationNo: null, plannedDate: null, plannedAt: null, plannedEndAt: null, appointmentAt: null, appointmentEndAt: null, appointmentStartUtc: null, appointmentEndUtc: null }
it('rebooks the original cargo using a new confirmed appointment without reallocating cargo', async () => {
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...detail, version: 5 })
it('rebooks the cancelled original batch as undated pending without creating or reallocating cargo', async () => {
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 5, appointmentStatus: 'PENDING' })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('货物分配仍保留')
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
expect(model.appointmentStatus).toBe('SUCCESS'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('')
Object.assign(model, { confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00' })
wrapper.getComponent({ name: 'FbaAppointmentFields' }).vm.$emit('confirmationReady', true)
expect(model.appointmentStatus).toBe('PENDING'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('')
await wrapper.findAll('button').find(button => button.text() === '保存本批重新预约')!.trigger('click'); await flushPromises()
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 4, idempotencyKey: expect.any(String), details: expect.objectContaining({ appointmentStatus: 'SUCCESS', plannedDate: null, confirmationNo: 'NEW-EXT', appointmentAt: '2026-10-20T10:00:00', appointmentEndAt: null }) }))
expect(updateFbaAppointment).toHaveBeenCalledWith('AP-001', expect.objectContaining({ version: 4, idempotencyKey: expect.any(String), details: expect.objectContaining({ appointmentStatus: 'PENDING', plannedDate: null, confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) }))
const payload = vi.mocked(updateFbaAppointment).mock.calls[0]![1]
expect(payload).not.toHaveProperty('items'); expect(payload).not.toHaveProperty('sources')
expect(deleteAppointment).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1)
@@ -30,7 +30,7 @@ async function load(preserve = false) {
try {
detail.value = await getFbaAppointment(props.appointmentNo)
if (!preserve || !initialized) {
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'SUCCESS'
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'PENDING'
baseline.value = JSON.stringify(appointmentPayload(form)); initialized = true
}
}
@@ -18,29 +18,22 @@ 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()).not.toContain('货物备妥窗口待核实')
expect(wrapper.text()).toContain('货物备妥窗口待核实')
expect(wrapper.text()).toContain('货量或重复关联需要核对。')
expect(wrapper.text()).not.toContain('READY_WINDOW_UNVERIFIED')
expect(wrapper.text()).not.toContain('CARGO_QUANTITY_REVIEW')
wrapper.unmount()
})
async function confirmRegistration(wrapper: ReturnType<typeof render>) {
const fields = wrapper.getComponent({ name: 'FbaAppointmentFields' })
Object.assign(fields.props('model'), { confirmationNo: 'EXT-CONFIRMED', appointmentAt: '2026-10-16T09:30:00' })
fields.vm.$emit('confirmationReady', true)
await flushPromises()
}
it('previews and creates the exact canonical external source and customer with real confirmation', async () => {
it('previews and creates the exact canonical external source and customer without inventing times', async () => {
const wrapper = render(); await flushPromises()
await confirmRegistration(wrapper)
expect(previewPlanningBatch).toHaveBeenCalledWith(sources)
await wrapper.get('input').setValue('2026-10-16'); await wrapper.get('button').trigger('click'); await flushPromises()
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources, originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', details: expect.objectContaining({ plannedDate: '2026-10-16', plannedAt: null, plannedEndAt: null, appointmentStatus: 'SUCCESS', confirmationNo: 'EXT-CONFIRMED' }) }))
expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources, originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', details: expect.objectContaining({ plannedDate: '2026-10-16', plannedAt: null, plannedEndAt: null, appointmentStatus: 'PENDING' }) }))
expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.text()).not.toContain('external-1'); wrapper.unmount()
})
it('retains the same immutable request after an unknown outcome and blocks leaving', async () => {
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = render(); await flushPromises(); await confirmRegistration(wrapper); await wrapper.get('button').trigger('click'); await flushPromises()
const wrapper = render(); await flushPromises(); await wrapper.get('button').trigger('click'); await flushPromises()
const request = vi.mocked(createSourceAppointment).mock.calls[0]![0]
expect(await guard()).toBe(false)
await wrapper.get('button').trigger('click'); await flushPromises()
@@ -49,7 +42,6 @@ it('retains the same immutable request after an unknown outcome and blocks leavi
it('refreshes qualification after conflict while keeping user-entered dates', async () => {
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new ApiResponseError('来源变化', 'A0409', 409))
const wrapper = render(); await flushPromises(); await wrapper.get('input').setValue('2026-10-16')
await confirmRegistration(wrapper)
await wrapper.get('button').trigger('click'); await flushPromises()
expect(previewPlanningBatch).toHaveBeenCalledTimes(2)
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-16')
@@ -19,7 +19,6 @@ const form = reactive(createAppointmentForm()), baseline = ref(JSON.stringify(fo
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 }
@@ -61,7 +60,8 @@ 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 visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
<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>
</section>
<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>
@@ -1,7 +1,7 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkDispatchForm from './WorkDispatchForm.vue'
import { createDispatch, getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, type BatchDispatchCandidate, type Candidate, type CommandResult, type DispatchDetail, type DispatchDraft } from '@/modules/overseas-warehouse'
import { ApiResponseError, http } from '@/shared/api/http'
import { parseApiJsonResponse } from '@chongchong/shared'
import { getReceivingContext, type ReceivingContext } from './receivingApi'
@@ -12,7 +12,7 @@ const identity = vi.hoisted(() => ({ id: '' }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.allowed && !access.denied.includes(permission) }) }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), createDispatch: vi.fn(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), convertDispatchDraft: vi.fn(), releaseDispatchDraft: vi.fn() }))
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
const batch = 'FBA_APPOINTMENT:1'
@@ -21,7 +21,7 @@ function row(id = '9007199254740993', customerId = 'customer-a', key = batch): B
function candidate(value = row()): Candidate { const base = { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] }; return { ...base, selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, selectionGroup: { groupKey: base.sourceKey, groupVersion: 'group-1', sources: [{ selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, sourceKey: base.sourceKey, customerId: base.customerId, customerName: base.customerName, sourceBusinessType: null, sourceBusinessNo: null, packages: base.availablePackages }], pallets: [{ warehousePalletId: base.selection.warehousePalletId, palletNo: base.palletNo, sourceKeys: [base.sourceKey], packageCount: base.availablePackages }], complete: true, blockReasons: [] } } }
function detail(no = 'DS/001', version = 1): DispatchDetail {
const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }
return { publishLooseboardDemand: false, dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], exceptions: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] }
}
function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } }
let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft
@@ -33,7 +33,7 @@ it.each([0, '0', '9007199254740993'])('preserves authoritative allocation versio
const wrapper = render()
try {
await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations[0]!.recordVersion).toBe(recordVersion)
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations[0]!.recordVersion).toBe(recordVersion)
} finally { wrapper.unmount() }
})
it.each([undefined, null, 'bad', 9007199254740992])('blocks an existing-batch candidate lacking a valid authoritative version: %s', async recordVersion => {
@@ -49,7 +49,6 @@ beforeEach(() => {
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key))
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] }))
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ total: 1, page: 1, size: 20, pages: 1, candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
vi.mocked(createDispatch).mockImplementation(async body => proof(body.idempotencyKey))
vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft))
vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) })
vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) })
@@ -65,8 +64,8 @@ it('creates the whole dispatch when native Long candidate quantities arrive as s
try {
await flushPromises(); expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(createDispatch).toHaveBeenCalledOnce(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual(row().allocations)
expect(vi.mocked(createDispatch).mock.calls[0]![0].cargo[0]!.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
expect(convertDispatchDraft).toHaveBeenCalledOnce(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations)
expect(vi.mocked(lockDispatchDraftPallet).mock.calls[0]![0].source.planPalletId).toBe('9007199254740993'); expect(wrapper.emitted('updated')).toHaveLength(1)
} finally { wrapper.unmount(); get.mockRestore() }
})
it('restores existing batch dispatches after remount and navigates to the same whole dispatch without any creation', async () => {
@@ -75,7 +74,7 @@ it('restores existing batch dispatches after remount and navigates to the same w
const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('DS/EXISTING')
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2FEXISTING']]); expect(wrapper.text()).not.toContain('创建整车排车单'); wrapper.unmount()
}
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(createDispatch).not.toHaveBeenCalled()
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(createDispatchDraft).not.toHaveBeenCalled(); expect(convertDispatchDraft).not.toHaveBeenCalled()
})
it('does not fall back to new creation when transport relations fail or their nested batch ownership differs', async () => {
vi.mocked(getReceivingContext).mockRejectedValueOnce(new Error('unavailable')); const failed = render(); await flushPromises(); expect(failed.text()).toContain('批次可排实物读取失败'); expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); failed.unmount()
@@ -85,52 +84,71 @@ it('does not fall back to new creation when transport relations fail or their ne
it('reads remaining physical cargo only after an explicit choice while retaining the original dispatch', async () => {
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key, [{ dispatchNo: 'DS-OLD', batchKey: key, customerId: 'customer-a', tripNo: null, version: null, status: 'ACTIVE', packageCount: 12 }]))
const wrapper = render(); await flushPromises(); expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(createDispatch).not.toHaveBeenCalled(); wrapper.unmount()
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises(); expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith(batch); expect(wrapper.text()).toContain('DS-OLD'); expect(convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
})
it('creates the whole dispatch with native source versions and exact batch allocations', async () => {
it('uses a server-owned draft, explicit zero versions, native source hashes and exact allocations for one whole dispatch', async () => {
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(createDispatch).toHaveBeenCalledWith(expect.objectContaining({ publishLooseboardDemand: false, warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null }))
expect(createDispatchDraft).toHaveBeenCalledWith({ warehouseId: 'origin-1', idempotencyKey: expect.any(String) })
expect(createDispatchDraftContainer).toHaveBeenCalledWith({ draftToken: 'server-owned-token', recordVersion: 0, idempotencyKey: expect.any(String), capacity: 30 })
expect(lockDispatchDraftPallet).toHaveBeenCalledWith({ draftToken: 'server-owned-token', draftContainerId: 'container-1', recordVersion: 1, idempotencyKey: expect.any(String), source: natives[0]!.selection })
const request = vi.mocked(convertDispatchDraft).mock.calls[0]![0]
expect(request).toMatchObject({ draftToken: 'server-owned-token', recordVersion: 2, groups: [{ draftContainerId: 'container-1', dispatch: { warehouseId: 'origin-1', cargo: [natives[0]!.selection], batchAllocations: row().allocations, vehicle: null, driver: null } }] })
expect(wrapper.text()).toContain('DS/001'); expect(wrapper.emitted('updated')).toHaveLength(1); expect(await guard()).toBe(true)
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); wrapper.unmount()
})
it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => {
const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] })
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = [{ dispatch: vi.mocked(createDispatch).mock.calls[0]![0] }]
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups
expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount()
})
it('preserves exact distinct batch allocations while combining selected same-warehouse batches', async () => {
const otherKey = 'FBA_APPOINTMENT:2', second = row('9007199254740994', 'customer-b', otherKey); natives.push(candidate(second))
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [key === batch ? row() : second], blockReasons: [] }))
const wrapper = render({ batchKeys: [batch, otherKey] }); await flushPromises(); for (const input of wrapper.findAll('input[type="checkbox"]')) await input.setValue(true)
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...row().allocations, ...second.allocations]); wrapper.unmount()
})
it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo out of the payload', async () => {
const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5')
wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value)))
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] })
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual(row().allocations); wrapper.unmount()
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount()
})
it('accepts partial pages but fails closed on missing versions and cross-warehouse batches', async () => {
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ total: 1, page: 1, size: 20, pages: 1, candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.text()).toContain('来源版本不完整'); missing.unmount()
vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value })
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(createDispatch).not.toHaveBeenCalled(); cross.unmount()
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount()
})
it('shows the confirmed dispatch link even when its detail read fails', async () => {
it('retains the entire original conversion and skips all prior locks after an unknown response, even after remount with the work closed', async () => {
vi.mocked(convertDispatchDraft).mockRejectedValueOnce(new Error('lost response'))
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(convertDispatchDraft).mock.calls[0]![0])
expect(await guard()).toBe(false); wrapper.unmount()
const restored = render({ canWrite: false }); await flushPromises(); expect(restored.text()).toContain('原请求已保留'); await button(restored, '核对并重试原请求').trigger('click'); await flushPromises()
expect(convertDispatchDraft).toHaveBeenNthCalledWith(2, original); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(restored.emitted('updated')).toHaveLength(1); restored.unmount()
})
it('retries the exact container command after a lost response instead of generating another container key', async () => {
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('unknown container'))
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const original = clone(vi.mocked(createDispatchDraftContainer).mock.calls[0]![0])
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original); expect(createDispatchDraft).toHaveBeenCalledTimes(1); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('keeps a proven CREATE across failed current-detail GET and only repeats the read', async () => {
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('GET unavailable'))
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(createDispatch).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toHaveLength(1)
await button(wrapper, '继续维护本车').trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches/DS%2F001']]); expect(await guard()).toBe(true); wrapper.unmount()
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(wrapper.emitted('updated')).toBeUndefined()
await button(wrapper, '核对并重试原请求').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1); expect(getDispatch).toHaveBeenCalledTimes(2); expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
})
it('never releases an unknown result for a later rejection, and releases only an explicitly rejected owned draft at its read-back version', async () => {
vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409))
const unknown = render(); await flushPromises(); await button(unknown, '创建整车排车单').trigger('click'); await flushPromises(); await button(unknown, '核对并重试原请求').trigger('click'); await flushPromises(); expect(unknown.text()).not.toContain('按明确拒绝结果释放'); expect(await guard()).toBe(false); unknown.unmount()
sessionStorage.clear(); vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce(new ApiResponseError('conflict', 'A0409', 409)); draft = { ...draft, draftToken: 'another-owned-token', containers: [], recordVersion: 0 }
const rejected = render(); await flushPromises(); await button(rejected, '创建整车排车单').trigger('click'); await flushPromises(); draft.recordVersion = 9
await button(rejected, '按明确拒绝结果释放本草稿并重新核对').trigger('click'); await flushPromises(); expect(releaseDispatchDraft).toHaveBeenCalledWith({ draftToken: 'another-owned-token', recordVersion: 9, idempotencyKey: expect.any(String) }); rejected.unmount()
})
it('preserves quantity-split navigation and uses create permission rather than the removed smart-dispatch permission', async () => {
access.denied = ['platform:overseas-warehouse:inbound-order:smart-dispatch']; const wrapper = render(); await flushPromises()
await button(wrapper, '按当前批次箱数分批').trigger('click'); expect(wrapper.emitted('batch')).toEqual([[batch]]); expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(createDispatch).toHaveBeenCalledTimes(1)
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
await button(wrapper, '为本车添加同仓货物').trigger('click'); expect(wrapper.getComponent({ name: 'WorkSharedTripForm' }).props('dispatchNo')).toBe('DS/001'); wrapper.unmount()
})
it('does not read scoped cargo without original permissions or create while only read access remains', async () => {
@@ -140,7 +158,7 @@ it('does not read scoped cargo without original permissions or create while only
it('shows batch-level blockers even without rows and refuses duplicate physical candidates across batches', async () => {
vi.mocked(getBatchDispatchCandidates).mockResolvedValueOnce({ batchKey: batch, version: 4, candidates: [], blockReasons: ['原批次已替代,请进入当前批次'] })
const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount()
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(createDispatch).not.toHaveBeenCalled(); duplicate.unmount()
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount()
})
it('keeps original batch allocations and selected cargo while paging through associated plan candidates', async () => {
@@ -158,8 +176,8 @@ it('keeps original batch allocations and selected cargo while paging through ass
expect(getBatchDispatchCandidates).toHaveBeenCalledTimes(1)
expect(listDispatchCandidates).toHaveBeenCalledTimes(2)
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(createDispatch).toHaveBeenCalledTimes(1)
expect(vi.mocked(createDispatch).mock.calls[0]![0].batchAllocations).toEqual([...first.allocations, ...second.allocations])
expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...first.allocations, ...second.allocations])
} finally { wrapper.unmount() }
})
it('rejects a selected batch source whose group version changed on another candidate page', async () => {
@@ -177,13 +195,3 @@ it('rejects a selected batch source whose group version changed on another candi
expect(createDispatchDraft).not.toHaveBeenCalled()
} finally { wrapper.unmount() }
})
it('failure preserves selection, releases navigation and creates a fresh request on the next click', async () => {
vi.mocked(createDispatch).mockRejectedValueOnce(new Error('timeout'))
const wrapper = render(); await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
const original = vi.mocked(createDispatch).mock.calls[0]![0]
expect(wrapper.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(true)
expect(await guard()).toBe(true); expect(sessionStorage.length).toBe(0)
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(vi.mocked(createDispatch).mock.calls[1]![0].idempotencyKey).not.toBe(original.idempotencyKey); expect(getReceivingContext).toHaveBeenCalledTimes(1); wrapper.unmount()
})
@@ -6,7 +6,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { getDispatch, createDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, dispatchVehicleSpecs, DispatchInputError, requireBatchVersions, expandSelectionGroups, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, type BatchDispatchCandidate, type BatchDispatchCandidates, type DispatchDetail, type Candidate } from '@/modules/overseas-warehouse'
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -17,8 +17,11 @@ import { useWorkDraft } from './useWorkDraft'
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
type BatchRow = BatchDispatchCandidate & { key: string; batchKey: string; native: Candidate | null; blockReasons: string[] }
type ReadyRow = BatchRow & { native: Candidate }
type Step = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'CONVERT' | 'RELEASE'; body: Record<string, unknown>; attempted: boolean; rejected: boolean }
type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; dispatchKey: string; draft: DispatchDraft | null; containerId: string | null; lockedKeys: string[]; step: Step | null; converted: ConvertedDraft | null; releasing: boolean }
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
const scope = JSON.stringify([...new Set(props.batchKeys)].sort())
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error, listDispatchCandidates)
@@ -27,24 +30,26 @@ const loading = computed(() => working.value || candidatePage.loading.value)
const selectedRows = ref<Record<string, BatchRow>>({})
let receivingContexts: Awaited<ReturnType<typeof getReceivingContext>>[] = []
let nativeGeneration = 0
const createdNo = ref('')
const capacity = ref(30), result = ref<DispatchDetail | null>(null)
const capacity = ref(30), result = ref<DispatchDetail | null>(null), pending = ref<Preparation | null>(null), corrupted = ref(false)
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
const writable = computed(() => props.canWrite && canCreate.value && (!existing.value.length || existingReady.value))
const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
const chosen = computed(() => selected.value.map(key => selectedRows.value[key]).filter((row): row is BatchRow => Boolean(row)))
const unresolved = computed(() => saving.value)
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !createdNo.value), unresolved)
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
let generation = 0
function ensureOwner() { if (!owner || auth.user?.id !== owner || JSON.stringify([...new Set(props.batchKeys)].sort()) !== scope) throw new DispatchInputError('排车登录身份或批次范围已变化,请重新进入原事项') }
function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
function reasons(row: BatchRow) { return row.blockReasons }
function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size }
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
async function load() {
if (!readable.value) return
if (!readable.value || pending.value || corrupted.value) return
const current = ++generation, batches = [...new Set(props.batchKeys)]
working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++
try {
@@ -117,24 +122,105 @@ function select(row: BatchRow, event: Event) {
}
input.checked = selected.value.includes(row.key)
}
function step(action: Step['action'], body: object) { pending.value!.step = { action, body: clone(body) as Record<string, unknown>, attempted: false, rejected: false }; persist() }
function common(draft: DispatchDraft) { return { recordVersion: requireVersion(draft.recordVersion), idempotencyKey: crypto.randomUUID(), draftToken: draft.draftToken } }
async function executeStep() {
const request = pending.value!, command = request.step!, previouslyAttempted = command.attempted
command.attempted = true; persist()
try {
let value: DispatchDraft | ConvertedDraft
if (command.action === 'CREATE') value = await createDispatchDraft(command.body as unknown as { idempotencyKey: string; warehouseId: string })
else if (command.action === 'CONTAINER') value = await createDispatchDraftContainer(command.body as unknown as VersionCommand & { draftToken: string; capacity: number })
else if (command.action === 'LOCK') value = await lockDispatchDraftPallet(command.body as unknown as DraftSelection)
else if (command.action === 'RELEASE') value = await releaseDispatchDraft(command.body as unknown as VersionCommand & { draftToken: string })
else value = await convertDispatchDraft(command.body as unknown as ConvertDraft)
ensureOwner()
if (!value.draftToken || command.body.draftToken && value.draftToken !== command.body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致,请保留原请求')
if (command.action === 'CONVERT') {
const converted = value as ConvertedDraft
if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致')
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey }, attempted: true, rejected: false, result: null })
request.converted = converted
} else {
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
if (draft.warehouseId !== request.warehouseId) throw new DispatchInputError('草稿起运仓与原批次不一致')
if (command.action === 'CONTAINER') {
if (draft.containers.length !== 1) throw new DispatchInputError('本次草稿装车容器不唯一,请保留原请求核对')
request.containerId = draft.containers[0]!.draftContainerId
}
if (command.action === 'LOCK') {
const selectedSource = command.body.source as DraftSelection['source']
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
const group = requireSelectionGroup(source)
assertSelectionGroupVersions([expected!.native], [source])
const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys
if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求')
request.lockedKeys.push(...group.sources.map(required => required.sourceKey))
}
request.draft = draft
}
request.step = null; persist()
} catch (cause) {
ensureOwner(); if (!previouslyAttempted && isDefiniteDispatchRejection(cause)) command.rejected = true
persist(); throw cause
}
}
async function save() {
if (saving.value || loading.value || !canCreate.value) return
saveError.value = null; saving.value = true
if (saving.value || loading.value || corrupted.value || !canCreate.value) return
saveError.value = null
try {
ensureOwner()
if (!writable.value || error.value || createdNo.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
const body = { publishLooseboardDemand: false, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(chosen.value.map(row => row.native!)), batchAllocations: chosen.value.flatMap(row => row.allocations) }
const proof = await createDispatch(body); ensureOwner()
validateCommandResult(proof, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: body.idempotencyKey } })
createdNo.value = proof.dispatchNo; selected.value = []; baseline.value = '[]'; emit('updated')
const latest = await getDispatch(proof.dispatchNo); ensureOwner(); validateCurrentDispatch(latest, proof)
result.value = latest; selected.value = []; baseline.value = '[]'
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`)
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车创建失败,请刷新核对当前货物后重新操作') }
if (!pending.value) {
if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist()
}
saving.value = true
const request = pending.value
while (!request.converted) {
if (!request.step) {
if (request.releasing) { step('RELEASE', common(request.draft!)) }
else if (!request.draft) step('CREATE', { idempotencyKey: crypto.randomUUID(), warehouseId: request.warehouseId })
else if (!request.containerId) step('CONTAINER', { ...common(request.draft), capacity: request.capacity })
else {
const next = request.rows.find(row => !request.lockedKeys.includes(row.native.sourceKey))
if (next) step('LOCK', { ...common(request.draft), draftContainerId: request.containerId, source: next.native.selection })
else {
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
}
}
}
await executeStep()
if (request.releasing) { pending.value = null; persist(); selected.value = []; await load(); return }
}
// Immutable CREATE proof is persisted before reading current facts; GET failure never reconverts.
const proof = request.converted!.vehicles[0]!.dispatch, latest = await getDispatch(proof.dispatchNo)
ensureOwner(); validateCurrentDispatch(latest, proof)
result.value = latest; pending.value = null; persist(); selected.value = []; baseline.value = '[]'
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`); emit('updated')
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次整车结果待确认,请核对并重试原请求') }
finally { saving.value = false }
}
async function remaining() { if (!unresolved.value && !addingBusy.value) { createdNo.value = ''; result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
async function releaseRejected() {
if (saving.value || !pending.value?.step?.rejected || pending.value.converted) return
saving.value = true; saveError.value = null
try {
ensureOwner(); const request = pending.value
if (!request.draft) { pending.value = null; persist(); return }
const owned = (await listDispatchDrafts()).find(row => row.draftToken === request.draft!.draftToken)
ensureOwner()
if (!owned || owned.warehouseId !== request.warehouseId) throw new DispatchInputError('原拥有者草稿尚未读回,请保留现场核对')
request.draft = owned; request.releasing = true; request.step = null; persist()
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '原草稿版本读取失败') }
finally { saving.value = false }
if (pending.value?.releasing) await save()
else if (!pending.value) await load()
}
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
onMounted(load)
onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() })
</script>
@@ -144,12 +230,12 @@ onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset()
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
<template v-if="existing.length && !loading && !error && !createdNo">
<template v-if="existing.length && !loading && !error && !result && !pending">
<WorkExistingDispatchForm :key="JSON.stringify(existing)" :dispatches="existing" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
<el-button v-if="!arrangingRemaining && writable" @click="remaining">核对本批剩余可排实物</el-button>
</template>
<template v-if="createdNo"><p>已创建整车排车单:{{ createdNo }}</p><el-button @click="emit('external', detailPath(createdNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result?.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="createdNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
<template v-else-if="readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
<template v-if="result"><p>已创建整车排车单:{{ result.dispatchNo }}</p><el-button @click="emit('external', detailPath(result.dispatchNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
<template v-else-if="!pending && !corrupted && readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
<p v-for="reason in [...new Set(contexts.flatMap(context => context.blockReasons))]" :key="reason" role="alert">{{ reason }}</p>
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved" class="workbench-input"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
@@ -158,6 +244,7 @@ onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset()
<p>上方总数为关联计划的候选数;当前页展示其中属于本批次的实物。已选 {{ chosen.length }} 条(含其他页)。</p>
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
</template>
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="pending?.step?.rejected && !pending.converted" :loading="saving" @click="releaseRejected">按明确拒绝结果释放本草稿并重新核对</el-button>
</section>
</template>
@@ -24,16 +24,16 @@ it('opens a real plan in the exact customer scope while leaving missing plans un
it('does not read the container source without its original permission', async () => {
state.canRead = false; const wrapper = render(); await flushPromises(); expect(getContainerPlanProgress).not.toHaveBeenCalled(); wrapper.unmount()
})
it('submits the actual preview snapshot and permits a fresh click after an unknown result', async () => {
it('submits the actual preview snapshot and replays the same request after an unknown result', async () => {
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue(preview())
vi.mocked(convertLastMileContainerToInboundPlans).mockRejectedValueOnce(new Error('lost response')).mockResolvedValueOnce([])
const wrapper = render(); await flushPromises(); await button(wrapper, '核对本柜入库计划预览').trigger('click'); await flushPromises()
await button(wrapper, '确认生成本柜计划').trigger('click'); await flushPromises()
const original = vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[0]!
expect(original).toEqual([{ sourceContainerBookingId: '9007199254740993', snapshotVersion: 4, snapshotHash: 'snapshot-4' }, expect.any(String)])
expect(await guard()).toBe(true)
await button(wrapper, '确认生成本柜计划').trigger('click'); await flushPromises()
expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]![0]).toEqual(original[0]); expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]![1]).not.toBe(original[1])
expect(await guard()).toBe(false)
await button(wrapper, '重试本次计划生成').trigger('click'); await flushPromises()
expect(vi.mocked(convertLastMileContainerToInboundPlans).mock.calls[1]).toEqual(original)
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
})
it('uses the original resource recovery snapshot and key instead of creating new plans', async () => {
@@ -35,9 +35,9 @@ async function createPlans() {
await convertLastMileContainerToInboundPlans(pending.value.body, pending.value.key)
pending.value = null; preview.value = null; ElMessage.success('本柜入库计划已确认,请查看原接仓记录'); emit('updated'); await load()
} catch (cause) {
saveError.value = getApiErrorDetails(cause, '入库计划生成失败,请重新操作')
saveError.value = getApiErrorDetails(cause, '入库计划生成结果待确认,请重试原请求')
if (rejectedCommand(cause)) { pending.value = null; preview.value = null }
} finally { pending.value = null; saving.value = false }
} finally { saving.value = false }
}
async function load() { if (!readable.value) return; loading.value = true; error.value = null; try { detail.value = await getContainerPlanProgress(props.containerBookingId) } catch (cause) { error.value = getApiErrorDetails(cause, '本柜入仓计划读取失败') } finally { loading.value = false } }
onMounted(load)
@@ -48,7 +48,7 @@ onMounted(load)
<template v-if="detail && !error"><p>{{ detail.summary.containerNo || '柜号待核实' }} · {{ detail.summary.overseasWarehouseName || '接仓仓库待核实' }}</p><p v-if="detail.summary.blockingReason">{{ detail.summary.blockingReason }}</p><table class="density-table"><thead><tr><th>操作</th><th>入库单号</th><th>入仓码 / 订单号</th></tr></thead><tbody><tr v-for="row in detail.orders" :key="`${row.customerId}:${row.orderId}`"><td><el-button :disabled="saving || !!pending || !row.planNo || !row.customerId || !row.planValid || !access.can('platform:overseas-warehouse:inbound-order:list')" @click="emit('external', `/overseas-warehouse/inbound-orders/${encodeURIComponent(row.planNo!)}?customerId=${encodeURIComponent(row.customerId)}`)">查看原接仓记录</el-button></td><td>{{ row.planNo || '尚未生成有效计划' }}</td><td>{{ row.inboundCode || row.orderNo || '—' }}</td></tr></tbody></table><p v-if="!detail.orders.length">本柜尚无可读取的入仓来源。</p></template>
<el-button v-if="writable && !preview && !pending" :loading="previewLoading" @click="loadPreview">核对本柜入库计划预览</el-button>
<LoadErrorState v-if="previewError" title="入库计划预览失败" :message="previewError.message" :loading="previewLoading" @retry="loadPreview"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState>
<div v-if="preview && !previewError"><h4>核对本柜计划与托盘</h4><p>{{ preview.containerNo || '柜号待核实' }} · {{ preview.overseasWarehouseName || '接仓仓库待核实' }} · {{ preview.pendingOrderCount }} 票待生成 · {{ preview.blockedOrderCount }} 票待处理</p><p>头程已有 {{ preview.sourcePalletCount }} 托;海外补充 {{ preview.supplementPalletCount }} 托;已保存计划 {{ preview.totalPlannedPalletCount }} 托。</p><table class="density-table"><thead><tr><th>客户</th><th>原票</th><th>目的地</th><th>箱数</th><th>目标托数</th><th>计划状态</th></tr></thead><tbody><tr v-for="row in preview.groups.flatMap(group => group.orders)" :key="`${row.customerId}:${row.orderId}`"><td>{{ row.customerName || '客户名称待核实' }}</td><td>{{ row.inboundCode || row.businessOrderNo || '—' }}</td><td>{{ row.destinationWarehouseName || row.fbaWarehouseCode || (row.destinationType === 'PRIVATE_ADDRESS' ? '客户地址' : '目的地待核实') }}</td><td>{{ row.packageCount }}</td><td>{{ row.targetPalletCount }}</td><td>{{ row.existingPlanNo || ({ READY: '可生成', RESOURCE_PENDING: '资源待确认', PLANNED: '已有计划', BLOCKED: '来源条件未就绪' }[row.readiness]) }}</td></tr></tbody></table><p v-if="!preview.ready">本柜尚不具备生成条件,请在原尾程订单核对阻塞来源。</p><el-button type="primary" :loading="saving" :disabled="!writable || (!pending && (!preview.ready || (!preview.resourceRetry && preview.pendingOrderCount <= 0)))" @click="createPlans">{{ preview.resourceRetry ? '重试原资源确认' : '确认生成本柜计划' }}</el-button></div>
<div v-if="preview && !previewError"><h4>核对本柜计划与托盘</h4><p>{{ preview.containerNo || '柜号待核实' }} · {{ preview.overseasWarehouseName || '接仓仓库待核实' }} · {{ preview.pendingOrderCount }} 票待生成 · {{ preview.blockedOrderCount }} 票待处理</p><p>头程已有 {{ preview.sourcePalletCount }} 托;海外补充 {{ preview.supplementPalletCount }} 托;已保存计划 {{ preview.totalPlannedPalletCount }} 托。</p><table class="density-table"><thead><tr><th>客户</th><th>原票</th><th>目的地</th><th>箱数</th><th>目标托数</th><th>计划状态</th></tr></thead><tbody><tr v-for="row in preview.groups.flatMap(group => group.orders)" :key="`${row.customerId}:${row.orderId}`"><td>{{ row.customerName || '客户名称待核实' }}</td><td>{{ row.inboundCode || row.businessOrderNo || '—' }}</td><td>{{ row.destinationWarehouseName || row.fbaWarehouseCode || (row.destinationType === 'PRIVATE_ADDRESS' ? '客户地址' : '目的地待核实') }}</td><td>{{ row.packageCount }}</td><td>{{ row.targetPalletCount }}</td><td>{{ row.existingPlanNo || ({ READY: '可生成', RESOURCE_PENDING: '资源待确认', PLANNED: '已有计划', BLOCKED: '来源条件未就绪' }[row.readiness]) }}</td></tr></tbody></table><p v-if="!preview.ready">本柜尚不具备生成条件,请在原尾程订单核对阻塞来源。</p><el-button type="primary" :loading="saving" :disabled="!writable || (!pending && (!preview.ready || (!preview.resourceRetry && preview.pendingOrderCount <= 0)))" @click="createPlans">{{ pending ? '重试本次计划生成' : preview.resourceRetry ? '重试原资源确认' : '确认生成本柜计划' }}</el-button></div>
<LoadErrorState v-if="saveError" title="入库计划尚未确认" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
</section>
</template>
@@ -236,7 +236,7 @@ function mixedPlans(): api.ReceivingContext {
c.agreements = [{ ...agreement(), members: [members()[0]!] }]
c.batch.items.push({ ...c.batch.items[1]!, id: 'item-d', customerId: 'customer-d', sourceKey: 'ORDER:D', inboundCode: 'IN-D' })
c.executionScope.members.push({ member: { batchKey: c.batch.batchKey!, customerId: 'customer-d' }, completed: false, canRebook: true, reason: null })
c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'PENDING', plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }]
c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }]
return c
}
async function enterBPlan(wrapper: ReturnType<typeof render>) {
@@ -373,7 +373,7 @@ it('inherits unconfirmed legacy header plans only without active independent cov
it('never inherits cancelled targets and keeps an explicit empty customer plan ahead of a pending header', async () => {
for (const ownPlan of [false, true]) {
const c = context(); Object.assign(c.batch, { appointmentStatus: ownPlan ? 'PENDING' : 'CANCELLED', plannedDate: '2026-10-27', appointmentRemark: '旧批原因' })
if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'PENDING' }) }]
if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload(createAppointmentForm()) }]
vi.mocked(api.getReceivingContext).mockResolvedValue(c)
const wrapper = render(); await flushPromises(); await button(wrapper, '保存目标 / 未约成').trigger('click'); await flushPromises()
expect((wrapper.get('[aria-label="客户计划日期"]').element as HTMLInputElement).value).toBe('')
@@ -143,7 +143,7 @@ async function openPlan(id: string) {
resetForm(); groupKey.value = null; group.value = null; customer.value = id; mode.value = 'plan'
const saved = customerPlan(id)
const header = context.value!.batch
const inherited = !context.value!.agreements.some(agreement => agreement.status === 'ACTIVE') && ['PENDING', 'FAILED'].includes(header.appointmentStatus || '') ? createAppointmentForm(header) : { ...createAppointmentForm(), appointmentStatus: 'PENDING' as const }
const inherited = !context.value!.agreements.some(agreement => agreement.status === 'ACTIVE') && ['PENDING', 'FAILED'].includes(header.appointmentStatus || '') ? createAppointmentForm(header) : createAppointmentForm()
Object.assign(planForm, createAppointmentForm(), saved ? Object.fromEntries(Object.entries(saved.details).map(([key, value]) => [key, value ?? ''])) : { ...inherited, confirmationNo: '', appointmentAt: '', appointmentEndAt: '', startOffset: '', endOffset: '' })
baseline.value = state()
}
@@ -1,7 +1,7 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkSharedTripForm from './WorkSharedTripForm.vue'
import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
import { getDispatch, useDispatchCommandRecovery, type CommandResult, type DispatchDetail } from '@/modules/overseas-warehouse'
import type { LeaveGuard } from './useWorkDraft'
const access = vi.hoisted(() => ({ allowed: true }))
const identity = vi.hoisted(() => ({ id: '' }))
@@ -25,14 +25,11 @@ it('honors native stage/gate and permissions and rejects a different returned bu
vi.mocked(getDispatch).mockResolvedValueOnce(detail('WRONG')); const wrong = render(); await flushPromises(); expect(wrong.get('[role="alert"]').text()).toContain('原排车单不一致'); wrong.unmount()
access.allowed = false; const count = vi.mocked(getDispatch).mock.calls.length, denied = render(); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(count); denied.unmount()
})
it('ignores an old add command without blocking the calendar or another action',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'DS-60',action:'ADD_CARGO',attempted:true,body:{idempotencyKey:'old'}}))
const wrapper=render();await flushPromises()
expect(button(wrapper,'选择同仓未安排货物').attributes('disabled')).toBeUndefined()
expect(await guard()).toBe(true)
expect(sessionStorage.getItem('dispatch-command:'+identity.id)).toBeNull()
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
wrapper.unmount()
it('retains an unknown original add command after the overlay closes and offers recovery without a new key', async () => {
const recovery = useDispatchCommandRecovery(() => identity.id); recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'original-key', cargo: [], batchAllocations: [] })
const wrapper = render(); await flushPromises(); expect(button(wrapper, '选择同仓未安排货物').attributes('disabled')).toBeDefined(); expect(await guard()).toBe(false)
await button(wrapper, '核对原加货请求').trigger('click'); expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
expect(recovery.pending.value?.body.idempotencyKey).toBe('original-key'); wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).vm.$emit('update:modelValue', false); await flushPromises(); expect(await guard()).toBe(false); wrapper.unmount()
})
it('retries lookup as a read and keeps the original detail navigation', async () => {
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('lookup failed')); const wrapper = render(); await flushPromises(); await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises(); expect(getDispatch).toHaveBeenCalledTimes(2)
@@ -53,12 +50,22 @@ it.each([false, true])('refreshes the original add-cargo gate after a rejection
if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货')
wrapper.unmount()
})
it('does not offer a stored command replay when opening the form again',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'DS-60',action:'ADD_CARGO',attempted:true,result:{recordVersion:1}}))
const wrapper=render();await flushPromises()
expect(wrapper.text()).not.toContain('刷新加货资料')
expect(button(wrapper,'选择同仓未安排货物').attributes('disabled')).toBeUndefined()
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
it('offers read refresh for proven add-cargo success while retaining the exact original command', async () => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('DS-60', 'ADD_CARGO', { recordVersion: 0, idempotencyKey: 'known-key', cargo: [], batchAllocations: [] })
recovery.pending.value!.attempted = true
const completed: CommandResult = {
dispatchNo: 'DS-60', action: 'ADD_CARGO', idempotencyKey: 'known-key', recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING',
occurredAt: '2026-10-20T00:00:00Z', effect: 'APPLIED', counts: { itemCount: 1, palletCount: 1, packageCount: 60, pickedPackageCount: 0, shippedPackageCount: 0 },
replayed: false, alreadyArranged: false, result: { dispatchNo: 'DS-60', changedItemIds: [] },
}
recovery.pending.value!.result = completed
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
expect(wrapper.text()).not.toContain('原排车请求尚未核对')
await button(wrapper, '刷新加货资料').trigger('click')
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(true)
expect(recovery.pending.value!.body.idempotencyKey).toBe('known-key')
wrapper.unmount()
})
it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => {
@@ -31,6 +31,9 @@ async function load() {
finally { if (current === generation) loading.value = false }
}
function prepare() { if (allowed.value && !loading.value && !recovery.pending.value) { rejectionError.value = null; open.value = true } }
function recover() {
if (recovery.pending.value?.dispatchNo === props.dispatchNo && recovery.pending.value.action === 'ADD_CARGO') open.value = true
}
function saved() { open.value = false; rejectionError.value = null; emit('updated'); void load() }
function rejected(details: ApiErrorDetails) { rejectionError.value = details; emit('updated'); void load() }
watch(() => props.dispatchNo, load, { immediate: true })
@@ -44,6 +47,7 @@ onBeforeUnmount(() => { generation++; emit('busy', false) })
<LoadErrorState v-if="rejectionError" title="本车加货未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
<p v-if="detail">{{ detail.vehicleInfo.vehicle?.plate || '车辆待维护' }} · {{ detail.counts.packageCount }} 箱</p>
<p v-if="detail && !allowed">{{ detail.actions.ADD_CARGO?.blockReason || '当前阶段或权限不允许加货,请从原排车单继续作业。' }}</p>
<p v-if="recovery.pending.value">{{ recovery.pending.value.result ? '原排车操作已完成,页面资料待刷新,不能另发加货命令。' : '原排车请求尚未核对,不能另发加货命令。' }}<el-button v-if="recovery.pending.value.dispatchNo === dispatchNo && recovery.pending.value.action === 'ADD_CARGO'" @click="recover">{{ recovery.pending.value.result ? '刷新加货资料' : '核对原加货请求' }}</el-button></p>
<el-button :disabled="!allowed || busy" :loading="loading" @click="prepare">选择同仓未安排货物</el-button><el-button v-if="readable" :disabled="busy" @click="emit('external', `/overseas-warehouse/dispatches/${encodeURIComponent(dispatchNo)}`)">查看原排车单</el-button>
<DispatchWorkflowDialog v-model="open" :dispatch-no="dispatchNo" action="ADD_CARGO" @saved="saved" @rejected="rejected" />
</section>
@@ -3,39 +3,27 @@ import { projectContainerSchedule } from './schedule'
import type { CalendarSources, WarehouseArrival } from './scheduleApi'
const range = { from: '2026-11-01', to: '2026-12-01' }
const sources: CalendarSources = { containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: 'Port', destinationWarehouseCode: null, arrivalAt: '2026-10-30T23:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true }
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true, plannedDate: '2026-11-01', isDefault: true }
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: true }
describe('four-type schedule projection', () => {
it('keeps inconsistent authoritative plan times undated instead of hiding them with an automatic date', () => {
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, planCount: 1, isDefault: false, canEdit: false }], range, true)
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, canEdit: false }], range, true)
expect(events[0]).toMatchObject({ type: 'warehouse', date: null, subtitle: '柜内计划时间不一致,待核实' })
})
it('adds two local calendar days across months without creating a time', () => {
const events = projectContainerSchedule(sources, [warehouse], range, true)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '', title: 'D0001 计划到仓' })
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '' })
})
it('changes the automatic date when the authoritative arrival changes and retains manual plans', () => {
const changed = { ...sources, containers: [{ ...sources.containers[0]!, arrivalAt: '2026-11-02T05:00:00' }] }
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedDate: '2026-11-04' }], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1, isDefault: false }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
expect(projectContainerSchedule(changed, [warehouse], range, true).find(row => row.type === 'warehouse')?.date).toBe('2026-11-04')
expect(projectContainerSchedule(changed, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00', version: 1 }], range, true).find(row => row.type === 'warehouse')).toMatchObject({ date: '2026-11-08', time: '09:30', subtitle: '人工维护' })
})
it('keeps manually moved containers visible when their arrival is outside this month', () => {
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedAt: '2026-11-08T09:30:00' }], range, true)
expect(events).toHaveLength(1)
expect(events[0]?.date).toBe('2026-11-08')
})
it('shows a date-only frozen plan as frozen without manufacturing midnight', () => {
const events = projectContainerSchedule(sources, [{ ...warehouse, planCount: 1, isDefault: false, canEdit: false }], range, true)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ date: '2026-11-01', time: '', subtitle: '已有入库计划,日期已冻结', canEdit: false })
expect(events[0]?.fields).toContainEqual({ label: '计划到仓', value: '2026-11-01' })
})
it('keeps cross-month frozen date-only cards and their details readable without a port-arrival row', () => {
const events = projectContainerSchedule({ ...sources, containers: [] }, [{ ...warehouse, plannedDate: '2026-11-08', planCount: 1, isDefault: false, canEdit: false }], range, true)
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ date: '2026-11-08', time: '', title: 'D0001 计划到仓', subtitle: '已有入库计划,日期已冻结' })
expect(events[0]?.fields).toContainEqual({ label: '计划到仓', value: '2026-11-08' })
})
it('does not replace the planned date with PDA actual arrival', () => {
const event = projectContainerSchedule(sources, [{ ...warehouse, plannedAt: '2026-11-05T09:00:00', actualAt: '2026-11-06T10:00:00', planCount: 1, canEdit: false }], range, true)[0]!
expect(event.date).toBe('2026-11-05')
@@ -43,7 +31,7 @@ describe('four-type schedule projection', () => {
expect(event.fields).toContainEqual({ label: '实际到仓', value: '2026-11-06 10:00' })
})
it('shows missing dates explicitly and never uses internal IDs as display numbers', () => {
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, cabinetNo: null, arrivalAt: null }] }, [{ ...warehouse, plannedDate: null }], range, false)
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, containerNo: null, cabinetNo: null, arrivalAt: null }] }, [warehouse], range, false)
expect(events.map(row => row.date)).toEqual([null, null])
expect(events.every(row => row.sourcePath === undefined)).toBe(true)
expect(events.map(row => row.title)).toEqual(['— 到港', '— 计划到仓'])
+8 -10
View File
@@ -22,9 +22,8 @@ const timestamp = (at: string | null) => at?.replace('T', ' ').slice(0, 16) || '
export function projectContainerSchedule(sources: CalendarSources, warehouses: WarehouseArrival[] | null, range: ScheduleRange, canViewContainer: boolean) {
const events: ScheduleEvent[] = []
const warehouseMap = new Map(warehouses?.map(row => [row.sourceContainerBookingId, row]) || [])
const containerMap = new Map(sources.containers.map(row => [row.containerBookingId, row]))
for (const row of sources.containers) {
const label = row.cabinetNo || '—'
const label = row.containerNo || row.cabinetNo || '—'
const fields = [{ label: '集装箱号', value: row.containerNo }, { label: '柜次号', value: row.cabinetNo },
{ label: '船名航次', value: [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') }, { label: '目的港', value: row.destinationPortName }]
const sourcePath = canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined
@@ -37,32 +36,31 @@ export function projectContainerSchedule(sources: CalendarSources, warehouses: W
if (warehouses === null) continue
const warehouse = warehouseMap.get(row.containerBookingId)
warehouseMap.delete(row.containerBookingId)
const projected = projectWarehouseArrival(warehouse)
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: projected.time, title: `${label} 计划到仓`, sourcePath, warehouse,
canEdit: warehouse?.canEdit === true,
subtitle: !planned ? '柜内计划时间不一致,待核实' : warehouse && warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : projected.isDefault ? '到港日期+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) }] })
}
// 人工改期可能跨月,与当前月份的到港记录无关。
for (const row of warehouseMap.values()) {
const projected = projectWarehouseArrival(row)
const date = projected.date
const date = row.plannedAt?.slice(0, 10) || null
if (!date || !inRange(date, range)) continue
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: projected.time,
title: `${row.cabinetNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: row.plannedAt?.slice(11, 16) || '',
title: `${row.containerNo || '—'} 计划到仓`, subtitle: row.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护', warehouse: row, canEdit: row.canEdit,
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.sourceContainerBookingId)}` : undefined,
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '接仓仓库', value: row.warehouseName || row.warehouseCode },
{ label: '计划到仓', value: row.plannedAt ? timestamp(row.plannedAt) : date }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
{ label: '计划到仓', value: timestamp(row.plannedAt) }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
}
for (const row of sources.emptyReturns) {
const at = row.actualEmptyReturnAt || row.emptyReturnWarning.deadlineAt
if (!at || !inRange(at.slice(0, 10), range)) continue
events.push({ key: `return-${row.containerBookingId}`, type: 'return', date: at.slice(0, 10), time: at.slice(11, 16),
title: `${containerMap.get(row.containerBookingId)?.cabinetNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
title: `${row.containerNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
subtitle: row.actualEmptyReturnAt ? '实际还柜' : row.emptyReturnWarning.status === 'OVERDUE' ? '已超过还柜截止时间' : '还柜截止提醒',
sourcePath: canViewContainer ? `/container-loading/overseas/${encodeURIComponent(row.containerBookingId)}` : undefined,
fields: [{ label: '集装箱号', value: row.containerNo }, { label: '车队', value: row.fleetSupplierName },
@@ -35,7 +35,7 @@ async function readSchedule(overrides: Record<string, unknown> = {}) {
describe('warehouse calendar response to schedule', () => {
it.each(['0', 0])('uses zero plan count %j to show arrival plus two days without inventing a time', async planCount => {
const { warehouses, event } = await readSchedule({ planCount, plannedDate: '2026-10-22', isDefault: true })
const { warehouses, event } = await readSchedule({ planCount })
expect(warehouses[0]).toMatchObject({ sourceContainerBookingId: bookingId, planCount: 0, plannedAt: null, actualAt: null })
expect(event).toMatchObject({ date: '2026-10-22', time: '', canEdit: true, subtitle: '到港日期+2个自然日(默认)' })
expect(event?.fields).toContainEqual({ label: '计划到仓', value: '2026-10-22' })
@@ -21,7 +21,6 @@ export interface CalendarReturn {
export interface CalendarSources { containers: CalendarContainer[]; emptyReturns: CalendarReturn[]; emptyReturnsReadable: boolean }
export interface WarehouseArrival {
sourceContainerBookingId: string
cabinetNo?: string | null
containerNo: string | null
warehouseName: string | null
warehouseCode: string | null
@@ -30,8 +29,6 @@ export interface WarehouseArrival {
version: number
planCount: number
canEdit: boolean
plannedDate?: string | null
isDefault?: boolean
}
export interface ScheduleRange { from: string; to: string }
type WarehouseArrivalWire = Omit<WarehouseArrival, 'planCount' | 'plannedAt' | 'actualAt'> & {
@@ -4,16 +4,15 @@ import { http } from '@/shared/api/http'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
const range = { from: '2026-10-01', to: '2026-11-01' }
function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
return { sourceType: 'DISPATCH', dispatchNo, containerNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
}
describe('location-based trip calendar', () => {
it('places the reported UTC plan on October 22 at 09:00 in Los Angeles and exposes every container in one event', () => {
const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: ['D1821', 'D1822'] }
const row = { ...trip('PC-CALENDAR-001', '2026-10-22T16:00:00Z'), containerNumbers: ['TSTU1234567', 'TSTU7654321'] }
const projected = projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, range)
expect(projected.events).toHaveLength(1)
expect(projected.events[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00', sourcePath: '/overseas-warehouse/dispatches/PC-CALENDAR-001' })
expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
expect(projected.events[0]!.fields).toContainEqual({ label: '关联柜次号', value: 'D1821、D1822' })
expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
expect(projected.events[0]!.fields).toContainEqual({ label: '计划出发(UTC)', value: '2026-10-22T16:00:00Z' })
const beijing = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
@@ -5,7 +5,6 @@ export interface TripCalendarEntry {
sourceType: 'DISPATCH'
dispatchNo: string
containerNumbers: string[]
cabinetNumbers: string[]
status: string
originWarehouseId: string | null
originWarehouseCode: string | null
@@ -82,7 +81,6 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
fields: [
{ label: '运输状态', value: labels[row.status] || '待核实' },
{ label: '关联柜号', value: row.containerNumbers.join('、') },
{ label: '关联柜次号', value: row.cabinetNumbers.join('、') },
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
{ label: '地点时区', value: row.originTimezone },
{ label: '计划出发(起运仓当地)', value: planned ? `${planned.date} ${planned.time}` : null },
@@ -7,8 +7,6 @@ export interface ContainerArrivalTime {
actualWarehouseArrivalAt: string | null
version: number
planCount: number
plannedDate?: string | null
isDefault?: boolean
}
function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime {
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import { http } from '@/shared/api/http'
import { createDeclarationBatch, submitDeclarationBatch, recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, downloadContainerCustomsFinal, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
const customs = {
aggregateId: 2092438843630391401n, loadingPlanId: 2092438843630391402n, containerBookingId: 2092438843630391403n,
@@ -99,42 +99,7 @@ describe('platform container customs API', () => {
expect(get.mock.calls).toEqual([
[`/platform/v1/customs/loading-plans/documents/${documentId}/download`, { responseType: 'blob' }],
[`/platform/v1/customs/loading-plans/documents/${documentId}/completion/download`, { responseType: 'blob' }],
[`/platform/v1/customs/loading-plans/documents/${documentId}/receipt/download`, { responseType: 'blob' }],
])
})
it.each([downloadContainerCustomsDocument, downloadContainerCustomsReceipt, downloadContainerCustomsFinal])('拒绝 HTTP 200 中的真实 JSON Blob 业务错误 %#', async (download) => {
const body = new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-customs-blob', traceId: 'trace-customs-blob' })], { type: 'application/json;charset=UTF-8' })
vi.spyOn(http, 'get').mockResolvedValue(body)
const error: unknown = await download('2092438843630391407').catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({ code: 'A0404', message: '预录单不存在', status: 200, requestId: 'request-customs-blob', traceId: 'trace-customs-blob' })
})
it('非 2xx 的 JSON Blob 也保留业务错误和诊断信息', async () => {
const body = new Blob([JSON.stringify({ code: 'A0403', message: '无权下载预录单', data: null, requestId: 'request-forbidden', traceId: 'trace-forbidden' })], { type: 'application/json' })
vi.spyOn(http, 'get').mockRejectedValue({ isAxiosError: true, response: { status: 403, data: body } })
await expect(downloadContainerCustomsReceipt('7')).rejects.toMatchObject({ code: 'A0403', message: '无权下载预录单', status: 403, requestId: 'request-forbidden', traceId: 'trace-forbidden' })
})
it.each([
['application/pdf', '%PDF-1.7 原文件'],
['application/octet-stream', '原始二进制'],
['application/json', '{"product":"报关资料","code":"ITEM-1"}'],
['application/json', '{invalid-json'],
])('保留原文件内容与对象:%s', async (type, contents) => {
const body = new Blob([contents], { type })
vi.spyOn(http, 'get').mockResolvedValue(body)
await expect(downloadContainerCustomsFinal('7')).resolves.toBe(body)
})
it('非 JSON 的传输错误沿用原始错误', async () => {
const error = { isAxiosError: true, response: { status: 502, data: new Blob(['bad gateway'], { type: 'text/html' }) } }
vi.spyOn(http, 'get').mockRejectedValue(error)
await expect(downloadContainerCustomsReceipt('7')).rejects.toBe(error)
})
})
@@ -1,5 +1,4 @@
import axios from 'axios'
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type ContainerCustomsStatus = 'WAITING_CUSTOMER_DOCUMENTS' | 'READY_TO_START' | 'DECLARING' | 'COMPLETED'
export type ContainerCustomsTaxMode = 'TAX_REFUND' | 'BUY_DECLARATION'
@@ -211,45 +210,16 @@ export async function getContainerCustoms(loadingPlanId: string) {
return value ? normalize(value) : null
}
// 报关资料允许下载原始 JSON 文件,仅把统一业务错误信封识别为失败。
async function customsDownloadError(blob: Blob, status: number): Promise<ApiResponseError | null> {
if (!blob.type.toLowerCase().includes('json')) return null
let payload: unknown
try { payload = JSON.parse(await blob.text()) } catch { return null }
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
const envelope = payload as Record<string, unknown>
if (typeof envelope.code !== 'string' || envelope.code === '200'
|| typeof envelope.message !== 'string' || !('data' in envelope)) return null
return new ApiResponseError(envelope.message, envelope.code, status, envelope.data,
typeof envelope.requestId === 'string' ? envelope.requestId : undefined,
typeof envelope.traceId === 'string' ? envelope.traceId : undefined)
}
async function downloadCustomsFile(path: string) {
try {
const blob = await http.get<Blob, Blob>(path, { responseType: 'blob' })
const error = await customsDownloadError(blob, 200)
if (error) throw error
return blob
} catch (error) {
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
const downloadError = await customsDownloadError(error.response.data, error.response.status)
if (downloadError) throw downloadError
}
throw error
}
}
export function downloadContainerCustomsDocument(documentId: string) {
return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/download`)
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/download`, { responseType: 'blob' })
}
export function downloadContainerCustomsReceipt(documentId: string) {
return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/completion/download`)
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/receipt/download`, { responseType: 'blob' })
}
export function downloadContainerCustomsFinal(documentId: string) {
return downloadCustomsFile(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`)
return http.get<Blob, Blob>(`${basePath}/documents/${encodeURIComponent(documentId)}/final/download`, { responseType: 'blob' })
}
async function action(loadingPlanId: string, name: 'start' | 'send-to-broker' | 'submit-to-broker' | 'complete-all-buy', expectedVersion: number, remark?: string) {
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessageBox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { ApiResponseError, http } from '@/shared/api/http'
import { ApiResponseError } from '@/shared/api/http'
import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms'
const api = vi.hoisted(() => ({
@@ -94,8 +94,6 @@ describe('ContainerCustomsPanel', () => {
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(wrapper.text()).toContain('ENTRY-001')
expect(wrapper.text()).not.toContain('版本')
await wrapper.findAll('button').find(button => button.text() === '标记海关异常/结果')!.trigger('click')
const state = (wrapper.vm.$ as unknown as { setupState: {
clearanceBatch: DeclarationBatch; clearanceResultType: ClearanceResultType; clearanceRemark: string; clearanceDialogVisible: boolean
@@ -116,12 +114,12 @@ describe('ContainerCustomsPanel', () => {
wrapper.unmount()
})
it('开始报关确认使用简短文案,取消不会冻结资料或调用开始接口', async () => {
it('开始报关确认显示柜次号,取消不会冻结资料或调用开始接口', async () => {
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
const wrapper = await render('READY_TO_START')
await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click')
await flushPromises()
expect(confirm).toHaveBeenCalledWith('确认开始整柜报送?', '整柜报关', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认' }))
expect(confirm).toHaveBeenCalledWith(expect.any(String), '开始整柜报关 · MCR08290001', expect.objectContaining({ customClass: 'admin-density-confirm' }))
expect(api.start).not.toHaveBeenCalled()
confirm.mockRestore()
wrapper.unmount()
@@ -166,14 +164,15 @@ describe('ContainerCustomsPanel', () => {
it('keeps READY_TO_START read-only while exposing source file name and download', async () => {
const wrapper = await render('READY_TO_START')
expect(wrapper.text()).toContain('开始报关')
expect(wrapper.find('[aria-label="报关概况"]').exists()).toBe(false)
expect(wrapper.get('[aria-label="报关概况"] dt').text()).toBe('实际箱数')
expect(wrapper.get('[aria-label="报关概况"] dd').text()).toBe('12')
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('客户退税资料')
expect(wrapper.text()).toContain('预录单')
expect(wrapper.text()).toContain('报关行回执')
expect(wrapper.text()).not.toContain('客户报关资料')
expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true)
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -206,13 +205,13 @@ describe('ContainerCustomsPanel', () => {
it('allows completion upload and overwrite only while DECLARING with manage permission', async () => {
let wrapper = await render('DECLARING', true, false)
expect(wrapper.text()).toContain('首次发送报关行')
expect(wrapper.text()).toContain('首次发送后上传预录单')
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
expect(wrapper.text()).toContain('首次发送后上传回执')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
wrapper.unmount()
wrapper = await render('DECLARING', true, true)
expect(wrapper.text()).toContain('发送报关行执行')
expect(wrapper.text()).toContain('覆盖预录单')
expect(wrapper.text()).toContain('覆盖报关行回执')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
@@ -220,8 +219,8 @@ describe('ContainerCustomsPanel', () => {
wrapper = await render('DECLARING', false, true)
expect(wrapper.text()).toContain('只读')
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -237,40 +236,14 @@ describe('ContainerCustomsPanel', () => {
wrapper.unmount()
})
it('隐藏资料版本但保留预录单历史、原文件名和上传并发版本', async () => {
const value = customs('DECLARING', true)
const document = value.documents[0]!
document.receipt!.version = 7
document.receiptHistory = [{ version: 6, file: { ...document.receipt!, version: 6 }, operatorName: '报关员', createdAt: '2026-08-29T10:50:00' }]
api.get.mockResolvedValue(value)
const wrapper = mount(ContainerCustomsPanel, {
props: { loadingPlanId: value.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
global: { plugins: [ElementPlus] },
})
await flushPromises()
expect(wrapper.findAll('th').map(header => header.text())).toEqual(['操作', '客户退税资料', '预录单', '真实报关完税文件', '关联入仓码', '报关行状态', '客户', '预录单情况'])
expect(wrapper.text()).not.toMatch(/版本|退税资料 v|回执 v|完税 v/)
expect(wrapper.text()).toContain('预录单历史:2026/8/29 10:50:00 · 报关员')
expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一')
expect(wrapper.find('[aria-label="下载预录单 报关行回执.pdf"]').exists()).toBe(true)
const selected = new File(['receipt'], '新预录单.pdf', { type: 'application/pdf' })
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
onChange({ raw: selected })
await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '提交')!.trigger('click')
await flushPromises()
expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7)
wrapper.unmount()
})
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
const wrapper = await render('COMPLETED', true, true)
expect(wrapper.text()).toContain('客户退税资料.pdf')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
expect(wrapper.text()).toContain('报关已完成,待其他离仓条件')
expect(buttonTexts(wrapper)).not.toContain('上传预录单')
expect(buttonTexts(wrapper)).not.toContain('覆盖预录单')
expect(buttonTexts(wrapper)).not.toContain('上传报关行回执')
expect(buttonTexts(wrapper)).not.toContain('覆盖报关行回执')
expect(buttonTexts(wrapper)).not.toContain('提交')
})
@@ -281,7 +254,7 @@ describe('ContainerCustomsPanel', () => {
expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407')
expect(api.downloadReceipt).not.toHaveBeenCalled()
await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click')
await wrapper.get('[aria-label="下载报关行回执 报关行回执.pdf"]').trigger('click')
await flushPromises()
expect(api.downloadReceipt).toHaveBeenCalledWith('2092438843630391407')
await wrapper.get('[aria-label="下载报关完税文件 真实报关完税文件.pdf"]').trigger('click')
@@ -305,46 +278,4 @@ describe('ContainerCustomsPanel', () => {
expect(wrapper.text()).toContain('trace-customs-download')
expect(api.downloadReceipt).not.toHaveBeenCalled()
})
it('下载预录单失败时透传正式名称与诊断信息,保留原文件名', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-pre-entry', traceId: 'trace-pre-entry' })], { type: 'application/json' }))
api.downloadReceipt.mockImplementationOnce(async (documentId: string) => {
const actual = await vi.importActual<typeof import('../api/platformContainerCustoms')>('../api/platformContainerCustoms')
return actual.downloadContainerCustomsReceipt(documentId)
})
const wrapper = await render('COMPLETED', false, true)
await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click')
await flushPromises()
await vi.waitFor(() => expect(wrapper.text()).toContain('预录单不存在'))
expect(wrapper.text()).toContain('request-pre-entry')
expect(wrapper.text()).toContain('trace-pre-entry')
expect(wrapper.text()).toContain('报关行回执.pdf')
expect(wrapper.text()).not.toContain('报关行回执不存在')
expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled()
expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/download', { responseType: 'blob' })
get.mockRestore()
wrapper.unmount()
})
it('上传冲突时展示服务端预录单提示并保留待上传文件', async () => {
const message = '客户确认的预录单已发送给报关行,不可再修改预录单'
api.upload.mockRejectedValueOnce(new ApiResponseError(message, 'A0409', 200, undefined, 'request-pre-entry-upload', 'trace-pre-entry-upload'))
const wrapper = await render('DECLARING', true, true)
const selected = new File(['receipt'], '预录单.pdf', { type: 'application/pdf' })
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
onChange({ raw: selected })
await flushPromises()
const submit = wrapper.findAll('button').find(button => button.text() === '提交')!
await submit.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain(message)
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-pre-entry-upload')
expect(wrapper.text()).toContain('trace-pre-entry-upload')
expect(submit.attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
})
@@ -67,6 +67,7 @@ function clearanceResultLabel(value: ClearanceResultType) {
}
const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || [])
const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0)))
const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0)
const brokerSubmission = computed(() => customs.value?.brokerSubmission || null)
const brokerSubmitted = computed(() => Boolean(customs.value?.brokerSubmittedAt))
@@ -79,6 +80,9 @@ const statusText: Record<ContainerCustomsStatus, string> = {
function documentCustomers(document: ContainerCustomsDocument) {
return [...new Set((customs.value?.inbounds || []).filter((item) => document.inboundIds.includes(item.inboundId)).map((item) => item.customerName))].join('、') || '—'
}
function receiptHistoryLabel(document: ContainerCustomsDocument) {
return (document.receiptHistory || []).map((item) => `v${item.version}`).join('、') || '—'
}
function confirmationActionLabel(action: ContainerCustomsDocument['confirmationHistory'][number]['action']) {
return action === 'CONFIRM' ? '确认' : '打回'
}
@@ -123,7 +127,7 @@ function declarationStatusLabel(value: DeclarationBatch['status']) {
async function createFormalDeclaration() {
if (!customs.value || !['DECLARING', 'COMPLETED'].includes(customs.value.status) || !brokerSubmitted.value || openDeclarationBatch.value) return
try { await ElMessageBox.confirm('当前柜的资料、预录单已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
try { await ElMessageBox.confirm('当前柜的资料、回执已确认并已发送报关行执行,确认创建正式申报批次?完税文件仍按报关行完成后回传。', customsDialogTitle('创建正式申报批次'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认创建' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { await createDeclarationBatch(props.loadingPlanId, customs.value.version); await load(); ElMessage.success('正式申报批次已创建') }
catch (error) { actionError.value = getApiErrorDetails(error, '正式申报批次创建失败') }
@@ -180,7 +184,7 @@ async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationB
async function start() {
if (!customs.value || customs.value.status !== 'READY_TO_START') return
try {
await ElMessageBox.confirm('确认开始整柜报送?', '整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认' })
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', customsDialogTitle('开始整柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
} catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
@@ -191,15 +195,15 @@ async function start() {
async function uploadReceipt(document: ContainerCustomsDocument) {
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
const selected = receiptFiles[document.documentId]
if (!selected) { ElMessage.warning('请选择对应的预录单'); return }
if (!selected) { ElMessage.warning('请选择对应的报关行回执'); return }
actionLoading.value = true; actionError.value = null
try {
await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0)
receiptFiles[document.documentId] = null
await load()
emit('refreshed')
ElMessage.success('预录单已上传,等待客户确认')
} catch (error) { actionError.value = getApiErrorDetails(error, '预录单上传失败') }
ElMessage.success('报关行回执已上传,等待客户确认')
} catch (error) { actionError.value = getApiErrorDetails(error, '报关行回执上传失败') }
finally { actionLoading.value = false }
}
@@ -236,9 +240,9 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
? await downloadContainerCustomsDocument(document.documentId)
: kind === 'receipt' ? await downloadContainerCustomsReceipt(document.documentId) : await downloadContainerCustomsFinal(document.documentId)
saveBlob(blob, target.fileName)
ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '预录单' : '报关完税文件'}下载已开始`)
ElMessage.success(`${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '报关行回执' : '报关完税文件'}下载已开始`)
} catch (error) {
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : kind === 'receipt' ? '预录单' : '报关完税文件'}下载失败`)
actionError.value = getApiErrorDetails(error, `${kind === 'source' ? '客户退税资料' : '报关完成文件'}下载失败`)
} finally {
downloadingKey.value = ''
}
@@ -246,7 +250,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
async function submitToBroker() {
if (!customs.value || customs.value.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value || !allReceiptsConfirmed.value) return
try { await ElMessageBox.confirm('确认已核对全部预录单并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
try { await ElMessageBox.confirm('确认已核对全部回执并把确认结果发送给报关行执行?', customsDialogTitle('发送报关行执行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await submitContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已发送给报关行执行') }
catch (error) { actionError.value = getApiErrorDetails(error, '发送报关行执行失败') }
@@ -255,9 +259,9 @@ async function submitToBroker() {
async function sendToBroker() {
if (!customs.value || customs.value.status !== 'DECLARING' || brokerSubmission.value || brokerSubmitted.value) return
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回预录单,关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
try { await ElMessageBox.confirm('确认将当前冻结的退税报关资料首次发送给报关行?发送后由报关行返回回执,资料版本和关联范围按当前快照处理。', customsDialogTitle('首次发送报关行'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认发送' }) } catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待预录单') }
try { customs.value = await sendContainerCustomsToBroker(props.loadingPlanId, customs.value.version); ElMessage.success('已首次发送给报关行,等待回执') }
catch (error) { actionError.value = getApiErrorDetails(error, '首次发送报关行失败') }
finally { actionLoading.value = false }
}
@@ -311,23 +315,26 @@ onMounted(load)
<template v-else-if="customs">
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
<template v-else>
<dl v-if="brokerSubmission" class="density-fields customs-summary" aria-label="报关概况">
<div class="submission-batch"><dt>首次发送批次</dt><dd>{{ brokerSubmission.batchNo }}</dd></div><div><dt>发送状态</dt><dd>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</dd></div>
<div class="submission-batch"><dt>发送人</dt><dd>{{ brokerSubmission.sentByName || '—' }}</dd></div><div class="submission-batch"><dt>发送时间</dt><dd>{{ formatDateTime(brokerSubmission.sentAt) }}</dd></div>
<dl class="density-fields customs-summary" aria-label="报关概况">
<div><dt>实际箱数</dt><dd>{{ customs.actualBoxCount }}</dd></div><div><dt>退税总票</dt><dd>{{ customs.taxRefundCount }}</dd></div><div><dt>已覆盖</dt><dd>{{ customs.coveredTaxRefundCount }}</dd></div><div><dt>缺失</dt><dd :class="{ 'coverage-missing': missingCoverageCount }">{{ missingCoverageCount }}</dd></div>
<template v-if="brokerSubmission">
<div class="submission-batch"><dt>首次发送批次</dt><dd>{{ brokerSubmission.batchNo }}</dd></div><div><dt>发送状态</dt><dd>{{ brokerSubmission.status === 'SENT' ? '已发送' : brokerSubmission.status }}</dd></div><div><dt>资料版本</dt><dd>v{{ brokerSubmission.sourceVersion }}</dd></div>
<div class="submission-batch"><dt>发送人</dt><dd>{{ brokerSubmission.sentByName || '—' }}</dd></div><div class="submission-batch"><dt>发送时间</dt><dd>{{ formatDateTime(brokerSubmission.sentAt) }}</dd></div>
</template>
</dl>
<div v-if="missingInbounds.length" class="compact-table-wrap">
<h3>缺退税资料清单</h3>
<div class="workbench-evidence"><table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table></div>
</div>
<div v-if="customs.documents.length" class="compact-table-wrap">
<h3>已冻结退税资料、预录单与完税文件</h3>
<div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>预录单</th><th>真实报关完税文件</th><th>关联入仓码</th><th>报关行状态</th><th>客户</th><th>预录单情况</th></tr></thead><tbody>
<h3>已冻结退税资料、报关行回执与完税文件</h3>
<div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关行回执</th><th>真实报关完税文件</th><th>关联入仓码</th><th>报关行状态</th><th>版本</th><th>客户</th><th>回执情况</th></tr></thead><tbody>
<tr v-for="document in customs.documents" :key="document.documentId">
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传预录单' : document.receipt ? '覆盖预录单' : '上传预录单' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传回执' : document.receipt ? '覆盖报关行回执' : '上传报关行回执' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载预录单 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td>
<td><div v-if="document.receipt" class="file-cell"><span :title="document.receipt.fileName">{{ document.receipt.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `receipt:${document.documentId}`" :aria-label="`下载报关行回执 ${document.receipt.fileName}`" @click="downloadFile(document, 'receipt')">下载</el-button></div><span v-else>未上传</span></td>
<td><div v-if="document.finalDocument" class="file-cell"><span :title="document.finalDocument.fileName">{{ document.finalDocument.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `final:${document.documentId}`" :aria-label="`下载报关完税文件 ${document.finalDocument.fileName}`" @click="downloadFile(document, 'final')">下载</el-button></div><span v-else>未上传</span></td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td><td>{{ documentCustomers(document) }}</td><td><small v-for="history in document.confirmationHistory" :key="history.id" class="confirmation-history-note">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small><small v-for="history in document.receiptHistory" :key="history.version" class="receipt-history-note">预录单历史:{{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}</small>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认预录单' : document.receiptStatus === 'REJECTED' ? '客户已打回预录单' : '待客户确认预录单') : '待上传预录单' }}</td>
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待回执确认' : '待首次发送报关行' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.receipt"> / 回执 v{{ document.receipt.version }}</span><span v-if="document.finalDocument"> / 完税 v{{ document.finalDocument.version }}</span><small v-if="document.receiptHistory?.length" class="receipt-history-note">回执历史:{{ receiptHistoryLabel(document) }}</small><small v-for="history in document.confirmationHistory" :key="history.id" class="confirmation-history-note">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small></td><td>{{ documentCustomers(document) }}</td><td>{{ document.receipt ? (document.receiptStatus === 'CONFIRMED' ? '客户已确认回执' : document.receiptStatus === 'REJECTED' ? '客户已打回回执' : '待客户确认回执') : '待上传报关行回执' }}</td>
</tr>
</tbody></table></div>
</div>
@@ -336,7 +343,7 @@ onMounted(load)
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
<div v-else class="declaration-batch-list">
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
<div class="declaration-batch-main"><strong>{{ batch.batchNo }}</strong><span>报关单号:{{ batch.customsEntryNo || '—' }}</span></div>
<div class="declaration-batch-main"><strong>{{ batch.batchNo }}</strong><span>报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}</span></div>
<el-tag size="small" :type="batch.status === 'CLOSED' ? 'success' : batch.status === 'CANCELLED' ? 'info' : 'warning'">{{ declarationStatusLabel(batch.status) }}</el-tag>
<div class="declaration-batch-actions" v-if="canManage"><el-button v-if="batch.status === 'READY'" size="small" type="primary" :loading="actionLoading" @click="submitFormalDeclaration(batch)">提交正式申报</el-button><el-button v-if="['SUBMITTED','ACKNOWLEDGED','RESULT_PENDING'].includes(batch.status)" size="small" plain :loading="actionLoading" @click="openFormalResultDialog(batch)">标记海关异常/结果</el-button></div>
<div v-if="batch.results.length" class="declaration-result-list"><div v-for="result in batch.results" :key="result.id" class="declaration-result-row"><span>{{ clearanceResultLabel(result.resultType) }}</span><el-tag size="small" :type="result.resultStatus === 'RESOLVED' ? 'success' : 'warning'">{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}</el-tag><small>{{ result.resolution || '—' }}</small><el-button v-if="canManage && result.resultStatus === 'OPEN'" link type="primary" size="small" :loading="actionLoading" @click="resolveFormalResult(batch, result)">处理</el-button></div></div>
@@ -363,5 +370,5 @@ onMounted(load)
</template>
<style scoped>
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--workbench-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--workbench-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--workbench-border);background:var(--workbench-surface-soft)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--workbench-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--workbench-muted)}
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.coverage-missing{color:var(--el-color-danger)!important}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.confirmation-history-note,.receipt-history-note{display:block;margin-top:var(--admin-space-2);color:var(--workbench-muted)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--workbench-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--workbench-border);background:var(--workbench-surface-soft)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--workbench-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--workbench-muted)}
</style>
@@ -186,7 +186,7 @@ onMounted(load)
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
</el-table>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="containerDialogTitle(editing ? '调整尾程费用' : '新增尾程费用', props)" width="min(920px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
<section class="density-dialog-section">
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form" label-position="left">
<div class="workbench-evidence"><table class="density-table density-table--editable fee-editor-table" aria-label="尾程费用填写"><tbody>
<tr><td><el-form-item label-position="left" label-width="110px" label="客户" required><span v-if="editing">{{ customerName(form.customerId) || editing.customerName || '—' }}</span><el-select v-else v-model="form.customerId" :disabled="voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item></td></tr>
@@ -153,7 +153,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
<section v-if="visibleSections.has('pda')" class="overview-section" aria-label="PDA作业摘要">
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
<template v-else><dl class="density-fields pda-summary" aria-label="PDA作业统计"><div class="pda-progress"><dt>完成进度</dt><dd><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /></dd></div></dl><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
<template v-else><dl class="density-fields pda-summary" aria-label="PDA作业统计"><div class="pda-progress"><dt>完成进度</dt><dd><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /></dd></div><div><dt>任务明细</dt><dd>{{ overview.pda.itemCount }}</dd></div><div><dt>已处理</dt><dd>{{ overview.pda.completedItemCount }}</dd></div><div><dt>未解决</dt><dd>{{ overview.pda.unresolvedItemCount }}</dd></div><div><dt>有效照片</dt><dd>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</dd></div></dl><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
</section>
@@ -170,9 +170,9 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
.overview-section p { margin: 5px 0; color: var(--workbench-muted); font-size: var(--admin-font-body); }
.exception-management{padding-top:var(--admin-space-4);padding-bottom:var(--admin-space-4)}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:var(--admin-space-5)}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{max-width:360px;white-space: normal;overflow-wrap: anywhere}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:var(--admin-font-caption)}.exception-management__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.exception-history{overflow-x:auto;border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:var(--admin-space-3);align-items:center;min-width:680px;padding:var(--admin-space-2) var(--admin-space-1)}.exception-history__head{color:var(--workbench-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--workbench-border)}.exception-history li time,.exception-history li span{color:var(--workbench-muted);white-space: normal;overflow-wrap: anywhere}.exception-history__empty{margin:0!important;padding:7px var(--admin-space-1);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)!important}
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--workbench-border)}.document-table{width:100%;min-width:940px;table-layout:fixed}.document-table tbody tr:last-child td{border-bottom:0}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--workbench-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:var(--admin-font-body)}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-blocked-reason{color:var(--workbench-muted)}.document-actions{white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--workbench-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
.overview-panel .pda-summary { grid-template-columns: minmax(0, 1fr); margin-bottom: var(--admin-space-4); }
.overview-panel .pda-summary { grid-template-columns: minmax(280px, 2fr) repeat(4, minmax(0, 1fr)); margin-bottom: var(--admin-space-4); }
.pda-progress dd { width: 100%; }
.photo-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,220px));gap:var(--admin-space-3);margin:var(--admin-space-4) 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--workbench-border);background:var(--admin-surface);color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;height:112px;object-fit:contain;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:var(--admin-space-3);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:grid;gap:var(--admin-space-2);padding:var(--admin-space-2) var(--admin-space-3)}.photo-caption small{white-space:nowrap}.compact-empty{margin:var(--admin-space-2) 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:var(--admin-space-3);padding:var(--admin-space-2) 0;border-top:1px solid var(--workbench-border);font-size:var(--admin-font-caption)}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--workbench-muted);text-overflow:ellipsis;white-space:nowrap}
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.overview-panel .pda-summary{grid-template-columns:1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.overview-panel .pda-summary{grid-template-columns:1fr 1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
@media (max-width: 720px) { .exception-management__header{align-items:flex-start;flex-direction:column}.exception-management__header>div:first-child{align-items:flex-start;flex-direction:column;gap:3px}.exception-current{align-items:flex-start;flex-direction:column;gap:2px}.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} }
</style>
@@ -47,9 +47,6 @@ describe('OceanBookingLinkDialog', () => {
it.each([
[' mscu1234567 ', 'MSCU1234567'],
['local-1', 'LOCAL-1'],
['MSCU6639871', 'MSCU6639871'],
['ABCU123456789012', 'ABCU123456789012'],
['', null],
])('normalizes and submits the optional real container number %#', async (input, expected) => {
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
@@ -101,9 +101,9 @@ defineExpose({ candidates, candidateKey, containerNo, save })
<template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" :model-value="modelValue" :title="containerDialogTitle('关联已放舱订舱', container)" fullscreenable width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
<section class="density-dialog-section">
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form" label-position="left" label-width="160px">
<div class="workbench-evidence"><table class="density-table" aria-label="关联订舱资料"><tbody>
<div class="workbench-evidence"><table class="density-table density-table--editable" aria-label="关联订舱资料"><tbody>
<tr><td><el-form-item label="柜次号"><span>{{ container?.cabinetNo || '—' }}</span></el-form-item></td></tr>
<tr><td><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item></td></tr>
-1
View File
@@ -1,5 +1,4 @@
export * from './api/platformContainerLoading'
export { listContainerArrivalTimes, type ContainerArrivalTime } from './api/containerArrivalTimes'
export * from './types'
export { projectWarehouseArrival } from './warehouseArrival'
export { getSmartLoadingChannelGroup, isSeaRailChannel, SMART_LOADING_CHANNEL_GROUPS } from './smartLoadingChannelGroups'
@@ -1,10 +1,10 @@
import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect, ElUpload } from 'element-plus'
import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { getContainer, getContainerOverview, linkContainerOceanBooking, getContainerClearance, uploadContainerClearance, uploadContainerDocument, completeContainerClearance } from '../api/platformContainerLoading'
import { getContainer, getContainerOverview, linkContainerOceanBooking } from '../api/platformContainerLoading'
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, recordTerminalPickup } from '../api/terminalDrayages'
import { listSuppliers } from '@/modules/suppliers'
@@ -59,7 +59,7 @@ 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, plannedDate: '2026-10-23', isDefault: true }])
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(默认推算)')
@@ -100,8 +100,7 @@ describe('ContainerDetailView empty return', () => {
vi.mocked(recordDirectDelivery).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...row, version: 1, actualAt: '2026-10-16T01:00:00' })
const wrapper = await mountDetail()
expect(getDirectDeliveries).not.toHaveBeenCalled()
expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false)
await (wrapper.vm as unknown as { openDirect(): Promise<void> }).openDirect(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
@@ -124,8 +123,7 @@ describe('ContainerDetailView empty return', () => {
canAccess.mockImplementation(code => code !== 'platform:firstleg:terminal-drayage:direct-unload:confirm')
vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }])
const wrapper = await mountDetail()
expect(wrapper.findAll('button').some(button => button.text() === '直送卸货清单')).toBe(false)
await (wrapper.vm as unknown as { openDirect(): Promise<void> }).openDirect(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title').startsWith('直送卸货登记'))!
await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises()
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读')
@@ -208,20 +206,17 @@ describe('ContainerDetailView empty return', () => {
granted.value = true
await flushPromises()
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
expect((wrapper.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
expect(wrapper.text()).toContain('已确认离仓')
expect(wrapper.text()).not.toContain('展开可查看去重后的完整门禁')
expect(wrapper.text()).not.toContain('全部门禁已通过,可确认整柜离仓')
})
it('reloads authoritative departure state once on each newly opened domestic detail', async () => {
const first = await mountDetail('DOMESTIC')
expect(first.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
expect((first.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
expect(first.text()).toContain('已确认离仓')
first.unmount()
const reopened = await mountDetail('DOMESTIC')
expect(reopened.find('[aria-label="国内出仓门禁"]').exists()).toBe(false)
expect((reopened.vm as unknown as { customsDepartureState: string }).customsDepartureState).toBe('DEPARTED')
expect(reopened.text()).toContain('已确认离仓')
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2)
})
@@ -442,54 +437,3 @@ describe('domestic container loading list and booking link', () => {
expect(routerPush).not.toHaveBeenCalled()
})
})
describe('destination document automatic upload', () => {
const uploaded = { id: 'doc-1', fileId: 'file-1', fileName: '资料.pdf', version: 1, completedAt: null }
beforeEach(() => {
canAccess.mockReset().mockReturnValue(true)
routeState.query = {}
vi.mocked(getContainer).mockReset().mockResolvedValue({ ...base, outboundConfirmedAt: '2026-10-20T08:00:00', allowedActions: ['MANAGE_DESTINATION_DOCUMENTS'] } as never)
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
vi.mocked(getContainerClearance).mockReset().mockResolvedValue(null as never)
vi.mocked(uploadContainerDocument).mockReset().mockResolvedValue(uploaded as never)
vi.mocked(uploadContainerClearance).mockReset().mockResolvedValue(uploaded as never)
vi.mocked(completeContainerClearance).mockReset()
})
it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('saves %s on selection without completing clearance', async type => {
const wrapper = await mountDetail()
await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type)
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())!
expect(dialog.props('showConfirm')).toBe(false)
const file = new File(['data'], '资料.pdf', { type: 'application/pdf' })
const select = dialog.findComponent(ElUpload).props('onChange')!
select({ raw: file } as never, [])
select({ raw: file } as never, [])
await flushPromises()
const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance
expect(upload).toHaveBeenCalledTimes(1)
expect(upload).toHaveBeenCalledWith(...(type === 'DELIVERY_ORDER' ? ['9001', type, file, 0, expect.any(String)] : ['9001', file, 0, expect.any(String)]))
expect(completeContainerClearance).not.toHaveBeenCalled()
expect(dialog.props('modelValue')).toBe(false)
})
it.each(['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'])('retains a failed %s upload and retries the same file', async type => {
const upload = type === 'DELIVERY_ORDER' ? uploadContainerDocument : uploadContainerClearance
vi.mocked(upload).mockRejectedValueOnce(new Error('网络中断'))
const wrapper = await mountDetail()
await (wrapper.vm as unknown as { openDocument(type: string): void }).openDocument(type)
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('modelValue') && item.findComponent(ElUpload).exists())!
const file = new File(['data'], '资料.pdf', { type: 'application/pdf' })
dialog.findComponent(ElUpload).props('onChange')!({ raw: file } as never, [])
await flushPromises()
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.text()).toContain('上传失败')
await dialog.findAll('button').find(button => button.text() === '重试上传')!.trigger('click')
await flushPromises()
expect(upload).toHaveBeenCalledTimes(2)
expect(vi.mocked(upload).mock.calls[1]).toEqual(vi.mocked(upload).mock.calls[0])
expect(dialog.props('modelValue')).toBe(false)
expect(completeContainerClearance).not.toHaveBeenCalled()
})
})
@@ -19,8 +19,10 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules'
import { ContainerFeesPanel, useLoadingListDetail, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingListMode } from '@/modules/loading-lists'
import {
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
type ContainerOutboundDispatchDetail,
type ContainerOutboundGateItem,
} from '@/modules/outbound-dispatch'
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
@@ -91,10 +93,12 @@ 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(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
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}${projected.isDefault ? '(默认推算)' : ''}` : '—'
: 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'))
@@ -242,6 +246,7 @@ async function saveDirect() {
}
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const canViewCurrentLoadingList = computed(() => {
@@ -271,7 +276,6 @@ const pickupCodeSaving = ref(false)
const clearanceOpen = ref(false)
const clearanceDocument = ref<BusinessDocument | null>(null)
const clearanceFile = ref<File | null>(null)
const clearanceUploadKey = ref(0)
const clearanceLoading = ref(false)
const clearanceSaving = ref(false)
const clearanceError = ref<ApiErrorDetails | null>(null)
@@ -280,8 +284,10 @@ const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref<ApiErrorDetails | null>(null)
const outboundLoading = ref(false)
const outboundConfirming = ref(false)
const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
const outboundError = ref<ApiErrorDetails | null>(null)
const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
const { detail: loadingDetail, loading: loadingDetailLoading, error: loadingDetailError, load: loadListDetail, reset: resetLoadingDetail } = useLoadingListDetail()
const emptyReturnOpen = ref(false)
@@ -316,6 +322,9 @@ const overseasStageText = computed(() => {
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')
const loadingListHeading = computed(() => loadingListMode.value === 'confirmed' ? '装柜清单' : '预装清单')
const loadingListDescription = computed(() => loadingListMode.value === 'confirmed'
? '核对作业证据、报关资料与装柜货物。'
: '核对预装货物与 PDA 作业进度。')
const overseasDestinationText = computed(() => {
const bookingDestination = [detail.value?.destinationWarehouseCode, detail.value?.destinationWarehouseName]
.filter(Boolean).join(' / ')
@@ -333,6 +342,32 @@ const emptyReturnWarningText = computed(() => {
return `${overdue}${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}`
})
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
const referenceOnlyGateCodes = new Set(['TRUCK_DEPARTED', 'SAILING_SCHEDULE'])
const outboundGateRows = computed<AggregatedGateRow[]>(() => {
const grouped = new Map<string, AggregatedGateRow>()
for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) {
for (const item of inbound.gate.items || []) {
if (referenceOnlyGateCodes.has(item.code)) continue
const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name])
const existing = grouped.get(key)
if (existing) {
if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode)
} else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] })
}
}
return [...grouped.values()]
})
const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed))
const outboundGateSummary = computed(() => ({
total: outboundGateRows.value.length,
passed: outboundGateRows.value.filter((row) => row.passed).length,
blocked: outboundGateRows.value.filter((row) => !row.passed).length,
}))
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
&& hasAction('CONFIRM_OUTBOUND')
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
&& outboundGateSummary.value.blocked === 0)
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES' | 'UNKNOWN'>(() =>
!outboundDetail.value ? 'UNKNOWN'
: outboundDetail.value.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
@@ -439,7 +474,7 @@ watch(
async function loadOutboundDispatch() {
if (!containerId.value || !canViewOutboundDispatch.value) return
outboundLoading.value = true; outboundError.value = null
outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false
try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) }
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
finally { outboundLoading.value = false }
@@ -458,6 +493,37 @@ async function refreshAfterCustoms() {
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 报关操作已成功,保留组件返回事实 */ }
}
async function confirmDeparture() {
const aggregate = outboundDetail.value?.aggregate
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
outboundConfirming.value = true
try {
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', containerDialogTitle('确认国内出仓', detail.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
} catch { outboundConfirming.value = false; return }
outboundError.value = null
try {
outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, {
expectedVersion: aggregate.version,
remark: '货柜详情确认国内出仓',
})
carrierRefreshKey.value += 1
ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新')
try { overview.value = await getContainerOverview(containerId.value) }
catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') }
} catch (cause) {
const details = getApiErrorDetails(cause, '确认国内出仓失败')
if (details.code === 'A0409') await loadOutboundDispatch()
outboundError.value = details
} finally { outboundConfirming.value = false }
}
function outboundStatusText(status?: string | null) {
return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record<string, string>)[status || ''] || status || '—'
}
function truckReferenceText(status?: string | null) {
return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record<string, string>)[status || ''] || status || '未关联'
}
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return }
if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return
@@ -465,14 +531,9 @@ function openDocument(type: string) {
documentType.value = type as UploadDocumentType
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
}
function selectDocumentFile(file: UploadFile) {
if (documentSaving.value) return
documentFile.value = file.raw || null; documentError.value = null
if (documentFile.value) void saveDocument()
}
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
function removeDocumentFile() { documentFile.value = null }
async function saveDocument() {
if (documentSaving.value || !canManageDestinationDocuments.value) return
if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
try {
@@ -545,20 +606,14 @@ async function saveException() {
}
async function openClearance() {
clearanceUploadKey.value += 1
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.value = null; clearanceLoading.value = true
try { clearanceDocument.value = await getContainerClearance(containerId.value) }
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
finally { clearanceLoading.value = false }
}
function selectClearanceFile(file: UploadFile) {
if (clearanceSaving.value || clearanceLoading.value) return
clearanceFile.value = file.raw || null; clearanceError.value = null
if (clearanceFile.value) void uploadClearance()
}
function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null }
function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() {
if (clearanceSaving.value || clearanceLoading.value || !canManageDestinationDocuments.value || clearanceDocument.value?.completedAt) return
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null
try {
@@ -647,7 +702,7 @@ onMounted(async () => {
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-voyage-heading">
<header class="detail-section__heading"><h2 id="container-voyage-heading">船名航次信息</h2><ContainerPortSchedulePanel :container="detail" @saved="load" /></header>
<div class="detail-fields"><dl class="density-fields"><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense hide-technical-details :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :cabinet-no="detail.cabinetNo" :container-no="detail.containerNo" :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</section>
<section v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-customs-heading">
@@ -660,15 +715,31 @@ onMounted(async () => {
<section v-if="isDomestic" v-show="activeTab === 'overview'" class="detail-section workbench-panel is-compact" aria-labelledby="container-receiving-heading">
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
<header class="outbound-panel__header"><div><h2>国内出仓门禁<InlineHelp title="离仓核验规则"><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></InlineHelp></h2></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
<template v-else-if="outboundDetail">
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
<div class="gate-list">
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
<div class="gate-row__coverage"><span>{{ row.passed ? '覆盖' : '影响' }} {{ row.inboundCodes.length }} 个入库码</span><el-tag v-for="code in row.inboundCodes.slice(0, 3)" :key="code" size="small" effect="plain">{{ code }}</el-tag><span v-if="row.inboundCodes.length > 3">+{{ row.inboundCodes.length - 3 }}</span></div>
</article>
</div>
</template>
</div>
</section>
<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="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
<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>{{ 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">
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2></div></header>
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2><p>{{ loadingListDescription }}</p></div></header>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
<LoadingListDetailDrawer dense :mode="loadingListMode" back-label="返回货柜概览" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
@@ -686,24 +757,24 @@ onMounted(async () => {
</template>
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" :show-confirm="!!documentError && !!documentFile" confirm-text="重试上传" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :disabled="documentSaving" :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button :loading="documentSaving"><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="false" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :key="clearanceUploadKey" :disabled="clearanceSaving || clearanceLoading" :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button :loading="clearanceSaving"><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="clearanceError && clearanceFile && !clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading" @click="uploadClearance">重试上传</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section" data-tone="work"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="!clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading || !clearanceFile" @click="uploadClearance">上传资料</el-button></div></template></AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
<p v-if="directLoading">正在读取实际装柜来源…</p>
<section v-if="!directError && !directLoading" class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">实际装柜来源</h3><div class="workbench-evidence"><table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table></div></section>
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
<section v-if="directSelected" class="density-dialog-section"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><template #label><span>实际卸货当地时间<InlineHelp title="直送卸货登记规则"><p>按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p></InlineHelp></span></template><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" /></el-form-item></td></tr></tbody></table></div></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
<section v-if="directSelected" class="density-dialog-section" :data-tone="!canWriteDirect || directSelected.actualAt ? 'source' : 'work'"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><div class="workbench-evidence"><table class="density-table density-table--editable container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><template #label><span>实际卸货当地时间<InlineHelp title="直送卸货登记规则"><p>按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p></InlineHelp></span></template><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.actualAt || '尚未登记' }}</span><el-date-picker v-else v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="地点时区(IANA)" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.timezone || '—' }}</span><el-input v-else v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item></td></tr><tr><td><el-form-item label="签收人" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.receivedBy || '—' }}</span><el-input v-else v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item></td><td><el-form-item label="真实签收凭据号" required><span v-if="!canWriteDirect || !!directSelected?.actualAt">{{ directSelected?.evidenceNo || '—' }}</span><el-input v-else v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="备注"><span v-if="!canWriteDirect || !!directSelected?.actualAt" class="direct-remark">{{ directSelected?.remark || '—' }}</span><el-input v-else v-model="directForm.remark" type="textarea" maxlength="1000" /></el-form-item></td></tr></tbody></table></div></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
</AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
<template v-if="terminal"><p class="density-dialog-note">时间沿用原业务当地时间。</p>
<section class="density-dialog-section"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></div></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
<section class="density-dialog-section" data-tone="work"><el-form class="density-form" label-position="left" label-width="140px" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError"><div class="workbench-evidence"><table class="density-table density-table--editable container-workflow-table" aria-label="提柜安排资料"><tbody><tr><td colspan="2"><el-form-item label="真实集装箱号"><span>{{ terminal?.containerNo || detail?.containerNo || '—' }}</span></el-form-item></td></tr><tr><td><el-form-item label="当前车队"><span>{{ terminal?.fleetSupplierName || (terminal?.fleetSupplierId ? '—' : '尚未安排') }}</span></el-form-item></td><td><el-form-item label="实际提柜"><span>{{ terminal?.actualPickupAt || '尚未登记' }}</span></el-form-item></td></tr><tr><td><el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="(visible: boolean) => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item></td><td><el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></td><td><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" /></el-form-item></td></tr><tr v-if="canRecordPickup"><td colspan="2"><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" /></el-form-item></td></tr><tr><td colspan="2"><el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item></td></tr></tbody></table></div></el-form></section><LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
@@ -713,7 +784,6 @@ onMounted(async () => {
</template>
<style scoped>
.container-detail-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
.container-workflow-table { table-layout: fixed; }
.direct-remark { white-space: pre-wrap; }
.loading-list-heading { margin-bottom: var(--admin-space-5); }
@@ -589,7 +589,7 @@ describe('ContainersView', () => {
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, plannedDate: '2026-10-23', isDefault: true }])
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()
@@ -286,11 +286,12 @@ 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 warehouseArrivalProjection(source: object) {
function defaultArrivalDate(source: object) {
const row = source as ContainerWorkbenchRow
const arrival = arrivalTimes.value[row.id]
const projected = projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
return projected
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 || '—' }
@@ -763,11 +764,11 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<template #toolbar-actions><ContainerSimulationCreate dense v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
<template #actions="{ row }"><RowActions text-actions :actions="containerRowActions(row as ContainerWorkbenchRow)" more-label="更多货柜操作" @action="handleContainerRowAction($event, row as ContainerWorkbenchRow)" /></template>
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<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" :title="warehouseArrivalProjection(row as ContainerWorkbenchRow).isDefault ? '到港日期+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="warehouseArrivalProjection(row as ContainerWorkbenchRow).date ? `${warehouseArrivalProjection(row as ContainerWorkbenchRow).date}${warehouseArrivalProjection(row as ContainerWorkbenchRow).isDefault ? '(默认)' : ''}` : '选择预计到仓'" :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 #pickupAppointmentAt="{ row }"><div class="arrival-time-cell" :title="defaultArrivalDate(row) ? '到港日期+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) ? `${defaultArrivalDate(row)}(默认)` : '选择预计到仓'" :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>
@@ -10,7 +10,6 @@ import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import GlobalSmartLoadingView from './GlobalSmartLoadingView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import SmartLoadingDialog from '../components/SmartLoadingDialog.vue'
import { loadSmartLoadingPlanCoLoadGroups } from '../smartLoadingPlanGroups'
import type { ContainerBookingResource } from '../types'
import {
assignPendingReviewSmartLoadingOrder,
@@ -211,9 +210,9 @@ describe('GlobalSmartLoadingView', () => {
wrapper.unmount()
})
it('uses one-line shared text clamps for block reasons and assigned-count summaries', () => {
it('uses one-line shared text clamps for co-load and assigned-count summaries', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source).toContain('<LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" />')
expect(source).toContain('<LineClampText :content="row.memberOrderSummaries.join(\';\')" />')
expect(source).toContain('<LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" />')
expect(source).toContain("row.unitType === 'SEALED_PALLET'")
expect(source).toContain('.candidate-group { display: grid; width: 100%; min-width: 0;')
@@ -609,16 +608,14 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
const candidatePane = wrapper.get('.candidate-pane')
expect(candidatePane.get('.candidate-group').text()).toBe('合柜报关 · 2票整组不可拆')
expect(candidatePane.text()).toContain('NYEXX2608290051、NYEXX2608290051-B、NYEXX2608290052')
expect(candidatePane.text()).not.toContain('CLG-2093539779795795969')
expect(candidatePane.text()).not.toContain('ORD-201')
expect(wrapper.text()).toContain('CLG-2093539779795795969 · 2 票')
expect(wrapper.text()).toContain('3 个入仓片段')
expect(wrapper.text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B')
const buttons = wrapper.findAll('.el-table__body-wrapper button').filter((button) => button.text() === '加入')
expect(buttons).toHaveLength(1)
await buttons[0].trigger('click')
await flushPromises()
expect(document.body.textContent).not.toContain('不能部分输入、单独分配或单独移除')
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
const popup = wrapper.getComponent(AppDialog)
expect(popup.props('title')).toBe('整组加入当前柜次 · CLG-2093539779795795969')
const contextRows = assignmentDialog().findAll('table[aria-label="全局装柜分配来源"] tr')
@@ -626,8 +623,6 @@ describe('GlobalSmartLoadingView', () => {
expect(contextRows[1]!.findAll('td').map(cell => cell.text())).toEqual(['义乌仓', '美森正班'])
await assignmentDialog().get('[aria-label="展开全屏"]').trigger('click')
expect(assignmentDialog().get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('NYEXX2608290051-B')
expect(assignmentDialog().get('table[aria-label="全局装柜同柜组成员"]').text()).toContain('ORD-201:NYEXX2608290051、NYEXX2608290051-B')
expect(assignmentDialog().text()).toContain('3 个入仓片段')
expect(document.body.textContent).not.toContain('本次分配箱数')
;([...document.body.querySelectorAll('button')].find((button) => button.textContent?.includes('确认整组分配')) as HTMLButtonElement).click()
await flushPromises()
@@ -689,7 +684,7 @@ describe('GlobalSmartLoadingView', () => {
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('合柜报关 · 3票')
expect(wrapper.text()).toContain('CLG-9900 · 3 票')
expect(wrapper.text()).toContain('同柜不可拆组成员候选不齐,缺少 1 票')
const button = wrapper.findAll('.el-table__body-wrapper button').find((item) => item.text() === '加入')!
expect(button.attributes('disabled')).toBeDefined()
@@ -754,36 +749,16 @@ describe('GlobalSmartLoadingView', () => {
wrapper.unmount()
})
it('uses one loading-rule column in both workbenches and keeps global list summaries compact', () => {
it('uses one loading-rule column and expandable group details in both workbenches', () => {
for (const page of ['GlobalSmartLoadingView.vue', 'SmartLoadingWorkbenchView.vue']) {
const source = readFileSync(resolve(process.cwd(), `src/modules/container-loading/pages/${page}`), 'utf8')
expect(source.match(/label: '装柜规则'/g)).toHaveLength(2)
expect(source).not.toContain("label: '分配单位'")
expect(source).not.toContain('<template #unitType=')
expect(source.match(/<summary>整组加入 · 不可拆<\/summary>/g)).toHaveLength(2)
expect(source.match(/'整托加入 · 不可拆' : '按箱加入'/g)).toHaveLength(2)
expect(source).toContain("row.memberOrderSummaries.join(';')")
}
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
expect(source.match(/slotName: 'coLoadGroup', showOverflowTooltip: false/g)).toHaveLength(2)
expect(source).not.toContain("row.memberOrderSummaries.join(';')")
})
it('shows compact locked plan groups with all inbound codes and fixed package totals', async () => {
const group = { groupId: 'G-1', groupNo: 'CLG-1', lockedPlanId: plan.id, memberOrderCount: 2, inboundFragmentCount: 3, fullPackageCount: 18 }
vi.mocked(loadSmartLoadingPlanCoLoadGroups).mockReturnValueOnce(new Map(['2', '3', '4'].map(id => [id, group])))
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...plan,
items: ['2', '3', '4'].map((inboundId, index) => ({ ...plan.items[0]!, id: `item-${inboundId}`, inboundId, inboundCode: `IN-${inboundId}`, orderId: index < 2 ? 'order-1' : 'order-2' })),
})
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const pane = wrapper.get('.plan-pane')
expect(pane.get('.candidate-group').text()).toBe('合柜报关 · 2票整组不可拆 · 已锁定')
expect(pane.text()).toContain('IN-2、IN-3、IN-4')
expect(pane.text()).toContain('18 箱(整组固定)')
expect(pane.text()).not.toContain('CLG-1')
expect(pane.text()).not.toContain('ORD-1')
expect(pane.findAll('.el-table__body-wrapper tr')).toHaveLength(1)
})
it('searches any shared-pallet inbound, retains its complete composition across pages, and resets the search', async () => {
@@ -157,7 +157,7 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
const planPalletMembersByPalletId = computed(() => new Map(
(plan.value?.palletMembers || []).map((entry) => [entry.palletId, entry.members]),
))
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[] }
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[]; memberOrderSummaries: string[] }
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
const result: SmartLoadingPlanRow[] = []
@@ -165,7 +165,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
for (const row of inboundRows) {
const group = planGroupsByInboundId.value.get(row.inboundId)
if (!group) {
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'] })
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
continue
}
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
@@ -191,6 +191,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
coLoadGroup: group,
palletMembers: [],
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
memberOrderSummaries: memberOrderSummaries(rows),
})
}
return result
@@ -246,7 +247,7 @@ const candidateColumns: DataTableColumn[] = [
{ key: 'totalGrossWeightKg', label: '重量', width: 100, slotName: 'candidateTotalWeight' },
{ key: 'totalVolumeCbm', label: '体积', width: 100, slotName: 'candidateTotalVolume' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup', showOverflowTooltip: false },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
{ key: 'unitGrossWeightKg', label: '单位毛重', width: 90, slotName: 'unitGrossWeight' },
{ key: 'averageUnitVolumeCbm', label: '单位体积', width: 90, slotName: 'averageUnitVolume' },
{ key: 'estimatedVolumeCbm', label: '预估体积', width: 100, slotName: 'estimatedVolume' },
@@ -259,7 +260,7 @@ const itemColumns: DataTableColumn[] = [
{ key: 'grossWeightKg', label: '毛重', width: 90, slotName: 'weight' },
{ key: 'volumeCbm', label: '体积', width: 90, slotName: 'volume' },
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup', showOverflowTooltip: false },
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
]
function formatNumber(value: number, digits = 3) {
@@ -752,16 +753,9 @@ onMounted(() => { void load(); void loadCustomers() })
@click="openAssign(row)"
>加入</el-button>
</template>
<template #candidateInbounds="{ row }"><span v-if="row.coLoadGroupId">{{ row.memberInboundCodes.join('、') }}</span><SmartLoadingCandidateInbounds v-else :candidate="row as SmartLoadingCandidate" /></template>
<template #candidateInbounds="{ row }"><SmartLoadingCandidateInbounds :candidate="row as SmartLoadingCandidate" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }">
<div v-if="row.coLoadGroupId" class="candidate-group">
<el-tag size="small" type="warning">合柜报关 · {{ row.memberOrderCount }}票</el-tag>
<span>整组不可拆{{ row.locked ? ' · 已锁定' : '' }}</span>
<LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" />
</div>
<LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" />
</template>
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroupId" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
<template #orderStatusSnapshot="{ row }"><el-tag size="small" effect="plain">{{ candidateStatusLabel(row.orderStatusSnapshot) }}</el-tag></template>
<template #unitGrossWeight="{ row }">{{ formatMeasurement(row.unitGrossWeightKg, 'kg', 1) }}</template>
<template #candidateTotalWeight="{ row }">{{ formatMeasurement(row.totalGrossWeightKg, 'kg', 1) }}</template>
@@ -834,15 +828,9 @@ onMounted(() => { void load(); void loadCustomers() })
</div>
<div class="workbench-table"><DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="danger" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
<template #planInbounds="{ row }"><span v-if="row.coLoadGroup">{{ row.memberInboundCodes.join('、') }}</span><SmartLoadingCandidateInbounds v-else :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #planInbounds="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
<template #coLoadGroup="{ row }">
<div v-if="row.coLoadGroup" class="candidate-group">
<el-tag size="small" type="warning">合柜报关 · {{ row.coLoadGroup.memberOrderCount }}票</el-tag>
<span>整组不可拆 · {{ row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定' }}</span>
</div>
<LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" />
</template>
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
<template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg</template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template>
@@ -864,7 +852,7 @@ onMounted(() => { void load(); void loadCustomers() })
</tbody></table></div>
</section>
<el-alert v-if="crossChannelAssignment" title="订单渠道与柜次渠道不同;这是人工装柜分配,确认后仍会加入。智能推荐不会跨渠道。" type="warning" :closable="false" />
<section class="density-dialog-section">
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form">
<div class="workbench-evidence"><table class="density-table density-table--editable assignment-context-table" aria-label="全局装柜分配填写"><tbody>
<tr v-if="selectedCandidate.coLoadGroupId"><th>整组范围</th><td colspan="3">组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</td></tr>
@@ -879,11 +867,13 @@ onMounted(() => { void load(); void loadCustomers() })
</section>
<template v-if="selectedCandidate.coLoadGroupId">
<section class="density-dialog-section" data-tone="source"><div class="workbench-evidence"><table class="density-table" aria-label="全局装柜同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></div></section>
<el-alert v-if="selectedCandidate.blockReason" :title="selectedCandidate.blockReason" type="error" :closable="false" />
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
</template>
<p v-else class="density-dialog-note">本次只记录该入仓码的计划装柜箱数,具体箱号归属为未核验。</p>
</div>
</AppDialog>
</template>
@@ -892,7 +882,6 @@ onMounted(() => { void load(); void loadCustomers() })
</template>
<style scoped>
.smart-loading-assignment-dialog :deep(.el-alert--warning) { background: var(--admin-surface, #fff); }
.assignment-context-table { table-layout: fixed; }
.assignment-context-table th { width: 120px; }
.assignment-required { margin-right: var(--admin-space-1); color: var(--el-color-danger); }
@@ -263,7 +263,7 @@ describe('SmartLoadingWorkbenchView', () => {
expect(wrapper.text()).toContain('DP-260908-0001')
await wrapper.find('.el-table__body-wrapper button').trigger('click')
await flushPromises()
expect(document.body.textContent).not.toContain('不可拆分单元整托加入')
expect(document.body.textContent).toContain('不可拆分单元整托加入')
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
const dialog = wrapper.getComponent(AppDialog)
expect(dialog.props('title')).toBe('整托加入当前柜次 · DP-260908-0001')
@@ -180,6 +180,14 @@ function startQuantityEdit(source: object) {
quantityDrafts[item.id] = item.packageCount
editingQuantityId.value = item.id
}
function explainCandidate(source: object) {
const candidate = candidateRow(source)
const volume = Number(candidate.averageUnitVolumeCbm || 0)
const weight = Number(candidate.unitGrossWeightKg || 0)
const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount
const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount
return `按 150% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
}
function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
if (!value) return '待生成'
const cabinetNo = String(value.cabinetNo || '').trim()
@@ -411,7 +419,7 @@ onMounted(load)
</template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="assignOpen" :title="assignmentTitle" width="min(780px, calc(100vw - 28px))" :fullscreenable="Boolean(selectedCandidate?.coLoadGroupId || selectedCandidate?.unitType === 'SEALED_PALLET')" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign">
<div v-if="selectedCandidate" class="density-dialog-sections">
<section class="density-dialog-section">
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form">
<div class="workbench-evidence"><table class="density-table density-table--editable assignment-table" aria-label="加入当前柜次"><tbody>
<tr><th>来源编号</th><td>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</td><th>当前柜次</th><td>{{ container?.cabinetNo || displayContainerNo(container) }}</td></tr>
@@ -427,10 +435,15 @@ onMounted(load)
</section>
<template v-if="selectedCandidate.coLoadGroupId">
<section class="density-dialog-section" data-tone="source"><div class="workbench-evidence"><table class="density-table" aria-label="同柜组成员"><thead><tr><th>组内订单及入仓片段</th></tr></thead><tbody><tr v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary"><td>{{ summary }}</td></tr></tbody></table></div></section>
<el-alert v-if="selectedCandidate.blockReason" :title="selectedCandidate.blockReason" type="error" :closable="false" />
<el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
</template>
<template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'">
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">托盘入仓组成</h3><SmartLoadingCandidateInbounds :candidate="selectedCandidate" /></section>
<el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" />
</template>
<template v-else>
<el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" />
<p class="density-dialog-note">只记录数量,具体上柜箱号未核验。</p>
</template>
</div>
</AppDialog>
@@ -7,22 +7,18 @@ describe('shared warehouse arrival projection', () => {
['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'],
])('displays the authoritative local date for %s without manufacturing a time', (arrivalAt, date) => {
expect(projectWarehouseArrival({ plannedAt: null, version: 0, planCount: 0, plannedDate: date, isDefault: true })).toEqual({ date, time: '', isDefault: true })
])('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(plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false })
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(plan).date).toBeNull()
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan).date).toBeNull()
}
expect(projectWarehouseArrival(undefined).date).toBeNull()
})
it('keeps the frozen server date without recalculating after a port date changes', () => {
expect(projectWarehouseArrival({ plannedAt: null, version: 0, planCount: 1, plannedDate: '2026-11-02', isDefault: false }))
.toEqual({ date: '2026-11-02', time: '', isDefault: false })
for (const arrival of [null, '', 'invalid', '2026-02-30T09:00:00']) expect(projectWarehouseArrival(arrival, undefined).date).toBeNull()
})
})
@@ -2,12 +2,19 @@ export interface WarehouseArrivalPlan {
plannedAt: string | null
version: number
planCount: number
plannedDate?: string | null
isDefault?: boolean
}
/** 仅展示服务端统一解析的日期,不在清关列表或日历重复推算。 */
export function projectWarehouseArrival(plan: WarehouseArrivalPlan | undefined) {
const date = plan && plan.version >= 0 ? plan.plannedAt?.slice(0, 10) || plan.plannedDate || null : null
return { date, time: date ? plan?.plannedAt?.slice(11, 16) || '' : '', isDefault: !!date && plan?.isDefault === true }
/** 默认值只有当地日期,不能作为人工维护的日期时间提交。 */
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 }
}
@@ -59,8 +59,6 @@
/* Customer dialog business geometry; common appearance stays in adminDensity.css. */
.customer-editor-dialog .density-form--outlined { --el-border-radius-base: var(--cc-radius-lg); }
.customer-editor-dialog .density-form--outlined .el-input-number .el-input__inner { text-align: left; }
.customer-editor-dialog .customer-editor-table { table-layout: fixed; }
.customer-editor-dialog .customer-editor-table td:not([colspan]) { width: calc(100% / 3); }
.customer-review-table th { width: 120px; }
@@ -182,7 +182,7 @@ describe('CustomersView', () => {
it('searches the customer list by customer name only', () => {
expect(customersSource).toContain("label: '客户名称', placeholder: '请输入客户名称'")
expect(customersSource).toContain("label: '客户昵称'")
expect(customersSource).toContain('placeholder="选填,默认自动生成"')
expect(customersSource).toContain('中文拼音大写首字母,如 SZWL')
expect(customersSource).not.toContain('公司名称、客户编号或联系人')
expect(customersSource).not.toContain('row.tenantCode.toLowerCase().includes(keyword)')
expect(customersSource).not.toContain('String(row.id).includes(keyword)')
@@ -210,16 +210,12 @@ describe('CustomersView', () => {
expect(document.querySelector<HTMLElement>('.customer-editor-dialog .qualification-credit-code .el-form-item__label')?.style.width).toBe('144px')
const company = document.querySelector<HTMLInputElement>('.customer-editor-dialog input[placeholder="请输入工商登记公司名称"]')!
expect(company.closest('.el-form-item')?.querySelector<HTMLElement>('.el-form-item__label')?.style.width).toBe('120px')
const legalName = document.querySelector<HTMLInputElement>('.customer-editor-dialog input[placeholder="请输入营业执照上的企业名称"]')!
expect(legalName.closest('.el-form-item')?.querySelector<HTMLElement>('.el-form-item__label')?.style.width).toBe('144px')
expect(company.closest('form')?.classList.contains('density-form--outlined')).toBe(true)
company.value = ''
company.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
company.dispatchEvent(new FocusEvent('blur'))
await vi.waitFor(() => expect(company.closest('.el-form-item')?.querySelector('.el-form-item__error')?.textContent).toContain('请输入公司名称'))
expect(document.querySelector('.customer-editor-dialog .el-dialog__footer')?.textContent).toContain('保存资料和资质')
expect(document.querySelector('.customer-editor-dialog .el-dialog__footer')?.textContent).not.toContain('保存资料时会一并上传已选资质')
expect(updatePlatformTenant).not.toHaveBeenCalled()
})
+12 -11
View File
@@ -1085,17 +1085,17 @@ onMounted(() => { void loadCustomers() })
</ListPageLayout>
<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-form--outlined density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">基础资料</h3>
<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>
<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="选填,默认自动生成" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" label-width="144px" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td></tr>
<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>
<tr><td colspan="3"><el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item></td></tr>
<tr><td colspan="3"><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
<section class="density-dialog-section workbench-evidence" data-tone="work"><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,8 +1103,8 @@ onMounted(() => { void loadCustomers() })
</tbody></table>
</section>
<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><el-tag v-if="editorStatus" :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></h3>
<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 v-if="!creditCodeEstablished">必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。</small></el-form-item></td></tr></tbody></table>
<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>
<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="客户资质文件">
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
@@ -1124,7 +1124,7 @@ onMounted(() => { void loadCustomers() })
</section>
</el-form>
<template #footer><div class="customer-editor-footer"><div class="density-actions density-actions--footer"><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div class="density-actions density-actions--footer"><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
</AppDialog>
<AppDialog v-model="qualificationPreviewOpen" :title="customerDialogTitle('查看资质文件', editorCustomer)" width="min(860px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog qualification-preview-dialog customer-qualification-preview admin-workbench is-dialog" fullscreenable destroy-on-close :show-confirm="false" cancel-text="关闭预览">
@@ -1183,12 +1183,13 @@ onMounted(() => { void loadCustomers() })
<div class="density-dialog-section qualification-preview" data-tone="source"><img v-if="reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></div>
</AppDialog>
<AppDialog v-model="passwordDialogOpen" title="重置客户密码" :confirm-loading="passwordSubmitting" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog admin-workbench is-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
<AppDialog v-model="passwordDialogOpen" :title="customerDialogTitle('重置客户密码', passwordTenant)" :confirm-loading="passwordSubmitting" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog admin-workbench is-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top" class="density-form">
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword" required><el-input v-model="passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="请再次输入新密码" /></el-form-item>
<el-checkbox v-model="passwordForm.forceChangePassword">客户下次登录时必须修改密码</el-checkbox>
<p class="density-dialog-note">重置对象为企业 OWNER 管理员,原密码将立即失效。</p>
</el-form>
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="passwordSubmitting" @click="passwordDialogOpen = false">取消</el-button><el-button type="primary" :loading="passwordSubmitting" @click="submitResetPassword">确认重置</el-button></div></template>
</AppDialog>
@@ -1218,7 +1219,7 @@ onMounted(() => { void loadCustomers() })
.customer-company-name { display:block; overflow:hidden; color:var(--workbench-text); font-weight:500; text-overflow:ellipsis; white-space:nowrap; }
:deep(.customer-list-table .customer-code-column .cell) { white-space:nowrap; }
.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-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-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(--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); }
@@ -1239,7 +1240,7 @@ onMounted(() => { void loadCustomers() })
.qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; }
.qualification-preview-action { position:absolute; inset:auto 0 0; display:flex; min-height:22px; align-items:center; justify-content:center; gap:3px; background:rgba(23,32,51,.78); color:var(--workbench-surface); }
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-4, 10px); margin-top:var(--admin-space-5, 12px); padding:var(--admin-space-4, 10px) var(--admin-space-5, 12px); border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:var(--admin-font-caption, 12px); }
.customer-editor-footer { display:flex; align-items:center; justify-content:flex-end; gap:var(--admin-space-6, 16px); }
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); } .customer-editor-footer > span { color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); text-align:left; }
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); background:var(--workbench-surface-soft); } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--workbench-text); font-size:var(--admin-font-body, 13px); } .decision-summary span { color:var(--workbench-muted); font-size:var(--admin-font-caption, 11px); }
.review-document-state { padding:14px; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
.review-document-error { padding:14px; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
@@ -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"><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" 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>
<div class="customer-fee-tier-groups">
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section">
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section" data-tone="work">
<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>
+1 -24
View File
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { refundFinanceConsumption } from './finance'
import { actionReconciliationStatement, adjustFinanceAccount, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, downloadFinanceRechargeVoucher, editFinanceFeeSource, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReceivableStatistics, getReconciliationStatement, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceRechargeRequests, listFinanceStatements, reviewFinanceFeeSource, reviewFinanceRechargeRequest, updateFinanceBill, updateFinanceCreditLimit, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => {
@@ -89,7 +88,7 @@ describe('finance api', () => {
})
it('uses the formal G1 account ledger path and fixed page DTO', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款', walletBusinessNo: 'AC202608110001', changeType: 'RECHARGE', walletAmount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-91', createdAt: '2026-08-11T12:00:00' }], page: { page: 1, pageSize: 50, total: 1 } })
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-91', createdAt: '2026-08-11T12:00:00' }], page: { page: 1, pageSize: 50, total: 1 } })
const result = await listFinanceAccountLedger('10001', { page: 1, size: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' })
@@ -97,22 +96,6 @@ describe('finance api', () => {
expect(result.records[0]).toMatchObject({ ledgerId: '81', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceNo: 'RC-91' })
})
it('preserves storage rent freeze and settlement amounts from the account ledger DTO', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [
{ ledgerId: '61', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230003', changeType: 'CONSUMPTION', walletAmount: -10.01, actualBalanceAfter: 9905.22, frozenBalanceAfter: 35.47, availableBalanceAfter: 9969.73 },
{ ledgerId: '60', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230002', changeType: 'FREEZE', walletAmount: -10.01, actualBalanceAfter: 9915.23, frozenBalanceAfter: 45.48, availableBalanceAfter: 9969.73 },
{ ledgerId: '59', documentType: 'WALLET_LEDGER', walletBusinessNo: 'AC202610230001', changeType: 'FREEZE', walletAmount: 0 },
{ ledgerId: '58', documentType: 'SETTLEMENT' },
], page: { total: 4 } })
const { records } = await listFinanceAccountLedger('10001')
expect(records[0]).toMatchObject({ businessNo: 'AC202610230003', amount: '-10.01', actualBalanceAfter: '9905.22', frozenBalanceAfter: '35.47', availableBalanceAfter: '9969.73' })
expect(records[1]).toMatchObject({ businessNo: 'AC202610230002', amount: '-10.01', actualBalanceAfter: '9915.23', frozenBalanceAfter: '45.48', availableBalanceAfter: '9969.73' })
expect(records[2].amount).toBe('0')
expect(records[3].amount).toBeNull()
})
it('uses frozen recharge review and account adjustment contracts', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ requestId: 91, requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: 81, voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', createdAt: '2026-09-04T10:00:00' }], page: { page: 1, pageSize: 200, total: 1 } })
const post = vi.spyOn(http, 'post')
@@ -328,9 +311,3 @@ describe('finance api', () => {
})
})
it('submits linked consumption refund with the caller retry key', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { changeType: 'REFUND', referenceNo: 'AC-61' } })
await expect(refundFinanceConsumption('9007199254740993', '61', { amount: '12.30', reason: '多扣' }, 'stable-key')).resolves.toMatchObject({ referenceNo: 'AC-61' })
expect(post).toHaveBeenCalledWith('/platform/finance/v1/accounts/9007199254740993/ledger/61/refunds', { amount: '12.30', reason: '多扣' }, { headers: { 'Idempotency-Key': 'stable-key' } })
})
+1 -6
View File
@@ -155,7 +155,7 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag
documentType: stringValue(item.documentType), documentId: stringValue(item.documentId), documentNo: stringValue(item.documentNo),
receivableChange: numberValue(item.receivableChange), payableChange: numberValue(item.payableChange),
outstandingReceivableAfter: numberValue(item.outstandingReceivableAfter), operatorName: stringValue(item.operatorName), remark: stringValue(item.remark),
businessNo: nullableStringValue(item.walletBusinessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.walletAmount),
businessNo: nullableStringValue(item.businessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.amount),
actualBalanceAfter: nullableStringValue(item.actualBalanceAfter), frozenBalanceAfter: nullableStringValue(item.frozenBalanceAfter),
availableBalanceAfter: nullableStringValue(item.availableBalanceAfter), sourceType: nullableStringValue(item.sourceType),
creditLimitAfter: nullableStringValue(item.creditLimitAfter), creditUsedAfter: nullableStringValue(item.creditUsedAfter),
@@ -208,11 +208,6 @@ export async function updateFinanceCreditLimit(customerId: string, payload: { cr
return unwrapApiResponse(response)
}
export async function refundFinanceConsumption(customerId: string, ledgerId: string, payload: { amount: string; reason: string }, key: string) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/accounts/${encodeURIComponent(customerId)}/ledger/${encodeURIComponent(ledgerId)}/refunds`, payload, { headers: { 'Idempotency-Key': key } })
return unwrapApiResponse(response)
}
export async function listFinanceFeeSources(params: {
page?: number
size?: number
@@ -6,7 +6,7 @@ import { ElMessage } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import { listPlatformTenants } from '@/modules/iam'
import * as api from '../api/finance'
import type { FinanceAccountSummary, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
import type { FinanceAccountSummary, FinanceRechargeRequest } from '../types'
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
@@ -17,7 +17,7 @@ vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('../api/finance', () => ({
listFinanceAccounts: vi.fn(), listFinanceAccountLedger: vi.fn(), listFinanceRechargeRequests: vi.fn(),
reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(), updateFinanceCreditLimit: vi.fn(),
reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), updateFinanceCreditLimit: vi.fn(),
downloadFinanceRechargeVoucher: vi.fn(), getFinanceCustomerTerms: vi.fn(), updateFinanceCustomerTerms: vi.fn(),
}))
const account: FinanceAccountSummary = {
@@ -287,105 +287,3 @@ describe('customer wallet native dialogs', () => {
expect(activeDialog(wrapper).find('.voucher-preview__image').exists()).toBe(true)
})
})
const consumption: FinanceLedgerEntry = {
ledgerId: '61', businessNo: 'AC-CONSUME-61', changeType: 'CONSUMPTION', amount: '-80.00',
sourceNo: 'RELABEL-1', occurredAt: '', businessType: 'ADJUSTMENT', documentType: '', documentId: '', documentNo: '',
receivableChange: 0, payableChange: 0, outstandingReceivableAfter: 0, operatorName: '', remark: '',
}
async function openRefundDialog() {
vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [consumption], total: 1, pages: 1 })
const wrapper = await mountPage()
await click(wrapper, '退款', '.ledger-table button')
return { wrapper, dialog: activeDialog(wrapper) }
}
describe('linked original consumption refunds', () => {
it.each(['', '0', '-1', '80.01', '1.001', 'NaN'])('rejects invalid refund %s', async amount => {
const { dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue(amount)
await dialog.get('textarea').setValue('测试退款')
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
})
it('requires a reason and keeps the original consumption visible', async () => {
const { dialog } = await openRefundDialog()
expect(dialog.text()).toContain('AC-CONSUME-61')
expect(dialog.text()).toContain('RELABEL-1')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
})
it('locks a pending refund, preserves failures, and retries the identical payload with the same key', async () => {
const pending = deferred<Record<string, unknown>>()
vi.mocked(api.refundFinanceConsumption).mockReturnValueOnce(pending.promise).mockResolvedValueOnce({})
const { wrapper, dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue('12.3')
await dialog.get('textarea').setValue(' 原单多扣 ')
await click(dialog, '确认退款')
expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
await click(dialog, '取消')
expect(dialog.props('modelValue')).toBe(true)
pending.reject(failure())
await flushPromises()
expect(dialog.text()).toContain('测试操作未成功')
const first = vi.mocked(api.refundFinanceConsumption).mock.calls[0]!
expect(first).toEqual([account.customerId, '61', { amount: '12.30', reason: '原单多扣' }, expect.stringMatching(/^finance-refund-/)])
await click(dialog, '确认退款')
expect(vi.mocked(api.refundFinanceConsumption).mock.calls[1]).toEqual(first)
expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('账户流水')
})
it('creates a new key for changed amount after a rejected request', async () => {
vi.mocked(api.refundFinanceConsumption).mockRejectedValue(failure())
const { dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
await dialog.get('textarea').setValue('退款')
await click(dialog, '确认退款')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('11')
await click(dialog, '确认退款')
const calls = vi.mocked(api.refundFinanceConsumption).mock.calls
expect(calls[0]![3]).not.toBe(calls[1]![3])
})
it('hides refunds without adjustment permission and labels refund original references', async () => {
can.mockImplementation(permission => permission !== 'platform:finance:customer-account:adjust')
vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [consumption, { ...consumption, ledgerId: '62', changeType: 'REFUND', amount: '10.00', referenceNo: 'AC-CONSUME-61' }], total: 2, pages: 1 })
const wrapper = await mountPage()
expect(wrapper.findAll('.ledger-table button').some(button => button.text() === '退款')).toBe(false)
expect(wrapper.text()).toContain('消费退款')
expect(wrapper.text()).toContain('原消费:')
})
})
describe('customer wallet detail error recovery', () => {
it.each(['ACCOUNT', 'LEDGER'] as const)('reloads the current detail after %s fails without same-route navigation', async failurePoint => {
if (failurePoint === 'ACCOUNT') vi.mocked(api.listFinanceAccounts).mockRejectedValueOnce(failure())
else vi.mocked(api.listFinanceAccountLedger).mockRejectedValueOnce(failure())
const wrapper = await mountPage()
expect(wrapper.get('.load-error-state').text()).toContain('测试操作未成功')
const pending = deferred<Awaited<ReturnType<typeof api.listFinanceAccounts>>>()
vi.mocked(api.listFinanceAccounts).mockReturnValueOnce(pending.promise)
await click(wrapper, '重新加载', '.load-error-state button')
expect(api.listFinanceAccounts).toHaveBeenCalledTimes(2)
pending.resolve({ records: [{ ...account, actualBalance: '90.00' }], total: 1, pages: 1 })
await flushPromises()
expect(wrapper.find('.load-error-state').exists()).toBe(false)
expect(wrapper.get('[aria-label="客户资金账户余额"]').text()).toContain('¥90.00')
expect(wrapper.find('.ledger-table').exists()).toBe(true)
expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(failurePoint === 'ACCOUNT' ? 1 : 2)
expect(api.listFinanceRechargeRequests).toHaveBeenCalledTimes(failurePoint === 'ACCOUNT' ? 1 : 2)
})
})
it('enforces the refund contract reason limit before submitting', async () => {
const { wrapper, dialog } = await openRefundDialog()
expect(dialog.get('textarea').attributes('maxlength')).toBe('500')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
const vm = wrapper.vm as unknown as { refundForm: { reason: string } }
vm.refundForm.reason = '退'.repeat(501)
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
vm.refundForm.reason = '退'.repeat(500)
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).toHaveBeenCalledWith(account.customerId, consumption.ledgerId, { amount: '10.00', reason: '退'.repeat(500) }, expect.any(String))
})
@@ -18,7 +18,7 @@ vi.mock('../api/finance', () => ({
listFinanceAccountLedger: vi.fn(),
listFinanceRechargeRequests: vi.fn(),
reviewFinanceRechargeRequest: vi.fn(),
adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(),
adjustFinanceAccount: vi.fn(),
updateFinanceCreditLimit: vi.fn(),
downloadFinanceRechargeVoucher: vi.fn(),
getFinanceCustomerTerms: vi.fn(),
@@ -22,7 +22,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { refundFinanceConsumption, adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
import { adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
import { financeBusinessTypeLabel } from '../status'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
@@ -45,7 +45,6 @@ const saving = ref(false)
const selected = ref<FinanceAccountSummary | null>(null)
const ledger = ref<FinanceLedgerEntry[]>([])
const ledgerLoading = ref(false)
const detailLoading = ref(false)
const ledgerError = ref<ApiErrorDetails | null>(null)
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
const creditDays = ref(30)
@@ -58,12 +57,6 @@ const rechargeLoading = ref(false)
const rechargeError = ref<ApiErrorDetails | null>(null)
const accountExportBusy = ref(false)
const ledgerExportBusy = ref(false)
const refundOpen = ref(false)
const refundSaving = ref(false)
const refundSource = ref<{ customerId: string; entry: FinanceLedgerEntry } | null>(null)
const refundForm = reactive({ amount: '', reason: '' })
const refundError = ref<ApiErrorDetails | null>(null)
let refundAttempt = { payload: '', key: '' }
const adjustmentOpen = ref(false)
const adjustmentSaving = ref(false)
const adjustmentAccount = ref<FinanceAccountSummary | null>(null)
@@ -121,7 +114,6 @@ const ledgerQueryFields: QueryField[] = [
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
]
const ledgerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' },
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, slotName: 'amount' },
{ key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' },
@@ -212,15 +204,7 @@ async function refreshWalletDetail() {
if (!selected.value) return
await Promise.all([loadRechargeRequests(), loadLedger()])
}
async function retryDrawer() {
if (!canView.value || detailLoading.value) return
if (editing.value) {
if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, true)
return
}
const customerId = String(route.params.customerId || selected.value?.customerId || '')
if (customerId) await restoreAccountDetail(customerId)
}
async function retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, editing.value) }
async function saveTerms() {
if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return }
saving.value = true
@@ -253,41 +237,7 @@ function paymentMethodLabel(value: unknown) {
return ({ BANK_TRANSFER: '银行转账', WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', OTHER: '其他' } as Record<string, string>)[String(value)] || '其他'
}
function walletBusinessType(value: unknown) {
return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', REFUND: '消费退款', CREDIT_LIMIT: '授信调整' } as Record<string, string>)[String(value)] || ledgerBusinessType(value)
}
function canRefund(entry: FinanceLedgerEntry) {
return canAdjust.value && entry.changeType === 'CONSUMPTION' && !!entry.ledgerId && Number(entry.amount) < 0
}
function openRefund(entry: FinanceLedgerEntry) {
if (!selected.value || !canRefund(entry) || refundSaving.value) return
refundSource.value = { customerId: selected.value.customerId, entry: { ...entry } }
Object.assign(refundForm, { amount: '', reason: '' })
refundError.value = null
refundAttempt = { payload: '', key: '' }
refundOpen.value = true
}
async function submitRefund() {
const source = refundSource.value
if (refundSaving.value || !source || !canRefund(source.entry) || selected.value?.customerId !== source.customerId) return
const raw = refundForm.amount.trim()
const amount = Number(raw)
if (!/^\d+(?:\.\d{1,2})?$/.test(raw) || !Number.isFinite(amount) || amount <= 0 || amount > Math.abs(Number(source.entry.amount))) {
ElMessage.warning('退款金额必须大于 0、最多两位小数,且不能超过原消费金额'); return
}
const reason = refundForm.reason.trim()
if (!reason || reason.length > 500) { ElMessage.warning('请填写退款说明(最多 500 字)'); return }
const payload = { amount: amount.toFixed(2), reason }
const signature = JSON.stringify([source.customerId, source.entry.ledgerId, payload])
if (refundAttempt.payload !== signature) refundAttempt = { payload: signature, key: 'finance-refund-' + crypto.randomUUID() }
refundSaving.value = true
refundError.value = null
try {
await refundFinanceConsumption(source.customerId, source.entry.ledgerId, payload, refundAttempt.key)
refundOpen.value = false
ElMessage.success('消费退款已入账')
if (selected.value?.customerId === source.customerId) await refreshWalletDetail()
} catch (error) { refundError.value = getApiErrorDetails(error, '退款失败,请重试') }
finally { refundSaving.value = false }
return ({ RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费结算', CREDIT_LIMIT: '授信调整' } as Record<string, string>)[String(value)] || ledgerBusinessType(value)
}
function openAdjustment(account: FinanceAccountSummary, type: 'RECHARGE' | 'DEDUCTION') {
adjustmentAccount.value = account
@@ -475,15 +425,12 @@ async function closeDetail() {
if (route.name === 'finance-customer-account-detail') await router.push({ name: 'finance-customer-accounts' })
}
async function restoreAccountDetail(customerId: string) {
if (!canView.value || detailLoading.value) return
detailLoading.value = true
detailOpen.value = true; editing.value = false; ledgerError.value = null
try {
selected.value = (await listFinanceAccounts({ page: 1, size: 1, customerId })).records.find(row => row.customerId === customerId) || null
if (!selected.value) { ledgerError.value = { message: '未找到该客户账户,可能已停用或无权访问' }; return }
await Promise.all([loadLedger(), loadRechargeRequests()])
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户详情加载失败,请重试') }
finally { detailLoading.value = false }
}
onMounted(() => {
const customerId = String(route.params.customerId || '')
@@ -521,7 +468,7 @@ onBeforeUnmount(clearVoucherPreview)
<DetailPageLayout class="finance-account-detail__layout finance-density-detail" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><h1 class="density-detail-title">{{ selected ? customerName(selected.customerId, selected.customerName) : '客户账户' }}</h1><span class="finance-detail-context">{{ editing ? '维护客户账期' : 'CNY 资金账户' }}</span></div><div v-if="selected && !editing" class="finance-detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button><el-button v-if="canAdjust" plain @click="openCreditLimit(selected)"><CreditCard :size="16" />设置授信</el-button></div></div></template>
<template v-if="selected && !ledgerError" #summary><dl class="density-detail-overview account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><dt>实际余额</dt><dd :class="{ 'is-negative': Number(selected.actualBalance) < 0 }">{{ money(selected.actualBalance) }} <small>CNY</small></dd></div><div class="account-metric is-frozen"><dt>冻结金额</dt><dd>{{ money(selected.frozenBalance) }} <small>CNY</small></dd></div><div class="account-metric is-available"><dt>总可用余额</dt><dd>{{ money(selected.availableBalance) }} <small>CNY</small></dd></div><div class="account-metric"><dt>授信额度</dt><dd>{{ money(selected.creditLimit) }} <small>{{ creditExpiryLabel(selected) }}</small></dd></div><div class="account-metric is-frozen"><dt>已用授信</dt><dd>{{ money(selected.creditUsed) }} <small>CNY</small></dd></div><div class="account-metric"><dt>可用授信</dt><dd>{{ money(selected.availableCredit) }} <small>CNY</small></dd></div></dl></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading || detailLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection class="customer-profile-section density-form-section" title="客户信息" :bordered="false"><el-descriptions border class="density-descriptions" :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="recharge-section density-form-section" title="充值申请" :bordered="false">
@@ -536,7 +483,7 @@ onBeforeUnmount(clearVoucherPreview)
</FormSection>
<FormSection v-if="!editing" class="ledger-section density-form-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query workbench-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" />
<div class="detail-table-scroll workbench-table"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #actions="{ row }"><el-button v-if="canRefund(row as FinanceLedgerEntry)" link type="primary" @click="openRefund(row as FinanceLedgerEntry)">退款</el-button><span v-else>—</span></template><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span v-if="row.changeType === 'REFUND'">原消费:</span><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
<div class="detail-table-scroll workbench-table"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #creditUsedAfter="{ row }">{{ money(row.creditUsedAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection>
<FormSection v-else class="term-section density-form-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection>
</template>
@@ -544,21 +491,6 @@ onBeforeUnmount(clearVoucherPreview)
</DetailPageLayout>
</section>
</AdminPage>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="refundOpen" title="原消费退款" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认退款" :confirm-loading="refundSaving" @confirm="submitRefund">
<div class="density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="source" aria-label="原消费资料"><h3 class="density-dialog-section__title">原消费(CNY)</h3><table class="density-table wallet-summary"><tbody>
<tr><th scope="row">原消费流水</th><td>{{ refundSource?.entry.businessNo || refundSource?.entry.ledgerId }}</td></tr>
<tr><th scope="row">关联业务单据</th><td>{{ refundSource?.entry.sourceNo || refundSource?.entry.documentNo || '—' }}</td></tr>
<tr><th scope="row">原消费金额</th><td>{{ money(refundSource?.entry.amount) }}</td></tr>
</tbody></table></section>
<el-alert title="退款会生成独立流水并关联原消费;累计可退金额以服务端校验为准。" type="info" :closable="false" />
<div v-if="refundError"><el-alert :title="refundError.message" type="error" :closable="false" /><ApiErrorMeta :details="refundError" /></div>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="退款登记"><el-form class="density-form" label-position="left" label-width="100px" :disabled="refundSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="退款金额" required><el-input v-model="refundForm.amount" inputmode="decimal" placeholder="请输入退款金额" /></el-form-item></td></tr>
<tr><td><el-form-item label="退款说明" required><el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写退款说明" /></el-form-item></td></tr>
</tbody></table></el-form></section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="adjustmentOpen" :title="`${adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'} · ${adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
<div class="density-dialog-sections">
<section class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前账户资料">
-2
View File
@@ -39,8 +39,6 @@ describe('finance status presentation', () => {
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
expect(financeOperationActionLabel('FEE_SOURCE_REVIEW_APPROVE')).toBe('审核通过')
expect(financeOperationActionLabel('FEE_SOURCE_PUBLISHED')).toBe('费用同步')
expect(financeOperationActionLabel('FEE_SOURCE_VERSION_ADJUST')).toBe('费用调整同步')
expect(financeOperationActionLabel('RECONCILIATION_ADJUST')).toBe('调整对账单')
expect(financeOperationActionLabel('SETTLEMENT_PAYMENT_CREATE')).toBe('登记收付款')
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
-1
View File
@@ -82,7 +82,6 @@ const financeOperationActionLabels: Record<string, string> = {
CREATE: '创建', APPROVE: '审核通过', REJECT: '审核驳回', UPDATE: '更新',
FEE_SOURCE_EDIT: '编辑费用', FEE_SOURCE_SUBMIT: '提交审核', FEE_SOURCE_REVIEW_APPROVE: '审核通过',
FEE_SOURCE_REVIEW_REJECT: '审核驳回', FEE_SOURCE_ADJUST: '调整费用', FEE_SOURCE_VOID: '作废费用',
FEE_SOURCE_PUBLISHED: '费用同步', FEE_SOURCE_VERSION_ADJUST: '费用调整同步',
RECONCILIATION_CREATE: '生成对账单', RECONCILIATION_CREATED: '生成对账单', RECONCILIATION_ADJUST: '调整对账单',
BILL_CREATE: '创建账单', BILL_ISSUE: '出账', BILL_ADJUST: '调整账单', BILL_VOID: '作废账单',
SETTLEMENT_AR_CREATE: '生成应收结算单', SETTLEMENT_AP_CREATE: '生成应付结算单', SETTLEMENT_PAYMENT_CREATE: '登记收付款',
@@ -69,7 +69,6 @@ describe('InboundTaskList measurement scope', () => {
expect(source).toContain(':show-actions="false"')
expect(source).toContain('<template #footer="{ close }">')
expect(source).toContain('reviewPanelRef?.confirmApprove()')
expect(source).not.toContain('修改重量')
})
it('loads only active customers and exposes a searchable name-only select', () => {
@@ -469,6 +469,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
<div class="review-dialog-footer">
<el-button :disabled="reviewNavigationLoading" @click="close">关闭</el-button>
<template v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview">
<el-button type="primary" plain @click="reviewPanelRef?.enableWeightCorrection()">修改重量</el-button>
<el-button type="primary" :loading="reviewPanelRef?.actionLoading === 'approve' || reviewNavigationLoading" :disabled="reviewApproved" @click="reviewPanelRef?.confirmApprove()">{{ reviewApproved ? '审核已通过' : reviewPanelRef?.approvalButtonLabel }}</el-button>
</template>
</div>
@@ -187,27 +187,13 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.find('.measurement-table').text()).toContain('FBA18SCAN5678')
expect(wrapper.find('.measurement-table').text()).toContain('0.6')
expect(wrapper.find('.measurement-table').text()).not.toContain('周长')
expect(wrapper.find('.el-table__expand-icon').exists()).toBe(false)
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
})
it('keeps server over-limit warnings visible without declaration deviations or an expandable row', async () => {
const detail = measurement()
detail.items[0]!.overLimitWarning = '箱规超过渠道限制,请复核'
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(detail)
const wrapper = await mountPanel()
expect(wrapper.find('.measurement-deviation-alert').exists()).toBe(false)
expect(wrapper.find('.el-table__expand-icon').exists()).toBe(false)
expect(wrapper.get('.measurement-table .measurement-limit-warning').text()).toBe('箱规超过渠道限制,请复核')
expect(wrapper.get('.measurement-table .measurement-limit-warning').attributes('role')).toBe('alert')
detail.items[0]!.overLimitWarning = null
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(detail)
await wrapper.setProps({ inboundId: '7002' })
await wrapper.find('.el-table__expand-icon').trigger('click')
await flushPromises()
expect(wrapper.find('.measurement-limit-warning').exists()).toBe(false)
expect(wrapper.findAll('.measurement-table th').some(cell => cell.text() === '超限提醒')).toBe(false)
expect(wrapper.text()).toContain('箱规周长')
expect(wrapper.text()).toContain('190 cm')
expect(wrapper.text()).toContain('MJ20210327')
expect(wrapper.text()).toContain('2026-08-04 10:30:00+08:00')
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
})
it('uses the list customer name when a legacy measurement detail has no customer snapshot', async () => {
@@ -421,14 +407,20 @@ describe('InboundMeasurementPanel', () => {
expect((countInput.element as HTMLInputElement).value).toBe('9')
})
it('retains server-calculated facts in the draft without the removed expandable row', async () => {
it('shows the complete server-calculated measurement facts in the expanded row', async () => {
const wrapper = await mountPanel()
expect(wrapper.find('.el-table__expand-icon').exists()).toBe(false)
expect(wrapper.vm.form.items[0]).toMatchObject({
boxSpecification: '50×40×30cm', coefficientMode: 'ACTUAL',
calculatedOuterLengthCm: 50, calculatedOuterWidthCm: 40, calculatedOuterHeightCm: 30,
calculationRuleVersion: 'DIMENSION_ACTUAL_V1',
})
await wrapper.get('.el-table__expand-icon').trigger('click')
await flushPromises()
const text = wrapper.text()
expect(text).toContain('箱规')
expect(text).toContain('50×40×30cm')
expect(text).toContain('计算方式')
expect(text).toContain('实测原尺寸')
expect(text).toContain('计算后外箱')
expect(text).toContain('50 × 40 × 30 cm')
expect(text).toContain('计算规则版本')
expect(text).toContain('DIMENSION_ACTUAL_V1')
})
it('reuses the established first-leg detail surface and status patterns', async () => {
@@ -619,9 +611,11 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.emitted('updated')).toBeUndefined()
})
it('retains the formal deviceMeasuredAt response field in the measurement draft', async () => {
it('maps the formal deviceMeasuredAt response field into the read-only measurement time', async () => {
const wrapper = await mountPanel()
expect(wrapper.vm.form.items[0].measuredAt).toBe('2026-08-04T10:30:00+08:00')
await wrapper.get('.el-table__expand-icon').trigger('click')
expect(wrapper.text()).toContain('2026-08-04 10:30:00+08:00')
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
expect(source).toContain('measuredAt: item.deviceMeasuredAt')
expect(source).not.toContain('measuredAt: item.measuredAt')
@@ -614,6 +614,10 @@ function updateWeightEvidence(
confirmedWeightAvailable.value = hasConfirmedWeight
}
function calculationModeLabel(value: EditableMeasurementItem['coefficientMode']) {
return value === 'ADD' ? '箱规尺寸' : value === 'MULTIPLY' ? '箱规系数' : '实测原尺寸'
}
function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
return validationErrors.value[row.clientKey]?.length ? 'measurement-table__row--error' : ''
}
@@ -785,6 +789,23 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
class="measurement-table"
:row-class-name="measurementRowClassName"
>
<el-table-column type="expand" width="42">
<template #default="{ row }">
<dl class="measurement-row-details">
<div><dt>箱规</dt><dd>{{ row.boxSpecification || '—' }}</dd></div>
<div><dt>机器编号</dt><dd>{{ row.machineNo || '人工录入' }}</dd></div>
<div><dt>测量时间</dt><dd>{{ row.measuredAt ? row.measuredAt.replace('T', ' ') : '—' }}</dd></div>
<div><dt>计算方式</dt><dd>{{ calculationModeLabel(row.coefficientMode) }}</dd></div>
<div><dt>计算后外箱</dt><dd>{{ formatNumber(row.calculatedOuterLengthCm) }} × {{ formatNumber(row.calculatedOuterWidthCm) }} × {{ formatNumber(row.calculatedOuterHeightCm) }} cm</dd></div>
<div><dt>单箱体积</dt><dd>{{ formatNumber(row.serverValues.unitVolumeCbm, 3, 3) }} m³</dd></div>
<div><dt>箱规周长</dt><dd>{{ formatNumber(row.serverValues.girthCm) }} cm</dd></div>
<div><dt>总材积重</dt><dd>{{ formatNumber(row.serverValues.volumeWeightKg) }} kg</dd></div>
<div><dt>计算规则版本</dt><dd>{{ row.calculationRuleVersion || '—' }}</dd></div>
<div><dt>超限提醒</dt><dd>{{ row.overLimitWarning || '—' }}</dd></div>
<div><dt>备注</dt><dd>{{ row.remark || '—' }}</dd></div>
</dl>
</template>
</el-table-column>
<el-table-column prop="itemNo" label="#" width="70" align="center" />
<el-table-column label="入仓码" min-width="154" show-overflow-tooltip>
<template #default="{ row }">{{ row.inboundCode || '—' }}</template>
@@ -801,9 +822,6 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
<el-table-column label="尺寸 长×宽×高(cm)" width="166" align="center">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
<el-table-column v-if="form.items.some(item => item.overLimitWarning?.trim())" label="超限提醒" min-width="200">
<template #default="{ row }"><span v-if="row.overLimitWarning?.trim()" class="measurement-limit-warning" role="alert">{{ row.overLimitWarning }}</span><span v-else>—</span></template>
</el-table-column>
<el-table-column label="仓库修改外箱(长)" width="165" align="center">
<template #default="{ row }"><el-input-number v-model="row.outerLengthCm" aria-label="仓库修改外箱长" :min="0.001" :precision="3" :controls="false" placeholder="不修改" /></template>
</el-table-column>
@@ -912,7 +930,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
.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: 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: var(--admin-surface, #fff); color: #18795d; }
.measurement-comparison thead th:nth-child(3) { background: #f4fbf8; color: #18795d; }
.measurement-comparison__difference { color: var(--el-color-danger-dark-2); font-weight: 600; }
.measurement-extra-facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
.measurement-extra-facts strong { color: var(--workbench-text); }
@@ -972,8 +990,10 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
.measurement-table :deep(.measurement-table__row--error > td.el-table__cell) { background: var(--el-color-danger-light-9); }
.warehouse-suggestion-table { display: grid; gap: var(--admin-space-4, 10px); margin-top: 18px; }.warehouse-suggestion-table h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section, 14px); }
.measurement-box-code { font-weight: 650; }
.measurement-limit-warning { white-space: normal; overflow-wrap: anywhere; color: var(--el-color-danger-dark-2); }
.measurement-count-editor { width: 104px !important; }
.measurement-row-details { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-3, 8px) 20px; margin: 0; padding: var(--admin-space-1, 4px) 18px var(--admin-space-3, 8px) 62px; }
.measurement-row-details dt { color: var(--workbench-muted); font-size: var(--admin-font-caption, 11px); }
.measurement-row-details dd { margin: 3px 0 0; overflow-wrap: anywhere; color: var(--workbench-text); font-size: var(--admin-font-caption, 12px); font-weight: 600; }
.measurement-panel__footer { position: sticky; bottom: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); padding: var(--admin-space-5, 12px) 20px; border-top: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
.measurement-panel__footer > span { display: inline-flex; align-items: center; gap: var(--admin-space-2, 6px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
.measurement-panel__footer > span:has(svg) { color: #b54708; font-weight: 650; }
@@ -981,6 +1001,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
@media (max-width: 960px) {
.measurement-data-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.measurement-row-details { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
@@ -994,6 +1015,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
.manual-entry-table colgroup { display: none; }
.manual-entry-table tr { display: grid; grid-template-columns: 36% minmax(0, 1fr); }
.manual-entry-table :is(th, td) { min-width: 0; }
.measurement-row-details { grid-template-columns: 1fr; padding-left: 54px; }
}
@media (max-width: 420px) {
@@ -1,12 +1,11 @@
/* Measurement workspace: keep all sections inside the panel's content bounds. */
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-section { min-width: 0; margin: 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-summary-section { min-width: 0; margin: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-summary-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-summary-bg); }
.inbound-measurement-workspace-dialog.inbound-density .measurement-summary-header { flex-wrap: wrap; align-items: center; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); }
.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(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
.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-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); }
@@ -17,7 +17,7 @@ vi.mock('./api', () => ({
vi.mock('../weight/components/WeightOcrPanel.vue', () => ({
default: {
name: 'WeightOcrPanel',
props: { customerId: String, inboundId: String, reviewMode: Boolean, correctionMode: Boolean, tableMode: Boolean },
props: { customerId: String, inboundId: String, reviewMode: Boolean, correctionMode: Boolean },
emits: ['changed'],
template: '<button class="mock-weight-change" @click="$emit(\'changed\')">重量证据</button>',
},
@@ -62,19 +62,13 @@ describe('MeasurementReviewPanel', () => {
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
})
it('keeps context in a scrolling toolbar and moves weight actions and totals to the weight details', async () => {
it('keeps customer context and pending actions in one sticky toolbar while reviewing long content', async () => {
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const toolbar = wrapper.get('.review-toolbar')
expect(toolbar.text()).toContain('待审核')
expect(toolbar.text()).not.toContain('修改重量')
const weightHeading = wrapper.get('.review-weight-items header')
expect(weightHeading.text()).toContain('修改重量')
expect(weightHeading.text()).toContain('实测毛重 120 kg')
expect(weightHeading.text()).toContain('货物重量 110 kg')
expect(weightHeading.find('.lucide-scale').exists()).toBe(true)
expect(weightHeading.find('.lucide-pencil').exists()).toBe(true)
expect(toolbar.text()).toContain('修改重量')
expect(toolbar.findAll('button').map(button => button.text())).toContain('通过')
expect(toolbar.text()).toContain('刷新')
expect(toolbar.text()).toContain('客户:柏悦客户')
@@ -89,7 +83,7 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.text()).not.toContain('审核历史')
expect(listMeasurementReviewHistory).not.toHaveBeenCalled()
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
expect(source).not.toContain('position: sticky')
expect(source).toContain('position: sticky')
expect(source).toContain('review-toolbar__context')
expect(source.indexOf('class="review-toolbar"')).toBeLessThan(source.indexOf('<WeightOcrPanel'))
expect(source).not.toContain('reasonDialog')
@@ -108,7 +102,7 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.text()).not.toContain('通过并进入')
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
await flushPromises()
expect(confirmation).toHaveBeenCalledWith('审核通过后,入库单将进入待分柜', '确认测量', expect.objectContaining({ confirmButtonText: '通过' }))
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('进入待一件代发组托'), '确认测量审核通过', expect.objectContaining({ confirmButtonText: '通过' }))
expect(initialPricingSucceeded ? success : warning).toHaveBeenCalledWith(expect.stringContaining('入库单已进入待一件代发组托'))
expect(approveMeasurement).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('changed')).toHaveLength(1)
@@ -234,7 +228,7 @@ describe('MeasurementReviewPanel', () => {
expect(wrapper.find('.review-toolbar__actions').exists()).toBe(false)
expect(wrapper.get('.review-toolbar__context').text()).toContain('客户:柏悦客户')
expect(wrapper.vm.approvalButtonLabel).toBe('通过')
await wrapper.get('.review-weight-summary button').trigger('click')
wrapper.vm.enableWeightCorrection()
await flushPromises()
expect(wrapper.getComponent({ name: 'WeightOcrPanel' }).props('correctionMode')).toBe(true)
await wrapper.vm.confirmApprove()
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check, Pencil, RotateCcw, Ruler, Scale } from '@lucide/vue'
import { Check, Pencil, RotateCcw } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -13,7 +13,6 @@ import { downloadInboundMeasurementImage } from '../measurement/api'
import MeasurementEvidenceThumbnail from '../measurement/components/MeasurementEvidenceThumbnail.vue'
import { inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
import type { WeightResultResource } from '../weight/types'
import { approveMeasurement, getMeasurementReviewDetail } from './api'
import type { MeasurementReviewDetail } from './types'
@@ -39,7 +38,6 @@ const loading = ref(false)
const actionLoading = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const correctionMode = ref(false)
const weightPanelRef = ref<InstanceType<typeof WeightOcrPanel> | null>(null)
const requestSerial = ref(0)
const imagePreviewOpen = ref(false)
const imagePreviewTitle = ref('测量图片')
@@ -127,7 +125,7 @@ async function loadReview() {
}
async function reload() {
await Promise.all([loadReview(), weightPanelRef.value?.reload()])
await loadReview()
}
async function handleWeightChanged() {
@@ -142,8 +140,8 @@ async function confirmApprove() {
if (!detail.value || !isPending.value || !canReview.value || actionLoading.value || props.postApproveLoading || props.approvalCompleted) return
try {
await ElMessageBox.confirm(
'审核通过后,入库单将进入待分柜',
'确认测量',
`审核通过后,入库单将进入${approvalTargetLabel.value}。系统会尝试生成初算费用,但初算失败不影响审核通过,后续可由平台人员补充调整。是否继续?`,
'确认测量审核通过',
{ customClass: 'admin-density-confirm', confirmButtonText: approvalButtonLabel.value, cancelButtonText: '返回', type: 'warning' },
)
} catch {
@@ -177,7 +175,7 @@ onMounted(() => {
})
onBeforeUnmount(releaseImagePreview)
defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, actionLoading })
defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPending, canReview, actionLoading })
</script>
<template>
@@ -185,6 +183,7 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
<div class="review-toolbar" aria-label="测量审核操作">
<div v-if="showActions" class="review-toolbar__actions">
<el-button v-if="isPending && canReview" type="primary" plain @click="enableWeightCorrection"><Pencil :size="16" />修改重量</el-button>
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve' || postApproveLoading" :disabled="approvalCompleted" @click="confirmApprove"><Check :size="16" />{{ approvalCompleted ? '审核已通过' : approvalButtonLabel }}</el-button>
</div>
<div class="review-toolbar__status">
@@ -206,7 +205,7 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
<template v-else-if="detail">
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
<section class="review-data density-dialog-section" aria-labelledby="review-data-title">
<section class="review-data density-dialog-section" data-tone="summary" aria-labelledby="review-data-title">
<header class="review-section-heading density-dialog-section__title"><h3 id="review-data-title">审核数据总览</h3></header>
<div class="workbench-evidence"><table class="review-comparison density-table" aria-label="客户申报与平台实测对比">
<thead><tr><th scope="col">指标</th><th scope="col">客户申报</th><th scope="col">平台实测</th><th scope="col">差异(实测 − 申报)</th></tr></thead>
@@ -221,13 +220,18 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
<section class="review-items density-dialog-section" data-tone="source" aria-labelledby="review-items-title">
<header class="review-section-heading density-dialog-section__title">
<h3 id="review-items-title" class="review-detail-heading"><Ruler :size="18" aria-hidden="true" />箱规测量明细</h3>
<div>
<h3 id="review-items-title">箱规测量明细</h3>
</div>
<span>{{ measurementCount }} 条</span>
</header>
<div v-if="detail.items.length" class="review-table-scroll">
<el-table :data="detail.items" border stripe table-layout="fixed" class="review-table">
<el-table-column type="index" label="#" width="44" align="center" />
<el-table-column label="测量图片" width="112" align="center">
<el-table-column label="箱号" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
</el-table-column>
<el-table-column label="测量图片" width="98" align="center">
<template #default="{ row }">
<MeasurementEvidenceThumbnail
:customer-id="customerId"
@@ -239,9 +243,6 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
/>
</template>
</el-table-column>
<el-table-column label="箱号" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.boxCode || row.itemNo || '—' }}</template>
</el-table-column>
<el-table-column label="尺寸 长×宽×高(cm)" min-width="200" align="center">
<template #default="{ row }">{{ formatNumber(row.lengthCm) }} × {{ formatNumber(row.widthCm) }} × {{ formatNumber(row.heightCm) }}</template>
</el-table-column>
@@ -257,7 +258,7 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
<el-table-column label="体积重(kg)" width="96" align="right">
<template #default="{ row }">{{ formatNumber(row.volumeWeightKg) }}</template>
</el-table-column>
<el-table-column label="测量时间" width="220" class-name="review-time-cell">
<el-table-column label="测量时间" width="190" class-name="review-time-cell">
<template #default="{ row }">{{ formatDate(row.deviceMeasuredAt) }}</template>
</el-table-column>
</el-table>
@@ -266,45 +267,31 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
</section>
<section class="review-items review-weight-items density-dialog-section" data-tone="source" aria-labelledby="review-weight-title">
<header class="review-section-heading density-dialog-section__title">
<h3 id="review-weight-title" class="review-detail-heading"><Scale :size="18" aria-hidden="true" />称重明细</h3>
<div class="review-weight-summary">
<span>实测毛重 <strong>{{ formatNumber(detail.actualGrossWeightKg) }}</strong> kg</span>
<span>货物重量 <strong>{{ formatNumber(detail.actualNetWeightKg) }}</strong> kg</span>
<el-button v-if="isPending && canReview" type="primary" plain @click="enableWeightCorrection"><Pencil :size="16" />修改重量</el-button>
<span>{{ weightCount }} 条</span>
</div>
</header>
<WeightOcrPanel
ref="weightPanelRef"
review-mode
:correction-mode="correctionMode && isPending && canReview"
:customer-id="customerId"
:inbound-id="inboundId"
@changed="handleWeightChanged"
>
<template #heading><span id="weight-ocr-title" class="sr-only">称重图片预览与修正</span></template>
<template #results="{ rows, openPreview, canCorrect, busy }">
<div v-if="rows.length" class="review-table-scroll">
<el-table :data="rows" border stripe table-layout="fixed" class="review-table review-weight-table">
<el-table-column v-if="canCorrect" label="操作" width="100">
<template #default="{ row, $index }"><el-button v-if="row.recognitionStatus === 'CONFIRMED'" link type="primary" :aria-label="`修改重量 ${$index + 1}`" :disabled="busy" @click="openPreview(row as WeightResultResource)">修改重量</el-button><span v-else>—</span></template>
</el-table-column>
<el-table-column type="index" label="#" width="44" align="center" />
<el-table-column label="称重图片" width="112" align="center"><template #default="{ row }"><MeasurementEvidenceThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`${row.palletCode || '托盘'}称重图片`" empty-text="无图片" @open="openPreview(row as WeightResultResource)" /></template></el-table-column>
<el-table-column prop="palletCode" label="托盘编码" width="160" show-overflow-tooltip />
<el-table-column prop="palletType" label="托盘类型" min-width="120" />
<el-table-column label="库位" min-width="130"><template #default="{ row }"><span class="review-location">{{ row.locationCode || '—' }}<template v-if="row.locationCode"> · {{ row.slotStatus === 'BOUND' ? '待上架' : row.slotStatus || '待上架' }}</template></span></template></el-table-column>
<el-table-column label="毛重(KG)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.grossWeightKg, 3) }}</template></el-table-column>
<el-table-column label="托盘皮重(kg)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.palletTareWeightKg, 3) }}</template></el-table-column>
<el-table-column label="货物重量(kg)" min-width="120" align="right"><template #default="{ row }"><strong class="review-weight-value">{{ formatNumber(row.netWeightKg, 3) }}</strong></template></el-table-column>
<el-table-column label="称重时间" width="220" class-name="review-time-cell"><template #default="{ row }">{{ formatDate(row.confirmedAt || row.adjustedAt) }}</template></el-table-column>
</el-table>
</div>
<el-empty v-else-if="!busy" description="暂无称重明细" :image-size="48" />
</template>
</WeightOcrPanel>
<header class="review-section-heading density-dialog-section__title"><h3 id="review-weight-title">称重明细</h3><span>{{ weightCount }} 条</span></header>
<div v-if="weightCount" class="review-table-scroll">
<el-table :data="detail.weightResults" border stripe table-layout="fixed" class="review-table review-weight-table">
<el-table-column type="index" label="#" width="44" align="center" />
<el-table-column label="称重图片" width="112" align="center"><template #default="{ row }"><MeasurementEvidenceThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`${row.palletCode || '托盘'}称重图片`" empty-text="无图片" @open="openImagePreview(row.fileId, row.palletCode || '托盘')" /></template></el-table-column>
<el-table-column prop="palletCode" label="托盘编码" min-width="120" />
<el-table-column prop="palletType" label="托盘类型" min-width="120" />
<el-table-column label="库位" min-width="130"><template #default="{ row }"><span class="review-location">{{ row.locationCode || '—' }}<template v-if="row.locationCode"> · {{ row.slotStatus === 'BOUND' ? '待上架' : row.slotStatus || '待上架' }}</template></span></template></el-table-column>
<el-table-column label="毛重(KG)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.grossWeightKg, 3) }}</template></el-table-column>
<el-table-column label="托盘皮重(kg)" min-width="120" align="right"><template #default="{ row }">{{ formatNumber(row.palletTareWeightKg, 3) }}</template></el-table-column>
<el-table-column label="货物重量(kg)" min-width="120" align="right"><template #default="{ row }"><strong class="review-weight-value">{{ formatNumber(row.netWeightKg, 3) }}</strong></template></el-table-column>
<el-table-column label="称重时间" min-width="150"><template #default="{ row }">{{ formatDate(row.confirmedAt || row.adjustedAt) }}</template></el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无称重明细" :image-size="48" />
</section>
<WeightOcrPanel
review-mode
:correction-mode="correctionMode"
:customer-id="customerId"
:inbound-id="inboundId"
@changed="handleWeightChanged"
:class="{ 'review-weight-editor--hidden': !correctionMode }"
/>
</template>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="imagePreviewOpen" :title="imagePreviewTitle" width="min(760px, 92vw)" fullscreenable :show-footer="false" :close-on-click-modal="true" destroy-on-close append-to-body @closed="releaseImagePreview">
@@ -322,8 +309,8 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
</template>
<style scoped>
.review-panel { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--admin-space-5, 12px); color: var(--workbench-text); }
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); flex-wrap: wrap; padding: var(--admin-space-3, 8px) 0; border-bottom: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
.review-panel { display: grid; gap: var(--admin-space-5, 12px); color: var(--workbench-text); }
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); position: sticky; top: 0; z-index: 3; flex-wrap: wrap; padding: var(--admin-space-3, 8px) 0; border-bottom: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3, 8px); }
.review-toolbar__status > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
.review-toolbar__actions { justify-content: flex-end; }
@@ -335,8 +322,6 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
.review-section-heading > div { min-width: 0; }
.review-section-heading h3 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section, 14px); line-height: 1.35; }
.review-section-heading > span { color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); white-space: nowrap; }
.review-items > .review-section-heading { min-height: var(--admin-control-height, 32px); }
.review-weight-items :deep(.weight-list) { margin-top: 0; }
.review-comparison { table-layout: fixed; }
.review-extra-facts { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-5, 12px); margin-top: var(--admin-space-3, 8px); color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
.review-extra-facts strong { color: var(--workbench-text); }
@@ -346,10 +331,7 @@ defineExpose({ confirmApprove, approvalButtonLabel, isPending, canReview, action
.review-table :deep(.measurement-evidence-thumb) { min-width: 72px; border-radius: var(--admin-radius, 4px); }
.review-weight-value { color: var(--el-color-success-dark-2); font-weight: 600; }
.review-location { white-space: nowrap; }
.review-detail-heading, .review-weight-summary { display: flex; align-items: center; gap: var(--admin-space-5, 12px); }
.review-detail-heading > svg { flex-shrink: 0; }
.review-weight-summary { flex-wrap: wrap; color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
.review-weight-summary strong { color: var(--el-color-success-dark-2); }
.review-weight-editor--hidden { display: none; }
.review-image-preview { display: grid; min-height: 240px; place-items: center; }
.review-image-preview img { display: block; width: auto; max-width: 100%; max-height: 70vh; object-fit: contain; }
.review-image-preview__error { display: grid; justify-items: center; gap: var(--admin-space-3, 8px); color: var(--el-color-danger); }
@@ -22,10 +22,6 @@ import {
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
import WeightOcrPanel from './WeightOcrPanel.vue'
import InboundMeasurementDialog from '../../measurement/InboundMeasurementDialog.vue'
import MeasurementReviewPanel from '../../review/MeasurementReviewPanel.vue'
import { getMeasurementReviewDetail } from '../../review/api'
vi.mock('../../review/api', () => ({ getMeasurementReviewDetail: vi.fn(), approveMeasurement: vi.fn() }))
vi.mock('../api/weightOcr', async () => {
const actual = await vi.importActual<typeof import('../api/weightOcr')>('../api/weightOcr')
@@ -104,17 +100,6 @@ const workspace: WeightWorkspaceResource = {
},
}
const confirmedReviewWorkspace: WeightWorkspaceResource = {
...workspace,
processStatus: 'MEASURE_PENDING',
actualGrossWeightKg: 120,
actualNetWeightKg: 108,
weightResults: [{
...pendingWeight, recognitionStatus: 'CONFIRMED', adjustedWeightValue: 120,
adjustedWeightUnit: 'KG', grossWeightKg: 120, netWeightKg: 108, version: 1,
}],
}
function palletPage(records = [{
palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', capacityCbm: 1.2, tareWeightKg: 12,
}]) {
@@ -143,7 +128,7 @@ function mountPanel(permissions = allWeightPermissions, props: Record<string, un
})
}
async function openWeightDialog(wrapper: Pick<VueWrapper, 'get'>) {
async function openWeightDialog(wrapper: VueWrapper) {
await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click')
await flushPromises()
}
@@ -511,59 +496,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(wrapper.text()).toContain('1747.500 KG')
})
it('corrects a weight in the single review details table and refreshes totals without adding an image section', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue(confirmedReviewWorkspace)
const reviewDetail = {
inboundId: '20001', inboundCode: 'RK-20001', companyName: '测试客户', channelCode: 'SEA', channelName: '海运',
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: null,
reviewReason: null, reviewerId: null, reviewerName: null, measurementId: '70001', sourceType: 'DEVICE',
measurementStatus: 'SUBMITTED', inboundVersion: 1, version: 1, originalDocumentNo: null,
originalGrossWeightKg: 100, originalVolumeCbm: 1, originalPackageCount: 1, actualPackageCount: 1,
actualGrossWeightKg: 120, actualNetWeightKg: 108, actualVolumeCbm: 1, volumeWeightKg: 167,
actualGoodsName: null, items: [], weightResults: confirmedReviewWorkspace.weightResults,
}
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(reviewDetail)
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = [...allWeightPermissions, 'firstleg:inbound:measurement-review']
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(MeasurementReviewPanel, {
attachTo: document.body,
props: { customerId: '10001', inboundId: '20001', showActions: false },
global: { plugins: [pinia, ElementPlus] },
})
await flushPromises()
expect(wrapper.findAll('.review-weight-table')).toHaveLength(1)
expect(wrapper.find('.weight-data-table').exists()).toBe(false)
expect(wrapper.find('.weight-heading').exists()).toBe(false)
expect(wrapper.find('button[aria-label="修改重量 1"]').exists()).toBe(false)
await wrapper.get('.review-weight-summary button').trigger('click')
await wrapper.get('button[aria-label="修改重量 1"]').trigger('click')
await flushPromises()
await dialogWeightInput().setValue('125')
await new DOMWrapper(document.body.querySelector<HTMLTextAreaElement>('#weight-correction-reason')!).setValue('复核磅秤读数')
const updatedWeight = { ...confirmedReviewWorkspace.weightResults[0], grossWeightKg: 125, netWeightKg: 113, version: 2 }
vi.mocked(confirmWeightResult).mockResolvedValue(updatedWeight)
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...confirmedReviewWorkspace, actualGrossWeightKg: 125, actualNetWeightKg: 113, weightResults: [updatedWeight] })
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...reviewDetail, actualGrossWeightKg: 125, actualNetWeightKg: 113, inboundVersion: 2 })
await dialogConfirmButton('保存重量修正').trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
version: 1, adjustedWeightValue: 125, adjustedWeightUnit: 'KG', adjustmentReason: '复核磅秤读数',
})
expect(wrapper.get('.review-weight-summary').text()).toContain('实测毛重 125 kg')
expect(wrapper.get('.review-weight-summary').text()).toContain('货物重量 113 kg')
expect(wrapper.get('.review-weight-table').text()).toContain('125')
expect(wrapper.findAll('.review-weight-table')).toHaveLength(1)
const workspaceCalls = vi.mocked(getWeightWorkspace).mock.calls.length
await wrapper.get('.review-refresh').trigger('click')
await flushPromises()
expect(getWeightWorkspace).toHaveBeenCalledTimes(workspaceCalls + 1)
})
it('opens the explicit table correction action and atomically confirms an approved weight during measurement review', async () => {
it('atomically corrects and confirms an approved weight during measurement review', async () => {
const confirmed = {
...pendingWeight,
recognitionStatus: 'CONFIRMED',
@@ -581,16 +514,12 @@ describe('WeightOcrPanel confirmation behavior', () => {
weightResults: [confirmed],
})
vi.mocked(confirmWeightResult).mockResolvedValue({ ...confirmed, grossWeightKg: 125, netWeightKg: 113, version: 2 })
const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true, tableMode: true })
const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true })
await flushPromises()
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('删除图片')
expect(wrapper.find('.weight-data-table').exists()).toBe(true)
expect(wrapper.find('.weight-grid').exists()).toBe(false)
expect(wrapper.get('.weight-data-table thead th').text()).toBe('操作')
expect(wrapper.find('.pallet-picker').exists()).toBe(false)
await wrapper.get('button[aria-label="修改重量 1"]').trigger('click')
await wrapper.findAll('button').find((button) => button.text().includes('修改含托盘毛重'))?.trigger('click')
await flushPromises()
await dialogWeightInput().setValue('125')
const reason = document.body.querySelector<HTMLTextAreaElement>('textarea[placeholder="请填写本次毛重修正原因"]')
@@ -608,58 +537,6 @@ describe('WeightOcrPanel confirmation behavior', () => {
expect(correctWeightResult).not.toHaveBeenCalled()
})
it.each([
{ permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:edit')), status: 'MEASURE_PENDING', correctionMode: true },
{ permissions: allWeightPermissions.filter(permission => !permission.endsWith('weight:confirm')), status: 'MEASURE_PENDING', correctionMode: true },
{ permissions: allWeightPermissions, status: 'WAIT_SPLIT', correctionMode: true },
{ permissions: allWeightPermissions, status: 'MEASURE_REJECTED', correctionMode: true },
{ permissions: allWeightPermissions, status: 'MEASURE_PENDING', correctionMode: false },
])('keeps table correction unavailable for missing permissions or inactive review: %j', async ({ permissions, status, correctionMode }) => {
vi.mocked(getWeightWorkspace).mockResolvedValue({ ...confirmedReviewWorkspace, processStatus: status })
const wrapper = mountPanel(permissions, { reviewMode: true, correctionMode, tableMode: true })
await flushPromises()
expect(wrapper.find('button[aria-label="修改重量 1"]').exists()).toBe(false)
await openWeightDialog(wrapper)
expect(dialogWeightInput().attributes('disabled')).toBeDefined()
expect(wrapper.getComponent(AppDialog).props('showConfirm')).toBe(false)
expect(confirmWeightResult).not.toHaveBeenCalled()
})
it('allows cancelling and reopening correction, and preserves the draft for retry after a save fails', async () => {
vi.mocked(getWeightWorkspace).mockResolvedValue(confirmedReviewWorkspace)
vi.mocked(confirmWeightResult)
.mockRejectedValueOnce(new Error('重量写入失败'))
.mockResolvedValueOnce({ ...confirmedReviewWorkspace.weightResults[0], grossWeightKg: 125, netWeightKg: 113, version: 2 })
const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true, tableMode: true })
await flushPromises()
const correctionButton = wrapper.get('button[aria-label="修改重量 1"]')
await correctionButton.trigger('click')
await flushPromises()
await dialogWeightInput().setValue('125')
await new DOMWrapper(document.body.querySelector<HTMLTextAreaElement>('#weight-correction-reason')!).setValue('复核磅秤读数')
const cancelButton = [...document.body.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')].find(button => button.textContent?.trim() === '取消')!
await new DOMWrapper(cancelButton).trigger('click')
await flushPromises()
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(false)
expect(confirmWeightResult).not.toHaveBeenCalled()
await correctionButton.trigger('click')
await flushPromises()
expect(Number(dialogWeightInput().element.value)).toBe(125)
await dialogConfirmButton('保存重量修正').trigger('click')
await flushPromises()
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
expect(Number(dialogWeightInput().element.value)).toBe(125)
expect(dialogConfirmButton('保存重量修正').attributes('disabled')).toBeUndefined()
expect(wrapper.emitted('changed')).toBeUndefined()
await dialogConfirmButton('保存重量修正').trigger('click')
await flushPromises()
expect(confirmWeightResult).toHaveBeenCalledTimes(2)
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(false)
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('closes the confirmation dialog immediately after a successful confirmation', async () => {
const wrapper = mountPanel()
await flushPromises()
@@ -109,9 +109,9 @@ const previewConfirmDisabled = computed(() => {
|| Boolean(activeAction.value)
})
const previewCanConfirm = computed(() => Boolean(previewResult.value && canConfirm.value
&& ((!props.reviewMode && editable.value) || (reviewCorrectionEnabled.value && canEdit.value))))
&& (editable.value || (reviewCorrectionEnabled.value && canEdit.value))))
const previewInputEditable = computed(() => Boolean(previewResult.value && canEdit.value && canConfirm.value
&& ((!props.reviewMode && editable.value) || reviewCorrectionEnabled.value)))
&& (editable.value || reviewCorrectionEnabled.value)))
const previewConfirmText = computed(() => previewResult.value?.recognitionStatus === 'CONFIRMED'
? '保存重量修正'
: '确认重量')
@@ -495,7 +495,6 @@ defineExpose({ reload: loadWorkspace })
<template>
<section class="weight-workspace admin-workbench" aria-labelledby="weight-ocr-title">
<slot name="heading">
<header class="weight-heading">
<div>
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
@@ -505,8 +504,9 @@ defineExpose({ reload: loadWorkspace })
<div class="weight-summary"><span>实测毛重</span><strong>{{ formatWeight(workspace.actualGrossWeightKg) }}</strong></div>
<div class="weight-summary"><span>货物重量</span><strong>{{ formatWeight(workspace.actualNetWeightKg) }}</strong></div>
</div>
<div v-if="canEdit && !reviewMode" class="pallet-picker">
<div class="pallet-picker">
<el-select-v2
v-if="canEdit && !reviewMode"
v-model="selectedPalletId"
:options="virtualPalletOptions"
aria-label="选择称重托盘"
@@ -536,7 +536,6 @@ defineExpose({ reload: loadWorkspace })
><ImagePlus :size="16" />上传称重图片</el-button>
</div>
</header>
</slot>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
当前账号缺少入库测量查看权限。
@@ -567,7 +566,7 @@ defineExpose({ reload: loadWorkspace })
<template #details><ApiErrorMeta :details="palletError" /></template>
</LoadErrorState>
<el-alert
v-else-if="!reviewMode && !palletLoading && palletOptions.length === 0 && editable && canEdit"
v-else-if="!palletLoading && palletOptions.length === 0 && editable && canEdit"
type="warning"
:closable="false"
show-icon
@@ -577,22 +576,15 @@ defineExpose({ reload: loadWorkspace })
<el-button size="small" @click="reloadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
</el-alert>
<div v-loading="loading" class="weight-list" aria-live="polite">
<slot name="results" :rows="rows" :open-preview="openPreview" :can-correct="reviewCorrectionEnabled && canEdit && canConfirm" :busy="loading || Boolean(activeAction)">
<div v-if="!loading && rows.length === 0" class="weight-empty">
<strong>尚未上传称重图片</strong>
</div>
<div v-else-if="tableMode" class="weight-table-wrap">
<div class="workbench-evidence"><table class="weight-data-table">
<colgroup v-if="reviewMode"><col /><col class="weight-index-column" /><col span="8" /></colgroup>
<thead><tr><th>操作</th><th>#</th><th>称重图片</th><th>托盘编码</th><th>托盘类型</th><th>库位</th><th>实测毛重(KG)</th><th>服务端确认毛重(KG)</th><th>托盘皮重(KG)</th><th>货物重量(KG)</th></tr></thead>
<thead><tr><th>#</th><th>称重图片</th><th>托盘编码</th><th>托盘类型</th><th>库位</th><th>实测毛重(KG)</th><th>服务端确认毛重(KG)</th><th>托盘皮重(KG)</th><th>货物重量(KG)</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="(row, index) in rows" :key="row.id">
<td>
<el-button v-if="reviewCorrectionEnabled && row.recognitionStatus === 'CONFIRMED' && canEdit && canConfirm" link type="primary" :aria-label="`修改重量 ${index + 1}`" :disabled="Boolean(activeAction)" @click="openPreview(row)">修改重量</el-button>
<el-tooltip v-else-if="canEdit && editable && !reviewMode" content="删除图片" placement="top"><el-button text type="danger" :aria-label="`删除称重图片 ${index + 1}`" :loading="isActionLoading('delete', row.id)" :disabled="Boolean(activeAction)" @click="removeResult(row)"><Trash2 :size="16" /></el-button></el-tooltip>
<span v-else>—</span>
</td>
<td>{{ index + 1 }}</td>
<td><WeightImageThumbnail :customer-id="customerId" :inbound-id="inboundId" :file-id="row.fileId" :alt="`称重图片 ${index + 1}`" @open="openPreview(row)" /></td>
<td>{{ row.palletCode || '—' }}</td>
@@ -602,6 +594,7 @@ defineExpose({ reload: loadWorkspace })
<td>{{ formatWeight(row.grossWeightKg) }}</td>
<td>{{ formatWeight(row.palletTareWeightKg) }}</td>
<td class="weight-net-value">{{ formatWeight(row.netWeightKg) }}</td>
<td><el-tooltip v-if="canEdit && editable && !reviewMode" content="删除图片" placement="top"><el-button text type="danger" :aria-label="`删除称重图片 ${index + 1}`" :loading="isActionLoading('delete', row.id)" :disabled="Boolean(activeAction)" @click="removeResult(row)"><Trash2 :size="16" /></el-button></el-tooltip><span v-else>—</span></td>
</tr>
</tbody>
</table></div>
@@ -651,7 +644,6 @@ defineExpose({ reload: loadWorkspace })
</div>
</article>
</div>
</slot>
</div>
</template>
</template>
@@ -752,7 +744,6 @@ 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 .weight-index-column { width: 44px; }
.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); }
+1 -1
View File
@@ -20,7 +20,7 @@
## 关键边界
- 预装详情进入后先调 `/loading-plans/{planId}/lifecycle`,再按返回状态决定可操作项;不自行推断生命周期阶段。
- 托盘码格式为“柜次号-FBA 地址内部编号-三位序号”,清单只取服务端按 FBA 仓分组的冻结结果,前端不生成、不改码、不重新编号;一件代发托盘不进入该清单。
- 托盘码格式为“真实柜号-FBA 地址内部编号-三位序号”,清单只取服务端按 FBA 仓分组的冻结结果,前端不生成、不改码、不重新编号;一件代发托盘不进入该清单。
- 确认预装和创建 PDA 任务前必须补齐真实集装箱号;历史上已建缺号任务可在预装详情首次补录,保存后恢复 PDA 可进入状态。
- 柜级公共费用只按服务端柜内客户/订单事实分摊,创建、审核、驳回、调整、作废均整组原子,金额必须守恒;待确认时禁止调整或重复作废。
- 独立清单详情与货柜详情页签共用同一套清单 API 与详情组件;改展示或打印时同步检查 `container-loading` 的装柜清单页签。
@@ -410,9 +410,9 @@ onMounted(() => void load())
<el-alert v-else-if="suppliersError" type="warning" :closable="false" show-icon :title="suppliersError.message" />
<el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" />
</template>
<section class="density-dialog-section">
<section class="density-dialog-section" data-tone="work">
<el-form ref="formRef" :model="form" :rules="rules" label-position="left" class="density-form density-form--flush-end">
<div class="workbench-evidence"><table class="density-table fee-editor-table" aria-label="柜次费用填写"><tbody>
<div class="workbench-evidence"><table class="density-table density-table--editable fee-editor-table" aria-label="柜次费用填写"><tbody>
<tr><td><el-form-item label-position="left" label-width="110px" label="方向" :prop="target ? undefined : 'direction'"><span v-if="target">{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}</span><el-segmented v-else v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers }]" /></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" :prop="target ? undefined : 'feeItemId'"><span v-if="target">{{ target.feeItemName }}</span><el-select v-else v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目" @change="applyFeeItemDefaultPrice"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></td></tr>
<tr v-if="target || form.direction === 'PAYABLE'"><td><el-form-item label-position="left" label-width="110px" label="供应商" :required="!target"><span v-if="target">{{ target.supplierName || '—' }}</span><el-select v-else v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem || !!suppliersError" placeholder="请先选择费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="结算方式" :required="!target"><span v-if="target">{{ settlementLabel(target) }}</span><el-radio-group v-else v-model="settlementModeModel"><el-radio-button value="ON_ACCOUNT">账期应付</el-radio-button><el-radio-button value="DIRECT_PAID">已付成本</el-radio-button></el-radio-group></el-form-item></td></tr>
<tr v-if="target"><td><el-form-item label="已付摘要" label-position="left" label-width="110px"><span>{{ paidSummary(target) }}</span></el-form-item></td><td><el-form-item label="币种" label-position="left" label-width="110px"><span>{{ target.currency }}</span></el-form-item></td></tr>
@@ -425,7 +425,7 @@ onMounted(() => void load())
</tbody></table></div>
<el-form-item label-position="top" v-if="allocationMode" label="业务归属分摊" class="allocation-editor" required>
<el-alert type="info" :closable="false" show-icon title="客户必须明确;入仓码没有时按订单归属,分摊合计必须等于费用总额。" />
<div class="workbench-evidence"><table class="density-table allocation-editor-table" aria-label="业务归属分摊"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th class="allocation-amount">分摊金额(CNY)</th></tr></thead><tbody>
<div class="workbench-evidence"><table class="density-table density-table--editable allocation-editor-table" aria-label="业务归属分摊"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th class="allocation-amount">分摊金额(CNY)</th></tr></thead><tbody>
<tr v-for="(row, index) in allocationDraft" :key="`${row.customerId}-${row.orderId}-${row.inboundId || 'none'}`"><td>{{ customerLabel(row.customerId || targetGroupRows[index]?.customerId || '') }}</td><td>{{ row.orderNo }}</td><td>{{ row.inboundCode || '无入仓码' }}</td><td><el-input-number v-model="row.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入分摊金额" /></td></tr>
</tbody><tfoot><tr><td colspan="4"><span class="allocation-editor__sum" :class="{ 'is-error': Math.abs(allocationSum - form.amount) > 0.001 }">已分摊 ¥{{ money(allocationSum) }} / 总额 ¥{{ money(form.amount) }}</span></td></tr></tfoot></table></div>
</el-form-item>
@@ -1,37 +1,11 @@
import { flushPromises, mount, shallowMount } from '@vue/test-utils'
import { flushPromises, shallowMount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import ElementPlus from 'element-plus'
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
import type { LoadingDetail } from '../api/platformLoadingLists'
afterEach(() => vi.restoreAllMocks())
describe('shared manifest presentation', () => {
it('names producer events and retains separate adjustments even when their visible facts match', async () => {
const actions = ['CREATE_PLAN', 'ADD_INBOUND', 'ADJUST_ITEM', 'ADJUST_ITEM', 'REMARKS', 'UPDATE_PRELOAD_TRANSPORT']
const wrapper = mount(LoadingListDetailDrawer, {
props: { mode: 'confirmed', canEdit: false, loading: false, error: null,
detail: {
summary: { planId: '1', warehouseId: '11', loadingListNo: 'LP-TEST', status: 'COMPLETED' }, items: [],
histories: actions.map((actionType, index) => ({
historyId: String(index + 1), operationId: `operation-${index + 1}`, itemId: null, actionType,
fromStatus: 'EDITING', toStatus: 'EDITING', beforeSnapshot: null, afterSnapshot: null,
operatorId: '7', operatorName: '操作员', operationReason: null, occurredAt: '2026-10-21T10:00:00',
})),
} as unknown as LoadingDetail },
global: { plugins: [ElementPlus], directives: { loading: {} }, stubs: { DataTable: true } },
})
await flushPromises()
const rows = wrapper.findAll('.history-section .el-table__body tbody tr')
expect(rows).toHaveLength(6)
expect(rows.map(row => row.findAll('td')[1]!.text())).toEqual([
'创建计划', '加入入仓货物', '调整箱数', '调整箱数', '修改备注', '修改预装运输信息',
])
expect(wrapper.get('.history-section__toggle').text()).toContain('6 条')
expect(wrapper.get('.history-section').text()).not.toContain('状态更新')
wrapper.unmount()
})
it.each([undefined, '返回货柜概览'])('keeps print in the common header and emits return for %s', async (backLabel) => {
const print = vi.spyOn(window, 'print').mockImplementation(() => {})
const wrapper = shallowMount(LoadingListDetailDrawer, {
@@ -145,16 +145,8 @@ function actionLabel(action: string) {
return ({
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
RECOMMEND: '智能排柜', PUSH_PRELOAD: '推送预装', UPDATE_TRANSPORT: '修改运输信息',
CREATE_PLAN: '创建计划', ADD_ITEM: '加入货物', ADD_INBOUND: '加入入仓货物',
ADD_PENDING_REVIEW_ORDER: '加入待审核订单', ADD_PALLET: '加入封托托盘',
ADD_COLOAD_GROUP: '加入同柜组', REMOVE_COLOAD_GROUP: '移除同柜组',
SMART_RECOMMEND: '智能排柜', ADJUST_ITEM: '调整箱数', REMARKS: '修改备注',
REMOVE_ITEM: '移除货物', REMOVE_PALLET: '移除封托托盘', REMOVE_UNPICKED: '移除未拣货物',
UPDATE_PRELOAD_TRANSPORT: '修改预装运输信息', CONFIRM_PRELOAD: '确认预装',
START_LOADING: '开始拣货', PDA_COMPLETE_LOADING: 'PDA完成装柜', COMPLETE: '完成装柜',
UPLOAD_DELIVERY_ORDER: '上传DO资料',
ATTACH_DESTINATION_CLEARANCE: '关联目的港清关资料', UPLOAD_DESTINATION_CLEARANCE: '上传目的港清关资料',
COMPLETE_DESTINATION_CLEARANCE: '确认目的港清关完成',
CONFIRM_PRELOAD: '确认预装',
START_LOADING: '开始拣货', COMPLETE: '完成装柜',
} as Record<string, string>)[action] || '状态更新'
}
@@ -117,11 +117,11 @@ describe('预装清单统一弹窗', () => {
const wrapper = await render(initial)
const drawer = wrapper.getComponent({ name: 'LoadingListDetailDrawer' })
drawer.vm.$emit('confirm-preload'); await flushPromises()
expect(dialog(wrapper, '推送PDA').props('modelValue')).toBe(false)
expect(dialog(wrapper, '确认预装').props('modelValue')).toBe(false)
wrapper.getComponent(ElSelect).vm.$emit('update:modelValue', '71')
await flushPromises()
drawer.vm.$emit('confirm-preload'); await flushPromises()
const popup = dialog(wrapper, '推送PDA')
const popup = dialog(wrapper, '确认预装')
expect(popup.get('table[aria-label="确认预装资料"]').text()).toContain('操作员甲')
vi.mocked(confirmPreloadPlan).mockResolvedValue({ planId: initial.summary.planId, containerBookingId: initial.summary.containerBookingId, confirmationStatus: 'CONFIRMED', version: 8, readiness: { ...initial.summary.readiness, confirmationStatus: 'CONFIRMED', confirmedPreloadCount: 1, pdaTaskId: '91', pdaTaskStatus: 'PENDING', canConfirm: false, readyForPda: true } })
popup.vm.$emit('confirm'); await flushPromises()
@@ -109,8 +109,7 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).toContain('summary?.readiness.canConfirm')
expect(source).toContain('confirmSaving.value')
expect(source).toContain('await loadDetail()')
expect(source).toContain("preloadDialogTitle('推送PDA')")
expect(source).not.toContain('本操作将确认整份预装计划')
expect(source).toContain('本操作将确认整份预装计划')
expect(source).toContain(':confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading"')
expect(source).not.toContain('/inbounds/')
})
@@ -297,9 +297,9 @@ watch([planId, warehouseId], () => {
</div>
<AppDialog v-model="editOpen" class="loading-transport-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle(editDialogTitle)" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
<section class="density-dialog-section">
<section class="density-dialog-section" data-tone="work">
<el-form ref="editFormRef" class="density-form" :model="editForm" :rules="editRules">
<div class="workbench-evidence"><table class="density-table preload-editor-table" aria-label="运输信息填写"><tbody>
<div class="workbench-evidence"><table class="density-table density-table--editable preload-editor-table" aria-label="运输信息填写"><tbody>
<tr><td><el-form-item label-position="left" label-width="120px" label="真实集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入提柜后取得的真实集装箱号" @blur="editForm.containerNo = normalizedContainerNo(editForm.containerNo)" /></el-form-item></td></tr>
<tr><td><el-form-item label-position="left" label-width="120px" label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
<tr><td><el-form-item label-position="left" label-width="120px" :label="isContainerCorrection ? '纠错原因' : '操作原因(可选)'" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" :placeholder="isContainerCorrection ? '必填,将记录在操作历史中' : '可填写补录说明'" /></el-form-item></td></tr>
@@ -310,9 +310,9 @@ watch([planId, warehouseId], () => {
<AppDialog v-model="remarkOpen" class="remarks-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle('编辑备注', remarkTarget?.inboundCode || remarkTarget?.orderNo)" width="min(800px, calc(100vw - 28px))" fullscreenable confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveRemarks">
<section class="density-dialog-section">
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form">
<div class="workbench-evidence"><table class="density-table preload-editor-table" aria-label="预装明细备注"><tbody>
<div class="workbench-evidence"><table class="density-table density-table--editable preload-editor-table" aria-label="预装明细备注"><tbody>
<tr><td><el-form-item label-position="left" label-width="128px" label="客户备注(只读)"><span class="customer-remark-text">{{ remarkTarget?.customerRemark || '—' }}</span></el-form-item></td></tr>
<tr><td><el-form-item label-position="left" label-width="128px" label="内部备注"><el-input v-model="remarkForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
<tr><td><el-form-item label-position="left" label-width="128px" label="仓库备注"><el-input v-model="remarkForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>
@@ -323,9 +323,10 @@ watch([planId, warehouseId], () => {
<LoadErrorState v-if="remarkError" compact title="备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle('推送PDA')" width="min(520px, calc(100vw - 28px))" confirm-text="确认并推送" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading" @confirm="submitConfirmPreload">
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog admin-workbench is-dialog" :title="preloadDialogTitle('确认预装并推送 PDA')" width="min(520px, calc(100vw - 28px))" confirm-text="确认并推送" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading" @confirm="submitConfirmPreload">
<div class="confirm-content">
<section class="density-dialog-section">
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" />
<section class="density-dialog-section" data-tone="summary">
<div class="workbench-evidence"><table class="density-table preload-confirm-table" aria-label="确认预装资料"><tbody>
<template v-if="detail"><tr><th>预装单号</th><td>{{ detail.summary.preloadNo }}</td></tr><tr><th>柜次号</th><td>{{ detail.summary.cabinetNo || '—' }}</td></tr><tr><th>集装箱号</th><td>{{ detail.summary.containerNo || '待补录' }}</td></tr></template>
<tr><th>PDA 操作员</th><td>{{ selectedAssignee?.displayName || '未选择' }}</td></tr>
+3 -5
View File
@@ -13,21 +13,19 @@
| 订舱列表、放舱状态与关联候选 | `pages/OceanBookingsView.vue`;`ocean-bookings` | `api/platformOceanBookings.ts`;`firstleg/oceanbooking/` |
| 新增订舱(登记线下已确认订舱) | `pages/OceanBookingDetailView.vue`;`ocean-bookings/new` | `api/platformOceanBookings.ts` |
| 订舱详情、资料维护与版本 | `pages/OceanBookingDetailView.vue`;`ocean-bookings/:bookingId` | `api/platformOceanBookings.ts` |
| 订舱附件组件(详情页已移除入口) | `components/OceanBookingDocumentsPanel.vue` | `firstleg/oceanbooking/`;附件归 `file/` |
| 订舱资料面板与字段边界 | `components/OceanBookingDocumentsPanel.vue` | `firstleg/oceanbooking/`;附件归 `file/` |
| 订舱资料打印 | `components/OceanBookingPrintDialog.vue` | `print/` 单据模板 |
| 柜型需求与数量换算 | `containerRequirements.ts` | `containerCount` 为柜型需求 `quantity` 总和 |
| 订舱代理候选 | `bookingAgents.ts` | 公共供应商列表,仅 `ACTIVE` 且类别/范围匹配 |
## 关键边界
- 订舱草稿按当前版本维护资料;放舱后除预计到港外的运输、关系方、柜型需求、预报货量和附件只读;预计到港经 `/estimated-arrival` 按编辑权限、版本和幂等键单独维护,不得清空或早于预计开船;实货量可由订舱编辑权限通过 `/actual-cargo` 按版本、幂等键和原因补录并审计,禁止绕过运输冻结边界。
- 打印对草稿与已放舱订舱均开放,按当前**已保存**资料生成,未填写字段显示“—”,不用未保存的表单值。托运单取委值,提单确认件取实值;实值由客服手工录入,不回退为委值,不生成正式提单。
- 订舱草稿按当前版本维护资料;放舱后资料与表单只读,不允许绕过版本直接改字段。
- 打印对草稿与已放舱订舱均开放,按当前**已保存**资料生成,未填写字段显示“—”,不用未保存的表单值。
- 关联候选只消费服务端 `/link-candidates` 分页结果,不在浏览器重排或本地拼接候选。
- 订舱是拖车预约的唯一归属:拖车由 `../trucking/` 创建,本模块不直接建拖车单。
- 出仓前船期只展示订舱级计划,实际节点归 `../port-schedules/` 与海外回写,本模块不写实际时间。
- 本轮布局与操作证据维护在 `PROTOTYPE_MATRIX.md`,缺截图时保留待人工确认。
## 验证
- 定向:`pnpm test src/modules/ocean-bookings/<目标测试>`;资料字段或打印变更同时核对 `print/` 模板与 `file/` 附件契约。
@@ -1,28 +0,0 @@
# 订舱页面受影响操作核对
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后允许单独维护预计到港及补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
订舱详情按用户 2026-10-23 截图确认移除“订舱资料”模块及两类文件上传入口,放舱不再要求上传订舱确认文件或提单文件;其他业务资料校验保留。修改后页面待人工截图确认。
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已通过阶段勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不产生预览事实,也不声称实际打印完成。预览标记随本次页面保留;刷新后按服务端保存/放舱/实值恢复进度,已放舱时托运单阶段保持通过,提示“可重新预览 / 打印”,避免前面灰色、后面完成的断开。放舱前托运单数据改变须重新预览;提单确认件始终要求已保存实值和对应 PDF 成功预览后才完成。
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 页面核对 |
|---|---|---|---|---|
| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 移除客户选择、港名、箱种、容量、限重、备注及包装单位选择;预计到港恢复在预计开船旁,并在放舱确认摘要展示;仓库和渠道保留在默认收起的“作业配置”,放舱缺配置时自动展开;委实成对输入,新增保存留在详情 | TEST #702 已核对预计到港恢复、录入保存和刷新回显;完整长字段展示待人工确认 |
| `/ocean-bookings/:bookingId` | 放舱后维护预计到港 | 本轮用户确认仅开放预计到港;沿用现有单字段AppDialog | 页头独立入口,560px紧凑时间表单,仅提交预计到港和版本,不触及其他冻结资料 | 待1920×1080、100%人工截图确认 |
| `/ocean-bookings/:bookingId` | 已放舱补实 | 客服补录实际货量 | 运输及预报只读,实值可按编辑权限补录,原因必填并版本审计 | 已核对放舱、原因校验、补实保存和历史;最终 TEST #698 回验实值保存后立即回显,无需刷新 |
| `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有PDF预览;读已保存委/实数据,缺失显示“—” | 弹框开关/重开、PDF 生成与数据映射已核对;内嵌 PDF 空白,显示未通过 |
人工按 1920×1080、100% 缩放核对三方长名称/地址、两类货量、保存失败与版本冲突、冻结/无编辑权限,以及两类 PDF 预览的打开、关闭和重开。自动检查结果在交付中单独报告,不能代替人工截图。
新增订舱默认运费条款 `FREIGHT PREPAID`、运输条款 `CY-CY`、委值 `500 / 15000 / 68`,实值留空;包装按 `CARTONS` 保存,新柜需求为整箱 FCL,容量和限重沿平台柜型规格计算。默认值只用于新增,不覆盖已保存资料。预计到港草稿可留空,放舱前必填且不得早于预计开船;放舱后预计到港允许独立窄命令维护,其余运输资料仍冻结。
用户后续确认预计到港供日历和入库操作使用,恢复该字段及前后端放舱门禁。代码核对:关联柜次时 `ContainerRepository.linkReleasedBooking` 传入订舱预计到港;日历在无实际到港时读柜级预计到港;海外 `ContainerWarehouseArrivalService` 在无人工或计划冻结日期时按到港当地日期加 2 天解析默认到仓,入库计划使用该解析结果,不能把订舱预计到港直接当成计划到仓日期。
恢复版本 Admin #702 / Firstleg #513 均 SUCCESS、容器 healthy,Jenkins Admin 原检出配置已恢复。前端 22 项及后端订舱/船期相关 32 项定向测试通过;真实 TEST 草稿 `OB202610220013` 录入预计到港 `2026-11-15T10:00:00` 后保存到版本 1,服务端读回和页面刷新回显一致。证据 `ui-arrival-restored.png`、`arrival-restored-verification.json` 位于同一证据目录;日历至入库日期链路完成代码核对,本轮未推进真实柜次和入库计划,不据此宣称全流程验收。
该轮经 Admin #700、Firstleg #512 发布,运行容器 healthy;前端 28 项、后端订舱 26 项定向测试及前端类型检查通过。真实 TEST 新增页以默认值保存为 `OB202610220013`,服务端读回条款和委值一致,实值及预计到港未填写;本轮没有再次推进放舱,取消预计到港必填由前后端定向测试覆盖。页面证据 `ui-booking-simplified-new.png`、`ui-booking-simplified-saved.png` 和 `simplified-form-verification.json` 位于同一证据目录。
同日流程断开修复经 TEST #699 发布:刷新已放舱测试单后,第 1~3 步保持通过、连接线连续,第 4 步仍为当前待预览;托运单提示“可重新预览 / 打印”。两份定向测试 24 项通过;截图为同一证据目录的 `ui-workflow-continuous.png`。本次未修改后端或测试业务数据,未重新验收内嵌 PDF 显示。
2026-10-22 用户明确授权 AI 操作 TEST 页面并单独授权结构 SQL 后,已接续同一测试订舱 `OB202610220011` 完成保存、确认放舱、手工补实及两类 PDF 请求的页面核对,委实分离、版本与原因历史正确。发现并修复数字控件过期禁用辅助标记和保存后的输入缓存;两份定向测试 23 项通过,最终版本经 TEST #698 发布并回验:修正件数为 481 后无需刷新,输入显示与服务器版本 5 一致,毛重和体积保持,委值仍为 500。截图及脱敏证据在本机 `C:/Users/BY-OY/.codex/booking-ui-acceptance-20261022/`;两类 PDF 可下载且外部渲染正常,但内置浏览器的内嵌显示为空白,不能记为完整打印显示通过。环境事实以部署主契约“订舱 TEST 结构同步与页面验收”为准。
@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingActualCargo, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
import { http } from '@/shared/api/http'
import { updateOceanBookingEstimatedArrival } from './platformOceanBookings'
const detailResponse = (version = 0, status = 'DRAFT') => ({
oceanBooking: {
@@ -15,54 +14,6 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({
})
describe('ocean bookings api', () => {
it.each([
['宁波', '洛杉矶', 'CNNGB', 'USLAX', '宁波', '洛杉矶'],
[null, null, 'CNNGB', 'USLAX', 'CNNGB', 'USLAX'],
['', ' ', 'CNNGB', 'USLAX', 'CNNGB', 'USLAX'],
['宁波', '洛杉矶', null, null, '宁波', '洛杉矶'],
[null, '', null, ' ', null, null],
])('列表港口名称 %s / %s 为空时展示已保存代码', async (departurePortName, destinationPortName, departurePortCode, destinationPortCode, pol, pod) => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: {
records: [{ ...detailResponse().oceanBooking, departurePortName, destinationPortName, departurePortCode, destinationPortCode }],
page: 1, size: 20, total: 1, pages: 1,
} })
const result = await listOceanBookings({ page: 1, size: 20 })
expect(result.records[0]).toMatchObject({ pol, pod, departurePortName, destinationPortName, departurePortCode, destinationPortCode })
})
it('预计到港使用窄命令并规范化详情回显与列表到港字段', async () => {
const payload = { expectedVersion: 3, estimatedArrivalAt: '2026-10-25T10:00:00' }
const raw = detailResponse(4, 'RELEASED')
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', data: { ...raw, oceanBooking: { ...raw.oceanBooking, estimatedArrivalAt: payload.estimatedArrivalAt } } })
const saved = await updateOceanBookingEstimatedArrival('123', payload)
expect(put).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/estimated-arrival', payload, expect.objectContaining({ headers: { 'Idempotency-Key': expect.any(String) } }))
expect(saved.estimatedArrivalAt).toBe(payload.estimatedArrivalAt)
expect(saved.eta).toBe(payload.estimatedArrivalAt)
expect(saved.version).toBe(4)
})
it('sends actual cargo only to the versioned supplement endpoint and normalizes decimal responses', async () => {
const payload = { expectedVersion: 3, actualPackageCount: 480, actualGrossWeightKg: 14000.125, actualVolumeCbm: 65.321, reason: '客服补录' }
const response = detailResponse(4, 'RELEASED')
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', data: {
...response, oceanBooking: { ...response.oceanBooking, actualPackageCount: 480, actualGrossWeightKg: '14000.125', actualVolumeCbm: '65.321', declaredPackageCount: 500 },
} })
const saved = await updateOceanBookingActualCargo('123', payload)
expect(put).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/actual-cargo', payload, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(saved).toMatchObject({ status: 'RELEASED', version: 4, actualPackageCount: 480, actualGrossWeightKg: 14000.125, actualVolumeCbm: 65.321, declaredPackageCount: 500 })
})
it('preserves the backend history creation timestamp when normalizing a detail response', async () => {
const createdAt = '2026-10-20T09:12:34'
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: {
...detailResponse(),
histories: [{ id: '9007199254740999', fromStatus: null, toStatus: 'DRAFT', action: 'CREATE', operatorName: '创建人', createdAt }],
} })
const result = await createOceanBooking({ expectedVersion: 0, data: { customerId: null, customerName: null, channelCode: null, warehouseId: null, billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null, parties: [], containerRequirements: [] } })
expect(post).toHaveBeenCalledOnce()
expect(result.histories[0]).toMatchObject({ id: '9007199254740999', action: 'CREATE', createdAt })
expect(result.statusHistory).toEqual(result.histories)
})
it('normalizes pagination and long ids', async () => {
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, bookingNo: 'OB-260825-0183', customerId: 456, shipperName: '发货企业', consigneeName: '收货企业', containerCount: '1' }], page: '2', size: '20', total: '21', pages: '2' } })
const result = await listOceanBookings({ page: 1, size: 10 })
@@ -1,6 +1,6 @@
import axios from 'axios'
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OceanBookingActualCargoRequest, OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingDocument, OceanBookingDocumentInvalidation, OceanBookingDocumentMutation, OceanBookingDocumentType, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingPrintType, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
import type { OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingDocument, OceanBookingDocumentInvalidation, OceanBookingDocumentMutation, OceanBookingDocumentType, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingPrintType, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
const basePath = '/platform/v1/ocean-bookings'
const maxErrorEnvelopeBytes = 1024 * 1024
@@ -21,14 +21,8 @@ function booking(raw: OceanBookingResource): OceanBookingResource {
carrierCode: raw.carrierCode ?? null,
bookingAgentId: raw.bookingAgentId == null ? null : String(raw.bookingAgentId),
containerCount: Number(raw.containerCount ?? 0),
declaredPackageCount: raw.declaredPackageCount == null ? null : Number(raw.declaredPackageCount),
declaredGrossWeightKg: raw.declaredGrossWeightKg == null ? null : Number(raw.declaredGrossWeightKg),
declaredVolumeCbm: raw.declaredVolumeCbm == null ? null : Number(raw.declaredVolumeCbm),
actualPackageCount: raw.actualPackageCount == null ? null : Number(raw.actualPackageCount),
actualGrossWeightKg: raw.actualGrossWeightKg == null ? null : Number(raw.actualGrossWeightKg),
actualVolumeCbm: raw.actualVolumeCbm == null ? null : Number(raw.actualVolumeCbm),
pol: raw.departurePortName?.trim() || raw.departurePortCode?.trim() || null,
pod: raw.destinationPortName?.trim() || raw.destinationPortCode?.trim() || null,
pol: raw.pol ?? raw.departurePortName ?? null,
pod: raw.pod ?? raw.destinationPortName ?? null,
carrierName: raw.carrierName ?? raw.shippingCompanyName ?? null,
bookingAgent: raw.bookingAgent ?? raw.bookingAgentName ?? null,
etd: raw.etd ?? raw.estimatedSailingAt ?? null,
@@ -94,14 +88,6 @@ export async function getOceanBooking(id: string) { const response = await http.
function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export async function updateOceanBookingDraft(id: string, payload: OceanBookingSaveRequest) { const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export async function updateOceanBookingActualCargo(id: string, payload: OceanBookingActualCargoRequest) {
const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/actual-cargo`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } })
return detail(unwrapApiResponse(response))
}
export async function updateOceanBookingEstimatedArrival(id: string, payload: { expectedVersion: number; estimatedArrivalAt: string }) {
const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/estimated-arrival`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } })
return detail(unwrapApiResponse(response))
}
export async function deleteOceanBookingDraft(id: string, expectedVersion: number) { await http.delete(`${basePath}/${encodeURIComponent(id)}`, { data: { expectedVersion }, headers: { 'Idempotency-Key': idempotencyKey() } }) }
export async function releaseOceanBooking(id: string, request: OceanBookingReleaseRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/release`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
const printPaths: Record<OceanBookingPrintType, string> = {
@@ -61,7 +61,7 @@ describe('OceanBookingDocumentsPanel contract', () => {
it('makes released booking files permanently read-only and preserves traced errors', () => {
expect(source).toContain("props.bookingStatus === 'DRAFT' && props.canManage")
expect(source).not.toContain('已放舱 · 永久只读')
expect(source).toContain('已放舱 · 永久只读')
expect(source).toContain('manageReason')
expect(source).toContain('<ApiErrorMeta :details="error" />')
expect(source).toContain('订舱资料上传失败,请稍后重试')
@@ -111,7 +111,7 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
<template>
<section class="booking-documents workbench-panel is-compact admin-density admin-workbench" aria-label="订舱资料">
<div class="booking-documents__heading"><h2>订舱资料<InlineHelp title="订舱资料说明"><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></InlineHelp></h2></div>
<div class="booking-documents__heading"><h2>订舱资料<InlineHelp title="订舱资料说明"><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></InlineHelp></h2><el-tag v-if="bookingStatus === 'RELEASED'" type="info">已放舱 · 永久只读</el-tag></div>
<el-alert v-if="bookingStatus === 'DRAFT' && !editable && manageReason" type="warning" :closable="false" show-icon :title="manageReason" />
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
<div class="booking-document-types">
@@ -33,7 +33,6 @@ describe('OceanBookingPrintDialog', () => {
await flushPromises()
expect(downloadOceanBookingPrint).toHaveBeenCalledWith('123', 'SHIPPING_INSTRUCTION')
expect(wrapper.emitted('previewed')).toEqual([[{ bookingId: '123', type: 'SHIPPING_INSTRUCTION' }]])
expect(document.body.querySelector('iframe')?.getAttribute('src')).toBe('blob:booking-print')
expect(document.body.querySelector('.el-dialog__title')?.textContent).toBe('出口货物托运单 · OB-123')
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click()
@@ -145,24 +144,4 @@ describe('OceanBookingPrintDialog identity and resource lifecycle', () => {
wrapper.unmount()
expect(URL.revokeObjectURL).toHaveBeenCalledExactlyOnceWith('blob:current-print')
})
it('opens the selected document directly and reloads the other type after closing', async () => {
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:booking-print'), revokeObjectURL: vi.fn() })
vi.mocked(downloadOceanBookingPrint).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
const wrapper = mount(OceanBookingPrintDialog, {
props: { modelValue: true, bookingId: '123', bookingNo: 'OB-123', allowed: true, initialType: 'SHIPPING_INSTRUCTION' },
global: { plugins: [ElementPlus] }, attachTo: document.body,
})
await flushPromises()
expect(downloadOceanBookingPrint).toHaveBeenLastCalledWith('123', 'SHIPPING_INSTRUCTION')
expect(document.body.querySelector('iframe')).not.toBeNull()
await wrapper.setProps({ modelValue: false })
await flushPromises()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:booking-print')
await wrapper.setProps({ initialType: 'BILL_OF_LADING_CONFIRMATION', modelValue: true })
await flushPromises()
expect(downloadOceanBookingPrint).toHaveBeenLastCalledWith('123', 'BILL_OF_LADING_CONFIRMATION')
expect(document.body.querySelector('.el-dialog__title')?.textContent).toBe('提单确认件 · OB-123')
wrapper.unmount()
})
})
@@ -10,8 +10,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { downloadOceanBookingPrint } from '../api/platformOceanBookings'
import type { OceanBookingPrintType } from '../types'
const props = defineProps<{ bookingId: string; bookingNo?: string | null; allowed: boolean; initialType?: OceanBookingPrintType }>()
const emit = defineEmits<{ previewed: [event: { bookingId: string; type: OceanBookingPrintType }] }>()
const props = defineProps<{ bookingId: string; bookingNo?: string | null; allowed: boolean }>()
const open = defineModel<boolean>({ default: false })
const selectedType = ref<OceanBookingPrintType | ''>('')
const loading = ref(false)
@@ -43,18 +42,15 @@ function reset() {
}
async function preview() {
if (!props.allowed || !selectedType.value || loading.value) return
const bookingId = props.bookingId
const type = selectedType.value
const request = ++previewRequest
loading.value = true
error.value = null
releasePreview()
try {
const blob = await downloadOceanBookingPrint(bookingId, type)
const blob = await downloadOceanBookingPrint(props.bookingId, selectedType.value)
if (request !== previewRequest || !open.value) return
printBlob.value = blob
previewUrl.value = URL.createObjectURL(blob)
emit('previewed', { bookingId, type })
} catch (cause) {
if (request === previewRequest) error.value = getApiErrorDetails(cause, '打印单据加载失败,请稍后重试')
} finally {
@@ -75,13 +71,7 @@ function download() {
URL.revokeObjectURL(url)
}
watch(open, (value) => {
if (!value) reset()
else if (props.initialType) {
selectedType.value = props.initialType
void preview()
}
}, { immediate: true })
watch(open, (value) => { if (!value) reset() })
onBeforeUnmount(() => { previewRequest += 1; releasePreview() })
</script>
@@ -1,7 +1,8 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ElementPlus, { ElDatePicker } from 'element-plus'
import ElementPlus, { ElSelect } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners } from '@/modules/master-data'
import { listPlatformWarehouses } from '@/modules/warehouses'
import {
@@ -11,13 +12,10 @@ import {
listWarehouseLogisticsChannels,
releaseOceanBooking,
updateOceanBookingDraft,
updateOceanBookingActualCargo,
updateOceanBookingEstimatedArrival,
} from '../api/platformOceanBookings'
import type { OceanBookingDetail, OceanBookingStatus } from '../types'
import OceanBookingDetailView from './OceanBookingDetailView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
const routerReplace = vi.hoisted(() => vi.fn())
const routeState = vi.hoisted(() => ({ name: 'ocean-booking-detail', params: { bookingId: '123' } }))
@@ -25,15 +23,16 @@ vi.mock('vue-router', () => ({
useRoute: () => routeState,
useRouter: () => ({ push: vi.fn(), replace: routerReplace }),
}))
const deniedPermissions = vi.hoisted(() => new Set<string>())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !deniedPermissions.has(permission) }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/core/workspace-tabs/workspaceTabs', () => ({ useWorkspaceTabsStore: () => ({ removeByIds: vi.fn(), bumpCacheEpoch: vi.fn() }) }))
vi.mock('@/modules/iam', async importOriginal => ({
...await importOriginal<typeof import('@/modules/iam')>(), listPlatformTenants: vi.fn() }))
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
vi.mock('../api/platformOceanBookings', () => ({
createOceanBooking: vi.fn(), deleteOceanBookingDraft: vi.fn(), getOceanBooking: vi.fn(),
listOceanBookingAgents: vi.fn(), listWarehouseLogisticsChannels: vi.fn(),
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(), updateOceanBookingActualCargo: vi.fn(), updateOceanBookingEstimatedArrival: vi.fn(),
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(),
}))
function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLadingNo = 'BL-001'): OceanBookingDetail {
@@ -66,19 +65,10 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading
}
type ViewModel = {
arrivalValue: string | null
arrivalDialogOpen: boolean
arrivalSaving: boolean
arrivalError: { message: string; code?: string } | null
openArrivalDialog: () => void
saveEstimatedArrival: () => Promise<void>
form: { data: import('../types').OceanBookingDraftData }
saveActualCargo: () => Promise<void>
actualReason: string
cargoWritable: boolean
hasUnsavedActualChanges: boolean
printDisabledReason: string
save: () => Promise<void>
selectCustomer: (value: unknown) => void
canManageDocuments: boolean
detail: OceanBookingDetail | null
actionError: { code?: string; requestId?: string; traceId?: string; message: string } | null
actionSaving: boolean
@@ -92,7 +82,7 @@ async function mountView() {
stubs: {
AdminPage: { template: '<main><slot /></main>' },
DetailPageLayout: { template: '<section><slot name="header" /><slot /></section>' },
PageToolbar: { props: ['title', 'description'], template: '<header><h1>{{ title }}</h1><p v-if="description" class="toolbar-description">{{ description }}</p><slot name="actions" /></header>' },
PageToolbar: { template: '<header><slot name="actions" /></header>' },
CarrierScheduleQueryPanel: true,
},
},
@@ -102,107 +92,36 @@ async function mountView() {
}
describe('OceanBookingDetailView one-click release', () => {
it('放舱后仅单独保存预计到港并立即回显,保留尚未提交的实货量', async () => {
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
const saved = { ...booking('RELEASED', 4), estimatedArrivalAt: '2026-09-25T08:00:00' }
vi.mocked(updateOceanBookingEstimatedArrival).mockResolvedValue(saved)
const { wrapper, vm } = await mountView()
expect(wrapper.findAll('button').some(button => button.text() === '维护预计到港')).toBe(true)
vm.form.data.actualPackageCount = 499
vm.actualReason = '尚未提交'
vm.openArrivalDialog()
vm.arrivalValue = saved.estimatedArrivalAt
await vm.saveEstimatedArrival()
expect(updateOceanBookingEstimatedArrival).toHaveBeenCalledExactlyOnceWith('123', { expectedVersion: 3, estimatedArrivalAt: saved.estimatedArrivalAt })
expect(vm.detail?.estimatedArrivalAt).toBe(saved.estimatedArrivalAt)
expect(vm.detail?.version).toBe(4)
expect(vm.form.data.actualPackageCount).toBe(499)
expect(vm.actualReason).toBe('尚未提交')
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
const coreForm = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('booking-groups'))!
expect(coreForm.props('disabled')).toBe(true)
wrapper.unmount()
})
it('无编辑权限不开放维护入口;版本冲突保留输入并恢复操作', async () => {
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
deniedPermissions.add('platform:firstleg:ocean-booking:edit')
let rendered = await mountView()
expect(rendered.wrapper.findAll('button').some(button => button.text() === '维护预计到港')).toBe(false)
rendered.vm.openArrivalDialog()
await rendered.vm.saveEstimatedArrival()
expect(updateOceanBookingEstimatedArrival).not.toHaveBeenCalled()
rendered.wrapper.unmount()
deniedPermissions.clear()
rendered = await mountView()
rendered.vm.openArrivalDialog()
rendered.vm.arrivalValue = '2026-09-25T08:00:00'
vi.mocked(updateOceanBookingEstimatedArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化,请刷新后重试', 'A0409'))
await rendered.vm.saveEstimatedArrival()
expect(rendered.vm.arrivalDialogOpen).toBe(true)
expect(rendered.vm.arrivalValue).toBe('2026-09-25T08:00:00')
expect(rendered.vm.arrivalSaving).toBe(false)
expect(rendered.vm.arrivalError?.code).toBe('A0409')
rendered.wrapper.unmount()
})
it('放舱后预计到港不得清空或早于预计开船', async () => {
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
const { wrapper, vm } = await mountView()
vm.openArrivalDialog()
for (const value of [null, '2026-08-01T08:00:00']) {
vm.arrivalValue = value
await vm.saveEstimatedArrival()
expect(vm.arrivalError?.message).toBeTruthy()
}
expect(updateOceanBookingEstimatedArrival).not.toHaveBeenCalled()
wrapper.unmount()
})
beforeEach(() => {
routeState.name = 'ocean-booking-detail'; routeState.params.bookingId = '123'
vi.clearAllMocks()
deniedPermissions.clear()
vi.mocked(getOceanBooking).mockResolvedValue(booking())
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([{ channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡' }])
vi.mocked(listOceanBookingAgents).mockResolvedValue([{ supplierId: '20', name: '订舱代理', carrierCodes: ['MATSON'] }])
})
it('renders actual workflow progress and advances only on a successful matching preview', async () => {
const { wrapper } = await mountView()
const states = () => wrapper.findAll('.booking-workflow li').map(item => item.attributes('data-state'))
expect(states()).toEqual(['complete', 'current', 'pending', 'pending'])
expect(wrapper.get('[aria-current="step"]').text()).toContain('托运单')
const print = wrapper.findComponent(OceanBookingPrintDialog)
print.vm.$emit('previewed', { bookingId: 'another', type: 'SHIPPING_INSTRUCTION' })
await flushPromises()
expect(states()).toEqual(['complete', 'current', 'pending', 'pending'])
print.vm.$emit('previewed', { bookingId: '123', type: 'SHIPPING_INSTRUCTION' })
await flushPromises()
expect(states()).toEqual(['complete', 'complete', 'current', 'pending'])
expect(wrapper.findAll('.booking-workflow li')[1]?.text()).toContain('已预览')
expect(wrapper.get('[aria-current="step"]').text()).toContain('确认放舱')
wrapper.unmount()
})
it('starts new bookings with the agreed declared defaults and no removed inputs', async () => {
it('finds a customer outside the initial page and saves its actual ID and name in a new booking', async () => {
routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new'
vi.mocked(createOceanBooking).mockResolvedValue(booking())
const customer = { id: '9007199254740999', tenantName: '柏悦物流', status: 'ACTIVE' } as PlatformTenantResource
vi.mocked(listPlatformTenants).mockImplementation(async query => ({
records: query.keyword ? [customer] : [], page: 1, size: 50, total: query.keyword ? 1 : 350, pages: query.keyword ? 1 : 7,
}))
vi.mocked(createOceanBooking).mockResolvedValue({ ...booking(), customerId: String(customer.id), customerName: customer.tenantName })
const { wrapper, vm } = await mountView()
expect(vm.form.data).toMatchObject({ freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS', declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68, actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null })
expect(wrapper.get('[aria-label="预报件数"]').element).toHaveProperty('value', '500')
expect(wrapper.get('[aria-label="预报毛重"]').element).toHaveProperty('value', '15000.000')
expect(wrapper.get('[aria-label="预报体积"]').element).toHaveProperty('value', '68.000')
const labels = wrapper.findAll('.el-form-item__label').map(item => item.text())
for (const removed of ['客户(选填)', '包装单位', '备注']) expect(labels).not.toContain(removed)
expect(labels).toContain('预计到港')
expect(wrapper.find('[aria-label="起运港名称"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="目的港名称"]').exists()).toBe(false)
expect(wrapper.findAll('.container-table th').map(item => item.text())).toEqual(['操作', '柜型', '数量'])
const selector = wrapper.findAllComponents(ElSelect).find(item => item.props('placeholder') === '单客户可选择,多客户可留空')!
expect(selector.props('remote')).toBe(true)
selector.vm.$emit('end-reached', 'bottom'); await flushPromises()
expect(listPlatformTenants).toHaveBeenLastCalledWith({ page: 2, size: 50, status: 'ACTIVE' })
await selector.props('remoteMethod')?.('柏悦'); await flushPromises()
expect(listPlatformTenants).toHaveBeenLastCalledWith({ page: 1, size: 50, status: 'ACTIVE', keyword: '柏悦' })
selector.vm.$emit('update:modelValue', customer.id); await flushPromises()
expect(vm.form.data.customerId).toBe(customer.id)
expect(vm.form.data.customerName).toBe('柏悦物流')
await vm.save()
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68, actualPackageCount: null }) }))
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ customerId: customer.id, customerName: '柏悦物流' }) }))
wrapper.unmount()
})
@@ -216,60 +135,20 @@ describe('OceanBookingDetailView one-click release', () => {
expect(dialog.props('modelValue')).toBe(true)
expect(dialog.props('title')).toBe('确认放舱 · OB-123')
expect(wrapper.get('.release-summary').text()).toContain('EDITED-BL')
expect(wrapper.get('.release-summary').element.closest('section')?.hasAttribute('data-tone')).toBe(false)
expect(wrapper.find('.el-alert--warning').exists()).toBe(false)
expect(dialog.find('.density-dialog-note').exists()).toBe(false)
expect(wrapper.text()).toContain('不能修改、不能删除')
expect(wrapper.text()).not.toContain('处理数据库')
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
expect(releaseOceanBooking).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps warehouse and channel in a collapsed operation section with their saved bindings', async () => {
const { wrapper, vm } = await mountView()
const header = wrapper.get('.booking-operation-config .el-collapse-item__header')
expect(header.attributes('aria-expanded')).toBe('false')
await header.trigger('click')
expect(header.attributes('aria-expanded')).toBe('true')
expect(vm.form.data).toMatchObject({ warehouseId: '8', channelCode: 'MATSON_CLX' })
it('shows the matching fixed logistics group instead of the underlying price-book channel', async () => {
const { wrapper } = await mountView()
expect(wrapper.text()).toContain('美森正班')
expect(wrapper.text()).not.toContain('美森正班海卡')
wrapper.unmount()
})
it('renders authoritative creation and release history timestamps from the response', async () => {
const value = booking('RELEASED')
value.histories = [
{ id: '61', fromStatus: null, toStatus: 'DRAFT', action: 'CREATE', operatorName: '创建人', createdAt: '2026-10-20T09:12:34' },
{ id: '62', fromStatus: 'DRAFT', toStatus: 'RELEASED', action: 'RELEASE', operatorName: '放舱人', createdAt: '2026-10-21T10:23:45' },
]
vi.mocked(getOceanBooking).mockResolvedValue(value)
const { wrapper } = await mountView()
const rows = wrapper.findAll('.booking-history-table tbody tr')
expect(rows[0]!.findAll('td')[0]!.text()).toBe('2026-10-20T09:12:34')
expect(rows[1]!.findAll('td')[0]!.text()).toBe('2026-10-21T10:23:45')
expect(rows[0]!.text()).toContain('创建人')
wrapper.unmount()
})
it('keeps the booking number without a subtitle for empty and populated route data', async () => {
const value = booking()
value.customerId = null; value.customerName = null
value.oceanBooking.customerId = null; value.oceanBooking.customerName = null
value.pol = null; value.pod = null
vi.mocked(getOceanBooking).mockResolvedValue(value)
const { wrapper } = await mountView()
expect(wrapper.get('.booking-page-toolbar h1').text()).toBe(value.bookingNo)
expect(wrapper.find('.toolbar-description').exists()).toBe(false)
expect(wrapper.text()).not.toContain('未关联客户')
wrapper.unmount()
vi.mocked(getOceanBooking).mockResolvedValue(booking())
const { wrapper: customerWrapper } = await mountView()
expect(customerWrapper.find('.toolbar-description').exists()).toBe(false)
customerWrapper.unmount()
})
it('saves dirty form data and releases with the returned latest version', async () => {
vi.mocked(updateOceanBookingDraft).mockResolvedValue(booking('DRAFT', 4, 'NEW-BL'))
vi.mocked(releaseOceanBooking).mockResolvedValue(booking('RELEASED', 5, 'NEW-BL'))
@@ -294,67 +173,26 @@ describe('OceanBookingDetailView one-click release', () => {
wrapper.unmount()
})
it('blocks release without estimated arrival while still allowing a partial draft save', async () => {
const saved = booking()
saved.estimatedArrivalAt = null
saved.oceanBooking.estimatedArrivalAt = null
vi.mocked(getOceanBooking).mockResolvedValue(saved)
vi.mocked(updateOceanBookingDraft).mockResolvedValue({ ...saved, version: 4 })
it('clears an existing customer before saving and releasing with the returned version', async () => {
const saved = booking('DRAFT', 4)
saved.customerId = null; saved.customerName = null; saved.oceanBooking.customerId = null; saved.oceanBooking.customerName = null
vi.mocked(updateOceanBookingDraft).mockResolvedValue(saved)
vi.mocked(releaseOceanBooking).mockResolvedValue({ ...saved, status: 'RELEASED', version: 5 })
const { vm, wrapper } = await mountView()
vm.selectCustomer('')
await vm.submitRelease()
expect(vm.actionError?.message).toBe('放舱前请填写预计到港时间')
expect(releaseOceanBooking).not.toHaveBeenCalled()
await vm.save()
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ estimatedArrivalAt: null }) }))
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ customerId: null, customerName: null }) }))
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 4 })
wrapper.unmount()
})
it('loads and saves estimated arrival from the date input and includes it in release confirmation', async () => {
const arrival = '2026-09-25T10:30:00'
vi.mocked(updateOceanBookingDraft).mockResolvedValue({ ...booking('DRAFT', 4), estimatedArrivalAt: arrival })
const { vm, wrapper } = await mountView()
const picker = wrapper.findAllComponents(ElDatePicker).find(item => item.props('placeholder') === '请选择预计到港时间')!
expect(picker.props('modelValue')).toBe('2026-09-20T08:00:00')
picker.vm.$emit('update:modelValue', arrival)
await flushPromises()
await vm.save()
expect(updateOceanBookingDraft).toHaveBeenCalledWith('123', expect.objectContaining({ data: expect.objectContaining({ estimatedArrivalAt: arrival }) }))
await wrapper.findAll('button').find(button => button.text() === '放舱')!.trigger('click')
await flushPromises()
expect(wrapper.get('.release-summary').text()).toContain(arrival)
wrapper.unmount()
})
it('blocks an arrival before the sailing date', async () => {
const { vm, wrapper } = await mountView()
vm.form.data.estimatedArrivalAt = '2026-08-31T08:00:00'
await vm.submitRelease()
expect(vm.actionError?.message).toBe('预计到港时间不能早于预计开船时间')
expect(releaseOceanBooking).not.toHaveBeenCalled()
wrapper.unmount()
})
it('opens operation configuration when release is missing a warehouse', async () => {
const saved = booking()
saved.warehouseId = null
vi.mocked(getOceanBooking).mockResolvedValue(saved)
const { vm, wrapper } = await mountView()
await vm.submitRelease()
await flushPromises()
expect(vm.actionError?.message).toBe('放舱前请填写装柜仓库')
expect(wrapper.get('.booking-operation-config .el-collapse-item__header').attributes('aria-expanded')).toBe('true')
expect(releaseOceanBooking).not.toHaveBeenCalled()
wrapper.unmount()
})
it('allows release without documents for a saved booking with no single customer', async () => {
it('allows documents and release for a saved booking with no single customer', async () => {
const empty = booking()
empty.customerId = null; empty.customerName = null; empty.oceanBooking.customerId = null; empty.oceanBooking.customerName = null
empty.documents = []
vi.mocked(getOceanBooking).mockResolvedValue(empty)
vi.mocked(releaseOceanBooking).mockResolvedValue({ ...empty, status: 'RELEASED', version: 4 })
const { vm, wrapper } = await mountView()
expect(wrapper.text()).not.toContain('订舱资料')
expect(vm.canManageDocuments).toBe(true)
await vm.submitRelease()
expect(releaseOceanBooking).toHaveBeenCalledWith('123', { expectedVersion: 3 })
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
@@ -410,111 +248,4 @@ describe('OceanBookingDetailView one-click release', () => {
await Promise.all([first, duplicate])
expect(vm.actionSaving).toBe(false)
})
it('keeps newly saved booking open and preserves partial parties and manual cargo', async () => {
routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new'
vi.mocked(createOceanBooking).mockResolvedValue(booking('DRAFT', 0))
const { vm, wrapper } = await mountView()
vm.form.data.parties[0]!.contactEmail = 'ops@example.test'
vm.form.data.declaredPackageCount = 500
vm.form.data.declaredGrossWeightKg = 15000
vm.form.data.declaredVolumeCbm = 68
await vm.save()
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({
freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS',
declaredPackageCount: 500, actualPackageCount: null,
parties: expect.arrayContaining([expect.objectContaining({ partyType: 'SHIPPER', partyName: '', contactEmail: 'ops@example.test' })]),
}) }))
expect(routerReplace).toHaveBeenCalledWith('/ocean-bookings/123')
expect(vm.detail?.bookingNo).toBe('OB-123')
wrapper.unmount()
})
it('saves released actual cargo alone with version and reason while transport remains frozen', async () => {
vi.mocked(getOceanBooking).mockResolvedValue({ ...booking('RELEASED'), declaredPackageCount: 500 })
vi.mocked(updateOceanBookingActualCargo).mockResolvedValue({ ...booking('RELEASED', 4), declaredPackageCount: 500, actualPackageCount: 480, actualGrossWeightKg: 14320.125, actualVolumeCbm: 65.321 })
const { vm, wrapper } = await mountView()
expect(wrapper.find('input[placeholder="请输入船名"]').attributes('disabled')).toBeDefined()
for (const label of ['实际件数', '实际毛重', '实际体积']) {
const actualInput = wrapper.get(`input[aria-label="${label}"]`)
expect(actualInput.attributes('disabled')).toBeUndefined()
expect(actualInput.attributes('aria-disabled')).toBe('false')
}
for (const label of ['预报件数', '预报毛重', '预报体积']) {
const declaredInput = wrapper.get(`input[aria-label="${label}"]`)
expect(declaredInput.attributes('disabled')).toBeDefined()
expect(declaredInput.attributes('aria-disabled')).toBe('true')
}
await wrapper.get('input[aria-label="实际件数"]').setValue('480')
await wrapper.get('input[aria-label="实际毛重"]').setValue('14320.125')
await wrapper.get('input[aria-label="实际体积"]').setValue('65.321')
vm.actualReason = ' 实装后补录 '
await flushPromises()
expect(vm.printDisabledReason).toContain('请先保存')
await vm.saveActualCargo()
expect(updateOceanBookingActualCargo).toHaveBeenCalledWith('123', { expectedVersion: 3, actualPackageCount: 480, actualGrossWeightKg: 14320.125, actualVolumeCbm: 65.321, reason: '实装后补录' })
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
expect(vm.detail?.version).toBe(4)
expect(vm.actualReason).toBe('')
expect(vm.hasUnsavedActualChanges).toBe(false)
expect(vm.form.data.declaredPackageCount).toBe(500)
expect((wrapper.get('input[aria-label="实际件数"]').element as HTMLInputElement).value).toBe('480')
expect((wrapper.get('input[aria-label="实际毛重"]').element as HTMLInputElement).value).toBe('14320.125')
expect((wrapper.get('input[aria-label="实际体积"]').element as HTMLInputElement).value).toBe('65.321')
wrapper.unmount()
})
it('shows the saved server values after input and blur without a change event', async () => {
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
vi.mocked(updateOceanBookingActualCargo).mockResolvedValue({ ...booking('RELEASED', 4), actualPackageCount: 480, actualGrossWeightKg: 14320.125, actualVolumeCbm: 65.321 })
const { vm, wrapper } = await mountView()
for (const [label, value] of [['实际件数', '480'], ['实际毛重', '14320.125'], ['实际体积', '65.321']] as const) {
const input = wrapper.get(`input[aria-label="${label}"]`)
const element = input.element as HTMLInputElement
element.value = value
await input.trigger('input')
await input.trigger('blur')
}
vm.actualReason = '补录'
await vm.saveActualCargo()
await flushPromises()
for (const [label, value] of [['实际件数', '480'], ['实际毛重', '14320.125'], ['实际体积', '65.321']] as const) {
expect((wrapper.get(`input[aria-label="${label}"]`).element as HTMLInputElement).value).toBe(value)
}
wrapper.unmount()
})
it('preserves actual inputs and reason on a stale-version failure and blocks duplicate writes', async () => {
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
let rejectSave!: (cause: unknown) => void
vi.mocked(updateOceanBookingActualCargo).mockReturnValue(new Promise((_, reject) => { rejectSave = reject }))
const { vm, wrapper } = await mountView()
vm.form.data.actualPackageCount = 480
await vm.saveActualCargo()
expect(updateOceanBookingActualCargo).not.toHaveBeenCalled()
expect(vm.actionError?.message).toContain('原因')
vm.actualReason = '修正'
const first = vm.saveActualCargo()
await vm.saveActualCargo()
expect(updateOceanBookingActualCargo).toHaveBeenCalledTimes(1)
rejectSave(new ApiResponseError('版本已变化', 'A0409', 409))
await first
expect(vm.form.data.actualPackageCount).toBe(480)
expect(vm.actualReason).toBe('修正')
expect(vm.detail?.version).toBe(3)
expect(vm.actionError?.code).toBe('A0409')
wrapper.unmount()
})
it('keeps actual cargo read-only without edit permission', async () => {
deniedPermissions.add('platform:firstleg:ocean-booking:edit')
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
const { vm, wrapper } = await mountView()
expect(vm.cargoWritable).toBe(false)
expect(wrapper.text()).not.toContain('保存实际货量')
vm.actualReason = '补录'
await vm.saveActualCargo()
expect(updateOceanBookingActualCargo).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -34,7 +34,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).toContain('function draftPayload(): OceanBookingDraftData')
expect(source).toContain('channelCode: form.data.channelCode?.trim() || null')
expect(source).toContain('warehouseId: form.data.warehouseId?.trim() || null')
expect(source).toContain('parties: form.data.parties.map')
expect(source).toContain('parties: form.data.parties.filter((party) => party.partyName.trim())')
expect(source).toContain('containerRequirements: form.data.containerRequirements.map')
expect(source).not.toContain('登记后直接确认')
})
@@ -51,7 +51,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).not.toContain('新增真实柜')
})
it('performs the complete release gate without the extra explanatory line', () => {
it('performs the complete release gate and presents the irreversible warning', () => {
expect(source).toContain('function releaseValidationError()')
expect(source).toContain('预计到港时间不能早于预计开船时间')
expect(source).toContain('放舱前请填写起运港代码或名称')
@@ -61,7 +61,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).toContain("if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'")
expect(source).not.toContain('请先保存草稿后再放舱')
expect(source).toContain('放舱前请填写${missing[1]}')
expect(source).not.toContain('放舱后运输资料和预报货量不能修改、不能删除;实际货量可另行补录并记录原因。请确认运输资料无误。')
expect(source).toContain('放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。')
expect(source).toContain('if (hasUnsavedDraftChanges.value)')
expect(source).toContain('const saved = await persistDraft(false)')
expect(source).toContain('releaseVersion = saved.version')
@@ -70,11 +70,12 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).toContain('if (!saved) return')
expect(source).toContain("{{ form.data.billOfLadingNo || '—' }}")
expect(source).not.toContain("{{ detail?.billOfLadingNo || '—' }}")
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value || arrivalSaving.value)')
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value)')
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
expect(source).not.toContain('releaseReason')
expect(source).not.toContain('放舱前请上传有效的订舱确认文件或提单文件')
expect(source).not.toContain('OceanBookingDocumentsPanel')
expect(source).toContain('放舱前请上传有效的订舱确认文件或提单文件')
expect(source).toContain('OceanBookingDocumentsPanel')
expect(source).toContain('@updated="updateDocuments"')
})
it('freezes the selected carrier standard code into the booking draft', () => {
@@ -111,16 +112,15 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).toContain('@media(max-width:720px)')
expect(source).toContain('@media(max-width:420px)')
expect(source).toContain('OceanBookingPrintDialog')
expect(source).toContain('打印托运单')
expect(source).toContain('打印提单确认件')
expect(source).toContain('打印单据')
})
it('keeps a DRAFT detail read-only when the user lacks write permission', () => {
expect(source).toContain('const formWritable = computed(() => canSave.value)')
expect(source).toContain(':disabled="!formWritable" class="booking-groups density-form"')
expect(source).toMatch(/\.booking-core\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1\.2fr\) minmax\(0,\s*1fr\)/)
expect(source).toMatch(/\.booking-group:last-child\s*\{\s*grid-column:\s*1\s*\/\s*-1/)
expect(source).toMatch(/\.container-table-wrap\s*\{[^}]*max-width:\s*100%;\s*overflow-x:\s*auto/)
expect(source).toContain('.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:var(--admin-space-5)}')
expect(source).toContain('.booking-group:last-child{grid-column:1/-1}')
expect(source).toContain('.container-table-wrap{max-width:100%;overflow-x:auto')
expect(source).toContain('v-if="formWritable" class="container-actions"')
expect(source).toContain(':disabled="!formWritable"')
})
@@ -4,7 +4,7 @@ import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Check, Printer, Save, Ship, Trash2 } from '@lucide/vue'
import { ArrowLeft, Printer, Save, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -17,6 +17,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { CONTAINER_SPECIFICATIONS } from '@/shared/domain/containerSpecifications'
import { listPlatformTenants, useActiveCustomerOptions } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
@@ -29,8 +30,6 @@ import {
releaseOceanBooking,
listWarehouseLogisticsChannels,
updateOceanBookingDraft,
updateOceanBookingActualCargo,
updateOceanBookingEstimatedArrival,
type OceanLogisticsChannel,
} from '../api/platformOceanBookings'
import type {
@@ -38,16 +37,16 @@ import type {
OceanBookingAgentOption,
OceanBookingCarrierCode,
OceanBookingDraftData,
OceanBookingDocument,
OceanBookingParty,
OceanBookingSaveRequest,
OceanBookingStatus,
OceanBookingPrintType,
} from '../types'
import { oceanBookingStatusLabel } from '../status'
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
import { shouldClearBookingAgent } from '../bookingAgents'
import { oceanBookingPrintSignature, oceanBookingWorkflow, type OceanBookingPreviewSignatures } from '../workflow'
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
import OceanBookingDocumentsPanel from '../components/OceanBookingDocumentsPanel.vue'
const partyTypes = [
{ value: 'SHIPPER', label: '发货人' },
@@ -55,6 +54,10 @@ const partyTypes = [
{ value: 'NOTIFY_PARTY', label: '通知人' },
] as const
const containerTypeOptions = CONTAINER_SPECIFICATIONS
const containerKindOptions = [
{ value: 'FCL', label: '整箱 FCL' },
{ value: 'LCL', label: '拼箱 LCL' },
]
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
@@ -81,6 +84,9 @@ const channelsError = ref('')
const selectedChannelName = ref('')
let channelRequestSerial = 0
let bookingAgentRequestSerial = 0
const selectableCustomers = computed(() => customers.value.filter((customer) => (
customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)
)))
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.types.includes('CARRIER')))
const channelGroups = computed(() => {
const activeCodes = new Set(channels.value.map((channel) => channel.channelCode))
@@ -89,13 +95,10 @@ const channelGroups = computed(() => {
const channelUnavailable = computed(() => Boolean(form.data.warehouseId) && !channelsLoading.value && !channelsError.value && channelGroups.value.length === 0)
function blankParties(): OceanBookingParty[] {
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', contactEmail: '', taxNo: '', address: '' }))
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', address: '' }))
}
function blankData(): OceanBookingDraftData {
return {
freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS',
cargoDescription: '', marksAndNos: '', declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68,
actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
customerId: null, customerName: null, channelCode: '', warehouseId: '', parties: blankParties(),
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
shippingCompanyId: null, shippingCompanyName: null, carrierCode: null,
@@ -103,6 +106,7 @@ function blankData(): OceanBookingDraftData {
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
const { customers, loading: customersLoading, error: customersError, search: loadCustomers, endReached: customerEndReached, retry: retryCustomers } = useActiveCustomerOptions(() => form.data.customerId, listPlatformTenants)
const savedDraftSignature = ref('')
const rules: FormRules = {}
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
@@ -111,33 +115,21 @@ const formWritable = computed(() => canSave.value)
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:ocean-booking:list'))
const actualSaving = ref(false)
const arrivalDialogOpen = ref(false)
const arrivalSaving = ref(false)
const arrivalValue = ref<string | null>(null)
const arrivalError = ref<ApiErrorDetails | null>(null)
const canMaintainArrival = computed(() => detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit'))
const actualReason = ref('')
const cargoWritable = computed(() => formWritable.value || (detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit')))
const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value || arrivalSaving.value)
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit'))
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '')
const workflowBusy = computed(() => saving.value || actionSaving.value)
const releaseDialogOpen = ref(false)
const operationSections = ref<string[]>([])
const printOpen = ref(false)
const printType = ref<OceanBookingPrintType>('SHIPPING_INSTRUCTION')
const previewSignatures = ref<OceanBookingPreviewSignatures>({})
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
return oceanBookingStatusLabel(status)
}
function historyActionLabel(action: string) {
const labels: Record<string, string> = { CREATE: '创建草稿', UPDATE_DRAFT: '编辑草稿', RELEASE: '确认放舱', UPDATE_ESTIMATED_ARRIVAL: '维护预计到港', UPDATE_ACTUAL_CARGO: '实货量补录/修正', UPLOAD_DOCUMENT: '上传文件', ATTACH_DOCUMENT: '关联文件', INVALIDATE_DOCUMENT: '作废文件' }
return labels[action] || '资料维护'
}
async function loadOptions() {
optionsLoading.value = true
optionsError.value = ''
try {
const [partnerResult, warehouseResult] = await Promise.all([
const [, partnerResult, warehouseResult] = await Promise.all([
loadCustomers(),
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' }),
])
@@ -154,6 +146,12 @@ async function loadOptions() {
optionsLoading.value = false
}
}
function selectCustomer(value: unknown) {
const customerId = value == null || value === '' ? null : String(value)
form.data.customerId = customerId
const customer = customerId == null ? null : customers.value.find((item) => String(item.id) === customerId)
form.data.customerName = customer?.tenantName || null
}
function draftPayload(): OceanBookingDraftData {
const customerId = form.data.customerId || null
return {
@@ -165,78 +163,12 @@ function draftPayload(): OceanBookingDraftData {
billOfLadingNo: form.data.billOfLadingNo.trim(),
vesselName: form.data.vesselName.trim(),
voyageNo: form.data.voyageNo.trim(),
parties: form.data.parties.map((party) => ({ ...party, partyName: party.partyName.trim() })),
parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })),
containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity: Number(quantity), capacityCbm, maxWeightKg })),
}
}
function draftSignature() { return JSON.stringify(draftPayload()) }
const hasUnsavedDraftChanges = computed(() => Boolean(detail.value?.status === 'DRAFT' && savedDraftSignature.value && draftSignature() !== savedDraftSignature.value))
const hasUnsavedActualChanges = computed(() => detail.value?.status === 'RELEASED' && (
(form.data.actualPackageCount ?? null) !== (detail.value.actualPackageCount ?? null)
|| (form.data.actualGrossWeightKg ?? null) !== (detail.value.actualGrossWeightKg ?? null)
|| (form.data.actualVolumeCbm ?? null) !== (detail.value.actualVolumeCbm ?? null)
))
const printDisabledReason = computed(() => !detail.value ? '请先保存草稿' : hasUnsavedDraftChanges.value || hasUnsavedActualChanges.value ? '请先保存修改,再打印已保存资料' : !canPrint.value ? '暂无订舱单据打印权限' : '')
const workflowSteps = computed(() => oceanBookingWorkflow(detail.value, hasUnsavedDraftChanges.value, hasUnsavedActualChanges.value, previewSignatures.value))
function onPrintPreviewed(event: { bookingId: string; type: OceanBookingPrintType }) {
if (!detail.value || event.bookingId !== detail.value.id || printDisabledReason.value) return
previewSignatures.value[event.type] = oceanBookingPrintSignature(detail.value, event.type)
}
function openPrint(type: OceanBookingPrintType) {
if (printDisabledReason.value || workflowBusy.value) return
printType.value = type
printOpen.value = true
}
function openArrivalDialog() {
if (!canMaintainArrival.value || workflowBusy.value) return
arrivalValue.value = detail.value?.estimatedArrivalAt || null
arrivalError.value = null
arrivalDialogOpen.value = true
}
async function saveEstimatedArrival() {
const current = detail.value
if (!current || !canMaintainArrival.value || workflowBusy.value) return
if (!arrivalValue.value || (current.estimatedSailingAt && arrivalValue.value < current.estimatedSailingAt)) {
arrivalError.value = { message: !arrivalValue.value ? '请选择预计到港时间' : '预计到港时间不能早于预计开船时间' }
return
}
arrivalSaving.value = true
arrivalError.value = null
try {
const saved = await updateOceanBookingEstimatedArrival(current.id, { expectedVersion: current.version, estimatedArrivalAt: arrivalValue.value })
// 单独保存预计到港不丢弃客服尚未提交的实际货量。
const pendingCargo = { actualPackageCount: form.data.actualPackageCount, actualGrossWeightKg: form.data.actualGrossWeightKg, actualVolumeCbm: form.data.actualVolumeCbm }
const pendingReason = actualReason.value
applyDetail(saved)
Object.assign(form.data, pendingCargo)
actualReason.value = pendingReason
workspace.bumpCacheEpoch('ocean-bookings')
arrivalDialogOpen.value = false
ElMessage.success('预计到港已保存')
} catch (cause) { arrivalError.value = getApiErrorDetails(cause, '预计到港保存失败') }
finally { arrivalSaving.value = false }
}
async function saveActualCargo() {
if (detail.value?.status !== 'RELEASED' || !cargoWritable.value || workflowBusy.value) return
const validationError = cargoValidationError()
if (validationError || !actualReason.value.trim()) {
actionError.value = { message: validationError || '请填写实货量补录或修正原因' }
return
}
actualSaving.value = true
actionError.value = null
try {
applyDetail(await updateOceanBookingActualCargo(detail.value.id, {
expectedVersion: detail.value.version,
actualPackageCount: form.data.actualPackageCount ?? null,
actualGrossWeightKg: form.data.actualGrossWeightKg ?? null,
actualVolumeCbm: form.data.actualVolumeCbm ?? null,
reason: actualReason.value.trim(),
}))
ElMessage.success('实际货量已保存')
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { actualSaving.value = false }
}
function selectWarehouse(value: unknown) {
const warehouseId = value == null || value === '' ? null : String(value)
form.data.warehouseId = warehouseId
@@ -316,18 +248,9 @@ function applyDetail(value: OceanBookingDetail) {
destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName,
placeOfDelivery: value.placeOfDelivery,
remark: value.remark,
freightTerm: value.freightTerm, serviceTerm: value.serviceTerm, packageUnit: value.packageUnit,
cargoDescription: value.cargoDescription, marksAndNos: value.marksAndNos,
declaredPackageCount: value.declaredPackageCount ?? null,
declaredGrossWeightKg: value.declaredGrossWeightKg == null ? null : Number(value.declaredGrossWeightKg),
declaredVolumeCbm: value.declaredVolumeCbm == null ? null : Number(value.declaredVolumeCbm),
actualPackageCount: value.actualPackageCount ?? null,
actualGrossWeightKg: value.actualGrossWeightKg == null ? null : Number(value.actualGrossWeightKg),
actualVolumeCbm: value.actualVolumeCbm == null ? null : Number(value.actualVolumeCbm),
parties: blankParties().map((blank) => ({ ...blank, ...value.parties.find((party) => party.partyType === blank.partyType) })),
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg }) => ({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg })) : [createBlankContainerRequirement()],
}
actualReason.value = ''
selectedChannelName.value = value.channelName || ''
savedDraftSignature.value = draftSignature()
channels.value = []
@@ -342,24 +265,9 @@ async function load() {
catch (cause) { error.value = getApiErrorDetails(cause) }
finally { loading.value = false }
}
function cargoValidationError() {
for (const kind of ['declared', 'actual'] as const) {
const count = form.data[`${kind}PackageCount`]
if (count != null && (!Number.isInteger(count) || count < 1 || count > 999999999)) return '货物件数必须为1到999999999的整数'
for (const number of [form.data[`${kind}GrossWeightKg`], form.data[`${kind}VolumeCbm`]]) {
if (number != null && (!Number.isFinite(number) || number <= 0 || number > 999999999999.999)) return '货物重量和体积必须为有效正数'
}
}
return ''
}
async function validateDraftForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (valid === false) { actionError.value = { message: '请检查表单中的必填项和格式' }; return false }
const cargoMessage = cargoValidationError()
if (cargoMessage) { actionError.value = { message: cargoMessage }; return false }
if (form.data.parties.some((party) => party.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(party.contactEmail))) {
actionError.value = { message: '请填写有效的关系方邮箱' }; return false
}
const requirementMessage = containerRequirementError(form.data.containerRequirements)
if (requirementMessage) { actionError.value = { message: requirementMessage }; return false }
return true
@@ -375,13 +283,10 @@ async function persistDraft(showSuccess: boolean) {
if (showSuccess) ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || '未生成订舱号'}` : '订舱草稿已保存')
if (isNew.value) {
workspace.removeByIds(['ocean-booking-create'])
applyDetail(saved)
workspace.bumpCacheEpoch('ocean-bookings')
await router.replace(`/ocean-bookings/${encodeURIComponent(saved.id)}`)
await router.replace('/ocean-bookings')
return saved
}
applyDetail(saved)
workspace.bumpCacheEpoch('ocean-bookings')
return saved
} catch (cause) { actionError.value = getApiErrorDetails(cause); return null }
}
@@ -409,14 +314,19 @@ function releaseValidationError() {
[form.data.shippingCompanyId, '船公司'], [form.data.bookingAgentId, '订舱代理'],
]
const missing = requiredFields.find(([value]) => !String(value || '').trim())
if (missing) {
if (missing[1] === '装柜仓库' || missing[1] === '物流渠道') operationSections.value = ['operation']
return `放舱前请填写${missing[1]}`
}
if (form.data.estimatedArrivalAt && form.data.estimatedSailingAt && form.data.estimatedArrivalAt < form.data.estimatedSailingAt) return '预计到港时间不能早于预计开船时间'
if (missing) return `放舱前请填写${missing[1]}`
if (form.data.estimatedArrivalAt! < form.data.estimatedSailingAt!) return '预计到港时间不能早于预计开船时间'
if (form.data.parties.some((party) => !party.partyName.trim())) return '放舱前请填写发货人、收货人和通知人名称'
if (!detail.value?.documents.some((document) => document.valid && ['BOOKING_CONFIRMATION', 'BILL_OF_LADING'].includes(document.documentType))) return '放舱前请上传有效的订舱确认文件或提单文件'
return containerRequirementError(form.data.containerRequirements)
}
function updateDocuments(documents: OceanBookingDocument[], bookingVersion: number) {
if (!detail.value) return
detail.value.documents = documents
detail.value.version = bookingVersion
detail.value.oceanBooking.version = bookingVersion
form.expectedVersion = bookingVersion
}
async function submitRelease() {
if (!detail.value || workflowBusy.value) return
actionSaving.value = true
@@ -467,123 +377,56 @@ onMounted(() => { void load(); void loadOptions() })
<template #header>
<div class="detail-heading">
<el-button link class="density-back" @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
<span class="booking-brand" aria-hidden="true"><Ship :size="22" /></span>
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱信息管理'">
<template #actions>
<div class="actions booking-page-actions">
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
<el-button v-if="canSave" plain :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
<el-button v-if="canMaintainArrival" plain :disabled="workflowBusy" @click="openArrivalDialog">维护预计到港</el-button>
</div>
</template>
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱草稿'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : ''">
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
</PageToolbar>
</div>
</template>
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else>
<ol class="booking-workflow" aria-label="订舱作业流程">
<li v-for="(step, index) in workflowSteps" :key="step.label" :data-state="step.state" :aria-current="step.state === 'current' ? 'step' : undefined">
<span class="booking-workflow__index" aria-hidden="true"><Check v-if="step.state === 'complete'" :size="16" /><template v-else>{{ index + 1 }}</template></span>
<span class="booking-workflow__label">{{ step.label }}</span>
<span class="booking-workflow__status">{{ step.status }}</span>
</li>
</ol>
<section class="workbench-panel is-compact booking-section booking-section--core">
<div class="section-title booking-section__heading">
<h2><span class="booking-section__number" aria-hidden="true">1</span>一、核心订舱信息</h2>
<section class="workbench-panel is-compact">
<div class="section-title">
<h2>订舱信息</h2>
<div class="actions">
<el-tooltip :content="canPrint ? '按当前已保存资料打印出口货物托运单或提单确认件' : '暂无订舱单据打印权限'" placement="top"><span><el-button v-if="detail" plain :disabled="!canPrint || workflowBusy" @click="printOpen = true"><Printer :size="16" />打印单据</el-button></span></el-tooltip>
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" :disabled="workflowBusy" @click="openReleaseDialog">放舱</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
</div>
</div>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<div class="booking-core">
<div class="booking-core__main">
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!formWritable" class="booking-groups density-form">
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!formWritable" class="booking-groups density-form">
<section class="booking-group" aria-label="基本信息">
<h3 class="booking-group__title"><span>基础信息</span></h3>
<div class="booking-group__body booking-group__body--fields">
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
<el-form-item label="客户(选填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="单客户可选择,多客户可留空" no-data-text="暂无可选客户" :loading="customersLoading" remote :remote-method="loadCustomers" @end-reached="customerEndReached" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select><LoadErrorState v-if="customersError" title="客户选项加载失败" :message="customersError.message" :loading="customersLoading" compact @retry="retryCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '保存后为草稿'" disabled /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
<el-form-item label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable :disabled="!editable || !form.data.carrierCode || bookingAgentsLoading || !!bookingAgentsError" :placeholder="form.data.carrierCode ? '请选择订舱代理' : '请先选择船公司'" :loading="bookingAgentsLoading" no-data-text="当前船公司暂无可用订舱代理" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="!editable && form.data.bookingAgentId && !bookingAgents.some((supplier) => supplier.supplierId === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in bookingAgents" :key="supplier.supplierId" :label="supplier.name" :value="supplier.supplierId" /></el-select><div v-if="bookingAgentsError" class="field-error"><ApiErrorMeta :details="bookingAgentsError" /><el-button link type="primary" :loading="bookingAgentsLoading" @click="form.data.carrierCode && loadBookingAgents(form.data.carrierCode)">重新加载</el-button></div></el-form-item>
</div>
</section>
<section class="booking-group" aria-label="航线船期">
<h3 class="booking-group__title"><span>港口航线</span></h3>
<div class="booking-group__body booking-group__body--fields">
<el-form-item label="起运港"><el-input v-model="form.data.departurePortCode" placeholder="港代码" aria-label="起运港代码" /></el-form-item>
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortCode" placeholder="港代码" aria-label="目的港代码" /></el-form-item>
<h3>航线船期</h3><div class="booking-group__body booking-group__body--fields">
<el-form-item label="船名" prop="vesselName"><el-input v-model="form.data.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="form.data.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" placeholder="例如:CNNGB" /></el-form-item>
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" placeholder="例如:宁波港" /></el-form-item>
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" placeholder="例如:USLAX" /></el-form-item>
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" placeholder="例如:洛杉矶港" /></el-form-item>
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
<el-form-item label="预计开船" prop="estimatedSailingAt"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<el-form-item label="预计到港" prop="estimatedArrivalAt"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
<el-form-item label="运费条款"><el-input v-model="form.data.freightTerm" maxlength="64" /></el-form-item>
<el-form-item label="运输条款"><el-input v-model="form.data.serviceTerm" maxlength="64" /></el-form-item>
</div>
</section>
</el-form>
<section class="booking-group" aria-label="货物信息">
<h3 class="booking-group__title"><span>货物信息</span></h3>
<el-form label-position="top" class="density-form booking-group__body">
<div class="cargo-metrics" role="group" aria-label="货物预报与实际数量">
<!-- 数字组件仅挂载时写入禁用标记,且可能保留输入缓存;资格切换及实值保存成功后按权威版本重建。 -->
<div class="cargo-metric">
<span class="cargo-metric__label">件数(委 / 实)</span>
<div class="cargo-metric__inputs">
<el-input-number :key="'declared-count-' + formWritable" v-model="form.data.declaredPackageCount" :disabled="!formWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="预报量" aria-label="预报件数" />
<span class="cargo-metric__separator" aria-hidden="true">/</span>
<el-input-number :key="'actual-count-' + cargoWritable + '-' + (detail?.version ?? 'new')" v-model="form.data.actualPackageCount" :disabled="!cargoWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="待录实值" aria-label="实际件数" />
<span class="cargo-metric__unit">{{ form.data.packageUnit || '—' }}</span>
</div>
</div>
<div class="cargo-metric">
<span class="cargo-metric__label">毛重(委 / 实)</span>
<div class="cargo-metric__inputs">
<el-input-number :key="'declared-weight-' + formWritable" v-model="form.data.declaredGrossWeightKg" :disabled="!formWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="预报量" aria-label="预报毛重" />
<span class="cargo-metric__separator" aria-hidden="true">/</span>
<el-input-number :key="'actual-weight-' + cargoWritable + '-' + (detail?.version ?? 'new')" v-model="form.data.actualGrossWeightKg" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际毛重" />
<span class="cargo-metric__unit">KGS</span>
</div>
</div>
<div class="cargo-metric">
<span class="cargo-metric__label">体积(委 / 实)</span>
<div class="cargo-metric__inputs">
<el-input-number :key="'declared-volume-' + formWritable" v-model="form.data.declaredVolumeCbm" :disabled="!formWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="预报量" aria-label="预报体积" />
<span class="cargo-metric__separator" aria-hidden="true">/</span>
<el-input-number :key="'actual-volume-' + cargoWritable + '-' + (detail?.version ?? 'new')" v-model="form.data.actualVolumeCbm" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际体积" />
<span class="cargo-metric__unit">CBM</span>
</div>
</div>
</div>
<div class="booking-group__body--fields">
<el-form-item label="唛头和号码"><el-input v-model="form.data.marksAndNos" :disabled="!formWritable" maxlength="1000" /></el-form-item>
<el-form-item class="booking-field--wide" label="品名及托运要求"><el-input v-model="form.data.cargoDescription" :disabled="!formWritable" type="textarea" :rows="2" maxlength="1000" placeholder="填写实际品名及免用箱等托运要求" /></el-form-item>
</div>
<template v-if="detail?.status === 'RELEASED' && cargoWritable">
<el-form-item label="补录或修正原因" required><el-input v-model="actualReason" type="textarea" :rows="2" maxlength="1000" placeholder="请填写本次补录或修正原因" /></el-form-item>
<div class="actions"><el-button type="primary" :loading="actualSaving" :disabled="workflowBusy || !hasUnsavedActualChanges" @click="saveActualCargo">保存实际货量</el-button></div>
</template>
</el-form>
</section>
</div>
<aside class="booking-parties" aria-label="订舱关系方"><h3 class="booking-group__title"><span>订舱关系方</span></h3>
<el-form class="party-grid density-form" label-position="top" :disabled="!formWritable">
<section v-for="(party, index) in form.data.parties" :key="party.partyType" class="subsection booking-party-card">
<h3>{{ partyTypes[index]?.label || party.partyType }}</h3>
<el-form-item label="单位名称" :required="true"><el-input v-model="party.partyName" :disabled="!formWritable" maxlength="255" :aria-label="`${partyTypes[index]?.label || party.partyType}单位名称(必填)`" placeholder="单位名称" /></el-form-item>
<el-form-item label="联系人" ><el-input v-model="party.contactName" :disabled="!formWritable" maxlength="128" :aria-label="`${partyTypes[index]?.label || party.partyType}联系人`" placeholder="联系人" /></el-form-item>
<el-form-item label="邮箱" ><el-input v-model="party.contactEmail" :disabled="!formWritable" maxlength="255" :aria-label="`${partyTypes[index]?.label || party.partyType}邮箱`" placeholder="邮箱" /></el-form-item>
<el-form-item label="联系电话" ><el-input v-model="party.contactPhone" :disabled="!formWritable" maxlength="64" :aria-label="`${partyTypes[index]?.label || party.partyType}联系电话`" placeholder="联系电话" /></el-form-item>
<el-form-item label="税号" ><el-input v-model="party.taxNo" :disabled="!formWritable" maxlength="128" :aria-label="`${partyTypes[index]?.label || party.partyType}税号`" placeholder="税号" /></el-form-item>
<el-form-item label="地址" ><el-input v-model="party.address" :disabled="!formWritable" maxlength="1000" :aria-label="`${partyTypes[index]?.label || party.partyType}地址`" placeholder="地址" type="textarea" :rows="2" /></el-form-item>
</section>
</el-form>
</aside>
</div>
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-requirements">
<section class="booking-group" aria-label="柜型需求">
<h3 class="booking-group__title"><span>柜型需求</span></h3><div class="booking-group__body">
<h3>柜型需求</h3><div class="booking-group__body">
<div class="container-table-wrap" aria-label="柜型需求">
<div class="workbench-evidence"><table class="container-table density-table">
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">数量</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">数量</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
<tbody>
<tr v-for="(requirement, index) in form.data.containerRequirements" :key="requirement.requirementId || index">
<td class="container-table__action">
@@ -593,59 +436,51 @@ onMounted(() => { void load(); void loadOptions() })
<span v-else aria-hidden="true">—</span>
</td>
<td><el-select :model-value="requirement.containerType" :disabled="!formWritable" placeholder="请选择柜型" @update:model-value="selectContainerRequirementType(requirement, String($event || ''))"><el-option v-for="item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" /></el-select></td>
<td><el-select v-model="requirement.containerKind" :disabled="!formWritable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></td>
<td><el-input-number v-model="requirement.quantity" :disabled="!formWritable" :min="1" :max="99" controls-position="right" placeholder="请输入柜量" /></td>
<td class="container-table__specification"><span>{{ requirement.capacityCbm }} m³</span></td>
<td class="container-table__specification"><span>{{ requirement.maxWeightKg }} kg</span></td>
<td v-if="detail?.status === 'RELEASED'"><span>{{ requirement.remainingQuantity ?? requirement.quantity }}</span></td>
</tr>
</tbody>
</table></div>
</div>
<div v-if="formWritable" class="container-actions"><el-button class="add-container density-action" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement">新增柜型需求</el-button><span>{{ form.data.containerRequirements.length }} / 20 项</span></div>
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" /></el-form-item>
</div>
</section>
</el-form>
<el-collapse v-model="operationSections" class="booking-operation-config">
<el-collapse-item title="作业配置" name="operation">
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-group__body--fields">
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
</el-form>
</el-collapse-item>
</el-collapse>
<footer class="booking-section__footer">
<el-tooltip :content="printDisabledReason || '按已保存预报货量打印托运单'" placement="top"><span><el-button type="primary" :disabled="!!printDisabledReason || workflowBusy" @click="openPrint('SHIPPING_INSTRUCTION')"><Printer :size="16" />打印托运单</el-button></span></el-tooltip>
</footer>
</section>
<section class="workbench-panel is-compact booking-section booking-section--release" aria-label="放舱信息">
<div class="section-title booking-section__heading"><h2><span class="booking-section__number" aria-hidden="true">3</span>二、放舱信息</h2></div>
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-group__body--fields">
<el-form-item label="船名" prop="vesselName"><el-input v-model="form.data.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="form.data.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item class="booking-field--wide" label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
</el-form>
<footer class="booking-section__footer">
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" :disabled="workflowBusy" @click="openReleaseDialog">放舱</el-button>
<el-tooltip :content="printDisabledReason || '按已保存实际货量打印提单确认件'" placement="top"><span><el-button type="primary" :disabled="!!printDisabledReason || workflowBusy" @click="openPrint('BILL_OF_LADING_CONFIRMATION')"><Printer :size="16" />打印提单确认件</el-button></span></el-tooltip>
</footer>
</section>
<section class="workbench-panel is-compact">
<h2>订舱关系方</h2>
<div class="party-grid">
<div v-for="(party, index) in form.data.parties" :key="party.partyType" class="subsection">
<h3><span class="required-mark" aria-hidden="true">*</span>{{ partyTypes[index]?.label || party.partyType }}</h3>
<el-input v-model="party.partyName" :disabled="!formWritable" :aria-label="`${partyTypes[index]?.label || party.partyType}单位名称(必填)`" placeholder="单位名称(必填)" />
<el-input v-model="party.contactName" :disabled="!formWritable" placeholder="联系人" />
<el-input v-model="party.contactPhone" :disabled="!formWritable" placeholder="联系电话" />
<el-input v-model="party.address" :disabled="!formWritable" placeholder="地址" />
</div>
</div>
</section>
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-no="detail.bookingNo" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
<div v-if="detail" class="workbench-panel is-compact"><CarrierScheduleQueryPanel dense :ocean-booking-id="detail.id" /></div>
<section v-if="detail" class="workbench-panel is-compact history-panel">
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
<div class="workbench-evidence"><table class="density-table booking-history-table"><thead><tr><th>时间</th><th>操作</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="event in detail.histories" :key="event.id"><td>{{ event.createdAt || '—' }}</td><td>{{ historyActionLabel(event.action) }}</td><td>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</td><td>{{ event.operatorName || '系统' }}</td><td>{{ event.reason || '—' }}</td></tr><tr v-if="!detail.histories.length"><td colspan="5">暂无状态历史</td></tr></tbody></table></div>
<div class="workbench-evidence"><table class="density-table booking-history-table"><thead><tr><th>时间</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="event in detail.histories" :key="event.id"><td>{{ event.occurredAt }}</td><td>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</td><td>{{ event.operatorName || '系统' }}</td><td>{{ event.reason || '—' }}</td></tr><tr v-if="!detail.histories.length"><td colspan="4">暂无状态历史</td></tr></tbody></table></div>
</section>
</template>
</DetailPageLayout>
<AppDialog v-if="detail?.status === 'RELEASED'" v-model="arrivalDialogOpen" class="admin-density-dialog admin-workbench is-dialog" :title="bookingDialogTitle('维护预计到港')" width="560px" :confirm-loading="arrivalSaving" :confirm-disabled="!canMaintainArrival" confirm-text="保存" @confirm="saveEstimatedArrival">
<el-form class="density-form" label-position="top"><el-form-item label="预计到港" required><el-date-picker v-model="arrivalValue" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item></el-form>
<LoadErrorState v-if="arrivalError" compact title="预计到港保存失败" :message="arrivalError.message" :show-retry="false"><template #details><ApiErrorMeta :details="arrivalError" /></template></LoadErrorState>
</AppDialog>
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" :initial-type="printType" @previewed="onPrintPreviewed" />
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" />
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="releaseDialogOpen" :title="bookingDialogTitle('确认放舱')" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<div class="density-dialog-sections">
<section class="density-dialog-section"><el-descriptions class="release-summary density-descriptions" :column="2" border>
<el-alert title="放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。" type="warning" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="summary"><el-descriptions class="release-summary density-descriptions" :column="2" border>
<el-descriptions-item label="提单号">{{ form.data.billOfLadingNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="船名">{{ form.data.vesselName || '—' }}</el-descriptions-item>
<el-descriptions-item label="航次">{{ form.data.voyageNo || '—' }}</el-descriptions-item>
@@ -658,193 +493,6 @@ onMounted(() => { void load(); void loadOptions() })
</template>
<style scoped>
.booking-detail {
min-width: 0;
}
.detail-heading {
display: flex;
align-items: center;
gap: var(--admin-space-5);
min-width: 0;
padding: var(--admin-space-3) 0 var(--admin-space-5);
border-bottom: 1px solid var(--workbench-border);
}
.booking-brand {
display: grid;
flex: 0 0 36px;
place-items: center;
width: 36px;
height: 36px;
border-radius: var(--admin-dialog-section-radius);
color: #fff;
background: var(--workbench-primary);
box-shadow: 0 3px 8px rgb(55 132 250 / 24%);
}
.booking-page-toolbar { min-width: 0; flex: 1; }
.booking-page-toolbar :deep(h1) { font-size: var(--admin-font-title); }
.booking-page-actions { align-items: center; }
.actions { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }
.booking-workflow {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
list-style: none;
margin: 0 0 var(--admin-space-6);
padding: var(--admin-space-6) var(--admin-space-5);
border: 1px solid var(--workbench-border);
border-radius: var(--admin-dialog-section-radius);
background: var(--workbench-surface-tint);
}
.booking-workflow li { position: relative; display: grid; justify-items: center; align-content: start; gap: var(--admin-space-3); min-width: 0; }
.booking-workflow li:not(:last-child)::after {
position: absolute;
top: 14px;
left: calc(50% + 16px);
width: calc(100% - 32px);
height: 2px;
background: var(--workbench-border);
content: '';
}
.booking-workflow__index {
z-index: 1;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
color: var(--workbench-muted);
background: var(--workbench-surface-soft);
border: 1px solid var(--workbench-border);
box-sizing: border-box;
font-size: var(--admin-font-section);
font-weight: 600;
}
.booking-workflow__label { padding: 0 var(--admin-space-2); color: var(--workbench-text); font-size: var(--admin-font-caption); font-weight: 600; text-align: center; line-height: 1.5; }
.booking-workflow__status { color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 18px; text-align: center; }
.booking-workflow li[data-state='current'] .booking-workflow__index { border-color: var(--workbench-primary); color: #fff; background: var(--workbench-primary); box-shadow: 0 0 0 4px var(--workbench-accent-soft); }
.booking-workflow li[data-state='current'] .booking-workflow__label,
.booking-workflow li[data-state='current'] .booking-workflow__status { color: var(--workbench-primary-active); }
.booking-workflow li[data-state='complete'] .booking-workflow__index { color: var(--workbench-primary-active); background: var(--workbench-accent-soft); border-color: var(--workbench-accent-border); }
.booking-workflow li[data-state='complete']::after { background: var(--workbench-primary); }
.booking-workflow li[data-state='pending'] .booking-workflow__label { color: var(--workbench-muted); font-weight: 500; }
.booking-detail.admin-workbench .booking-section {
padding: var(--admin-space-6);
margin-bottom: var(--admin-space-6);
border-radius: var(--admin-dialog-section-radius);
}
.section-title { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); }
.booking-section__heading { margin-bottom: var(--admin-space-6); }
.booking-section__heading h2 {
display: flex;
align-items: center;
gap: var(--admin-space-3);
margin: 0;
color: var(--workbench-text);
font-size: var(--admin-font-section);
font-weight: 700;
line-height: 24px;
}
.booking-section__number {
display: inline-grid;
flex: 0 0 24px;
place-items: center;
width: 24px;
height: 24px;
border-radius: var(--admin-action-radius);
background: var(--workbench-primary);
color: #fff;
box-shadow: 0 2px 5px rgb(55 132 250 / 20%);
font-size: var(--admin-font-caption);
}
.booking-core { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--admin-space-7); }
.booking-core__main, .booking-parties, .booking-group { min-width: 0; }
.booking-groups { display: grid; grid-template-columns: 1fr; gap: var(--admin-space-6); }
.booking-group:last-child { grid-column: 1 / -1; }
.booking-group__title {
display: flex;
align-items: center;
gap: var(--admin-space-3);
margin: 0 0 var(--admin-space-5);
color: var(--workbench-muted);
font-size: var(--admin-font-caption);
font-weight: 600;
line-height: 20px;
}
.booking-group__title::before { flex: 0 0 5px; width: 5px; height: 5px; border-radius: 50%; background: var(--workbench-accent-border); content: ''; }
.booking-group__title::after { flex: 1; height: 1px; background: var(--workbench-border); content: ''; }
.booking-group__body--fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--admin-space-5); row-gap: var(--admin-space-3); }
.booking-field--wide { grid-column: 1 / -1; }
.booking-core__main > .booking-group { margin-top: var(--admin-space-6); }
.booking-parties { padding-left: var(--admin-space-7); border-left: 1px solid var(--workbench-border); }
.party-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); align-items: start; }
.booking-party-card { display: grid; min-width: 0; gap: var(--admin-space-3); padding: var(--admin-space-4); border: 1px solid var(--workbench-border); border-radius: var(--admin-dialog-section-radius); background: var(--workbench-surface-tint); }
.booking-party-card h3 { margin: 0 0 var(--admin-space-1); padding-bottom: var(--admin-space-3); border-bottom: 1px dashed var(--workbench-border); color: var(--workbench-text); font-size: var(--admin-font-body); font-weight: 600; line-height: 20px; }
/* 网格间距负责排版,避免再叠加 Element 表单项的默认下边距。 */
.booking-detail :deep(.density-form .el-form-item) { margin-bottom: 0; }
.booking-detail :deep(.density-form .el-form-item__label) { height: auto; margin-bottom: var(--admin-space-1); padding: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); font-weight: 500; line-height: 18px; }
.booking-detail :deep(.density-form .el-input),
.booking-detail :deep(.density-form .el-select),
.booking-detail :deep(.density-form .el-textarea) {
--el-input-bg-color: var(--workbench-surface);
--el-fill-color-blank: var(--workbench-surface);
--el-input-text-color: var(--workbench-text);
--el-input-border-color: var(--el-border-color-darker);
--el-input-hover-border-color: var(--workbench-primary-hover);
--el-input-focus-border-color: var(--workbench-primary);
--el-border-color: var(--el-border-color-darker);
--el-border-color-hover: var(--workbench-primary-hover);
}
/* 白底和清晰文字表示可编辑;禁用控件继续使用组件原生灰底、弱文字及禁止光标。 */
.booking-detail :deep(.density-form .el-input:not(.is-disabled)),
.booking-detail :deep(.density-form .el-textarea:not(.is-disabled)),
.booking-detail :deep(.density-form .el-select__wrapper:not(.is-disabled)) {
--el-input-placeholder-color: var(--workbench-muted);
--el-text-color-placeholder: var(--workbench-muted);
}
.booking-detail :deep(.density-form .el-input__inner),
.booking-detail :deep(.density-form .el-textarea__inner),
.booking-detail :deep(.density-form .el-select__wrapper) { font-size: var(--admin-font-body); }
.booking-detail :deep(.density-form .el-input__wrapper),
.booking-detail :deep(.density-form .el-select__wrapper) { min-height: var(--admin-control-height); box-sizing: border-box; }
.cargo-metrics { display: grid; gap: var(--admin-space-3); margin-bottom: var(--admin-space-5); }
.cargo-metric { display: grid; gap: var(--admin-space-1); }
.cargo-metric__label { color: var(--workbench-muted); font-size: var(--admin-font-caption); font-weight: 500; line-height: 18px; }
.cargo-metric__inputs { display: grid; grid-template-columns: minmax(0, 1fr) 12px minmax(0, 1fr) 64px; align-items: center; gap: var(--admin-space-2); }
.cargo-metric__separator { color: var(--workbench-muted); text-align: center; }
.cargo-metric__unit { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.cargo-metric__inputs :deep(.el-input__inner) { text-align: left; }
.booking-operation-config { margin-top: var(--admin-space-4); }
.booking-requirements { margin-top: var(--admin-space-6); }
.container-table-wrap { max-width: 100%; overflow-x: auto; margin-bottom: var(--admin-space-4); }
.container-table { width: 100%; min-width: 420px; table-layout: fixed; }
.container-table th:first-child, .container-table td:first-child { width: 80px; }
.container-table th:nth-child(2), .container-table td:nth-child(2) { width: 180px; }
.container-table th:nth-child(3), .container-table td:nth-child(3) { width: 90px; }
.container-table th:nth-child(4), .container-table td:nth-child(4) { width: 110px; }
.container-table td :deep(.el-select), .container-table td :deep(.el-input) { width: 100%; min-width: 0; }
.container-table__action { text-align: center !important; }
.container-actions { display: flex; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-5); color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.add-container { margin: 0; }
.booking-section__footer { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--admin-space-3); margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px dashed var(--workbench-border); }
.field-error, .field-hint { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); margin-top: var(--admin-space-1); color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 20px; }
.field-error { color: var(--el-color-danger); }
.history-panel h2 { margin: 0; font-size: var(--admin-font-section); }
.history-panel p { margin: var(--admin-space-1) 0 var(--admin-space-5); color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.booking-history-table th:nth-child(1) { width: 180px; }
.booking-history-table th:nth-child(2) { width: 160px; }
.booking-history-table th:nth-child(3) { width: 200px; }
.booking-history-table th:nth-child(4) { width: 140px; }
@media(max-width:720px) {
.booking-core, .booking-workflow, .party-grid { grid-template-columns: 1fr; }
.booking-parties { padding-left: 0; border-left: 0; }
.detail-heading { flex-wrap: wrap; }
.booking-page-toolbar { width: 100%; }
.booking-workflow li:not(:last-child)::after { display: none; }
}
@media(max-width:520px) {
.booking-group__body--fields { grid-template-columns: 1fr; }
.booking-field--wide { grid-column: auto; }
}
@media(max-width:420px) {
.actions :deep(.el-button) { min-height: 44px; }
}
.channel-code{display:block;margin-left:var(--admin-space-3);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-3);margin-top:5px;color:var(--workbench-muted);font-size:var(--admin-font-caption);line-height:20px}.field-error{color:var(--el-color-danger)}
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;gap:var(--admin-space-5);padding:var(--admin-space-3) var(--admin-space-5);background:var(--admin-surface);border:1px solid var(--workbench-border);align-items:center}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-body)}.heading-status{display:flex;align-items:center;gap:var(--admin-space-3)}.surface-panel h2{margin:0;font-size:var(--admin-font-section)}.section-title{display:flex;justify-content:space-between;gap:var(--admin-space-5);align-items:flex-start;margin-bottom:var(--admin-space-5)}.actions{display:flex;flex-wrap:wrap;gap:var(--admin-space-3)}.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:var(--admin-space-5)}.booking-group{min-width:0;overflow:hidden;background:var(--admin-surface);border:0}.booking-group:last-child{grid-column:1/-1}.booking-group>h3{margin:0;color:#1f2937;font-size:var(--admin-font-section);padding:var(--admin-space-3) 0;border-bottom:1px solid var(--workbench-border)}.booking-group__body{padding:var(--admin-space-3) 0 0}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:var(--admin-space-4);row-gap:var(--admin-space-3)}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4);margin-top:var(--admin-space-5)}.subsection{display:grid;gap:var(--admin-space-4);border:0;padding:0}.subsection h3{margin:0;font-size:var(--admin-font-section)}.required-mark{margin-right:var(--admin-space-1);color:var(--el-color-danger)}.container-table-wrap{max-width:100%;overflow-x:auto;margin-bottom:var(--admin-space-4)}.container-table{width:100%;min-width:780px;table-layout:fixed}.container-table th:first-child,.container-table td:first-child{width:80px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.container-table th:nth-child(5),.container-table td:nth-child(5),.container-table th:nth-child(6),.container-table td:nth-child(6){width:100px}.container-table td :deep(.el-select),.container-table td :deep(.el-input){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__specification{color:var(--workbench-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:var(--admin-space-4);margin-bottom:var(--admin-space-5);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.add-container{margin:0}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column;padding:var(--admin-space-3) var(--admin-space-5);background:var(--admin-surface);border:1px solid var(--workbench-border);align-items:center}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.booking-group__body{padding:var(--admin-space-3) 0 0}.actions :deep(.el-button){min-height:44px}}.booking-history-table th:nth-child(1){width:180px}.booking-history-table th:nth-child(2){width:220px}.booking-history-table th:nth-child(3){width:140px}.booking-page-toolbar :deep(h1){font-size:var(--admin-font-title)}
</style>
+4 -24
View File
@@ -32,7 +32,7 @@ export interface OceanBookingAgentOption {
carrierCodes: OceanBookingCarrierCode[]
}
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null; contactEmail?: string | null; taxNo?: string | null }
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null }
export interface OceanBookingContainerRequirement {
id?: string
requirementId?: string
@@ -53,27 +53,7 @@ export interface OceanBookingAllocatedContainer {
capacityCbm: number
maxWeightKg: number
}
export interface OceanBookingCargoData {
freightTerm?: string | null
serviceTerm?: string | null
packageUnit?: 'CARTONS' | 'PCS' | 'PALLETS' | null
cargoDescription?: string | null
marksAndNos?: string | null
declaredPackageCount?: number | null
declaredGrossWeightKg?: number | null
declaredVolumeCbm?: number | null
actualPackageCount?: number | null
actualGrossWeightKg?: number | null
actualVolumeCbm?: number | null
}
export interface OceanBookingActualCargoRequest {
expectedVersion: number
actualPackageCount: number | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
reason: string
}
export interface OceanBookingDraftData extends OceanBookingCargoData {
export interface OceanBookingDraftData {
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainerRequirement[]
channelCode: string | null; warehouseId: string | null
billOfLadingNo: string; vesselName: string; voyageNo: string
@@ -83,7 +63,7 @@ export interface OceanBookingDraftData extends OceanBookingCargoData {
placeOfDelivery?: string | null
remark?: string | null
}
export interface OceanBookingResource extends OceanBookingCargoData {
export interface OceanBookingResource {
id: string; bookingNo: string | null; status: OceanBookingStatus; version: number
customerId: string | null; customerName: string | null; billOfLadingNo: string | null
/** 订舱柜型需求 quantity 的总和,不是已关联真实柜数。 */
@@ -101,7 +81,7 @@ export interface OceanBookingResource extends OceanBookingCargoData {
amount: string | null; currency: string | null
}
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainerRequirement[]; containers: OceanBookingAllocatedContainer[]; documents: OceanBookingDocument[]; histories: OceanBookingStatusEvent[]; statusHistory: OceanBookingStatusEvent[] }
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; createdAt: string }
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; occurredAt: string }
export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number }
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; customerId?: string }
export interface OceanBookingLinkCandidate {
@@ -1,87 +0,0 @@
import { describe, expect, it } from 'vitest'
import { oceanBookingPrintSignature, oceanBookingWorkflow } from './workflow'
import type { OceanBookingDetail, OceanBookingResource } from './types'
function detail(): OceanBookingDetail {
// 流程只消费订舱已保存事实,不依赖权限、选项请求或页面临时输入。
const resource: OceanBookingResource = {
id: '123', bookingNo: 'OB-123', status: 'DRAFT', version: 1,
customerId: null, customerName: null, billOfLadingNo: null,
shipperName: null, consigneeName: null, containerCount: 1,
channelCode: null, channelName: null, warehouseId: null, warehouseName: null,
shippingCompanyId: null, shippingCompanyName: null, carrierCode: null, bookingAgentId: null, bookingAgentName: null,
vesselName: null, voyageNo: null, departurePortCode: null, departurePortName: null,
destinationPortCode: null, destinationPortName: null, placeOfDelivery: null,
estimatedSailingAt: null, estimatedArrivalAt: null, remark: null, createdAt: null, updatedAt: null,
orderNo: null, carrierName: null, bookingAgent: null, pol: null, pod: null,
mblShipper: null, mblConsignee: null, soNo: null, cutoffAt: null,
etd: null, atd: null, eta: null, ata: null, containerSummary: null, amount: null, currency: null,
declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68,
actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
}
return { ...resource, oceanBooking: resource, parties: [], containerRequirements: [], containers: [], documents: [], histories: [], statusHistory: [] }
}
describe('ocean booking workflow milestones', () => {
it('starts on the first step and does not mark an unsaved form as complete', () => {
expect(oceanBookingWorkflow(null, false, false, {}).map(step => step.state))
.toEqual(['current', 'pending', 'pending', 'pending'])
expect(oceanBookingWorkflow(detail(), true, false, {})[0]).toMatchObject({ state: 'current', status: '修改待保存' })
})
it('advances from saved draft to preview, then to release without inventing a print record', () => {
const saved = detail()
const initial = oceanBookingWorkflow(saved, false, false, {})
expect(initial.map(step => step.state)).toEqual(['complete', 'current', 'pending', 'pending'])
expect(initial[0]?.status).toBe('已保存')
const previews = { SHIPPING_INSTRUCTION: oceanBookingPrintSignature(saved, 'SHIPPING_INSTRUCTION') }
const previewed = oceanBookingWorkflow(saved, false, false, previews)
expect(previewed.map(step => step.state)).toEqual(['complete', 'complete', 'current', 'pending'])
expect(previewed[1]?.status).toBe('已预览')
expect(previewed.some(step => step.status === '已打印')).toBe(false)
})
it('restores released progress from server data and requires all saved actual values before final completion', () => {
const saved = detail()
saved.status = 'RELEASED'
let steps = oceanBookingWorkflow(saved, false, false, {})
expect(steps.map(step => step.state)).toEqual(['complete', 'complete', 'complete', 'current'])
expect(steps[1]).toMatchObject({ state: 'complete', status: '可重新预览 / 打印' })
expect(steps[2]).toMatchObject({ state: 'complete', status: '已放舱' })
expect(steps[3]).toMatchObject({ state: 'current', status: '待补齐实值' })
saved.actualPackageCount = 480
saved.actualGrossWeightKg = 14320
saved.actualVolumeCbm = 65
const previews = { BILL_OF_LADING_CONFIRMATION: oceanBookingPrintSignature(saved, 'BILL_OF_LADING_CONFIRMATION') }
expect(oceanBookingWorkflow(saved, false, true, previews)[3]).toMatchObject({ state: 'current', status: '实值待保存' })
steps = oceanBookingWorkflow(saved, false, false, {})
expect(steps[3]).toMatchObject({ state: 'current', status: '可预览 / 打印' })
expect(oceanBookingWorkflow(saved, false, false, previews)[3]).toMatchObject({ state: 'complete', status: '已预览' })
})
it('invalidates only previews whose actual data changed', () => {
const saved = detail()
saved.status = 'RELEASED'
saved.actualPackageCount = 480; saved.actualGrossWeightKg = 14320; saved.actualVolumeCbm = 65
const previews = {
SHIPPING_INSTRUCTION: oceanBookingPrintSignature(saved, 'SHIPPING_INSTRUCTION'),
BILL_OF_LADING_CONFIRMATION: oceanBookingPrintSignature(saved, 'BILL_OF_LADING_CONFIRMATION'),
}
saved.version = 100 // 附件等引起的订舱版本变化不会伪造或抹去相同单据的预览事实。
saved.actualPackageCount = 490
let steps = oceanBookingWorkflow(saved, false, false, previews)
expect(steps[1]?.state).toBe('complete')
expect(steps[3]).toMatchObject({ state: 'current', status: '可预览 / 打印' })
saved.declaredPackageCount = 510
steps = oceanBookingWorkflow(saved, false, false, previews)
expect(steps[1]).toMatchObject({ state: 'complete', status: '可重新预览 / 打印' })
})
it('requires a fresh preview when draft print data changes before release', () => {
const saved = detail()
const previews = { SHIPPING_INSTRUCTION: oceanBookingPrintSignature(saved, 'SHIPPING_INSTRUCTION') }
saved.declaredPackageCount = 510
expect(oceanBookingWorkflow(saved, false, false, previews).map(step => step.state))
.toEqual(['complete', 'current', 'pending', 'pending'])
})
})
-53
View File
@@ -1,53 +0,0 @@
import type { OceanBookingDetail, OceanBookingPrintType } from './types'
export interface OceanBookingWorkflowStep {
label: string
status: string
state: 'complete' | 'current' | 'pending'
}
export type OceanBookingPreviewSignatures = Partial<Record<OceanBookingPrintType, string>>
// 预览只对应其实际消费的数据;补录实值不会作废已经预览的托运单。
export function oceanBookingPrintSignature(detail: OceanBookingDetail, type: OceanBookingPrintType): string {
return JSON.stringify([
detail.id, detail.billOfLadingNo, detail.vesselName, detail.voyageNo, detail.estimatedSailingAt,
detail.shippingCompanyName, detail.bookingAgentName,
detail.departurePortCode, detail.departurePortName, detail.destinationPortCode, detail.destinationPortName,
detail.placeOfDelivery, detail.serviceTerm, detail.freightTerm, detail.packageUnit,
detail.cargoDescription, detail.marksAndNos, detail.remark,
detail.parties.map(party => [party.partyType, party.partyName, party.address, party.contactName, party.contactPhone, party.contactEmail, party.taxNo]).sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
detail.containerRequirements.map(item => [item.containerType, item.containerKind, item.quantity]),
type === 'SHIPPING_INSTRUCTION'
? [detail.declaredPackageCount, detail.declaredGrossWeightKg, detail.declaredVolumeCbm]
: [detail.actualPackageCount, detail.actualGrossWeightKg, detail.actualVolumeCbm],
])
}
export function oceanBookingWorkflow(
detail: OceanBookingDetail | null,
draftDirty: boolean,
actualDirty: boolean,
previews: OceanBookingPreviewSignatures,
): OceanBookingWorkflowStep[] {
const saved = Boolean(detail && !draftDirty)
const released = detail?.status === 'RELEASED'
const actualComplete = Boolean(detail && [detail.actualPackageCount, detail.actualGrossWeightKg, detail.actualVolumeCbm]
.every(value => value != null && Number(value) > 0))
const previewed = (type: OceanBookingPrintType) => Boolean(detail && previews[type] === oceanBookingPrintSignature(detail, type))
const instructionPreviewed = saved && previewed('SHIPPING_INSTRUCTION')
// 已放舱代表已进入后续作业;刷新清除预览标记时,不让先前阶段倒退。
// 阶段通过不代表有打印记录,仍用状态文案区分本次是否预览。
const instructionPassed = released || instructionPreviewed
const confirmationPreviewed = saved && released && actualComplete && !actualDirty && previewed('BILL_OF_LADING_CONFIRMATION')
const current = !saved ? 0 : !released ? instructionPreviewed ? 2 : 1 : confirmationPreviewed ? -1 : 3
const step = (index: number, label: string, complete: boolean, status: string): OceanBookingWorkflowStep => ({
label, status, state: complete ? 'complete' : current === index ? 'current' : 'pending',
})
return [
step(0, '填写并保存订舱信息', saved, saved ? '已保存' : detail ? '修改待保存' : '待填写并保存'),
step(1, '托运单预览 / 打印', instructionPassed, instructionPreviewed ? '已预览' : released ? '可重新预览 / 打印' : saved ? '可预览 / 打印' : '待保存资料'),
step(2, '补齐资料并确认放舱', released, released ? '已放舱' : saved ? '待确认放舱' : '待补齐资料'),
step(3, '补实并预览提单确认件', confirmationPreviewed, !released ? '待放舱' : actualDirty ? '实值待保存' : !actualComplete ? '待补齐实值' : confirmationPreviewed ? '已预览' : '可预览 / 打印'),
]
}
@@ -1,75 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const client = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() }))
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: client }))
import { actOnOverseasFee, createOverseasFee, getOverseasFee, listBusinessFeeOptions, listBusinessOverseasFees, listOverseasFeeItems, listOverseasFees, listOverseasFeeRetries, type SaveOverseasFee } from './overseasFees'
const base = '/platform/v1/last-leg/finance-fee-sources'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
const row = { id: '9007199254740993', sourceFeeId: '9007199254740993', customerId: '1', businessId: '9007199254740995', direction: 'RECEIVABLE', allowedActions: ['APPROVE'] }
describe('海外费用真实接口', () => {
beforeEach(() => vi.clearAllMocks())
it('分页不退回旧非分页接口,长整型身份保持字符串', async () => {
client.get.mockResolvedValue(envelope({ records: [row], total: '1', pages: '1', page: 1, size: 20 }))
const result = await listOverseasFees({ page: 1, size: 20, feeItemCode: 'F1' })
expect(client.get).toHaveBeenCalledWith(`${base}/page`, { params: { page: 1, size: 20, feeItemCode: 'F1' } })
expect(result.records[0]?.businessId).toBe('9007199254740995')
expect(result.total).toBe(1)
client.get.mockResolvedValue(envelope(row))
expect((await getOverseasFee(row.sourceFeeId)).sourceFeeId).toBe(row.sourceFeeId)
})
it('创建传正式业务订单及幂等键,审核和锁款重试各用正式命令', async () => {
client.post.mockResolvedValue(envelope(row))
const payload = { businessNo: 'WT1', businessType: 'WAREHOUSE_TRANSFER', amount: 10 } as SaveOverseasFee
await createOverseasFee(payload, 'create-1')
expect(client.post).toHaveBeenCalledWith(base, payload, { headers: { 'Idempotency-Key': 'create-1' } })
await actOnOverseasFee(row.sourceFeeId, 'REJECT', { decision: 'REJECT', reason: '重复费用', idempotencyKey: 'review-1' })
expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/review`, { decision: 'REJECT', reason: '重复费用', idempotencyKey: 'review-1' })
await actOnOverseasFee(row.sourceFeeId, 'RETRY_FREEZE', { idempotencyKey: 'retry-1' })
expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/retry-funding`, { idempotencyKey: 'retry-1' })
await actOnOverseasFee(row.sourceFeeId, 'RETRY_PUBLISH', { expectedVersion: 1, eventId: 'original-event', idempotencyKey: 'retry-2' })
expect(client.post).toHaveBeenLastCalledWith(`${base}/${row.sourceFeeId}/retry-delivery`, { expectedVersion: 1, eventId: 'original-event', idempotencyKey: 'retry-2' })
})
it('目录和业务订单选项读取真实数据,不套用人民币默认价', async () => {
client.get.mockResolvedValueOnce(envelope([{ code: 'F1', name: '操作费', defaultPrice: 10 }]))
.mockResolvedValueOnce(envelope([{ businessId: '9007199254740995', customerId: '1', orderId: null, warehouseId: '2' }]))
expect(await listOverseasFeeItems()).toEqual([{ feeCode: 'F1', feeTypeName: '操作费', defaultPrice: 10 }])
expect(client.get).toHaveBeenLastCalledWith(`${base}/fee-items`, { params: { direction: 'RECEIVABLE' } })
expect((await listBusinessFeeOptions({ businessType: 'WAREHOUSE_TRANSFER', keyword: 'WT' }))[0]?.businessId).toBe('9007199254740995')
expect(client.get).toHaveBeenLastCalledWith(`${base}/business-options`, { params: { businessType: 'WAREHOUSE_TRANSFER', keyword: 'WT' } })
})
it('应付项目明确传递方向', async () => {
client.get.mockResolvedValue(envelope([]))
await listOverseasFeeItems('PAYABLE')
expect(client.get).toHaveBeenCalledWith(`${base}/fee-items`, { params: { direction: 'PAYABLE' } })
})
it('按稳定费用身份查询完整重试记录', async () => {
client.get.mockResolvedValue(envelope([]))
expect(await listOverseasFeeRetries(row.sourceFeeId)).toEqual([])
expect(client.get).toHaveBeenCalledWith(`${base}/${row.sourceFeeId}/retries`)
})
it('单据费用先严格匹配客户、类型及ID,合并去重后分页', async () => {
const business = { businessId: row.businessId, businessType: 'LASTLEG_ORDER', businessNo: 'TB1', customerId: '1', customerName: '测试客户', orderId: null, warehouseId: null }
const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' }
const first = { ...row, businessType: business.businessType, createdAt: '2026-10-22T10:00:00' }
const second = { ...first, sourceFeeId: '9007199254740994', businessId: related.businessId, businessType: related.businessType }
client.get.mockResolvedValue(envelope([first, second, first,
{ ...first, sourceFeeId: 'wrong-id', businessId: 'another' },
{ ...first, sourceFeeId: 'wrong-type', businessType: 'RETURN_ORDER' },
{ ...first, sourceFeeId: 'wrong-customer', customerId: '2' },
]))
const result = await listBusinessOverseasFees([business, related], { page: 2, size: 1 })
expect(client.get).toHaveBeenCalledWith(base, { params: { customerId: '1', businessType: 'LASTLEG_ORDER', businessNo: 'TB1' } })
expect(result).toMatchObject({ total: 2, pages: 2, page: 2, size: 1 })
expect(result.records.map(fee => fee.sourceFeeId)).toEqual([first.sourceFeeId])
})
it.each(['RECEIVABLE', 'PAYABLE'] as const)('混合返回时先按 %s 分开,再统计分页,不遗漏第二页数据', async direction => {
const business = { businessId: row.businessId, businessType: 'LASTLEG_ORDER', businessNo: 'TB1', customerId: '1', customerName: '客户', orderId: null, warehouseId: null }
const first = { ...row, businessType: 'LASTLEG_ORDER', direction, createdAt: '2026-10-23T10:00:00' }
const second = { ...first, sourceFeeId: 'second', createdAt: '2026-10-23T11:00:00' }
const opposite = { ...first, sourceFeeId: 'opposite', direction: direction === 'RECEIVABLE' ? 'PAYABLE' : 'RECEIVABLE' }
client.get.mockResolvedValue(envelope([first, opposite, second, { ...first, sourceFeeId: 'unknown', direction: null }]))
const result = await listBusinessOverseasFees(business, { page: 2, size: 1 }, direction)
expect(result).toMatchObject({ total: 2, pages: 2 })
expect(result.records.map(fee => fee.sourceFeeId)).toEqual([first.sourceFeeId])
expect(result.records.every(fee => fee.direction === direction)).toBe(true)
})
})
@@ -1,99 +0,0 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface FeePage<T> { records: T[]; page: number; size: number; total: number; pages: number }
export type OverseasFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export interface OverseasFee {
storageRent?: { outboundNo: string; outboundAt: string; calculatedAmount: number; chargedAmount: number | null;
lines: Array<{ lotId: string; inboundAt: string; quantity: number; days: number; calculatedAmount: number;
sourceBusinessType?: string | null; sourceBusinessNo?: string | null; batchNo?: string | null }> } | null
id: string; sourceFeeId: string; sourceVersion: number; customerId: string; customerName: string
sourceType: string; businessType: string; businessId: string | null; businessNo: string
displayBusinessType?: string
direction: OverseasFeeDirection
supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null
settlementMode?: 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID' | null
paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[] | null
orderId: string | null; warehouseId: string | null; feeItemCode: string; feeItemName: string
feeRemark?: string | null
businessDate: string; currency: string; amount: number; originalCurrency: string; originalAmount: number
exchangeRate?: number | null; exchangeRateMonth?: string | null
chargeableQuantity: number | null; quantityUnit: string | null; unitPrice: number | null
periodStart: string | null; periodEnd: string | null; reviewStatus: string; sourceStatus: string
publicationStatus: string; collectionMode: 'BILL' | 'WALLET'; walletStatus: string | null
deliveryStatus?: 'PENDING' | 'PROCESSING' | 'PUBLISHED' | 'DEAD' | null
deliveryKind?: 'FEE' | 'FUNDING' | null; deliveryEventId?: string | null; deliveryErrorMessage?: string | null
walletErrorMessage: string | null; reviewReason: string | null; reviewerId?: string | null; reviewerName?: string | null; reviewedAt: string | null
createdAt: string; updatedAt: string; allowedActions: string[]
}
export interface BusinessFeeOption {
businessId: string; businessType: string; businessNo: string; customerId: string; customerName: string
warehouseId: string | null; orderId: string | null; orderType?: string
}
export interface SaveOverseasFee {
customerId: string; customerName: string; sourceType: string; businessType: string
businessId: string; businessNo: string; orderId: string | null; warehouseId: string | null
feeItemCode: string; feeItemName: string; direction: OverseasFeeDirection; businessDate: string
feeRemark?: string | null
supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null
settlementMode?: 'BILL' | 'WALLET' | 'ON_ACCOUNT' | 'DIRECT_PAID' | null
paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[] | null
currency: 'CNY' | 'USD'; amount: number
chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
}
const base = '/platform/v1/last-leg/finance-fee-sources'
export interface OverseasFeeRetryRecord {
requestId: string; requestedAt: string; operatorName: string; action: string
result: 'SUCCESS' | 'FAILED' | 'SUBMITTED'; errorMessage: string | null
}
export async function listOverseasFeeRetries(id: string): Promise<OverseasFeeRetryRecord[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<OverseasFeeRetryRecord[]>, ApiEnvelope<OverseasFeeRetryRecord[]>>(`${base}/${encodeURIComponent(id)}/retries`))
}
export async function listOverseasFeeItems(direction: 'RECEIVABLE' | 'PAYABLE' = 'RECEIVABLE') {
const response = await http.get<ApiEnvelope<Array<{ code: string; name: string; defaultPrice: number | null }>>, ApiEnvelope<Array<{ code: string; name: string; defaultPrice: number | null }>>>(`${base}/fee-items`, { params: { direction } })
return unwrapApiResponse(response).map(item => ({ feeCode: item.code, feeTypeName: item.name, defaultPrice: item.defaultPrice }))
}
export async function listOverseasFeeCustomers() {
const response = await http.get<ApiEnvelope<Array<{ customerId: string; customerName: string }>>, ApiEnvelope<Array<{ customerId: string; customerName: string }>>>(`${base}/customer-options`)
return unwrapApiResponse(response).map(row => ({ ...row, customerId: String(row.customerId) }))
}
function normalizeFee(value: OverseasFee): OverseasFee {
return { ...value, id: String(value.id), sourceFeeId: String(value.sourceFeeId), customerId: String(value.customerId),
businessId: value.businessId == null ? null : String(value.businessId), orderId: value.orderId == null ? null : String(value.orderId),
warehouseId: value.warehouseId == null ? null : String(value.warehouseId), allowedActions: value.allowedActions || [] }
}
export async function listOverseasFees(query: Record<string, string | number | undefined>): Promise<FeePage<OverseasFee>> {
const response = await http.get<ApiEnvelope<FeePage<OverseasFee>>, ApiEnvelope<FeePage<OverseasFee>>>(`${base}/page`, { params: query })
const data = unwrapApiResponse(response)
return { ...data, records: data.records.map(normalizeFee), total: Number(data.total), pages: Number(data.pages) }
}
export async function listBusinessOverseasFees(business: BusinessFeeOption | BusinessFeeOption[], pagination: { page: number; size: number }, direction: OverseasFeeDirection = 'RECEIVABLE'): Promise<FeePage<OverseasFee>> {
const { page, size } = pagination
const scopes = Array.isArray(business) ? business : [business]
if (!scopes.length || scopes.some(scope => !scope.businessId || !scope.customerId || !scope.businessType)) throw new Error('业务单据信息不完整')
// 非分页接口返回两种方向;先按客户、业务身份及收付方向匹配,再合并分页。
const groups = await Promise.all(scopes.map(async scope => {
const response = await http.get<ApiEnvelope<OverseasFee[]>, ApiEnvelope<OverseasFee[]>>(base, {
params: { customerId: scope.customerId, businessType: scope.businessType, businessNo: scope.businessNo },
})
return unwrapApiResponse(response).map(normalizeFee).filter(row => row.customerId === scope.customerId && row.businessType === scope.businessType && row.businessId === scope.businessId && row.direction === direction)
}))
const records = [...new Map(groups.flat().map(row => [row.sourceFeeId, row])).values()]
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.sourceFeeId.localeCompare(left.sourceFeeId))
return { records: records.slice((page - 1) * size, page * size), total: records.length, pages: Math.ceil(records.length / size), page, size }
}
export async function getOverseasFee(id: string) {
return normalizeFee(unwrapApiResponse(await http.get<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(`${base}/${encodeURIComponent(id)}`)))
}
export async function listBusinessFeeOptions(params: { businessType: string; keyword?: string; customerId?: string }) {
const response = await http.get<ApiEnvelope<BusinessFeeOption[]>, ApiEnvelope<BusinessFeeOption[]>>(`${base}/business-options`, { params })
return unwrapApiResponse(response).map(row => ({ ...row, businessId: String(row.businessId), customerId: String(row.customerId), orderId: row.orderId == null ? null : String(row.orderId), warehouseId: row.warehouseId == null ? null : String(row.warehouseId) }))
}
export async function createOverseasFee(payload: SaveOverseasFee, key: string) {
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(base, payload, { headers: { 'Idempotency-Key': key } })))
}
export async function actOnOverseasFee(id: string, action: string, body: Record<string, unknown>) {
const endpoint = action === 'APPROVE' || action === 'REJECT' ? 'review'
: ['RETRY_PUBLISH', 'RETRY_FUNDING_DELIVERY'].includes(action) ? 'retry-delivery'
: action.startsWith('RETRY_') ? 'retry-funding' : action.toLowerCase()
return normalizeFee(unwrapApiResponse(await http.post<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(`${base}/${encodeURIComponent(id)}/${endpoint}`, body)))
}
@@ -57,8 +57,8 @@ export async function listOverseasPurchaseCustomerOptions(keyword = '', limit =
const response = await http.get<ApiEnvelope<OverseasPurchaseCustomerOption[]>, ApiEnvelope<OverseasPurchaseCustomerOption[]>>(`${procurementBasePath}/customer-options`, { params: { keyword: keyword.trim() || undefined, limit } })
return unwrapApiResponse(response)
}
export async function listOverseasPurchaseWarehouseOptions(procurementDirection?: import('../procurementDirection').ProcurementDirection) {
const response = await http.get<ApiEnvelope<OverseasPurchaseWarehouseOption[]>, ApiEnvelope<OverseasPurchaseWarehouseOption[]>>(`${procurementBasePath}/warehouse-options`, { params: { procurementDirection } })
export async function listOverseasPurchaseWarehouseOptions() {
const response = await http.get<ApiEnvelope<OverseasPurchaseWarehouseOption[]>, ApiEnvelope<OverseasPurchaseWarehouseOption[]>>(`${procurementBasePath}/warehouse-options`)
return unwrapApiResponse(response).map((item) => ({ ...item, warehouseId: String(item.warehouseId) }))
}
export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) {
@@ -3,7 +3,6 @@ import type { ProcurementFile, ProcurementFileCategory, ProcurementRemainingKind
export type ProcurementCommand = 'review' | 'purchase' | 'purchase/confirm' | 'budget-authorizations' | 'planned-warehouse' | 'cancel' | 'overseas-receipts' | 'domestic-receipts' | 'overseas-receipts/complete' | 'domestic-receipts/complete' | 'packing' | 'packing/confirm' | 'packing/withdraw' | 'dispositions' | 'receipts/correct' | 'dispositions/revoke' | 'labels' | 'international-estimate' | 'labels/apply' | 'domestic-repack' | 'overseas-shipment' | 'customer-shipment' | 'charges/confirm' | 'fees/retry' | 'fees/terminate' | 'fees/release-estimate' | 'fees/correct-target' | 'fees/refund' | 'fees/replace-failed' | 'fees/additional' | 'control-notes' | 'control-notes/resolve' | 'actual-warehouse/correct' | 'exception-close'
export interface ProcurementCommandBody {
procurementDirection?: import('../procurementDirection').ProcurementDirection
name?: string
targetType?: 'PURCHASE' | 'WAREHOUSE' | 'RECEIPT' | 'SHIPMENT'; targetId?: string; blocking?: boolean; clientSummary?: string; noteId?: string
resolutions?: ProcurementResolution[]; customerVoucherFileId?: string; financialConclusion?: string
@@ -1,33 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const client = vi.hoisted(() => ({ get: vi.fn() }))
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: client }))
import { getStorageRentLot, listStorageRentLots, listStorageRentWarehouses } from './storageRent'
const base = '/platform/v1/last-leg/storage-rent'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
const lot = { id: '9223372036854775001', customerId: '9223372036854775002', warehouseId: '9223372036854775003', businessNo: 'WT1' }
describe('仓租查询接口', () => {
beforeEach(() => vi.clearAllMocks())
it('uses the paginated root endpoint with server filters and preserves long IDs', async () => {
client.get.mockResolvedValue(envelope({ records: [lot], total: '1', page: 1, size: 20, pages: 1 }))
const params = { page: 1, size: 20, businessType: 'WAREHOUSE_TRANSFER', warehouseId: lot.warehouseId, status: 'IN_STOCK' }
const result = await listStorageRentLots(params)
expect(client.get).toHaveBeenCalledWith(base, { params })
expect(result.total).toBe(1)
expect(result.records[0]).toMatchObject(lot)
})
it('reads a lot with its actual charges and warehouse options from dedicated endpoints', async () => {
const charge = { id: '9223372036854775004', outboundNo: 'OUT1', quantity: 2, originalAmount: 23, currency: 'CNY', settlementAmount: 23 }
const currencyLot = { ...lot, generatedAmount: null, generatedCurrency: null, generatedAmounts: [{ currency: 'USD', amount: 12 }, { currency: 'CNY', amount: 23 }] }
client.get.mockResolvedValueOnce(envelope({ lot: currencyLot, charges: [charge] }))
.mockResolvedValueOnce(envelope([{ id: lot.warehouseId, warehouseName: '测试仓' }]))
const detail = await getStorageRentLot(lot.id)
expect(client.get).toHaveBeenNthCalledWith(1, `${base}/${lot.id}`)
expect(detail.lot.id).toBe(lot.id)
expect(detail.lot).toMatchObject(currencyLot)
expect(detail.charges).toEqual([charge])
expect(await listStorageRentWarehouses()).toEqual([{ id: lot.warehouseId, warehouseName: '测试仓' }])
expect(client.get).toHaveBeenNthCalledWith(2, `${base}/warehouse-options`)
})
})
@@ -1,39 +0,0 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { FeePage } from './overseasFees'
import type { OverseasFee } from './overseasFees'
export async function adjustStorageRentFee(id: string, body: { expectedVersion: number; amount: number; reason: string; idempotencyKey: string }) {
return unwrapApiResponse(await http.post<ApiEnvelope<OverseasFee>, ApiEnvelope<OverseasFee>>(`/platform/v1/last-leg/storage-rent/fees/${encodeURIComponent(id)}/adjust`, body))
}
export interface StorageRentLot {
id: string; customerId: string; customerName: string; businessType: string; businessNo: string
warehouseId: string; warehouseName: string; batchNo: string; inboundAt: string; storageDays: number
originalQuantity: number; remainingQuantity: number; quantityUnit: string; billingQuantity: number
billingUnit: string; ruleCode: string; status: string; generatedAmount: number | null
generatedCurrency: string | null; generatedAmounts: Array<{ currency: string; amount: number }>
settledAmount?: number; chargeCount?: number; settledChargeCount?: number
}
export interface StorageRentCharge {
id: string; outboundNo: string; businessNo: string; quantity: number; storageDays: number
originalAmount: number; currency: string; settlementAmount: number; currencyRate: number; status: string
createdAt: string; outboundAt: string
}
export interface StorageRentDetail { lot: StorageRentLot; charges: StorageRentCharge[] }
const base = '/platform/v1/last-leg/storage-rent'
function normalizeLot(row: StorageRentLot): StorageRentLot {
return { ...row, id: String(row.id), customerId: String(row.customerId), warehouseId: String(row.warehouseId) }
}
export async function listStorageRentLots(params: Record<string, string | number | undefined>) {
const response = await http.get<ApiEnvelope<FeePage<StorageRentLot>>, ApiEnvelope<FeePage<StorageRentLot>>>(base, { params })
const data = unwrapApiResponse(response)
return { ...data, total: Number(data.total), records: data.records.map(normalizeLot) }
}
export async function getStorageRentLot(id: string) {
const data = unwrapApiResponse(await http.get<ApiEnvelope<StorageRentDetail>, ApiEnvelope<StorageRentDetail>>(`${base}/${encodeURIComponent(id)}`))
return { lot: normalizeLot(data.lot), charges: data.charges.map(row => ({ ...row, id: String(row.id) })) }
}
export async function listStorageRentWarehouses() {
const data = unwrapApiResponse(await http.get<ApiEnvelope<Array<{ id: string; warehouseName: string }>>, ApiEnvelope<Array<{ id: string; warehouseName: string }>>>(`${base}/warehouse-options`))
return data.map(row => ({ ...row, id: String(row.id) }))
}
@@ -1,59 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BusinessDocumentFees from './BusinessDocumentFees.vue'
import { listBusinessFeeOptions } from '../api/overseasFees'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/overseasFees', () => ({ listBusinessFeeOptions: vi.fn() }))
vi.mock('./BusinessOrderFees.vue', () => ({ default: { name: 'BusinessOrderFees', props: ['business', 'relatedBusinesses', 'canCreate', 'readonly', 'direction'], template: '<div><slot name="toolbar-actions" /></div>' } }))
const business = { businessType: 'LASTLEG_ORDER', businessId: '9223372036854775001', businessNo: 'TB-01', customerId: 'C1', customerName: '客户', orderId: '9223372036854775001', warehouseId: null }
function page(props = {}) {
return mount(BusinessDocumentFees, { props: { ...business, ...props }, global: { stubs: {
LoadErrorState: { props: ['message'], template: '<div>{{ message }}</div>' }, ApiErrorMeta: true, ElSkeleton: true, ElAlert: true,
} } })
}
beforeEach(() => { vi.resetAllMocks(); can.mockReturnValue(true); vi.mocked(listBusinessFeeOptions).mockResolvedValue([business]) })
describe('单据费用归属', () => {
it('passes the payable direction to the review panel', async () => {
const wrapper = page({ direction: 'PAYABLE', canCreate: false }); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props()).toMatchObject({ direction: 'PAYABLE', canCreate: false })
})
it('requires exact customer, type, number and long identity', async () => {
vi.mocked(listBusinessFeeOptions).mockResolvedValue([{ ...business, businessNo: 'TB-010' }, { ...business, customerId: 'C2' }, business])
const wrapper = page(); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business')).toEqual(business)
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('canCreate')).toBe(true)
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('readonly')).toBe(false)
expect(listBusinessFeeOptions).toHaveBeenCalledWith({ businessType: 'LASTLEG_ORDER', customerId: 'C1', keyword: 'TB-01' })
})
it('does not permit a missing or ambiguous match to create charges', async () => {
vi.mocked(listBusinessFeeOptions).mockResolvedValue([{ ...business, businessId: 'another' }])
const wrapper = page(); await flushPromises()
expect(wrapper.findComponent({ name: 'BusinessOrderFees' }).exists()).toBe(false)
expect(wrapper.text()).toContain('未找到唯一关联单据')
})
it('resolves source order charges alongside tail charges', async () => {
const source = { ...business, businessType: 'RETURN_ORDER', businessId: 'return-id', businessNo: 'TH-01', orderId: null }
vi.mocked(listBusinessFeeOptions).mockResolvedValueOnce([business]).mockResolvedValueOnce([source])
const wrapper = page({ relatedDocuments: [source] }); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('relatedBusinesses')).toEqual([source])
})
it('can resolve a unique relabel id without inventing a customer', async () => {
const wrapper = page({ customerId: undefined }); await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business').customerId).toBe('C1')
})
it('keeps the real tail order usable when its source number is only an external reference', async () => {
vi.mocked(listBusinessFeeOptions).mockResolvedValueOnce([business]).mockResolvedValueOnce([])
const wrapper = page({ relatedDocuments: [{ businessType: 'RELABEL_ORDER', businessNo: 'external-reference', customerId: 'C1' }] })
await flushPromises()
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('business')).toEqual(business)
expect(wrapper.getComponent({ name: 'BusinessOrderFees' }).props('relatedBusinesses')).toEqual([])
})
it('does not query fees without read permission', async () => {
can.mockReturnValue(false)
const wrapper = page(); await flushPromises()
expect(listBusinessFeeOptions).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'BusinessOrderFees' }).exists()).toBe(false)
})
})
@@ -1,63 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import BusinessOrderFees from './BusinessOrderFees.vue'
import { listBusinessFeeOptions, listBusinessOverseasFees, type BusinessFeeOption, type OverseasFeeDirection } from '../api/overseasFees'
import { feePermissions } from '../overseasFeePresentation'
interface DocumentIdentity { businessType: string; businessNo: string; customerId?: string; businessId?: string }
class DocumentIdentityError extends Error {}
const props = withDefaults(defineProps<DocumentIdentity & { relatedDocuments?: DocumentIdentity[]; canCreate?: boolean; readonly?: boolean; direction?: OverseasFeeDirection; hideWhenEmpty?: boolean; heading?: string }>(), { canCreate: true, readonly: false, direction: 'RECEIVABLE', hideWhenEmpty: false })
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const canRead = computed(() => access.can(feePermissions.list) || access.can('platform:finance:customer-arap:list'))
const business = ref<BusinessFeeOption | null>(null), related = ref<BusinessFeeOption[]>([])
const loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const panel = ref<InstanceType<typeof BusinessOrderFees>>()
let generation = 0
async function load() {
const request = ++generation
business.value = null; related.value = []; error.value = null; loading.value = false
if (!canRead.value) return
loading.value = true
try {
const identities: DocumentIdentity[] = [props, ...(props.relatedDocuments || [])]
const resolved = await Promise.all(identities.map(async (identity, index) => {
if ((!identity.customerId && !identity.businessId) || !identity.businessNo) throw new DocumentIdentityError('单据信息不完整,无法读取费用')
const options = await listBusinessFeeOptions({ businessType: identity.businessType, customerId: identity.customerId, keyword: identity.businessNo })
// 候选搜索是模糊查询,只有当前客户和完整单号一致时才能承载收费。
const matches = options.filter(option => (!identity.customerId || option.customerId === identity.customerId) && option.businessType === identity.businessType
&& option.businessNo === identity.businessNo && (!identity.businessId || option.businessId === identity.businessId))
// 尾程来源号可能只是外部参考号;没有内部业务实体时不能据此造出费用归属。
if (index > 0 && matches.length === 0) return null
if (matches.length !== 1) throw new DocumentIdentityError(`未找到唯一关联单据:${identity.businessNo}`)
return matches[0]!
}))
if (request === generation) { business.value = resolved[0]!; related.value = resolved.slice(1).filter((value): value is BusinessFeeOption => value !== null) }
} catch (failure) { if (request === generation) error.value = failure instanceof DocumentIdentityError ? { message: failure.message } : getApiErrorDetails(failure, '单据费用加载失败') }
finally { if (request === generation) loading.value = false }
}
watch(() => [props.businessType, props.businessNo, props.customerId, props.businessId, props.relatedDocuments, canRead.value], load, { immediate: true, deep: true })
async function refresh() { if (business.value) await panel.value?.refresh(); else await load() }
/** 新增前读取完整单据范围,重复项目提示不受当前表格分页影响。 */
async function getFees() {
if (!business.value) await load()
if (!business.value || error.value) throw new Error(error.value?.message || '单据费用尚未加载完成')
return (await listBusinessOverseasFees([business.value, ...related.value], { page: 1, size: Number.MAX_SAFE_INTEGER }, props.direction)).records
}
defineExpose({ refresh, getFees })
</script>
<template>
<section aria-label="单据费用">
<el-alert v-if="!canRead" title="暂无费用查看权限" type="info" :closable="false" />
<LoadErrorState v-else-if="error" title="单据费用加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-skeleton v-else-if="loading" :rows="3" animated />
<BusinessOrderFees v-else-if="business" ref="panel" :business="business" :related-businesses="related" :direction="direction" :can-create="canCreate" :readonly="readonly" :hide-when-empty="hideWhenEmpty" :heading="heading" @changed="emit('changed')">
<template v-if="$slots['toolbar-actions']" #toolbar-actions><slot name="toolbar-actions" /></template>
</BusinessOrderFees>
</section>
</template>
@@ -1,328 +0,0 @@
import ElementPlus, { ElDialog } from 'element-plus'
import { createPinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import OverseasFeeEditor from './OverseasFeeEditor.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import * as api from '../api/overseasFees'
import type { OverseasFee } from '../api/overseasFees'
import BusinessOrderFees from './BusinessOrderFees.vue'
import { adjustStorageRentFee } from '../api/storageRent'
vi.mock('../api/storageRent', () => ({ adjustStorageRentFee: vi.fn() }))
const can = vi.hoisted(() => vi.fn((_permission: string) => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/overseasFees', () => ({
actOnOverseasFee: vi.fn(), getOverseasFee: vi.fn(), listOverseasFeeCustomers: vi.fn(), listOverseasFeeItems: vi.fn(),
listOverseasFeeRetries: vi.fn(),
listOverseasFees: vi.fn(), listBusinessOverseasFees: vi.fn(), listBusinessFeeOptions: vi.fn(), createOverseasFee: vi.fn(),
}))
const fee: OverseasFee = {
direction: 'RECEIVABLE',
id: '9223372036854775001', sourceFeeId: '9223372036854775001', sourceVersion: 3,
customerId: '9223372036854775002', customerName: '测试客户', sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_TRANSFER',
businessId: '9223372036854775003', businessNo: 'WT202610220001', orderId: null, warehouseId: '9223372036854775004',
feeItemCode: 'HANDLING', feeItemName: '操作费', businessDate: '2026-10-22', currency: 'CNY', amount: 70,
originalCurrency: 'USD', originalAmount: 10, chargeableQuantity: null, quantityUnit: null, unitPrice: null,
periodStart: null, periodEnd: null, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', publicationStatus: 'UNPUBLISHED',
collectionMode: 'WALLET', walletStatus: 'FROZEN', walletErrorMessage: null, reviewReason: null, reviewerId: null, reviewedAt: null,
createdAt: '2026-10-22T10:00:00', updatedAt: '', allowedActions: ['APPROVE', 'REJECT', 'CANCEL'],
}
const business = { businessId: fee.businessId!, businessType: fee.businessType, businessNo: fee.businessNo, customerId: fee.customerId, customerName: fee.customerName, warehouseId: fee.warehouseId, orderId: fee.orderId }
function deferred<T>() {
let resolve!: (value: T) => void, reject!: (reason: unknown) => void
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
async function mountPage() {
const wrapper = mount(BusinessOrderFees, { props: { business }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises()
return wrapper
}
async function click(wrapper: VueWrapper, label: string, selector = 'button') {
const button = wrapper.findAll(selector).find(node => node.text().trim() === label)
expect(button, label).toBeDefined()
await button!.trigger('click'); await flushPromises()
}
function activeDialog(wrapper: VueWrapper) {
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('modelValue'))
expect(dialog).toBeDefined()
return dialog!.getComponent({ name: 'ElDialogContent' })
}
enableAutoUnmount(afterEach)
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.mocked(api.listOverseasFeeRetries).mockResolvedValue([])
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(api.listOverseasFeeCustomers).mockResolvedValue([{ customerId: fee.customerId, customerName: fee.customerName }])
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'HANDLING', feeTypeName: '操作费', defaultPrice: 20 }])
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [fee], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(fee)
vi.mocked(api.actOnOverseasFee).mockResolvedValue(fee)
vi.mocked(api.createOverseasFee).mockResolvedValue(fee)
vi.mocked(api.listBusinessFeeOptions).mockResolvedValue([{ businessId: fee.businessId!, businessType: fee.businessType, businessNo: fee.businessNo,
customerId: fee.customerId, customerName: fee.customerName, warehouseId: fee.warehouseId, orderId: null }])
})
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
describe('单据费用操作', () => {
it.each(['RECEIVABLE', 'PAYABLE'] as const)('hides an empty %s block and reveals it when fees are added', async direction => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
const wrapper = mount(BusinessOrderFees, { props: { business, direction, hideWhenEmpty: true, canCreate: false, heading: direction === 'RECEIVABLE' ? '应收明细' : '应付明细' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises()
expect(wrapper.isVisible()).toBe(false)
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, direction }], total: 1, page: 1, size: 20, pages: 1 })
await wrapper.vm.refresh(); await flushPromises()
expect(wrapper.isVisible()).toBe(true)
expect(wrapper.get('h3').text()).toBe(direction === 'RECEIVABLE' ? '应收明细' : '应付明细')
expect(wrapper.text()).toContain('操作费')
expect(wrapper.findAll('button').some(button => button.text().trim() === '新增费用')).toBe(false)
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
await wrapper.vm.refresh(); await flushPromises()
expect(wrapper.isVisible()).toBe(false)
})
it('queries the exact document and creates without a second order selector or catalog default amount', async () => {
const wrapper = await mountPage()
expect(api.listBusinessOverseasFees).toHaveBeenCalledWith([business], { page: 1, size: 20 }, 'RECEIVABLE')
await click(wrapper, '新增费用')
expect(api.listBusinessFeeOptions).not.toHaveBeenCalled()
const dialog = activeDialog(wrapper)
expect(dialog.text()).not.toContain('业务类型')
expect(dialog.text()).not.toContain('业务订单')
expect(dialog.findComponent({ name: 'ElDatePicker' }).exists()).toBe(false)
const selects = dialog.findAllComponents({ name: 'ElSelect' })
selects[1]!.vm.$emit('update:modelValue', 'CNY')
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.34)
selects[0]!.vm.$emit('update:modelValue', 'HANDLING')
selects[0]!.vm.$emit('change', 'HANDLING')
await flushPromises()
expect(dialog.getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(12.34)
expect(dialog.text()).toContain('USD 10.00 · 待审核')
await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled()
dialog.findAllComponents({ name: 'ElInput' }).find(input => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '补收本次额外操作费用')
await flushPromises()
await click(dialog, '保存')
expect(api.createOverseasFee).toHaveBeenCalledWith(expect.objectContaining({ ...business, feeRemark: '补收本次额外操作费用', amount: 12.34, currency: 'CNY', businessDate: new Date().toLocaleDateString('sv-SE') }), expect.any(String))
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('checks refreshed eligibility and rejects a fee belonging to another order', async () => {
const wrapper = await mountPage()
vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, allowedActions: [] })
await click(wrapper, '审核', '.el-table__body-wrapper button')
expect(wrapper.findAllComponents(ElDialog).some(dialog => dialog.props('modelValue'))).toBe(false)
vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, businessId: 'different-order' })
await click(wrapper, '审核', '.el-table__body-wrapper button')
expect(wrapper.findAllComponents(ElDialog).some(dialog => dialog.props('modelValue'))).toBe(false)
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
it('keeps supplier payables separate and reviews them for settlement without customer billing hints', async () => {
const payable: OverseasFee = { ...fee, businessType: 'LASTLEG_ORDER', direction: 'PAYABLE', supplierId: 'supplier-1', supplierName: '测试供应商', settlementMode: 'ON_ACCOUNT', collectionMode: 'BILL', walletStatus: 'NOT_REQUIRED' }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [payable], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(payable)
const wrapper = mount(BusinessOrderFees, { props: { business: { ...business, businessType: 'LASTLEG_ORDER' }, direction: 'PAYABLE' }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await flushPromises()
expect(api.listBusinessOverseasFees).toHaveBeenLastCalledWith([expect.objectContaining({ businessType: 'LASTLEG_ORDER' })], { page: 1, size: 20 }, 'PAYABLE')
expect(wrapper.text()).toContain('测试供应商'); expect(wrapper.text()).toContain('推送结算')
expect(wrapper.findAll('button').map(button => button.text())).not.toContain('新增费用')
await click(wrapper, '审核', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).not.toContain('请核对原报价')
await click(dialog, '审核通过')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'APPROVE', expect.objectContaining({ decision: 'APPROVE' }))
})
it('cannot review the opposite direction returned by a refreshed fee', async () => {
const wrapper = await mountPage()
vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, direction: 'PAYABLE' })
await click(wrapper, '审核', '.el-table__body-wrapper button')
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('modelValue'))).toBe(false)
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
})
it('keeps read-only documents read-only and closes an editor when the order changes', async () => {
const wrapper = await mountPage()
await click(wrapper, '新增费用')
await wrapper.setProps({ business: { ...business, businessId: 'other' } })
await flushPromises()
expect(wrapper.getComponent(OverseasFeeEditor).props('modelValue')).toBe(false)
await wrapper.setProps({ readonly: true })
const visibleButtons = wrapper.findAll('button').filter(button => button.isVisible()).map(button => button.text())
expect(visibleButtons).not.toContain('新增费用')
expect(visibleButtons).not.toContain('审核')
expect(visibleButtons).not.toContain('驳回')
expect(visibleButtons).not.toContain('取消费用')
})
it('does not create against an order or customer changed after the editor was opened', async () => {
const wrapper = mount(OverseasFeeEditor, { props: { modelValue: false, fee: null, business, items: [{ feeCode: 'HANDLING', feeTypeName: '操作费', defaultPrice: 20 }] }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
await wrapper.setProps({ modelValue: true }); await flushPromises()
const dialog = activeDialog(wrapper)
dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'HANDLING')
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10)
await wrapper.setProps({ business: { ...business, customerId: 'different-customer' } })
await flushPromises(); await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled()
})
it('blocks automatic storage rent from manual addition', async () => {
vi.mocked(api.listOverseasFeeItems).mockResolvedValue([{ feeCode: 'FEE_TYPE_077', feeTypeName: '仓储费', defaultPrice: null }])
const wrapper = await mountPage()
await click(wrapper, '新增费用')
const dialog = activeDialog(wrapper)
const select = dialog.findAllComponents({ name: 'ElSelect' })[0]!
expect(select.findAllComponents({ name: 'ElOption' })).toHaveLength(0)
select.vm.$emit('update:modelValue', 'FEE_TYPE_077')
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 10)
await flushPromises(); await click(dialog, '保存')
expect(api.createOverseasFee).not.toHaveBeenCalled()
})
it('includes verified related orders but rejects another customer and keeps adjustment on the original order', async () => {
const related = { ...business, businessId: 'return-1', businessType: 'RETURN_ORDER' }
const relatedFee = { ...fee, ...related, allowedActions: ['ADJUST'] }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [relatedFee], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(relatedFee)
const wrapper = await mountPage()
await wrapper.setProps({ relatedBusinesses: [related, { ...business, customerId: 'other-customer' }] })
await flushPromises()
expect(api.listBusinessOverseasFees).toHaveBeenLastCalledWith([business, related], { page: 1, size: 20 }, 'RECEIVABLE')
await click(wrapper, '调整', '.el-table__body-wrapper button')
expect(wrapper.getComponent(OverseasFeeEditor).props('business')).toEqual(related)
const dialog = activeDialog(wrapper)
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 15)
await flushPromises(); await click(dialog, '保存')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'ADJUST', expect.objectContaining({
expectedVersion: fee.sourceVersion, fee: expect.objectContaining({ businessId: related.businessId, businessType: 'RETURN_ORDER', amount: 15 }),
}))
})
it('shows a voided freeze-failure fixture as closed instead of pending review', async () => {
const closed = { ...fee, sourceStatus: 'VOIDED', reviewStatus: 'PENDING', walletStatus: 'FREEZE_FAILED', walletErrorMessage: '可用余额和授信额度不足', allowedActions: [] }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [closed], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(closed)
const wrapper = await mountPage()
expect(wrapper.text()).toContain('已作废')
expect(wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())).toEqual(['详情'])
await click(wrapper, '详情', '.el-table__body-wrapper button')
expect(activeDialog(wrapper).text()).toContain('冻结失败')
expect(activeDialog(wrapper).text()).toContain('此费用已作废,无需继续审核')
expect(activeDialog(wrapper).text()).not.toContain('待审核')
})
it('retries a failed debit only when the refreshed server action is allowed', async () => {
const failed = { ...fee, walletStatus: 'SETTLE_FAILED', allowedActions: ['RETRY_SETTLE'] }
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [failed], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(failed)
const wrapper = await mountPage()
await click(wrapper, '重试扣费', '.el-table__body-wrapper button')
await click(activeDialog(wrapper), '重试扣费')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'RETRY_SETTLE', { idempotencyKey: expect.any(String) })
expect(wrapper.emitted('changed')).toHaveLength(1)
})
it('requires a rejection reason and submits the review contract', async () => {
const wrapper = await mountPage()
vi.mocked(api.getOverseasFee).mockResolvedValueOnce({ ...fee, sourceVersion: 5 })
await click(wrapper, '审核', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
const reject = dialog.findAll('button').find(button => button.text().trim() === '驳回')
expect(reject?.attributes('disabled')).toBeDefined()
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
await dialog.get('textarea').setValue(' 金额不符 ')
await click(dialog, '驳回')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'REJECT', { decision: 'REJECT', reason: '金额不符', expectedVersion: 5, idempotencyKey: expect.any(String) })
})
it('keeps rejected fees read-only while allowing failed frozen funds to be released', async () => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, reviewStatus: 'REJECTED', allowedActions: ['SUBMIT', 'RETRY_RELEASE', 'CANCEL'] }], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, reviewStatus: 'REJECTED', allowedActions: ['SUBMIT', 'RETRY_RELEASE', 'CANCEL'] })
const wrapper = await mountPage()
const visibleButtons = wrapper.findAll('.el-table__body-wrapper button').map(button => button.text().trim())
expect(visibleButtons).toContain('详情')
expect(visibleButtons).not.toContain('审核')
expect(visibleButtons).not.toContain('驳回')
expect(visibleButtons).not.toContain('提交审核')
expect(visibleButtons).toContain('重试释放')
await click(wrapper, '重试释放', '.el-table__body-wrapper button')
await click(activeDialog(wrapper), '重试释放')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'RETRY_RELEASE', expect.objectContaining({ idempotencyKey: expect.any(String) }))
await click(wrapper, '详情', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain('审核状态')
expect(dialog.findAll('button').some(button => button.text().trim() === '审核')).toBe(false)
})
it('voids with the freshly loaded version and a required reason', async () => {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [{ ...fee, allowedActions: ['VOID'] }], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue({ ...fee, sourceVersion: 5, allowedActions: ['VOID'] })
const wrapper = await mountPage()
await click(wrapper, '作废', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
await click(dialog, '作废')
expect(api.actOnOverseasFee).not.toHaveBeenCalled()
await dialog.get('textarea').setValue(' 重复费用 ')
await click(dialog, '作废')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(fee.sourceFeeId, 'VOID', { expectedVersion: 5, reason: '重复费用', idempotencyKey: expect.any(String) })
})
it('locks in-flight approvals and preserves diagnostics and idempotency when retried', async () => {
const pending = deferred<OverseasFee>()
vi.mocked(api.actOnOverseasFee).mockReturnValueOnce(pending.promise)
const wrapper = await mountPage()
await click(wrapper, '审核', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
await click(dialog, '审核通过')
expect(wrapper.findAllComponents(ElDialog).find(component => component.props('modelValue'))!.props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(dialog.get('textarea').attributes('disabled')).toBeDefined()
pending.reject(new ApiResponseError('余额服务暂不可用', 'FUNDING_ERROR', 409, null, 'test-request-1'))
await flushPromises()
expect(wrapper.findAllComponents(AppDialog).some(component => component.props('modelValue'))).toBe(true)
expect(dialog.text()).toContain('余额服务暂不可用')
expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('')
const firstKey = vi.mocked(api.actOnOverseasFee).mock.calls[0]![2].idempotencyKey
await click(dialog, '审核通过')
expect(vi.mocked(api.actOnOverseasFee).mock.calls[1]![2].idempotencyKey).toBe(firstKey)
})
})
describe('仓租费用审核与调价', () => {
const rent: OverseasFee = { ...fee, feeItemCode: 'FEE_TYPE_077', feeItemName: '仓储费', allowedActions: ['APPROVE', 'REJECT', 'ADJUST_RENT'],
storageRent: { outboundNo: 'OUT-1', outboundAt: '2026-10-22T10:00:00Z', calculatedAmount: 10, chargedAmount: null,
lines: [
{ lotId: '1', inboundAt: '2026-10-01T10:00:00Z', quantity: 2, days: 21, calculatedAmount: 5, sourceBusinessType: 'LASTLEG_ORDER', sourceBusinessNo: 'WC-SOURCE-1', batchNo: 'B-SOURCE-1' },
{ lotId: '2', inboundAt: '2026-10-01T10:00:00Z', quantity: 2, days: 21, calculatedAmount: 5, sourceBusinessType: 'RETURN_ORDER', sourceBusinessNo: 'TH-SOURCE-2', batchNo: 'B-SOURCE-2' },
] } }
async function setupRent() {
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [rent], total: 1, page: 1, size: 20, pages: 1 })
vi.mocked(api.getOverseasFee).mockResolvedValue(rent)
return mountPage()
}
it('审核显示实际出入库与原计算额,批准以前不请求扣款', async () => {
const wrapper = await setupRent()
await click(wrapper, '审核', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.text()).toContain('入库时间'); expect(dialog.text()).toContain('出库时间')
expect(dialog.text()).toContain('原计算金额'); expect(dialog.text()).toContain('实扣金额')
expect(dialog.text()).toContain('来源单号'); expect(dialog.text()).toContain('头程尾程订单')
expect(dialog.text()).toContain('WC-SOURCE-1'); expect(dialog.text()).toContain('TH-SOURCE-2')
expect(dialog.text()).toContain('退货单'); expect(dialog.text()).toContain('B-SOURCE-2')
expect(dialog.text()).toContain('2026-10-01'); expect(api.actOnOverseasFee).not.toHaveBeenCalled()
await click(dialog, '审核通过')
expect(api.actOnOverseasFee).toHaveBeenCalledWith(rent.sourceFeeId, 'APPROVE', expect.objectContaining({ decision: 'APPROVE' }))
})
it('调价说明必填,失败保留金额说明且同参数重试复用请求编号', async () => {
const wrapper = await setupRent()
vi.mocked(adjustStorageRentFee).mockRejectedValueOnce(new Error('暂时不可用')).mockResolvedValue(rent)
await click(wrapper, '调整仓租', '.el-table__body-wrapper button')
const dialog = activeDialog(wrapper)
expect(dialog.getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(10)
expect(dialog.findAll('button').find(button => button.text() === '保存调整')!.attributes('disabled')).toBeDefined()
dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12)
dialog.findAllComponents({ name: 'ElInput' }).find(input => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', '人工核对调整')
await flushPromises(); await click(dialog, '保存调整')
expect(dialog.text()).toContain('仓租操作失败')
await click(dialog, '保存调整')
const calls = vi.mocked(adjustStorageRentFee).mock.calls
expect(calls).toHaveLength(2); expect(calls[0]).toEqual(calls[1])
expect(calls[0]).toEqual([rent.sourceFeeId, expect.objectContaining({ amount: 12, reason: '人工核对调整', expectedVersion: 3 })])
})
it('只读单据在仓租弹框已打开时同样禁止修改', async () => {
const wrapper = await setupRent(); await click(wrapper, '调整仓租', '.el-table__body-wrapper button')
await wrapper.setProps({ readonly: true }); await flushPromises()
expect(activeDialog(wrapper).findAll('button').some(button => button.text() === '保存调整')).toBe(false)
expect(adjustStorageRentFee).not.toHaveBeenCalled()
})
})

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