Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8100103946 |
@@ -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 验收后仍由人工完成页面和截图确认。
|
||||
|
||||
@@ -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/` |
|
||||
|
||||
@@ -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(['— 到港', '— 计划到仓'])
|
||||
|
||||
@@ -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,9 +186,9 @@ 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 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="客户" 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>
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
|
||||
<tr><td :colspan="form.direction === 'PAYABLE' ? 1 : 2"><el-form-item label-position="left" label-width="110px" label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item></td><td v-if="form.direction === 'PAYABLE'"><el-form-item label-position="left" label-width="110px" label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></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,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'
|
||||
|
||||
@@ -93,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'))
|
||||
@@ -320,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(' / ')
|
||||
@@ -697,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="isOverseas" :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">
|
||||
@@ -734,7 +739,7 @@ onMounted(async () => {
|
||||
</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)" />
|
||||
@@ -752,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))" 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 :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="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="!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"><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="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>
|
||||
@@ -779,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,11 @@ 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) {
|
||||
const row = source as ContainerWorkbenchRow
|
||||
function defaultArrivalDate(row: 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 +763,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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('操作成功')
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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 ? '已预览' : '可预览 / 打印'),
|
||||
]
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ProcurementNodeActions from './ProcurementNodeActions.vue'
|
||||
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
|
||||
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
|
||||
import { readPendingProcurement } from '../procurementPending'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() }))
|
||||
vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() }))
|
||||
@@ -73,7 +74,7 @@ describe('procurement purchase node operations', () => {
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps ordinary correction drafts scoped to operator/customer/order while ignoring old requests', async () => {
|
||||
it('saves and restores a separate correction while the immutable original request is pending, scoped to operator/customer/order', async () => {
|
||||
const value = fixture('FINANCE_ADJUST', false)
|
||||
value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'PENDING', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
|
||||
const original = JSON.stringify({ command: 'fees/correct-target', payload: { version: 3, idempotencyKey: 'original-request', feeId: '11', targetOriginalAmount: '11.00', reason: '原请求', voucherFileId: '501' } })
|
||||
@@ -89,11 +90,12 @@ describe('procurement purchase node operations', () => {
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.getItem(storageKey)).toBe(original)
|
||||
expect(JSON.parse(sessionStorage.getItem(`${storageKey}:correction-draft`)!)).toMatchObject({ targetOriginalAmount: '12.00', feeId: '11', reason: '准备下一版本' })
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '核对并继续确认')!.attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.findAll('button').find(button => button.text() === '核对并继续确认')!.attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount(); wrapper = render(value)
|
||||
await click('恢复费用更正草稿')
|
||||
expect((wrapper.findComponent(AppDialog).find('input[placeholder="填写原费用链正确总额,最多两位小数"]').element as HTMLInputElement).value).toBe('12.00')
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
await click('确认上次提交结果')
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/correct-target', JSON.parse(original).payload)
|
||||
expect(sessionStorage.getItem(`${storageKey}:correction-draft`)).not.toBeNull()
|
||||
const latest = structuredClone(value)
|
||||
latest.actions[0].allowed = true; latest.customerView.order.version = 9
|
||||
@@ -102,7 +104,7 @@ describe('procurement purchase node operations', () => {
|
||||
await click('恢复费用更正草稿'); await click('核对并继续确认')
|
||||
wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '本单凭证')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501')
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findComponent(AppDialog).text()).toContain('原实际费用及来源关联尚未完成')
|
||||
latest.fees[0].walletStatus = 'SETTLED'; latest.fees[0].walletLedgerId = 'LEDGER-1'
|
||||
latest.customerView.fees = [{ feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', name: '样品货款', version: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeNo: null, walletStatus: 'SETTLED', sourceLinked: true }]
|
||||
@@ -249,20 +251,22 @@ describe('procurement purchase node operations', () => {
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'purchase', expect.objectContaining({ productName: '样品商品', specification: '黑色', quantity: 2, amount: '0.00', currency: 'USD', voucherFileId: '101', purchasedAt: '2026-10-08T10:00:00' }))
|
||||
})
|
||||
it('unknown request leaves inputs editable and a later click submits current input', async () => {
|
||||
it('recovers an uncertain request after reload without changing its body or key', async () => {
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout'))
|
||||
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper, '客户取消'); await confirm(wrapper)
|
||||
const original = vi.mocked(submitProcurementCommand).mock.calls[0]![3]
|
||||
expect(sessionStorage.getItem(storageKey)).toBeNull(); await explain(wrapper, '修改后的原因'); await confirm(wrapper)
|
||||
const next = vi.mocked(submitProcurementCommand).mock.calls[1]![3]
|
||||
expect(next.idempotencyKey).not.toBe(original.idempotencyKey); expect(next.reason).toBe('修改后的原因')
|
||||
let wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper, '客户取消'); await confirm(wrapper)
|
||||
const original = vi.mocked(submitProcurementCommand).mock.calls[0][3]; expect(sessionStorage.getItem(storageKey)).not.toBeNull(); wrapper.unmount()
|
||||
wrapper = render(fixture('CANCEL')); await flushPromises()
|
||||
const retry = wrapper.findAll('button').find(button => button.text() === '确认上次提交结果')!; await retry.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(original); expect(sessionStorage.getItem(storageKey)).toBeNull()
|
||||
})
|
||||
it('consecutive failures never retain a request or block another click', async () => {
|
||||
it('preserves an earlier uncertain financial or physical command when replay is rejected', async () => {
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValueOnce(new Error('timeout')).mockRejectedValueOnce(new ApiResponseError('资源正在处理中', 'A0409'))
|
||||
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper); await confirm(wrapper)
|
||||
const calls = vi.mocked(submitProcurementCommand).mock.calls
|
||||
expect(calls).toHaveLength(2); expect(calls[1]![3].idempotencyKey).not.toBe(calls[0]![3].idempotencyKey)
|
||||
expect(sessionStorage.getItem(storageKey)).toBeNull(); expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
const wrapper = render(fixture('CANCEL')); await open(wrapper); await explain(wrapper); await confirm(wrapper)
|
||||
const saved = sessionStorage.getItem(storageKey); expect(saved).not.toBeNull()
|
||||
await confirm(wrapper)
|
||||
expect(sessionStorage.getItem(storageKey)).toBe(saved)
|
||||
expect(vi.mocked(submitProcurementCommand).mock.calls[1][3]).toEqual(vi.mocked(submitProcurementCommand).mock.calls[0][3])
|
||||
expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
})
|
||||
it('keeps a version conflict visible, releases the failed command and does not report success', async () => {
|
||||
vi.mocked(submitProcurementCommand).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新', 'A0409'))
|
||||
@@ -296,9 +300,9 @@ describe('procurement purchase node operations', () => {
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1); complete(); await flushPromises()
|
||||
})
|
||||
it('old malformed request storage does not disable current actions', async () => {
|
||||
it('rejects malformed recovery records instead of allowing a fresh competing operation', async () => {
|
||||
sessionStorage.setItem(storageKey, '{bad')
|
||||
const wrapper = render(); expect(wrapper.text()).not.toContain('上次操作记录无法读取')
|
||||
expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeUndefined()
|
||||
const wrapper = render(); expect(wrapper.text()).toContain('上次操作记录无法读取'); expect(wrapper.find('.purchase-actions button').attributes('disabled')).toBeDefined()
|
||||
expect(() => readPendingProcurement(JSON.stringify({ command: 'purchase', payload: { version: 3, idempotencyKey: 'a', amount: '-2' } }))).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
|
||||
import { submitProcurementCommand, uploadProcurementFile, type ProcurementCommand, type ProcurementCommandBody } from '../api/procurementCommands'
|
||||
import { commandKey, type PendingProcurement } from '../procurementPending'
|
||||
import { commandKey, definitelyRejected, readPendingProcurement, type PendingProcurement } from '../procurementPending'
|
||||
import { readCorrectionDraft, type ProcurementCorrectionDraft } from '../procurementCorrectionDraft'
|
||||
import type { PlatformProcurement, ProcurementAction, ProcurementFile } from '../procurementTypes'
|
||||
import type { OverseasPurchaseWarehouseOption } from '../types'
|
||||
@@ -75,6 +75,8 @@ const { captureBaseline } = useUnsavedWorkRegistration({ key: 'procurement-purch
|
||||
watch(key, () => {
|
||||
opened.value = false; pending.value = null; storageError.value = ''; extraFiles.value = []; active.value = null; editingDraft.value = false; correctionDraft.value = null; draftError.value = ''
|
||||
if (!key.value) return
|
||||
try { pending.value = readPendingProcurement(sessionStorage.getItem(key.value)) }
|
||||
catch { storageError.value = '上次操作记录无法读取,请先联系管理员核对该订单的处理结果。' }
|
||||
try { correctionDraft.value = readCorrectionDraft(sessionStorage.getItem(draftKey.value)) }
|
||||
catch { draftError.value = '本浏览器的费用更正草稿无法读取,请核对后重新填写。' }
|
||||
}, { immediate: true })
|
||||
@@ -121,6 +123,11 @@ function useCorrectionDraft() {
|
||||
const fee = props.detail.fees.find(row => row.id === finance.value.feeId)
|
||||
active.value = action; financialCommand.value = fee && !fee.active && ['TERMINATED', 'RELEASED'].includes(fee.walletStatus) ? 'fees/replace-failed' : 'fees/correct-target'; editingDraft.value = false
|
||||
}
|
||||
async function retryPending() {
|
||||
if (editingDraft.value) { saveCorrectionDraft(); if (draftError.value) return }
|
||||
editingDraft.value = false; opened.value = false
|
||||
await submit()
|
||||
}
|
||||
watch(financialCommand, () => { if (editingDraft.value) return; finance.value.feeId = ''; finance.value.targetOriginalAmount = ''; finance.value.refundAmount = ''; form.voucherFileId = ''; error.value = null }, { flush: 'sync' })
|
||||
watch(needsWarehouse, value => { if (value && opened.value) void loadWarehouses() })
|
||||
function build(): ProcurementCommandBody {
|
||||
@@ -150,29 +157,33 @@ function build(): ProcurementCommandBody {
|
||||
async function submit() {
|
||||
if (editingDraft.value) { saveCorrectionDraft(); return }
|
||||
if (saving.value || uploading.value || storageError.value || !key.value) return
|
||||
const resuming = !!pending.value
|
||||
const scope = key.value; const orderNo = props.detail.customerView.order.orderNo; const customer = props.customerId
|
||||
if (!pending.value) {
|
||||
if (!active.value?.allowed || !access.can(active.value.permission) || !command.value) return
|
||||
try {
|
||||
if (managementBlock.value) throw new Error(managementBlock.value)
|
||||
const request = { command: command.value, payload: build() }
|
||||
pending.value = request
|
||||
const serialized = JSON.stringify(request); readPendingProcurement(serialized)
|
||||
sessionStorage.setItem(scope, serialized); pending.value = request
|
||||
} catch (issue) { error.value = { message: issue instanceof Error ? issue.message : '提交信息保存失败,请重试' }; return }
|
||||
}
|
||||
const retryFee = pending.value.command === 'fees/retry' ? props.detail.fees.find(fee => fee.id === pending.value?.payload.feeId) : null
|
||||
const requiredPermission = pending.value.command === 'fees/retry' ? (retryFee ? feeRetryPermission(retryFee) : '') : `platform:overseas-business:purchase:${commandPermissions[pending.value.command]}`
|
||||
if (!requiredPermission || !access.can(requiredPermission)) { pending.value = null; error.value = { message: '当前账号无此节点操作权限' }; return }
|
||||
if (!requiredPermission || !access.can(requiredPermission)) { error.value = { message: '当前账号无此节点操作权限' }; return }
|
||||
saving.value = true; error.value = null
|
||||
const confirmsCorrection = ['fees/correct-target', 'fees/replace-failed'].includes(pending.value.command)
|
||||
const confirmsCorrection = !resuming && ['fees/correct-target', 'fees/replace-failed'].includes(pending.value.command)
|
||||
try {
|
||||
await submitProcurementCommand(orderNo, customer, pending.value.command, pending.value.payload)
|
||||
sessionStorage.removeItem(scope)
|
||||
if (key.value !== scope) return
|
||||
if (confirmsCorrection) { sessionStorage.removeItem(draftKey.value); correctionDraft.value = null }
|
||||
pending.value = null; opened.value = false; captureBaseline(); ElMessage.success('操作已保存,请核对最新业务及资金结果'); emit('changed')
|
||||
} catch (issue) {
|
||||
if (key.value !== scope) return
|
||||
error.value = getApiErrorDetails(issue, '操作失败,请重新提交')
|
||||
} finally { pending.value = null; saving.value = false }
|
||||
if (!resuming && definitelyRejected(issue)) { sessionStorage.removeItem(scope); pending.value = null }
|
||||
error.value = getApiErrorDetails(issue, '结果尚未确认,请使用原请求重试')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
async function upload(event: Event) {
|
||||
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
|
||||
@@ -201,9 +212,10 @@ async function upload(event: Event) {
|
||||
<dl><template v-for="action in unavailableActions" :key="action.key"><dt>{{ action.label }}</dt><dd>{{ action.reason || '当前不可操作' }}</dd></template></dl>
|
||||
</details>
|
||||
<el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
|
||||
<el-alert v-if="pending" title="上次提交结果尚未确认,重试将使用原始内容与原请求编号。" type="warning" :closable="false"><p>原操作:{{ pending.command === 'review' ? (pending.payload.decision === 'APPROVE' ? '审核通过' : '审核拒绝') : actions.find(action => routes[action.key] === pending?.command)?.label || '采购节点操作' }} · {{ pending.payload.remark || pending.payload.reason }}</p><p v-if="pending.payload.amount !== undefined">原提交金额:{{ pending.payload.amount }} {{ pending.payload.currency || (['international-estimate', 'fees/refund'].includes(pending.command) ? 'CNY' : '') }}</p><el-button :loading="saving" @click="retryPending">确认上次提交结果</el-button></el-alert>
|
||||
<ApiErrorMeta v-if="error && !opened" :details="error" />
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : '确认提交'" @confirm="submit">
|
||||
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit">
|
||||
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。原请求未决时只能保存,结果明确后请刷新订单再核对确认。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
|
||||
<el-alert v-if="managementBlock" :title="managementBlock" type="warning" :closable="false" />
|
||||
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
|
||||
<el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option v-for="option in manageOptions" :key="option.value" :value="option.value" :disabled="!!option.reason" :label="option.reason ? `${option.label}(${option.reason})` : option.label" /></el-select></el-form-item>
|
||||
@@ -223,7 +235,7 @@ async function upload(event: Event) {
|
||||
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
|
||||
<el-form-item label="操作说明" required><el-input v-model="form.reason" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : '确认提交' }}</el-button></div></template>
|
||||
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交' }}</el-button></div></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -19,6 +19,7 @@ const commands = computed(() => access.can(overseasBusinessPermissions.truckStat
|
||||
const open = ref(false), saving = ref(false)
|
||||
const active = ref<TruckCommand | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null), validationError = ref('')
|
||||
// 响应丢失时保留原命令、版本和幂等键,重试不得变更请求内容。
|
||||
const pending = ref<TruckOperation | null>(null)
|
||||
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', actualPalletCount: 1, packageCount: undefined as number | undefined, palletIds: [] as string[], remark: '' })
|
||||
const selectablePallets = computed(() => props.detail.pallets.filter(pallet => pallet.status === 'RECEIVED'))
|
||||
@@ -70,7 +71,7 @@ function buildOperation(): TruckOperation | null {
|
||||
async function submit() {
|
||||
if (saving.value || !active.value || !commands.value.includes(active.value)) return
|
||||
validationError.value = ''; error.value = null
|
||||
const operation = buildOperation()
|
||||
const operation = pending.value ?? buildOperation()
|
||||
if (!operation) return
|
||||
pending.value = operation; saving.value = true
|
||||
const customerId = props.detail.customerId, orderNo = props.detail.orderNo
|
||||
@@ -82,20 +83,20 @@ async function submit() {
|
||||
emit('updated', updated)
|
||||
} catch (cause) {
|
||||
if (props.detail.customerId === customerId && props.detail.orderNo === orderNo) error.value = getApiErrorDetails(cause, '操作未确认成功,请重试或刷新订单核对最新记录')
|
||||
} finally { pending.value = null; saving.value = false }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="commands.length" class="truck-order-actions">
|
||||
<el-button v-for="command in commands" :key="command" :type="command === 'cancel' ? 'danger' : 'primary'" :plain="command === 'cancel'" :disabled="saving" @click="start(command)">{{ truckCommandLabels[command] }}</el-button>
|
||||
|
||||
<p v-if="pending && !open" role="status">上次操作尚未确认结果,请重新打开重试,或刷新订单核对最新记录。</p>
|
||||
</div>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="'确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="active ? truckCommandLabels[active] : '单据操作'" width="680px" :confirm-text="pending ? '重试原操作' : '确认提交'" :confirm-type="active === 'cancel' || (review && form.decision === 'REJECT') ? 'danger' : 'primary'" :confirm-loading="saving" :confirm-disabled="!active || !commands.includes(active)" @confirm="submit">
|
||||
<template v-if="active">
|
||||
<p>{{ detail.orderNo }} · {{ truckCommandHints[active] }}</p>
|
||||
<el-alert v-if="validationError" :title="validationError" type="warning" :closable="false" role="alert" />
|
||||
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可修改后重新提交;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
|
||||
<div v-if="error" role="alert"><el-alert :title="error.message" type="error" :closable="false" /><ApiErrorMeta :details="error" /><p>可重试原操作;如提示版本冲突,请关闭弹框并刷新订单,核对最新状态及操作记录。</p></div>
|
||||
<el-form :model="form" label-width="130px" :disabled="saving || !!pending" @submit.prevent="submit">
|
||||
<el-form-item v-if="review" label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
|
||||
<template v-if="active === 'receive-pallets'">
|
||||
|
||||
@@ -4,7 +4,7 @@ import '@/styles/adminDensity.css'
|
||||
import { computed, onActivated, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { procurementStatuses, statusLabel, statusTone, money } from '../procurementPresentation'
|
||||
import { procurementStatuses, statusLabel, money } from '../procurementPresentation'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -39,7 +39,7 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
|
||||
{ key: 'budgetUnitPrice', label: '预算单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => money(row.budgetUnitPrice, row.currency) },
|
||||
{ key: 'purchaseQuantity', prop: 'purchaseQuantity', label: '采购数量', width: 100, align: 'right' },
|
||||
{ key: 'status', label: '状态', minWidth: 145, slotName: 'status' },
|
||||
{ key: 'status', label: '状态', minWidth: 145, formatter: (row: OverseasPurchaseSummary) => statusLabel(row.status) },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: OverseasPurchaseSummary) => formatDateTime(row.createdAt) },
|
||||
]
|
||||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
@@ -80,7 +80,6 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
|
||||
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as OverseasPurchaseSummary)">详情</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(overseasBusinessPermissions.purchaseEdit)" size="small" @click="open(row as OverseasPurchaseSummary, true)">编辑</el-button></div></template>
|
||||
<template #status="{ row }"><el-tag :type="statusTone(row.status)" size="small" effect="light">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
<template #pagination-left>共 {{ total }} 条海外采购订单</template>
|
||||
</DataTable></div></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
|
||||
@@ -42,13 +42,6 @@ function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arra
|
||||
const filters = ref<QueryModel>({ orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
|
||||
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
|
||||
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
|
||||
const statusLabels = computed<Record<string, string>>(() => isLooseboard.value ? looseboardStatusLabels : truckStatusLabels)
|
||||
const statusTones: Record<LocalTransportStatus | LocalLooseboardStatus, 'warning' | 'primary' | 'success' | 'danger' | 'info'> = {
|
||||
PENDING_REVIEW: 'warning', PENDING_ACCEPTANCE: 'warning', WAITING_INBOUND: 'warning',
|
||||
RECEIVING: 'primary', WAITING_CONSOLIDATION: 'warning', PALLET_OVERAGE_REVIEW: 'warning',
|
||||
PARTIALLY_ALLOCATED: 'primary', ALLOCATED: 'primary', PENDING_DISPATCH: 'warning',
|
||||
DISPATCHED: 'primary', IN_TRANSIT: 'primary', COMPLETED: 'success', REJECTED: 'danger', CANCELLED: 'info',
|
||||
}
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderNo', type: 'input', label: '商业单号', placeholder: '请输入商业单号', width: 230 },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: Object.entries(isLooseboard.value ? looseboardStatusLabels : truckStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
@@ -63,7 +56,7 @@ const truckColumns: DataTableColumn[] = [
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
|
||||
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
|
||||
{ key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.pickupAt) },
|
||||
{ key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.deliveryAt) }, { key: 'status', label: '状态', width: 120, slotName: 'status' },
|
||||
{ key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.deliveryAt) }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.createdAt) },
|
||||
]
|
||||
const looseboardColumns: DataTableColumn[] = [
|
||||
@@ -72,7 +65,7 @@ const looseboardColumns: DataTableColumn[] = [
|
||||
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
|
||||
{ key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
|
||||
{ key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' }, { key: 'budgetAmount', label: '预算总价', width: 115, align: 'right', formatter: (row: LocalLooseboardOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
|
||||
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 150 }, { key: 'estimatedPalletCount', prop: 'estimatedPalletCount', label: '预计托数', width: 100 }, { key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.pickupAt) }, { key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.deliveryAt) }, { key: 'status', label: '状态', width: 105, slotName: 'status' },
|
||||
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 150 }, { key: 'estimatedPalletCount', prop: 'estimatedPalletCount', label: '预计托数', width: 100 }, { key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.pickupAt) }, { key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.deliveryAt) }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.createdAt) },
|
||||
]
|
||||
const columns = computed(() => {
|
||||
@@ -135,7 +128,6 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
|
||||
<template #actions="{ row }"><RowActions v-if="!isLooseboard" :actions="truckActions(row as LocalTransportOrder)" text-actions @action="handleTruckAction($event, row as LocalTransportOrder)" /><div v-else class="row-actions density-actions admin-text-actions"><el-button v-if="row.fleetArrangement" class="density-action" @click="showArrangement(row as LocalTransportOrder | LocalLooseboardOrder)">承运安排</el-button><el-tooltip v-if="!row.fleetArrangement && access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button class="density-action" data-tone="neutral" size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)">编辑</el-button></span></el-tooltip></div></template>
|
||||
<template #status="{ row }"><el-tag :type="statusTones[row.status as LocalTransportStatus | LocalLooseboardStatus] || 'info'" size="small" effect="light">{{ statusLabels[row.status] || '—' }}</el-tag></template>
|
||||
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
|
||||
<template #routeMap="{ row }"><el-button v-if="row.orderNo" link type="primary" @click="openRoute(row as LocalLooseboardOrder)">查看路线</el-button></template>
|
||||
</DataTable></div></section>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isAxiosError } from 'axios'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import type { ProcurementCommand, ProcurementCommandBody } from './api/procurementCommands'
|
||||
export interface PendingProcurement { command: ProcurementCommand; payload: ProcurementCommandBody }
|
||||
const commands = new Set(['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'])
|
||||
@@ -5,3 +7,126 @@ export function commandKey() {
|
||||
const bytes = new Uint8Array(16); globalThis.crypto.getRandomValues(bytes)
|
||||
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
export function readPendingProcurement(raw: string | null): PendingProcurement | null {
|
||||
if (raw === null) return null
|
||||
const value: unknown = JSON.parse(raw)
|
||||
if (!value || typeof value !== 'object' || !('command' in value) || !('payload' in value) || !commands.has(String(value.command))) throw new Error('invalid pending command')
|
||||
const payload = value.payload
|
||||
if (!payload || typeof payload !== 'object' || !('version' in payload) || !Number.isSafeInteger(payload.version) || Number(payload.version) < 0 || !('idempotencyKey' in payload) || typeof payload.idempotencyKey !== 'string' || !payload.idempotencyKey) throw new Error('invalid pending payload')
|
||||
const fields = new Set(['version', 'idempotencyKey', 'reason', 'decision', 'remark', 'purchaseWarehouseId', 'warehouseId', 'productName', 'specification', 'quantity', 'amount', 'currency', 'purchasedAt', 'estimatedArrivalAt', 'voucherFileId', 'leg', 'sourceCartonId', 'receiptId', 'dispositionId', 'goodQuantity', 'damagedQuantity', 'extraQuantity', 'receivedAt', 'kind', 'packingVersion', 'cartons', 'labelVersion', 'receiver', 'addressVoucherFileId', 'labels', 'cartonIds', 'businessDate', 'appliedAt', 'shippedAt', 'cartonId', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'feeId', 'targetOriginalAmount', 'internationalFreight', 'duty', 'domesticFreight', 'otherDetermination', 'otherFees', 'targetType', 'targetId', 'blocking', 'clientSummary', 'noteId', 'resolutions', 'customerVoucherFileId', 'financialConclusion', 'name'])
|
||||
const body = payload as Record<string, unknown>
|
||||
if (Object.keys(body).some(field => !fields.has(field))) throw new Error('unexpected pending field')
|
||||
const nonempty = (field: string) => typeof body[field] === 'string' && !!body[field].trim()
|
||||
const positiveId = (field: string) => nonempty(field) && /^[1-9]\d*$/.test(String(body[field]))
|
||||
if (value.command === 'review') {
|
||||
if (!['APPROVE', 'REJECT'].includes(String(body.decision)) || (body.decision === 'APPROVE' && !positiveId('purchaseWarehouseId'))) throw new Error('invalid pending review')
|
||||
} else if (!nonempty('reason')) throw new Error('missing pending reason')
|
||||
if (value.command === 'planned-warehouse' && !positiveId('warehouseId')) throw new Error('invalid pending warehouse')
|
||||
if (['purchase', 'budget-authorizations'].includes(String(value.command))) {
|
||||
if (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !['CNY', 'USD'].includes(String(body.currency)) || !positiveId('voucherFileId')) throw new Error('invalid pending amount')
|
||||
}
|
||||
if (value.command === 'purchase' && (!nonempty('productName') || !Number.isSafeInteger(body.quantity) || Number(body.quantity) < 1 || !nonempty('purchasedAt') || !nonempty('estimatedArrivalAt'))) throw new Error('invalid pending purchase')
|
||||
const count = (field: string, minimum: number) => Number.isSafeInteger(body[field]) && Number(body[field]) >= minimum
|
||||
if (['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'receipts/correct', 'dispositions'].includes(String(value.command))) {
|
||||
if (!['OVERSEAS', 'DOMESTIC'].includes(String(body.leg))) throw new Error('invalid receipt leg')
|
||||
if ((String(value.command).startsWith('overseas') && body.leg !== 'OVERSEAS') || (String(value.command).startsWith('domestic') && body.leg !== 'DOMESTIC')) throw new Error('wrong receipt endpoint')
|
||||
}
|
||||
if (['overseas-receipts', 'domestic-receipts', 'receipts/correct'].includes(String(value.command))) {
|
||||
if (!['goodQuantity', 'damagedQuantity', 'extraQuantity'].every(field => count(field, 0)) || Number(body.goodQuantity) + Number(body.damagedQuantity) + Number(body.extraQuantity) <= 0 || !nonempty('receivedAt')) throw new Error('invalid pending receipt')
|
||||
if (body.leg === 'OVERSEAS' ? !positiveId('warehouseId') || body.sourceCartonId != null : !positiveId('sourceCartonId') || body.warehouseId != null) throw new Error('invalid receipt source')
|
||||
}
|
||||
if (['receipts/correct', 'dispositions', 'dispositions/revoke'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing correction evidence')
|
||||
if (value.command === 'receipts/correct' && !positiveId('receiptId')) throw new Error('missing original receipt')
|
||||
if (value.command === 'dispositions/revoke' && !positiveId('dispositionId')) throw new Error('missing original disposition')
|
||||
if (value.command === 'dispositions') {
|
||||
const decisions: Record<string, string> = { SHORTAGE: 'ACCEPT_SHORTAGE', DAMAGED: 'DISPOSE_DAMAGED', EXTRA: 'RETURN_EXTRA' }
|
||||
if (!count('quantity', 1) || !decisions[String(body.kind)] || decisions[String(body.kind)] !== body.decision || (body.kind !== 'SHORTAGE' && !positiveId('receiptId')) || (body.kind === 'SHORTAGE' && body.receiptId != null) || (body.leg === 'DOMESTIC' && !positiveId('sourceCartonId'))) throw new Error('invalid pending disposition')
|
||||
}
|
||||
if (value.command === 'packing/confirm' && !count('packingVersion', 1)) throw new Error('invalid packing version')
|
||||
if (value.command === 'packing') {
|
||||
if (!Array.isArray(body.cartons) || !body.cartons.length) throw new Error('missing packing list')
|
||||
const names = new Set<string>()
|
||||
for (const item of body.cartons) {
|
||||
if (!item || typeof item !== 'object' || typeof item.cartonNo !== 'string' || !item.cartonNo.trim() || names.has(item.cartonNo) || Object.keys(item).some(key => !['cartonNo', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'sources'].includes(key))) throw new Error('invalid packing carton')
|
||||
names.add(item.cartonNo)
|
||||
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
|
||||
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
|
||||
if (typeof item[field] !== 'string' || !pattern.test(item[field]) || Number(item[field]) <= 0) throw new Error('invalid packing measurement')
|
||||
}
|
||||
if (!Array.isArray(item.sources) || !item.sources.length || item.sources.some((source: unknown) => {
|
||||
if (!source || typeof source !== 'object') return true
|
||||
const row = source as Record<string, unknown>
|
||||
return Object.keys(row).some(key => !['receiptId', 'quantity'].includes(key)) || typeof row.receiptId !== 'string' || !/^[1-9]\d*$/.test(row.receiptId) || !Number.isSafeInteger(row.quantity) || Number(row.quantity) < 1
|
||||
})) throw new Error('invalid packing source')
|
||||
}
|
||||
}
|
||||
if (value.command === 'labels') {
|
||||
const receiver = body.receiver
|
||||
if (!count('packingVersion', 1) || !['OVERSEAS', 'DOMESTIC'].includes(String(body.leg)) || !receiver || typeof receiver !== 'object' || !['name', 'phone', 'address'].every(key => typeof (receiver as Record<string, unknown>)[key] === 'string' && String((receiver as Record<string, unknown>)[key]).trim())) throw new Error('invalid label address')
|
||||
if (!Array.isArray(body.labels) || !body.labels.length) throw new Error('missing labels')
|
||||
const cartonIds = new Set<string>()
|
||||
for (const row of body.labels) {
|
||||
if (!row || typeof row !== 'object' || typeof row.cartonId !== 'string' || !/^[1-9]\d*$/.test(row.cartonId) || typeof row.fileId !== 'string' || !/^[1-9]\d*$/.test(row.fileId) || !Number.isSafeInteger(row.page) || row.page < 1 || !row.carrier?.trim() || !row.trackingNo?.trim() || cartonIds.has(row.cartonId)) throw new Error('invalid label association')
|
||||
cartonIds.add(row.cartonId)
|
||||
}
|
||||
}
|
||||
if (value.command === 'international-estimate' && (!count('labelVersion', 1) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || !nonempty('businessDate'))) throw new Error('invalid pending estimate')
|
||||
if (['labels/apply', 'overseas-shipment', 'customer-shipment'].includes(String(value.command))) {
|
||||
if (!count('labelVersion', 1) || !Array.isArray(body.cartonIds) || !body.cartonIds.length || body.cartonIds.some(id => typeof id !== 'string' || !/^[1-9]\d*$/.test(id)) || new Set(body.cartonIds).size !== body.cartonIds.length) throw new Error('invalid transport cartons')
|
||||
if (!nonempty(value.command === 'labels/apply' ? 'appliedAt' : 'shippedAt')) throw new Error('missing actual transport time')
|
||||
if ((value.command === 'overseas-shipment' && body.leg !== 'OVERSEAS') || (value.command === 'customer-shipment' && body.leg !== 'DOMESTIC')) throw new Error('wrong shipment leg')
|
||||
}
|
||||
if (value.command === 'domestic-repack') {
|
||||
if (!positiveId('cartonId') || !positiveId('voucherFileId')) throw new Error('invalid repack reference')
|
||||
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'grossWeightKg']) {
|
||||
const pattern = field === 'grossWeightKg' ? /^\d{1,9}(?:\.\d{1,3})?$/ : /^\d{1,10}(?:\.\d{1,2})?$/
|
||||
if (typeof body[field] !== 'string' || !pattern.test(String(body[field])) || Number(body[field]) <= 0) throw new Error('invalid repack measurement')
|
||||
}
|
||||
}
|
||||
if (String(value.command).startsWith('fees/') && value.command !== 'fees/additional') {
|
||||
if (!positiveId('feeId')) throw new Error('invalid original fee')
|
||||
if (['fees/correct-target', 'fees/refund', 'fees/replace-failed'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing finance evidence')
|
||||
if (['fees/correct-target', 'fees/replace-failed'].includes(String(value.command)) && !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.targetOriginalAmount))) throw new Error('invalid correction target')
|
||||
if (value.command === 'fees/refund' && (!/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0)) throw new Error('invalid refund amount')
|
||||
}
|
||||
if (value.command === 'charges/confirm') {
|
||||
const selection = (input: unknown) => {
|
||||
if (!input || typeof input !== 'object') throw new Error('missing fee determination')
|
||||
const item = input as Record<string, unknown>
|
||||
if ((item.feeId == null) === (item.value == null) || Object.keys(item).some(key => !['feeId', 'value'].includes(key))) throw new Error('ambiguous fee determination')
|
||||
if (item.feeId != null) { if (typeof item.feeId !== 'string' || !/^[1-9]\d*$/.test(item.feeId)) throw new Error('invalid fee reference'); return }
|
||||
if (!item.value || typeof item.value !== 'object') throw new Error('invalid fee amount')
|
||||
const value = item.value as Record<string, unknown>
|
||||
if (Object.keys(value).some(key => !['determination', 'amount', 'currency', 'businessDate', 'voucherFileId', 'reason'].includes(key))) throw new Error('unexpected fee amount field')
|
||||
if (!['POSITIVE', 'ZERO', 'NOT_APPLICABLE'].includes(String(value.determination)) || typeof value.amount !== 'string' || !/^\d{1,16}(?:\.\d{1,2})?$/.test(value.amount) || (value.determination === 'POSITIVE' ? Number(value.amount) <= 0 : Number(value.amount) !== 0) || !['CNY', 'USD'].includes(String(value.currency)) || typeof value.businessDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.businessDate) || typeof value.reason !== 'string' || !value.reason.trim()) throw new Error('invalid fee determination value')
|
||||
if (value.determination === 'POSITIVE' && (typeof value.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(value.voucherFileId))) throw new Error('missing fee voucher')
|
||||
}
|
||||
for (const key of ['internationalFreight', 'duty', 'domesticFreight']) selection(body[key])
|
||||
if (!['NOT_APPLICABLE', 'ITEMIZED'].includes(String(body.otherDetermination)) || !Array.isArray(body.otherFees) || (body.otherDetermination === 'NOT_APPLICABLE') !== (body.otherFees.length === 0)) throw new Error('invalid other fee determination')
|
||||
const codes = new Set<string>()
|
||||
for (const row of body.otherFees) {
|
||||
if (!row || typeof row !== 'object' || typeof row.code !== 'string' || !/^[A-Z][A-Z0-9_]{0,31}$/.test(row.code) || codes.has(row.code) || typeof row.name !== 'string' || !row.name.trim()) throw new Error('invalid other fee')
|
||||
codes.add(row.code); selection(row.selection)
|
||||
}
|
||||
}
|
||||
if (['control-notes', 'control-notes/resolve', 'actual-warehouse/correct'].includes(String(value.command)) && !positiveId('voucherFileId')) throw new Error('missing control evidence')
|
||||
if (value.command === 'control-notes' && (!['PURCHASE', 'WAREHOUSE', 'RECEIPT', 'SHIPMENT'].includes(String(body.targetType)) || typeof body.blocking !== 'boolean' || (body.targetType === 'PURCHASE' ? body.targetId != null : !positiveId('targetId')))) throw new Error('invalid control target')
|
||||
if (value.command === 'control-notes/resolve' && !positiveId('noteId')) throw new Error('missing original blocker')
|
||||
if (value.command === 'actual-warehouse/correct' && !positiveId('warehouseId')) throw new Error('invalid actual warehouse')
|
||||
if (value.command === 'exception-close') {
|
||||
if (!positiveId('customerVoucherFileId') || !nonempty('financialConclusion') || !nonempty('clientSummary') || !Array.isArray(body.resolutions)) throw new Error('incomplete closure facts')
|
||||
const decisions: Record<string, string[]> = { PURCHASE_REMAINDER: ['SUPPLIER_RETAINED', 'RETURNED_TO_SUPPLIER', 'LOSS_CONFIRMED'], TRANSIT_REMAINDER: ['LOSS_CONFIRMED', 'RETURNED_TO_SENDER'], GOOD: ['RETURNED_TO_SUPPLIER', 'CUSTOMER_AUTHORIZED_RETENTION', 'DISPOSED'], DAMAGED: ['DISPOSED', 'RETURNED_TO_SUPPLIER'], EXTRA: ['RETURNED_TO_SUPPLIER'] }
|
||||
const sources = new Set<string>()
|
||||
for (const row of body.resolutions) {
|
||||
if (!row || typeof row !== 'object' || !decisions[row.kind]?.includes(row.decision) || !Number.isSafeInteger(row.quantity) || row.quantity < 1 || typeof row.destination !== 'string' || !row.destination.trim() || typeof row.voucherFileId !== 'string' || !/^[1-9]\d*$/.test(row.voucherFileId) || (row.kind === 'PURCHASE_REMAINDER' ? row.sourceId != null : typeof row.sourceId !== 'string' || !/^[1-9]\d*$/.test(row.sourceId))) throw new Error('invalid closure resolution')
|
||||
const key = `${row.kind}:${row.sourceId ?? ''}`; if (sources.has(key)) throw new Error('duplicate closure source'); sources.add(key)
|
||||
}
|
||||
}
|
||||
if (value.command === 'fees/additional' && (!nonempty('name') || !nonempty('businessDate') || !['CNY', 'USD'].includes(String(body.currency)) || !/^\d{1,16}(?:\.\d{1,2})?$/.test(String(body.amount)) || Number(body.amount) <= 0 || !positiveId('voucherFileId'))) throw new Error('invalid independent financial fee')
|
||||
return value as PendingProcurement
|
||||
}
|
||||
export function definitelyRejected(error: unknown) {
|
||||
const status = error instanceof ApiResponseError ? error.status : isAxiosError(error) ? error.response?.status : undefined
|
||||
const code = error instanceof ApiResponseError ? error.code : ''
|
||||
return [400, 401, 403, 404, 409, 422].includes(status ?? 0) || ['A0400', 'A0401', 'A0403', 'A0404', 'A0409', 'A0422'].includes(code)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
|
||||
- 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。
|
||||
|
||||
- 当前散板需求:正式列表使用`pages/DispatchCapacityDemandsView.vue`、`api/dispatchCapacityDemands.ts`,只读消费日常派送排车的`/dispatches/capacity-demands`摘要;发布选项仅在智能排车建单时选择,默认不勾选;维护车辆不提供发布修改,预约日历建单不自动发布,重发先取消原排车再重新创建,规则见拼车主契约当前页面范围。不得以实托数推算标准托位,未知余位明确待确认,不读取客户或司机私人资料。原登记和旧车次入口代码保留隐藏。
|
||||
|
||||
- 保留的内部拼车实现:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
|
||||
- 内部拼车需求池:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
|
||||
|
||||
- 外接预订可选销售报价:`TripBookingPanel.vue`沿车次作用域`/bookings/quote-candidates`读取候选,不由浏览器推城市或有效日期;来源报价版本与实际成交价分别显示。引用要求quote:list,手填成交价不新增该权限门禁;改变客户/卸货站/币种/计价方式后清除不再匹配的引用,迟到候选不覆盖新表单。确认承运由后端复验版本和有效性,实收调价保留来源快照。
|
||||
|
||||
@@ -15,7 +13,7 @@
|
||||
|
||||
- 车队报价`pages/FleetQuotesView.vue`、`api/fleetQuotes.ts`沿`fleet-quotes-v1.yaml`区分采购/销售;原型通用菜单,编辑780px、CSV导入1040px,未知价格与零价分开。出车记录`pages/TripOperationsView.vue`、`api/tripOperations.ts`沿`fleet-operations-v1.yaml`按实际DEPART查询,列表/图表同筛选,缺少成本或财务事实不推为零;分别使用fleet:quote:list与fleet:operations:list,写入/导出独立权限。
|
||||
|
||||
- 车次“直接成本”页签:`components/TripCostPanel.vue`、`api/tripCosts.ts`,选择页签才读取一次分页及整趟汇总;登记/更正分别使用trip:cost-create/cost-edit,历史沿trip:list。编辑先取最新历史快照版本;不修改内容不写入,请求失败即释放提交态并保留表单,下一次点击按当前输入发起新请求,不保存失败请求用于重放。按原币种展示,不将未登记或未分摊当零,不推定财务付款;费用表操作列在左,分摊表只读。后端合同为`fleet-trip-costs-v1.yaml`。
|
||||
- 车次“直接成本”页签:`components/TripCostPanel.vue`、`api/tripCosts.ts`,选择页签才读取一次分页及整趟汇总;登记/更正分别使用trip:cost-create/cost-edit,历史沿trip:list。编辑先取最新历史快照版本;不修改内容不写入,未知新增结果保留原键及内容重试。按原币种展示,不将未登记或未分摊当零,不推定财务付款;费用表操作列在左,分摊表只读。后端合同为`fleet-trip-costs-v1.yaml`。
|
||||
|
||||
- 车辆概览中的派送与更换记录由`components/VehicleFulfillmentTable.vue`、`api/vehicleFulfillment.ts`读取,替代原提示文字。派送要求vehicle:list与delivery:list,更换要求vehicle:list与trip:list;无对应权限不请求接口。两个可见分区各一次分页请求,不逐行加载;换车/换页忽略旧响应,错误保留重试及诊断。更换显示原/新车辆与司机、历史车次及排车业务号;现有快照没有操作人姓名时显示“—”,不用内部操作者ID回退。
|
||||
|
||||
@@ -24,7 +22,7 @@
|
||||
- 共享车次接口在 `api/trips.ts`,类型在 `tripTypes.ts`,后端合同为 `fleet-trips-v1.yaml`;覆盖计划/分配、客户排车关联、实际装载、货物结案和分别结束车辆/司机任务。接口保留稳定幂等键、版本与货物hash,长ID按字符串透传;数量和容量展示服务端结果,不在浏览器推算整车可发或可结案。新页面承载按 `PROTOTYPE_MATRIX.md` 确定,不因API存在推导页面能力。
|
||||
- 当前日常作业统一从排车列表进入,侧栏`fleet-trips`为hidden;“出车记录”保留实际发车后的运营查询。原`pages/TripsView.vue`、`pages/TripDetailView.vue`及附加能力代码保留,不能作为新排车的前置步骤。旧详情仍按页签按需读取,资源候选聚焦或搜索时查询,不逐行读取。新整车审核、PDA真实拣货、双端装车确认及实际发车规则以`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`为准;详情只有已确认四页签,不把旧八项附加功能自动迁入或以派送完成自动释放资源。
|
||||
- 车辆实际托位/载重kg/容积m³分别由服务端返回,三项同填正数或全空待确认;不按车型名称推算。占用期间主档关键变更由服务端拒绝,下载中心 XLSX 导出使用车牌业务标识并包含容量字段。
|
||||
- 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;保存失败即恢复编辑、关闭及重新提交,不保存失败请求;已确认创建成功的记录和附件继续使用返回的业务身份。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。
|
||||
- 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;未知保存结果保留原请求键,照片重试不重复建记录。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。
|
||||
- 维修/故障/救援的“更新处理”已获用户确认,复用原620px弹框及record-edit权限;更正说明选填,空值由页面填标准动作说明,不移除服务端不可变历史。
|
||||
- 司机列表 `pages/DriversView.vue`、详情 `pages/DriverDetailView.vue`,编辑和证照预览在 `components/DriverEditorDialog.vue`、`DriverLicensePreview.vue`。详情独立路由;编辑居中1080px三列表单;预览居中620px。
|
||||
- 列表与统计使用同一次服务端响应;统计范围与列表筛选一致,30天提醒按证照时区计算。页面不能用当前分页行数替代总统计,也不能在浏览器重算派车资格。
|
||||
@@ -37,7 +35,7 @@
|
||||
|
||||
- 车次详情“计划容量”页签按需读写`/capacity-plan`,复用`TripLoadingPanel`的计划模式,预计重量/体积与实际装载分别保存;只在该页签展示逐段计划余位。`planValid=false`时不展示旧余位,货物变化后重新核对;保存计划只更新车次版本,不把理货审核变成通过或写入实载。
|
||||
|
||||
- `TripBookingPanel.vue`在车次详情“外接预订”页签按需读取列表;1080px居中弹框登记/编辑或只读详情,客户聚焦搜索才读取`/fleet/trips/customer-options`。询价只要求客户、既有卸货站和箱数,载荷/价格/联系信息可后补;确认承运由服务端校验余位及完整报价。create/edit/confirm/cancel分别使用独立`trip:booking-*`权限,读操作沿trip:list。创建及确认失败即释放提交态,下一次点击使用当前输入及新请求键,成功用命令结果更新列表和车次版本;计划容量表展示availableSegments(已扣除外接占位),不使用旧自有货余位覆盖。
|
||||
- `TripBookingPanel.vue`在车次详情“外接预订”页签按需读取列表;1080px居中弹框登记/编辑或只读详情,客户聚焦搜索才读取`/fleet/trips/customer-options`。询价只要求客户、既有卸货站和箱数,载荷/价格/联系信息可后补;确认承运由服务端校验余位及完整报价。create/edit/confirm/cancel分别使用独立`trip:booking-*`权限,读操作沿trip:list。未知创建及确认结果保留原请求重试,成功用命令结果更新列表和车次版本;计划容量表展示availableSegments(已扣除外接占位),不使用旧自有货余位覆盖。
|
||||
|
||||
- 外接履约弹框`TripBookingFulfillmentDialog.vue`为1080px居中:登记来货、按服务器实托/receiptHash核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。
|
||||
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
# 海外通用资料原型核对矩阵
|
||||
|
||||
## 散板需求只读展示(2026-10-21)
|
||||
|
||||
依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。
|
||||
|
||||
| 页面/操作 | 当前承载与调整 | 验收 |
|
||||
|---|---|---|
|
||||
| 散板需求列表/查询、分页 | 同一正式路由改读当前派送排车摘要,按排车号展示起运仓、路线、计划发车、车辆容量、已排实托及真实/待确认余位;无登记和旧车次入口,原实现保留 | 本轮8文件115项定向测试及受影响文件类型检查通过;待人工1920×1080、100%截图确认 |
|
||||
| 散板需求详情/查看、重新核对 | 保持独立详情,仅展示运输摘要、联系方式和分段余位;车次号改为纯文本,不能进入旧车次详情;隐藏登记、意向列表、来货及履约窗口,旧登记参数不再打开窗口 | 同上 |
|
||||
|
||||
## 2026-10-20 列表顶部紧凑收尾
|
||||
|
||||
依据为本轮用户整柜业务截图及“所有列表顶部紧凑”的确认;列表公共工具栏和筛选内距由 `adminWorkbench.css` 统一维护。
|
||||
@@ -72,16 +63,6 @@
|
||||
|
||||
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
|
||||
|
||||
## 自有业务列表字段补齐(2026-10-21)
|
||||
|
||||
依据本轮用户字段截图 `codex-clipboard-9d532c45-a5f3-4f90-a8f2-f8a3cdfb8a3e.png`;用户确认派送仓库就是目的仓,最晚装车完成时间先空着。此前详情、运营登记、照片的承载与权限不变。
|
||||
|
||||
| 页面/操作 | 截图与当前确认 | 本次调整 | 验收 |
|
||||
|---|---|---|---|
|
||||
| 自有业务列表/读取字段 | 总价、单价、预约到达、实际到达各为独立列 | 拆开原合并列;一车一行,各到达时间保留目的仓标识,金额沿服务端原值 | 8项定向检查通过;待修改后人工截图 |
|
||||
| 自有业务列表/仓库与时间 | 派送仓库等于目的仓;最晚装车完成时间暂无来源 | 派送仓库复用目的仓展示;最晚装车完成时间显示“—”,不拿计划发车替代;保留已有计划发车列 | 8项定向检查通过;待修改后人工截图 |
|
||||
| 自有业务列表/字段名称 | 图中第一处“托数”装的是仓库名称 | 保留正确的“提货仓库”和唯一“实际托数”;“阶段”列改名“状态”,取值不变 | 8项定向检查通过;待修改后人工截图 |
|
||||
|
||||
## 自有业务行操作样式核对(2026-10-21)
|
||||
|
||||
依据本轮用户截图 `codex-clipboard-eb6aa95b-c8a2-4b85-9294-22f3127cf72c.png` 与 Admin 界面规范“列表”纯文字行操作规则;仅修正列表按钮外观。
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listDispatchCapacityDemands } from './dispatchCapacityDemands'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }))
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('reads only dispatch demand summaries and preserves unknown quantities distinctly from zero', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { page: '1', size: '20', total: '1', pages: '1', records: [{ dispatchNo: 'PC-1', vehiclePalletSpaces: '30', scheduledPalletCount: '0', remainingPalletSpaces: null, remainingWeightKg: null, remainingVolumeCbm: null, capacityKnown: false }] } })
|
||||
const value=await listDispatchCapacityDemands({page:1,size:20,origin:'测试仓'})
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/dispatches/capacity-demands',{params:{page:1,size:20,origin:'测试仓'}})
|
||||
expect(value).toMatchObject({total:1,records:[{dispatchNo:'PC-1',vehiclePalletSpaces:30,scheduledPalletCount:0,remainingPalletSpaces:null}]})
|
||||
})
|
||||
it.each(['invalid', -1, true])('rejects invalid capacity quantities %s', async remainingPalletSpaces => {
|
||||
vi.mocked(http.get).mockResolvedValue({data:{page:1,size:20,total:1,pages:1,records:[{remainingPalletSpaces}]}})
|
||||
await expect(listDispatchCapacityDemands({page:1,size:20})).rejects.toThrow('数量无效')
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
export interface DispatchCapacityDemand {
|
||||
dispatchNo: string; warehouseName: string; route: string | null; plannedDepartureAt: string | null
|
||||
vehiclePalletSpaces: number | null; scheduledPalletCount: number | null; remainingPalletSpaces: number | null
|
||||
remainingWeightKg: number | null; remainingVolumeCbm: number | null; capacityKnown: boolean; capacityReason: string | null
|
||||
}
|
||||
interface Page { records: DispatchCapacityDemand[]; page: number; size: number; total: number; pages: number }
|
||||
interface Query { page: number; size: number; origin?: string; destination?: string }
|
||||
const quantity = (value: unknown): number => {
|
||||
if (value == null || typeof value === 'string' && value.trim() === '') throw new Error('散板需求数量缺失')
|
||||
if (typeof value !== 'number' && typeof value !== 'string') throw new Error('散板需求数量无效')
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) throw new Error('散板需求数量无效')
|
||||
return parsed
|
||||
}
|
||||
export async function listDispatchCapacityDemands(query: Query): Promise<Page> {
|
||||
const value = unwrapApiResponse(await http.get<ApiEnvelope<Page>, ApiEnvelope<Page>>('/platform/v1/last-leg/dispatches/capacity-demands', { params: query }))
|
||||
const nullable = (number: unknown) => number == null ? null : quantity(number)
|
||||
return { ...value, page: quantity(value.page), size: quantity(value.size), total: quantity(value.total), pages: quantity(value.pages), records: value.records.map(row => ({ ...row,
|
||||
vehiclePalletSpaces: nullable(row.vehiclePalletSpaces), scheduledPalletCount: nullable(row.scheduledPalletCount), remainingPalletSpaces: nullable(row.remainingPalletSpaces),
|
||||
remainingWeightKg: nullable(row.remainingWeightKg), remainingVolumeCbm: nullable(row.remainingVolumeCbm),
|
||||
})) }
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export async function createExchangeRate(payload: { month: string; usdToCnyAvera
|
||||
export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await http.put<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload))) }
|
||||
export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return normalizeExchangeRate(unwrapApiResponse(await http.patch<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) }
|
||||
|
||||
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus; dispatchNo?: string }
|
||||
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
|
||||
type VehicleEditableFields = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'>
|
||||
export type VehicleCreate = VehicleEditableFields & Pick<VehicleDetail, 'status'>
|
||||
export type VehicleUpdate = VehicleEditableFields & Pick<VehicleDetail, 'recordVersion'>
|
||||
|
||||
@@ -147,13 +147,3 @@ it('rejects missing stable-reference arrays instead of silently falling back to
|
||||
await expect(getTrip(trip.tripNo)).rejects.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps unknown actual load measurements unknown when reading the trip', async () => {
|
||||
const unknown = structuredClone(confirmed) as unknown as { pallets: Array<{ weightKg: unknown; volumeCbm: unknown }>; segments: Array<{ occupied: { weightKg: unknown; volumeCbm: unknown }; remaining: { weightKg: unknown; volumeCbm: unknown } }> }
|
||||
unknown.pallets.forEach(row => { row.weightKg = null; row.volumeCbm = null })
|
||||
unknown.segments.forEach(row => { row.occupied.weightKg = null; row.occupied.volumeCbm = null; row.remaining.weightKg = null; row.remaining.volumeCbm = null })
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ tripNo: trip.tripNo, recordVersion: '3', cargo: [cargo], manifestHash: confirmed.manifestHash, confirmedLoad: unknown, blockReason: null }))
|
||||
const result = await getTripLoading(trip.tripNo)
|
||||
expect(result.confirmedLoad?.pallets[0]?.weightKg).toBeNull()
|
||||
expect(result.confirmedLoad?.segments[0]?.remaining.volumeCbm).toBeNull()
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ type WirePreviousDispatch = Omit<TripPreviousDispatch, 'recordVersion'> & { reco
|
||||
type WireDetail = Omit<TripDetail, 'trip' | 'dispatches' | 'loadingReview' | 'previousDispatches'> & { trip: WireTrip; dispatches: WireBinding[]; loadingReview: WireReview; previousDispatches: WirePreviousDispatch[] }
|
||||
type WirePage<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
type WireCargo = Omit<TripCargo, 'packages'> & { packages: Numeric }
|
||||
type WireLoad = { palletSpaces: Numeric; weightKg: Numeric | null; volumeCbm: Numeric | null }
|
||||
type WireLoad = { [K in keyof TripLoad]: Numeric }
|
||||
type WireMeasurement = Omit<TripMeasurement, 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'deliveryIndex'> & WireLoad & { deliveryIndex: Numeric }
|
||||
type WireConfirmed = Omit<TripConfirmedLoad, 'cargo' | 'pallets' | 'segments'> & {
|
||||
cargo: WireCargo[]; pallets: WireMeasurement[]
|
||||
@@ -56,7 +56,7 @@ export const normalizeTripDetail = (value: WireDetail): TripDetail => ({ ...valu
|
||||
const cargo = (value: WireCargo): TripCargo => ({ ...value, packages: integer(value.packages) })
|
||||
const outcome = (value: WireOutcome): TripOutcome => ({ ...value, packages: integer(value.packages),
|
||||
signedPackages: integer(value.signedPackages), returnedPackages: integer(value.returnedPackages), remainingPackages: integer(value.remainingPackages) })
|
||||
const load = (value: WireLoad): TripLoad => ({ palletSpaces: integer(value.palletSpaces), weightKg: value.weightKg == null ? null : number(value.weightKg), volumeCbm: value.volumeCbm == null ? null : number(value.volumeCbm) })
|
||||
const load = (value: WireLoad): TripLoad => ({ palletSpaces: integer(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) })
|
||||
function confirmed(value: WireConfirmed | null): TripConfirmedLoad | null {
|
||||
return value == null ? null : { ...value, cargo: value.cargo.map(cargo),
|
||||
pallets: value.pallets.map(row => ({ ...row, ...load(row), deliveryIndex: integer(row.deliveryIndex) })),
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function listVehicleCareFinance(vehicleId: string, recordNos: strin
|
||||
export async function submitVehicleCareFinance(vehicleId: string, recordNo: string, request: VehicleCareFinanceSubmit): Promise<VehicleCareFinanceSubmitted> {
|
||||
type Wire = Omit<VehicleCareFinanceSubmitted, 'sourceVersion' | 'submittedRecordVersion'> & { sourceVersion: Numeric; submittedRecordVersion: Numeric }
|
||||
const response = unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${path(vehicleId, recordNo)}/finance`, request))
|
||||
if (response.recordNo !== recordNo || response.status !== 'PENDING_FINANCE') throw new Error('财务提交响应不匹配,请刷新当前记录')
|
||||
if (response.recordNo !== recordNo || response.status !== 'PENDING_FINANCE') throw new Error('财务提交响应不匹配,请使用原请求重试')
|
||||
return { ...response, sourceVersion: financeVersion(response.sourceVersion), submittedRecordVersion: financeVersion(response.submittedRecordVersion) }
|
||||
}
|
||||
export async function listVehicleCareSuppliers(keyword = ''): Promise<Array<{ id: string; name: string }>> {
|
||||
|
||||
@@ -2,10 +2,11 @@ import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia } from 'pinia'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
|
||||
import CapacityInquiryDialog from './CapacityInquiryDialog.vue'
|
||||
import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue'
|
||||
import CapacityOfferDetailView from '../pages/CapacityOfferDetailView.vue'
|
||||
import type { CapacityOffer } from '../capacityOfferTypes'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
@@ -63,13 +64,11 @@ it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited i
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('preserves route-leave protection in the retained native inquiry dialog without duplicating the global prompt', async () => {
|
||||
it('blocks browser back with the real router, cached detail and native dialog, without duplicating the global prompt', async () => {
|
||||
const pinia = createPinia(), registry = useUnsavedWorkStore(pinia), confirms = vi.spyOn(ElMessageBox, 'confirm')
|
||||
// 登记页面入口已隐藏;保留组件自身与真实路由的未保存保护回归。
|
||||
const InquiryHost = defineComponent({ components: { CapacityInquiryDialog }, setup: () => ({ offer, open: ref(false) }), template: '<button @click="open = true">打开保留的登记组件</button><CapacityInquiryDialog v-model="open" :offer="offer" :inquiry="null" :readonly="false" />' })
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/pool', name: 'overseas-master-data-capacity-offers', component: { template: '<p data-testid="pool-list">运力列表</p>' }, meta: { requiresAuth: true } },
|
||||
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: InquiryHost, meta: { requiresAuth: true } },
|
||||
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: CapacityOfferDetailView, meta: { requiresAuth: true } },
|
||||
{ path: '/outside', name: 'outside', component: { template: '<p>外部页面</p>' }, meta: { workspaceTab: false } },
|
||||
] })
|
||||
// Match the application's intentional policy: cross-workspace switching skips the global prompt.
|
||||
@@ -82,7 +81,7 @@ it('preserves route-leave protection in the retained native inquiry dialog witho
|
||||
await router.push('/pool'); await router.isReady()
|
||||
const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper)
|
||||
await router.push('/pool/CO-CLOSE'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get('details summary').trigger('click')
|
||||
const input = wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]')
|
||||
await input.setValue('UI-修复后保留输入'); await flushPromises()
|
||||
@@ -93,15 +92,15 @@ it('preserves route-leave protection in the retained native inquiry dialog witho
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(wrapper.get('.app-dialog').isVisible()).toBe(true)
|
||||
await continueEditing()
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail')
|
||||
expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).not.toHaveBeenCalled()
|
||||
expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
|
||||
confirms.mockClear(); router.back(); await flushPromises()
|
||||
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
|
||||
const leave = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '放弃并离开') as HTMLButtonElement
|
||||
expect(leave).toBeTruthy(); leave.click(); await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers')
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).not.toHaveBeenCalled()
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
|
||||
await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]').setValue('另一次修改'); await flushPromises()
|
||||
confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises()
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认')
|
||||
|
||||
@@ -93,7 +93,7 @@ it('locks the destination in readonly follow-up even without offer detail, while
|
||||
expect(saveCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('matches an existing looseboard order without inventing space, arrival or agreed price and submits a fresh command after failure', async () => {
|
||||
it('matches an existing looseboard order without inventing space, arrival or agreed price and retries the same source', async () => {
|
||||
permissions.values = new Set(['platform:fleet:capacity-offer:manage'])
|
||||
const source = { orderNo: 'LB-ORIGINAL', customerId: '19', version: 4, origin: 'LA', destination: 'ONT8', productName: '家居用品', estimatedPalletCount: 3, packageCount: 12, totalWeightKg: 100, totalVolumeCbm: 2.5, currency: 'USD', originContactName: 'Alice', originContactPhone: '555', remark: '原单备注' }
|
||||
vi.mocked(saveCapacityInquiry).mockRejectedValue(new Error('network'))
|
||||
@@ -107,6 +107,6 @@ it('matches an existing looseboard order without inventing space, arrival or agr
|
||||
const body = vi.mocked(saveCapacityInquiry).mock.calls[0]![2]
|
||||
expect(body).toMatchObject({ sourceOrderNo: 'LB-ORIGINAL', sourceOrderVersion: 4, customerId: '19', businessType: 'LOCAL_LOOSEBOARD', estimatedArrivalAt: null, expectedPalletCount: 3, cargo: { packageCount: 12, palletSpaces: null, unitPrice: null, weightKg: 100, volumeCbm: 2.5 } })
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, null, { ...body, idempotencyKey: expect.any(String) }); expect(vi.mocked(saveCapacityInquiry).mock.calls[1]![2].idempotencyKey).not.toBe(body.idempotencyKey)
|
||||
expect(saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, null, body)
|
||||
expect(listOfferCustomers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user