Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a86b19604d | ||
|
|
24ebfcf2e1 | ||
|
|
aac878c3d4 | ||
|
|
d49210575b | ||
|
|
30a0d7ce68 | ||
|
|
29f4791b66 | ||
|
|
3e77c3976e | ||
|
|
090431b409 | ||
|
|
ef85ed30e4 | ||
|
|
2e26477db5 | ||
|
|
6d2ef9fffc | ||
|
|
ad33b21869 | ||
|
|
93a31b3872 | ||
|
|
d8fbc16fd7 | ||
|
|
dbd7c473c2 | ||
|
|
875d2b2b04 | ||
|
|
e959a99db8 | ||
|
|
a4cb6c3757 | ||
|
|
c367327049 | ||
|
|
7c43fcacc4 | ||
|
|
2ecf4cf3fe | ||
|
|
bc27480e2c | ||
|
|
872d197531 | ||
|
|
b835c1cdfc | ||
|
|
ba072611dc | ||
|
|
98049c4f8c | ||
|
|
64667af5c6 | ||
|
|
4f5cec1b94 | ||
|
|
c367fadf09 | ||
|
|
fde16235ea | ||
|
|
22e60248d2 | ||
|
|
f8c9334e98 | ||
|
|
67acd02ea5 | ||
|
|
c510cc4e2e | ||
|
|
e2f99b4f4b | ||
|
|
5e8daad5a0 | ||
|
|
215cb7c6d5 | ||
|
|
8356e98ce3 | ||
|
|
6ab3bbe289 | ||
|
|
a4c7f8edd5 | ||
|
|
a9c0285e22 |
@@ -120,7 +120,8 @@ pnpm typecheck
|
||||
pnpm build
|
||||
```
|
||||
|
||||
- 源码、依赖或构建配置变更至少运行 `pnpm typecheck` 和受影响测试,交付前运行 `pnpm test` 与 `pnpm build`;不影响代码、契约或配置的纯文档/索引变更只检查路径、链接及索引合同。当前没有 lint 脚本,不声称 lint 通过。
|
||||
- 按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”执行;命令表不是每轮清单。文案/纯展示核对差异,局部逻辑跑定向测试,不自动 typecheck、全量测试或构建。类型关系、模板绑定或模块引用可能受影响时,补最小类型检查;公共能力沿实际消费者验证。
|
||||
- 构建和全量测试分别按实际影响触发,先说明扩大范围的原因;不因提交或对话收尾触发。仅复用同配置、同文件范围且相关输入未变的成功编译结果。纯文档/索引只检查路径、链接及索引合同。无 lint 脚本,不声称 lint 通过。
|
||||
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
|
||||
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
|
||||
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
|
||||
|
||||
@@ -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/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`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/` | 订舱草稿按当前版本维护资料,放舱后运输/预报/关系方/柜型/附件只读;客服实货量经 `/actual-cargo` 按编辑权限、版本、幂等键和原因补录;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,托运单取委值,提单确认件取手工实值,未填显示“—”且不以委值回退。`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/` |
|
||||
|
||||
@@ -108,7 +108,8 @@ describe('four-type appointment calendar', () => {
|
||||
expect(wrapper.text()).toContain('正在读取安排')
|
||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||
await wrapper.setProps({ loading: false, incomplete: true })
|
||||
expect(wrapper.text()).toContain('部分数据不可用')
|
||||
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('当前已加载 0 项安排')
|
||||
expect(wrapper.text()).not.toContain('部分数据不可用')
|
||||
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,9 +70,9 @@ function changeMode(next: CalendarMode) {
|
||||
</div>
|
||||
</div>
|
||||
<aside class="calendar-detail workbench-panel is-flush" aria-label="当天安排">
|
||||
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排<span v-if="incomplete">(部分数据不可用)</span></p></header>
|
||||
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排</p></header>
|
||||
<div class="detail-events" aria-live="polite">
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排,请先处理上方数据提示。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
|
||||
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
|
||||
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
|
||||
<div class="event-heading"><span class="event-type" :class="event.type">{{ scheduleLabels[event.type] }}</span><span>{{ event.time || (event.date ? '日期计划' : '日期未定') }}</span></div>
|
||||
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
|
||||
|
||||
@@ -161,7 +161,7 @@ describe('simplified appointment entry', () => {
|
||||
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains a complete dispatch with its container search fields while naming a different unresolved dispatch and its reason', async () => {
|
||||
it('retains a complete dispatch with its container search fields without showing an unresolved dispatch notice', async () => {
|
||||
const good = dispatchEntry()
|
||||
const unknown = dispatchEntry({ dispatchNo: 'PC-TIMEZONE-MISSING', containerNumbers: ['OTHER7654321'], originTimezone: null, incompleteReasons: ['ORIGIN_TIMEZONE_UNKNOWN'] })
|
||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [good, good, unknown], unresolvedRecords: [] })
|
||||
@@ -173,11 +173,7 @@ describe('simplified appointment entry', () => {
|
||||
expect(dispatches[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
|
||||
expect(dispatches[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
|
||||
expect(panel.props('incomplete')).toBe(true)
|
||||
const notice = wrapper.get('.data-notice').text()
|
||||
expect(notice).toContain('1 条排车未能定位到日历日期')
|
||||
expect(notice).toContain('PC-TIMEZONE-MISSING:起运仓时区待核实')
|
||||
expect(notice).toContain('无法确定当地日期')
|
||||
expect(notice).not.toContain('PC-CALENDAR-001')
|
||||
expect(wrapper.find('.data-notice').exists()).toBe(false)
|
||||
expect(dispatches.some(event => event.key === 'dispatch-PC-TIMEZONE-MISSING')).toBe(false)
|
||||
expect(listTripCalendar).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
|
||||
@@ -16,7 +16,7 @@ import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||
import { calendarRange, dateKey, type CalendarMode } from './calendar'
|
||||
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
|
||||
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
|
||||
import { listTripCalendar, projectTripCalendar, tripIssues } from './tripCalendar'
|
||||
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
|
||||
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
|
||||
import type { PlanningSourceLookup } from './planningApi'
|
||||
import type { WorkAction } from './workApi'
|
||||
@@ -37,7 +37,8 @@ const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mo
|
||||
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
|
||||
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
|
||||
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
|
||||
const incomplete = computed(() => !!errors.value.length || !!notices.value.length)
|
||||
const hasUnresolvedTrips = ref(false)
|
||||
const incomplete = computed(() => !!errors.value.length || !!notices.value.length || hasUnresolvedTrips.value)
|
||||
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
|
||||
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
|
||||
let generation = 0, controller: AbortController | null = null
|
||||
@@ -46,6 +47,7 @@ async function load(next = range.value) {
|
||||
const request = ++generation; controller?.abort(); controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
|
||||
hasUnresolvedTrips.value = false
|
||||
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
|
||||
await Promise.allSettled([
|
||||
(async () => {
|
||||
@@ -71,10 +73,7 @@ async function load(next = range.value) {
|
||||
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
|
||||
if (request !== generation) return
|
||||
trips.value = result.events
|
||||
if (result.unresolved.length) {
|
||||
const details = result.unresolved.slice(0, 3).map(row => `${row.dispatchNo?.trim() || '排车单号缺失'}:${tripIssues(row) || '资料待核实'}`).join(';')
|
||||
notices.value.push(`${result.unresolved.length} 条排车未能定位到日历日期(${details}${result.unresolved.length > 3 ? ';其余请到排车列表核对' : ''})。`)
|
||||
}
|
||||
hasUnresolvedTrips.value = result.unresolved.length > 0
|
||||
} catch (cause) { fail('排车读取失败', cause) }
|
||||
})(),
|
||||
])
|
||||
|
||||
@@ -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" data-tone="source" aria-label="正式确认前的货物资料" :aria-busy="loading">
|
||||
<section v-show="active || editing" class="density-dialog-section" 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,6 +30,12 @@ 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()
|
||||
|
||||
@@ -49,14 +49,16 @@ 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 cancelled original batch as undated pending without creating or reallocating cargo', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...cancelled, version: 5, appointmentStatus: 'PENDING' })
|
||||
it('rebooks the original cargo using a new confirmed appointment without reallocating cargo', async () => {
|
||||
vi.mocked(getFbaAppointment).mockResolvedValueOnce(cancelled).mockResolvedValue({ ...detail, version: 5 })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('货物分配仍保留')
|
||||
const model = wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('model')
|
||||
expect(model.appointmentStatus).toBe('PENDING'); expect(model.confirmationNo).toBe(''); expect(model.appointmentAt).toBe('')
|
||||
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)
|
||||
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: 'PENDING', plannedDate: null, confirmationNo: null, appointmentAt: null, appointmentEndAt: null }) }))
|
||||
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 }) }))
|
||||
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 = 'PENDING'
|
||||
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'SUCCESS'
|
||||
baseline.value = JSON.stringify(appointmentPayload(form)); initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,22 +18,29 @@ beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(previewPla
|
||||
it('shows readable preview requirements without exposing missing-field machine codes', async () => {
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ ...preview, missingFields: ['READY_WINDOW_UNVERIFIED', 'CARGO_QUANTITY_REVIEW'] })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('货物备妥窗口待核实')
|
||||
expect(wrapper.text()).not.toContain('货物备妥窗口待核实')
|
||||
expect(wrapper.text()).toContain('货量或重复关联需要核对。')
|
||||
expect(wrapper.text()).not.toContain('READY_WINDOW_UNVERIFIED')
|
||||
expect(wrapper.text()).not.toContain('CARGO_QUANTITY_REVIEW')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('previews and creates the exact canonical external source and customer without inventing times', async () => {
|
||||
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 () => {
|
||||
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: 'PENDING' }) }))
|
||||
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(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 wrapper.get('button').trigger('click'); await flushPromises()
|
||||
const wrapper = render(); await flushPromises(); await confirmRegistration(wrapper); 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()
|
||||
@@ -42,6 +49,7 @@ 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,6 +19,7 @@ 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 }
|
||||
@@ -60,8 +61,7 @@ onMounted(load)
|
||||
<tr><th scope="row">票数</th><td>{{ preview.group.sourceCount }}</td><th scope="row">箱数</th><td>{{ preview.group.packages ?? '箱数待核实' }}</td></tr>
|
||||
<tr><th scope="row">货物业务号</th><td colspan="3"><template v-for="(source, index) in preview.sources" :key="`${source.customerId}:${source.sourceKey}`">{{ index ? '、' : '' }}{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}</template></td></tr>
|
||||
</tbody></table>
|
||||
<p v-for="field in preview.missingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
|
||||
<p v-if="preview.group.readinessIncomplete" role="status" class="density-dialog-note">货物备妥窗口待核实。</p>
|
||||
<p v-for="field in visibleMissingFields" :key="field" role="status" class="density-dialog-note">{{ workReason(field) }}</p>
|
||||
</section>
|
||||
<FbaAppointmentFields continuous 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>
|
||||
|
||||
@@ -139,7 +139,7 @@ async function executeStep() {
|
||||
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 })
|
||||
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey } })
|
||||
request.converted = converted
|
||||
} else {
|
||||
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
|
||||
@@ -190,7 +190,7 @@ async function save() {
|
||||
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)
|
||||
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { publishLooseboardDemand: true, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(), plannedDate: '2026-10-24', appointmentRemark: '等待仓库' }) }]
|
||||
c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-b', timezone: 'America/Los_Angeles', details: appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'PENDING', 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()) }]
|
||||
if (ownPlan) c.plans = [{ batchKey: c.batch.batchKey!, customerId: 'customer-a', timezone: c.batch.timezone || null, details: appointmentPayload({ ...createAppointmentForm(), appointmentStatus: 'PENDING' }) }]
|
||||
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()
|
||||
const inherited = !context.value!.agreements.some(agreement => agreement.status === 'ACTIVE') && ['PENDING', 'FAILED'].includes(header.appointmentStatus || '') ? createAppointmentForm(header) : { ...createAppointmentForm(), appointmentStatus: 'PENDING' as const }
|
||||
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 CommandResult, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import { getDispatch, useDispatchCommandRecovery, type DispatchDetail } from '@/modules/overseas-warehouse'
|
||||
import type { LeaveGuard } from './useWorkDraft'
|
||||
const access = vi.hoisted(() => ({ allowed: true }))
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
@@ -25,11 +25,14 @@ 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('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('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('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)
|
||||
@@ -50,22 +53,12 @@ it.each([false, true])('refreshes the original add-cargo gate after a rejection
|
||||
if (!readFails) expect(wrapper.text()).toContain('货物已开始真实拣货')
|
||||
wrapper.unmount()
|
||||
})
|
||||
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')
|
||||
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()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('clears a prior add-cargo rejection after the corrected original-dispatch workflow succeeds', async () => {
|
||||
|
||||
@@ -31,9 +31,6 @@ 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 })
|
||||
@@ -47,7 +44,6 @@ 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,27 +3,39 @@ 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', containerNo: 'TEST-CONT', warehouseName: 'US-W', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 0, planCount: 0, canEdit: 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 }
|
||||
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, canEdit: false }], range, true)
|
||||
const events = projectContainerSchedule(sources, [{ ...warehouse, version: -1, planCount: 1, isDefault: false, 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: '' })
|
||||
expect(events[0]).toMatchObject({ type: 'warehouse', date: '2026-11-01', time: '', title: 'D0001 计划到仓' })
|
||||
})
|
||||
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], 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: '人工维护' })
|
||||
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: '人工维护' })
|
||||
})
|
||||
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')
|
||||
@@ -31,7 +43,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]!, containerNo: null, cabinetNo: null, arrivalAt: null }] }, [warehouse], range, false)
|
||||
const events = projectContainerSchedule({ ...sources, containers: [{ ...sources.containers[0]!, cabinetNo: null, arrivalAt: null }] }, [{ ...warehouse, plannedDate: null }], 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,8 +22,9 @@ 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.containerNo || row.cabinetNo || '—'
|
||||
const label = 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
|
||||
@@ -36,31 +37,32 @@ export function projectContainerSchedule(sources: CalendarSources, warehouses: W
|
||||
if (warehouses === null) continue
|
||||
const warehouse = warehouseMap.get(row.containerBookingId)
|
||||
warehouseMap.delete(row.containerBookingId)
|
||||
const projected = projectWarehouseArrival(row.arrivalAt, warehouse)
|
||||
const projected = projectWarehouseArrival(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: warehouse?.plannedAt ? warehouse.planCount > 0 ? '已有入库计划,日期已冻结' : '人工维护' : projected.isDefault ? '到港日期+2个自然日(默认)' : '柜内计划时间不一致,待核实',
|
||||
subtitle: !planned ? '柜内计划时间不一致,待核实' : warehouse && 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 date = row.plannedAt?.slice(0, 10) || null
|
||||
const projected = projectWarehouseArrival(row)
|
||||
const date = projected.date
|
||||
if (!date || !inRange(date, range)) continue
|
||||
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,
|
||||
events.push({ key: `warehouse-${row.sourceContainerBookingId}`, type: 'warehouse', date, time: projected.time,
|
||||
title: `${row.cabinetNo || '—'} 计划到仓`, 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: timestamp(row.plannedAt) }, { label: '实际到仓', value: timestamp(row.actualAt) }] })
|
||||
{ label: '计划到仓', value: row.plannedAt ? timestamp(row.plannedAt) : date }, { 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: `${row.containerNo || '—'} ${row.actualEmptyReturnAt ? '已还柜' : '还柜'}`,
|
||||
title: `${containerMap.get(row.containerBookingId)?.cabinetNo || '—'} ${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 })
|
||||
const { warehouses, event } = await readSchedule({ planCount, plannedDate: '2026-10-22', isDefault: true })
|
||||
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,6 +21,7 @@ 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
|
||||
@@ -29,6 +30,8 @@ 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'> & {
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface ContainerArrivalTime {
|
||||
actualWarehouseArrivalAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
plannedDate?: string | null
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
function normalizeArrival(item: ContainerArrivalTime): ContainerArrivalTime {
|
||||
|
||||
@@ -114,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.any(String), '开始整柜报关 · MCR08290001', expect.objectContaining({ customClass: 'admin-density-confirm' }))
|
||||
expect(confirm).toHaveBeenCalledWith('确认开始整柜报送?', '整柜报关', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认' }))
|
||||
expect(api.start).not.toHaveBeenCalled()
|
||||
confirm.mockRestore()
|
||||
wrapper.unmount()
|
||||
@@ -164,8 +164,7 @@ 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.get('[aria-label="报关概况"] dt').text()).toBe('实际箱数')
|
||||
expect(wrapper.get('[aria-label="报关概况"] dd').text()).toBe('12')
|
||||
expect(wrapper.find('[aria-label="报关概况"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
||||
expect(wrapper.text()).toContain('客户退税资料')
|
||||
expect(wrapper.text()).toContain('报关行回执')
|
||||
|
||||
@@ -67,7 +67,6 @@ 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))
|
||||
@@ -184,7 +183,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('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', customsDialogTitle('开始整柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
|
||||
await ElMessageBox.confirm('确认开始整柜报送?', '整柜报关', { 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('整柜报关已开始') }
|
||||
@@ -315,12 +314,9 @@ onMounted(load)
|
||||
<template v-else-if="customs">
|
||||
<el-empty v-if="!customs.hasActualCargo" description="当前装柜计划没有实际装柜货物,无需报关" :image-size="56" />
|
||||
<template v-else>
|
||||
<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 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><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>
|
||||
</dl>
|
||||
<div v-if="missingInbounds.length" class="compact-table-wrap">
|
||||
<h3>缺退税资料清单</h3>
|
||||
@@ -370,5 +366,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}.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)}
|
||||
.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)}
|
||||
</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" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<el-form class="density-form" label-position="left">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable fee-editor-table" aria-label="尾程费用填写"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table 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><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>
|
||||
<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>
|
||||
</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(280px, 2fr) repeat(4, minmax(0, 1fr)); margin-bottom: var(--admin-space-4); }
|
||||
.overview-panel .pda-summary { grid-template-columns: 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 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}.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>
|
||||
|
||||
@@ -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" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<el-form class="density-form" label-position="left" label-width="160px">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable" aria-label="关联订舱资料"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table" 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,4 +1,5 @@
|
||||
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,12 +93,10 @@ async function rememberWorkbenchTab(tabName: string | number) {
|
||||
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
|
||||
const arrivalTime = ref<ContainerArrivalTime | null>(null)
|
||||
const expectedArrivalText = computed(() => {
|
||||
const row = workbenchDetail.value
|
||||
const arrival = arrivalTime.value
|
||||
const projected = projectWarehouseArrival(row?.actualFacts?.arrivedDestinationPortAt || row?.actualPortArrivalAt || row?.estimatedPortArrivalAt,
|
||||
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
const projected = projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return arrival?.estimatedWarehouseArrivalAt ? formatTime(arrival.estimatedWarehouseArrivalAt)
|
||||
: projected.date ? `${projected.date}(默认推算)` : '—'
|
||||
: projected.date ? `${projected.date}${projected.isDefault ? '(默认推算)' : ''}` : '—'
|
||||
})
|
||||
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
|
||||
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
|
||||
@@ -322,9 +320,6 @@ 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(' / ')
|
||||
@@ -739,7 +734,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><p>{{ loadingListDescription }}</p></div></header>
|
||||
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2></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)" />
|
||||
@@ -757,24 +752,24 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
||||
<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">
|
||||
<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">
|
||||
|
||||
<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" :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>
|
||||
<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>
|
||||
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
|
||||
<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">
|
||||
<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" 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" />
|
||||
<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" />
|
||||
|
||||
|
||||
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
|
||||
@@ -784,6 +779,7 @@ 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 }])
|
||||
vi.mocked(listContainerArrivalTimes).mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0, planCount: 0, plannedDate: '2026-10-23', isDefault: true }])
|
||||
const wrapper = await mountOverseasRoute()
|
||||
const picker = wrapper.findAllComponents({ name: 'ElDatePicker' }).find(item => item.props('placeholder') === '2026-10-23(默认)')!
|
||||
expect(picker).toBeDefined()
|
||||
|
||||
@@ -286,12 +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 defaultArrivalDate(source: object) {
|
||||
function warehouseArrivalProjection(source: object) {
|
||||
const row = source as ContainerWorkbenchRow
|
||||
const arrival = arrivalTimes.value[row.id]
|
||||
const projected = projectWarehouseArrival(row.actualFacts?.arrivedDestinationPortAt || row.actualPortArrivalAt || row.estimatedPortArrivalAt,
|
||||
arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return projected.isDefault ? projected.date : null
|
||||
const projected = projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
|
||||
return projected
|
||||
}
|
||||
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 || '—' }
|
||||
@@ -764,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><small>系统生成</small></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong></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="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 #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 #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>
|
||||
|
||||
@@ -615,7 +615,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('不能部分输入、单独分配或单独移除')
|
||||
expect(document.body.textContent).not.toContain('不能部分输入、单独分配或单独移除')
|
||||
const popup = wrapper.getComponent(AppDialog)
|
||||
expect(popup.props('title')).toBe('整组加入当前柜次 · CLG-2093539779795795969')
|
||||
const contextRows = assignmentDialog().findAll('table[aria-label="全局装柜分配来源"] tr')
|
||||
|
||||
@@ -852,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" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<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>
|
||||
@@ -867,13 +867,11 @@ 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 :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入、单独分配或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
||||
<el-alert v-if="selectedCandidate.blockReason" :title="selectedCandidate.blockReason" type="error" :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>
|
||||
@@ -882,6 +880,7 @@ 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).toContain('不可拆分单元整托加入')
|
||||
expect(document.body.textContent).not.toContain('不可拆分单元整托加入')
|
||||
expect(document.body.textContent).not.toContain('本次计划装柜箱数')
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe('整托加入当前柜次 · DP-260908-0001')
|
||||
|
||||
@@ -180,14 +180,6 @@ 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()
|
||||
@@ -419,7 +411,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" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<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>
|
||||
@@ -435,15 +427,10 @@ 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 :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" />
|
||||
<el-alert v-if="selectedCandidate.blockReason" :title="selectedCandidate.blockReason" type="error" :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,18 +7,22 @@ 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'],
|
||||
])('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 })
|
||||
])('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 })
|
||||
})
|
||||
it('keeps manually maintained and frozen dates instead of following port arrival', () => {
|
||||
for (const plan of [{ plannedAt: '2026-10-28T09:30:00', version: 1, planCount: 0 }, { plannedAt: '2026-10-28T09:30:00', version: 0, planCount: 1 }]) {
|
||||
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false })
|
||||
expect(projectWarehouseArrival(plan)).toEqual({ date: '2026-10-28', time: '09:30', isDefault: false })
|
||||
}
|
||||
})
|
||||
it('does not replace inconsistent or frozen missing dates with defaults', () => {
|
||||
for (const plan of [{ plannedAt: null, version: -1, planCount: 0 }, { plannedAt: null, version: 0, planCount: 1 }, { plannedAt: null, version: 1, planCount: 0 }]) {
|
||||
expect(projectWarehouseArrival('2026-10-21T15:49:00', plan).date).toBeNull()
|
||||
expect(projectWarehouseArrival(plan).date).toBeNull()
|
||||
}
|
||||
for (const arrival of [null, '', 'invalid', '2026-02-30T09:00:00']) expect(projectWarehouseArrival(arrival, undefined).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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,19 +2,12 @@ export interface WarehouseArrivalPlan {
|
||||
plannedAt: string | null
|
||||
version: number
|
||||
planCount: number
|
||||
plannedDate?: string | null
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
/** 默认值只有当地日期,不能作为人工维护的日期时间提交。 */
|
||||
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 }
|
||||
/** 仅展示服务端统一解析的日期,不在清关列表或日历重复推算。 */
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
|
||||
|
||||
/* 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('中文拼音大写首字母,如 SZWL')
|
||||
expect(customersSource).toContain('placeholder="选填,默认自动生成"')
|
||||
expect(customersSource).not.toContain('公司名称、客户编号或联系人')
|
||||
expect(customersSource).not.toContain('row.tenantCode.toLowerCase().includes(keyword)')
|
||||
expect(customersSource).not.toContain('String(row.id).includes(keyword)')
|
||||
@@ -210,12 +210,16 @@ 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-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<section class="density-dialog-section workbench-evidence" data-tone="work"><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-form--outlined density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">基础资料</h3>
|
||||
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
|
||||
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="公司名称" prop="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="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" data-tone="work"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
|
||||
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
|
||||
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
|
||||
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item></td></tr>
|
||||
@@ -1103,8 +1103,8 @@ onMounted(() => { void loadCustomers() })
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="density-dialog-section qualification-section workbench-evidence" data-tone="work"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp></h3><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||
<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>
|
||||
<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>
|
||||
<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"><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>
|
||||
<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>
|
||||
</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,13 +1183,12 @@ 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="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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -1219,7 +1218,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-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-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); }
|
||||
@@ -1240,7 +1239,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: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; }
|
||||
.customer-editor-footer { display:flex; align-items:center; justify-content:flex-end; gap:var(--admin-space-6, 16px); }
|
||||
.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" data-tone="work"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
|
||||
<section class="density-dialog-section workbench-evidence"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
|
||||
<div class="customer-fee-tier-groups">
|
||||
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section" data-tone="work">
|
||||
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section">
|
||||
<div class="customer-fee-tier-heading"><h3 class="density-dialog-section__title">{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div>
|
||||
<div class="customer-fee-tier-table-wrap workbench-evidence"><table class="density-table density-table--editable customer-fee-tier-table" :aria-label="groupIndex === 0 ? '仓租阶梯填写' : '退货仓储费阶梯填写'"><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody>
|
||||
<tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></td></tr>
|
||||
|
||||
@@ -39,6 +39,8 @@ 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,6 +82,7 @@ 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: '登记收付款',
|
||||
|
||||
@@ -187,15 +187,29 @@ 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('周长')
|
||||
await wrapper.find('.el-table__expand-icon').trigger('click')
|
||||
await flushPromises()
|
||||
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('.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 flushPromises()
|
||||
expect(wrapper.find('.measurement-limit-warning').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.measurement-table th').some(cell => cell.text() === '超限提醒')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the list customer name when a legacy measurement detail has no customer snapshot', async () => {
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ companyName: '' }))
|
||||
const wrapper = await mountPanel(undefined, { customerName: '柏悦物流' })
|
||||
@@ -407,20 +421,14 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect((countInput.element as HTMLInputElement).value).toBe('9')
|
||||
})
|
||||
|
||||
it('shows the complete server-calculated measurement facts in the expanded row', async () => {
|
||||
it('retains server-calculated facts in the draft without the removed expandable row', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
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')
|
||||
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',
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses the established first-leg detail surface and status patterns', async () => {
|
||||
@@ -611,11 +619,9 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect(wrapper.emitted('updated')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps the formal deviceMeasuredAt response field into the read-only measurement time', async () => {
|
||||
it('retains the formal deviceMeasuredAt response field in the measurement draft', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
await wrapper.get('.el-table__expand-icon').trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('2026-08-04 10:30:00+08:00')
|
||||
expect(wrapper.vm.form.items[0].measuredAt).toBe('2026-08-04T10: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,10 +614,6 @@ 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' : ''
|
||||
}
|
||||
@@ -789,23 +785,6 @@ 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>
|
||||
@@ -822,6 +801,9 @@ 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>
|
||||
@@ -930,7 +912,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: #f4fbf8; color: #18795d; }
|
||||
.measurement-comparison thead th:nth-child(3) { background: var(--admin-surface, #fff); 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); }
|
||||
@@ -990,10 +972,8 @@ 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; }
|
||||
@@ -1001,7 +981,6 @@ 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) {
|
||||
@@ -1015,7 +994,6 @@ 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,11 +1,12 @@
|
||||
/* 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(--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-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-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(--admin-dialog-work-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-work-bg); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-items-section { min-width: 0; margin: var(--admin-space-5, 12px) 0 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-surface, #fff); }
|
||||
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-deviation-alert { background: var(--admin-surface, #fff); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-subheading { color: #18795d; font-size: var(--admin-font-section, 14px); }
|
||||
.inbound-measurement-workspace-dialog.inbound-density .measurement-declaration { min-width: 0; margin-top: var(--admin-space-3, 8px); padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); border: 1px solid var(--admin-dialog-source-border); border-radius: var(--admin-dialog-section-radius); background: var(--admin-dialog-source-bg); }
|
||||
|
||||
|
||||
@@ -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 },
|
||||
props: { customerId: String, inboundId: String, reviewMode: Boolean, correctionMode: Boolean, tableMode: Boolean },
|
||||
emits: ['changed'],
|
||||
template: '<button class="mock-weight-change" @click="$emit(\'changed\')">重量证据</button>',
|
||||
},
|
||||
@@ -193,6 +193,7 @@ describe('MeasurementReviewPanel', () => {
|
||||
|
||||
const weightPanel = wrapper.getComponent({ name: 'WeightOcrPanel' })
|
||||
expect(weightPanel.props('reviewMode')).toBe(true)
|
||||
expect(weightPanel.props('tableMode')).toBe(true)
|
||||
expect(weightPanel.props('correctionMode')).toBe(false)
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('修改重量'))?.trigger('click')
|
||||
expect(weightPanel.props('correctionMode')).toBe(true)
|
||||
|
||||
@@ -205,7 +205,7 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
<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" data-tone="summary" aria-labelledby="review-data-title">
|
||||
<section class="review-data density-dialog-section" 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>
|
||||
@@ -286,6 +286,7 @@ defineExpose({ confirmApprove, enableWeightCorrection, approvalButtonLabel, isPe
|
||||
|
||||
<WeightOcrPanel
|
||||
review-mode
|
||||
table-mode
|
||||
:correction-mode="correctionMode"
|
||||
:customer-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
|
||||
@@ -496,7 +496,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
expect(wrapper.text()).toContain('1747.500 KG')
|
||||
})
|
||||
|
||||
it('atomically corrects and confirms an approved weight during measurement review', async () => {
|
||||
it('opens a table image and atomically corrects and confirms an approved weight during measurement review', async () => {
|
||||
const confirmed = {
|
||||
...pendingWeight,
|
||||
recognitionStatus: 'CONFIRMED',
|
||||
@@ -514,13 +514,14 @@ 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 })
|
||||
const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true, tableMode: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('删除图片')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('修改含托盘毛重'))?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.weight-data-table').exists()).toBe(true)
|
||||
expect(wrapper.find('.weight-grid').exists()).toBe(false)
|
||||
await openWeightDialog(wrapper)
|
||||
await dialogWeightInput().setValue('125')
|
||||
const reason = document.body.querySelector<HTMLTextAreaElement>('textarea[placeholder="请填写本次毛重修正原因"]')
|
||||
expect(reason).not.toBeNull()
|
||||
|
||||
@@ -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" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<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 density-table--editable fee-editor-table" aria-label="柜次费用填写"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table 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 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>
|
||||
<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>
|
||||
<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,11 +1,37 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { flushPromises, mount, 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,8 +145,16 @@ function actionLabel(action: string) {
|
||||
return ({
|
||||
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
|
||||
RECOMMEND: '智能排柜', PUSH_PRELOAD: '推送预装', UPDATE_TRANSPORT: '修改运输信息',
|
||||
CONFIRM_PRELOAD: '确认预装',
|
||||
START_LOADING: '开始拣货', COMPLETE: '完成装柜',
|
||||
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: '确认目的港清关完成',
|
||||
} 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, '确认预装').props('modelValue')).toBe(false)
|
||||
expect(dialog(wrapper, '推送PDA').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, '确认预装')
|
||||
const popup = dialog(wrapper, '推送PDA')
|
||||
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,7 +109,8 @@ describe('PreloadPlanDetailView prototype contract', () => {
|
||||
expect(source).toContain('summary?.readiness.canConfirm')
|
||||
expect(source).toContain('confirmSaving.value')
|
||||
expect(source).toContain('await loadDetail()')
|
||||
expect(source).toContain('本操作将确认整份预装计划')
|
||||
expect(source).toContain("preloadDialogTitle('推送PDA')")
|
||||
expect(source).not.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" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<el-form ref="editFormRef" class="density-form" :model="editForm" :rules="editRules">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable preload-editor-table" aria-label="运输信息填写"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table 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" data-tone="work">
|
||||
<section class="density-dialog-section">
|
||||
<el-form class="density-form">
|
||||
<div class="workbench-evidence"><table class="density-table density-table--editable preload-editor-table" aria-label="预装明细备注"><tbody>
|
||||
<div class="workbench-evidence"><table class="density-table 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,10 +323,9 @@ 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">
|
||||
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" />
|
||||
<section class="density-dialog-section" data-tone="summary">
|
||||
<section class="density-dialog-section">
|
||||
<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>
|
||||
|
||||
@@ -20,12 +20,14 @@
|
||||
|
||||
## 关键边界
|
||||
|
||||
- 订舱草稿按当前版本维护资料;放舱后资料与表单只读,不允许绕过版本直接改字段。
|
||||
- 打印对草稿与已放舱订舱均开放,按当前**已保存**资料生成,未填写字段显示“—”,不用未保存的表单值。
|
||||
- 订舱草稿按当前版本维护资料;放舱后运输、关系方、柜型需求、预报货量和附件只读;仅实货量可由订舱编辑权限通过 `/actual-cargo` 按版本、幂等键和原因补录并审计,禁止绕过运输冻结边界。
|
||||
- 打印对草稿与已放舱订舱均开放,按当前**已保存**资料生成,未填写字段显示“—”,不用未保存的表单值。托运单取委值,提单确认件取实值;实值由客服手工录入,不回退为委值,不生成正式提单。
|
||||
- 关联候选只消费服务端 `/link-candidates` 分页结果,不在浏览器重排或本地拼接候选。
|
||||
- 订舱是拖车预约的唯一归属:拖车由 `../trucking/` 创建,本模块不直接建拖车单。
|
||||
- 出仓前船期只展示订舱级计划,实际节点归 `../port-schedules/` 与海外回写,本模块不写实际时间。
|
||||
|
||||
- 本轮布局与操作证据维护在 `PROTOTYPE_MATRIX.md`,缺截图时保留待人工确认。
|
||||
|
||||
## 验证
|
||||
|
||||
- 定向:`pnpm test src/modules/ocean-bookings/<目标测试>`;资料字段或打印变更同时核对 `print/` 模板与 `file/` 附件契约。
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# 订舱页面受影响操作核对
|
||||
|
||||
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后仅补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
|
||||
|
||||
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已通过阶段勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不产生预览事实,也不声称实际打印完成。预览标记随本次页面保留;刷新后按服务端保存/放舱/实值恢复进度,已放舱时托运单阶段保持通过,提示“可重新预览 / 打印”,避免前面灰色、后面完成的断开。放舱前托运单数据改变须重新预览;提单确认件始终要求已保存实值和对应 PDF 成功预览后才完成。
|
||||
|
||||
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 页面核对 |
|
||||
|---|---|---|---|---|
|
||||
| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 独立页面;按产品稿重排视觉层级,保留仓库、渠道、预计到港、多柜型和附件;港代码与港名成组、委实成对输入,新增保存留在详情 | 已核对新增保存、初始步骤、控件状态与仓库渠道联动;完整长字段展示待人工确认 |
|
||||
| `/ocean-bookings/:bookingId` | 已放舱补实 | 客服补录实际货量 | 运输及预报只读,实值可按编辑权限补录,原因必填并版本审计 | 已核对放舱、原因校验、补实保存和历史;最终 TEST #698 回验实值保存后立即回显,无需刷新 |
|
||||
| `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有PDF预览;读已保存委/实数据,缺失显示“—” | 弹框开关/重开、PDF 生成与数据映射已核对;内嵌 PDF 空白,显示未通过 |
|
||||
|
||||
人工按 1920×1080、100% 缩放核对三方长名称/地址、两类货量、保存失败与版本冲突、冻结/无编辑权限,以及两类 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,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
|
||||
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingActualCargo, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
|
||||
import { http } from '@/shared/api/http'
|
||||
|
||||
const detailResponse = (version = 0, status = 'DRAFT') => ({
|
||||
@@ -14,6 +14,29 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({
|
||||
})
|
||||
|
||||
describe('ocean bookings api', () => {
|
||||
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 { OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingDocument, OceanBookingDocumentInvalidation, OceanBookingDocumentMutation, OceanBookingDocumentType, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingPrintType, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
|
||||
import type { OceanBookingActualCargoRequest, 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,6 +21,12 @@ 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.pol ?? raw.departurePortName ?? null,
|
||||
pod: raw.pod ?? raw.destinationPortName ?? null,
|
||||
carrierName: raw.carrierName ?? raw.shippingCompanyName ?? null,
|
||||
@@ -88,6 +94,10 @@ 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 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> = {
|
||||
|
||||
@@ -33,6 +33,7 @@ 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()
|
||||
@@ -144,4 +145,24 @@ 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,7 +10,8 @@ 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 }>()
|
||||
const props = defineProps<{ bookingId: string; bookingNo?: string | null; allowed: boolean; initialType?: OceanBookingPrintType }>()
|
||||
const emit = defineEmits<{ previewed: [event: { bookingId: string; type: OceanBookingPrintType }] }>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const selectedType = ref<OceanBookingPrintType | ''>('')
|
||||
const loading = ref(false)
|
||||
@@ -42,15 +43,18 @@ 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(props.bookingId, selectedType.value)
|
||||
const blob = await downloadOceanBookingPrint(bookingId, type)
|
||||
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 {
|
||||
@@ -71,7 +75,13 @@ function download() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
watch(open, (value) => { if (!value) reset() })
|
||||
watch(open, (value) => {
|
||||
if (!value) reset()
|
||||
else if (props.initialType) {
|
||||
selectedType.value = props.initialType
|
||||
void preview()
|
||||
}
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(() => { previewRequest += 1; releasePreview() })
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ import {
|
||||
listWarehouseLogisticsChannels,
|
||||
releaseOceanBooking,
|
||||
updateOceanBookingDraft,
|
||||
updateOceanBookingActualCargo,
|
||||
} 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' } }))
|
||||
@@ -23,7 +25,8 @@ vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
useRouter: () => ({ push: vi.fn(), replace: routerReplace }),
|
||||
}))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
const deniedPermissions = vi.hoisted(() => new Set<string>())
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !deniedPermissions.has(permission) }) }))
|
||||
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() }))
|
||||
@@ -32,7 +35,7 @@ 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(),
|
||||
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(), updateOceanBookingActualCargo: vi.fn(),
|
||||
}))
|
||||
|
||||
function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLadingNo = 'BL-001'): OceanBookingDetail {
|
||||
@@ -66,6 +69,11 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading
|
||||
|
||||
type ViewModel = {
|
||||
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
|
||||
@@ -82,7 +90,7 @@ async function mountView() {
|
||||
stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
DetailPageLayout: { template: '<section><slot name="header" /><slot /></section>' },
|
||||
PageToolbar: { template: '<header><slot name="actions" /></header>' },
|
||||
PageToolbar: { props: ['description'], template: '<header><p class="toolbar-description">{{ description }}</p><slot name="actions" /></header>' },
|
||||
CarrierScheduleQueryPanel: true,
|
||||
},
|
||||
},
|
||||
@@ -95,6 +103,7 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
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 })
|
||||
@@ -103,6 +112,23 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
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('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'
|
||||
const customer = { id: '9007199254740999', tenantName: '柏悦物流', status: 'ACTIVE' } as PlatformTenantResource
|
||||
@@ -135,7 +161,9 @@ 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.text()).toContain('不能修改、不能删除')
|
||||
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()).not.toContain('处理数据库')
|
||||
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
|
||||
expect(releaseOceanBooking).not.toHaveBeenCalled()
|
||||
@@ -149,6 +177,37 @@ describe('OceanBookingDetailView one-click release', () => {
|
||||
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('shows the route alone in the heading without a single customer and keeps an actual customer name', async () => {
|
||||
const value = booking()
|
||||
value.customerId = null; value.customerName = null
|
||||
value.oceanBooking.customerId = null; value.oceanBooking.customerName = null
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(value)
|
||||
const { wrapper } = await mountView()
|
||||
expect(wrapper.get('.toolbar-description').text()).toBe('上海 → 洛杉矶')
|
||||
expect(wrapper.text()).not.toContain('未关联客户')
|
||||
wrapper.unmount()
|
||||
|
||||
vi.mocked(getOceanBooking).mockResolvedValue(booking())
|
||||
const { wrapper: customerWrapper } = await mountView()
|
||||
expect(customerWrapper.get('.toolbar-description').text()).toBe('测试客户 · 上海 → 洛杉矶')
|
||||
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'))
|
||||
@@ -248,4 +307,111 @@ 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.filter((party) => party.partyName.trim())')
|
||||
expect(source).toContain('parties: form.data.parties.map')
|
||||
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 and presents the irreversible warning', () => {
|
||||
it('performs the complete release gate without the extra explanatory line', () => {
|
||||
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).toContain('放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。')
|
||||
expect(source).not.toContain('放舱后运输资料和预报货量不能修改、不能删除;实际货量可另行补录并记录原因。请确认运输资料无误。')
|
||||
expect(source).toContain('if (hasUnsavedDraftChanges.value)')
|
||||
expect(source).toContain('const saved = await persistDraft(false)')
|
||||
expect(source).toContain('releaseVersion = saved.version')
|
||||
@@ -70,7 +70,7 @@ 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)')
|
||||
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value)')
|
||||
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
|
||||
expect(source).not.toContain('releaseReason')
|
||||
expect(source).toContain('放舱前请上传有效的订舱确认文件或提单文件')
|
||||
@@ -112,15 +112,16 @@ 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).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).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('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, Printer, Save, Trash2 } from '@lucide/vue'
|
||||
import { ArrowLeft, Check, Printer, Save, Ship, 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'
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
releaseOceanBooking,
|
||||
listWarehouseLogisticsChannels,
|
||||
updateOceanBookingDraft,
|
||||
updateOceanBookingActualCargo,
|
||||
type OceanLogisticsChannel,
|
||||
} from '../api/platformOceanBookings'
|
||||
import type {
|
||||
@@ -41,10 +42,12 @@ import type {
|
||||
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'
|
||||
|
||||
@@ -95,10 +98,13 @@ 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: '', address: '' }))
|
||||
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', contactEmail: '', taxNo: '', address: '' }))
|
||||
}
|
||||
function blankData(): OceanBookingDraftData {
|
||||
return {
|
||||
freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS',
|
||||
cargoDescription: '', marksAndNos: '', declaredPackageCount: null, declaredGrossWeightKg: null, declaredVolumeCbm: null,
|
||||
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,
|
||||
@@ -117,13 +123,22 @@ const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can
|
||||
const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:ocean-booking:list'))
|
||||
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 actualSaving = ref(false)
|
||||
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)
|
||||
const releaseDialogOpen = ref(false)
|
||||
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_ACTUAL_CARGO: '实货量补录/修正', UPLOAD_DOCUMENT: '上传文件', ATTACH_DOCUMENT: '关联文件', INVALIDATE_DOCUMENT: '作废文件' }
|
||||
return labels[action] || '资料维护'
|
||||
}
|
||||
async function loadOptions() {
|
||||
optionsLoading.value = true
|
||||
optionsError.value = ''
|
||||
@@ -163,12 +178,49 @@ function draftPayload(): OceanBookingDraftData {
|
||||
billOfLadingNo: form.data.billOfLadingNo.trim(),
|
||||
vesselName: form.data.vesselName.trim(),
|
||||
voyageNo: form.data.voyageNo.trim(),
|
||||
parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })),
|
||||
parties: form.data.parties.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
|
||||
}
|
||||
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
|
||||
@@ -248,9 +300,18 @@ function applyDetail(value: OceanBookingDetail) {
|
||||
destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName,
|
||||
placeOfDelivery: value.placeOfDelivery,
|
||||
remark: value.remark,
|
||||
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
|
||||
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) })),
|
||||
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 = []
|
||||
@@ -265,9 +326,24 @@ 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
|
||||
@@ -283,10 +359,13 @@ async function persistDraft(showSuccess: boolean) {
|
||||
if (showSuccess) ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || '未生成订舱号'}` : '订舱草稿已保存')
|
||||
if (isNew.value) {
|
||||
workspace.removeByIds(['ocean-booking-create'])
|
||||
await router.replace('/ocean-bookings')
|
||||
applyDetail(saved)
|
||||
workspace.bumpCacheEpoch('ocean-bookings')
|
||||
await router.replace(`/ocean-bookings/${encodeURIComponent(saved.id)}`)
|
||||
return saved
|
||||
}
|
||||
applyDetail(saved)
|
||||
workspace.bumpCacheEpoch('ocean-bookings')
|
||||
return saved
|
||||
} catch (cause) { actionError.value = getApiErrorDetails(cause); return null }
|
||||
}
|
||||
@@ -377,53 +456,123 @@ 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>
|
||||
<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>
|
||||
<span class="booking-brand" aria-hidden="true"><Ship :size="22" /></span>
|
||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱信息管理'" :description="detail ? `${detail.customerName ? `${detail.customerName} · ` : ''}${detail.pol || '—'} → ${detail.pod || '—'}` : '出口订舱作业'">
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!formWritable" class="booking-groups density-form">
|
||||
<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">
|
||||
<section class="booking-group" aria-label="基本信息">
|
||||
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
|
||||
<h3 class="booking-group__title"><span>基础信息</span></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>
|
||||
<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="装柜仓库" 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>
|
||||
</div>
|
||||
</section>
|
||||
<section class="booking-group" aria-label="航线船期">
|
||||
<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>
|
||||
<h3 class="booking-group__title"><span>港口航线</span></h3>
|
||||
<div class="booking-group__body booking-group__body--fields">
|
||||
<el-form-item label="起运港"><div class="booking-port-fields"><el-input v-model="form.data.departurePortCode" placeholder="港代码" aria-label="起运港代码" /><el-input v-model="form.data.departurePortName" placeholder="港口名称" aria-label="起运港名称" /></div></el-form-item>
|
||||
<el-form-item label="目的港"><div class="booking-port-fields"><el-input v-model="form.data.destinationPortCode" placeholder="港代码" aria-label="目的港代码" /><el-input v-model="form.data.destinationPortName" placeholder="港口名称" aria-label="目的港名称" /></div></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-select v-model="form.data.packageUnit" :disabled="!formWritable" placeholder="请选择包装单位"><el-option label="箱 CARTONS" value="CARTONS" /><el-option label="件 PCS" value="PCS" /><el-option label="托 PALLETS" value="PALLETS" /></el-select></el-form-item>
|
||||
<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>柜型需求</h3><div class="booking-group__body">
|
||||
<h3 class="booking-group__title"><span>柜型需求与备注</span></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 scope="col">数量</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
|
||||
@@ -450,19 +599,21 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</div>
|
||||
</section>
|
||||
</el-form>
|
||||
<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">
|
||||
<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 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>
|
||||
|
||||
<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" />
|
||||
@@ -471,16 +622,15 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
|
||||
<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></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>
|
||||
<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>
|
||||
</section>
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" />
|
||||
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" :initial-type="printType" @previewed="onPrintPreviewed" />
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<section class="density-dialog-section"><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>
|
||||
@@ -493,6 +643,196 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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)}
|
||||
.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-toolbar :deep(.page-toolbar__description) { margin-top: var(--admin-space-1); font-size: var(--admin-font-caption); color: var(--workbench-muted); }
|
||||
.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-port-fields { display: grid; grid-template-columns: minmax(76px, .65fr) minmax(0, 1fr); gap: var(--admin-space-2); width: 100%; }
|
||||
.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-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: 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; }
|
||||
.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; }
|
||||
}
|
||||
</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 }
|
||||
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 OceanBookingContainerRequirement {
|
||||
id?: string
|
||||
requirementId?: string
|
||||
@@ -53,7 +53,27 @@ export interface OceanBookingAllocatedContainer {
|
||||
capacityCbm: number
|
||||
maxWeightKg: number
|
||||
}
|
||||
export interface OceanBookingDraftData {
|
||||
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 {
|
||||
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainerRequirement[]
|
||||
channelCode: string | null; warehouseId: string | null
|
||||
billOfLadingNo: string; vesselName: string; voyageNo: string
|
||||
@@ -63,7 +83,7 @@ export interface OceanBookingDraftData {
|
||||
placeOfDelivery?: string | null
|
||||
remark?: string | null
|
||||
}
|
||||
export interface OceanBookingResource {
|
||||
export interface OceanBookingResource extends OceanBookingCargoData {
|
||||
id: string; bookingNo: string | null; status: OceanBookingStatus; version: number
|
||||
customerId: string | null; customerName: string | null; billOfLadingNo: string | null
|
||||
/** 订舱柜型需求 quantity 的总和,不是已关联真实柜数。 */
|
||||
@@ -81,7 +101,7 @@ export interface OceanBookingResource {
|
||||
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; occurredAt: string }
|
||||
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; createdAt: 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 {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
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 ? '已预览' : '可预览 / 打印'),
|
||||
]
|
||||
}
|
||||
@@ -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, money } from '../procurementPresentation'
|
||||
import { procurementStatuses, statusLabel, statusTone, 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, formatter: (row: OverseasPurchaseSummary) => statusLabel(row.status) },
|
||||
{ key: 'status', label: '状态', minWidth: 145, slotName: '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,6 +80,7 @@ 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,6 +42,13 @@ 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 })) },
|
||||
@@ -56,7 +63,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, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] },
|
||||
{ key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.deliveryAt) }, { key: 'status', label: '状态', width: 120, slotName: 'status' },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalTransportOrder) => formatDateTime(row.createdAt) },
|
||||
]
|
||||
const looseboardColumns: DataTableColumn[] = [
|
||||
@@ -65,7 +72,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, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.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, slotName: 'status' },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.createdAt) },
|
||||
]
|
||||
const columns = computed(() => {
|
||||
@@ -128,6 +135,7 @@ 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>
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
|
||||
- 运输路线地图:`components/TransportRouteDialog.vue`经`index.ts`公开导出,排车装车后、本土散板列表、本土卡车表单共用;来源字段、缺值规则和原型映射唯一见`PROTOTYPE_MATRIX.md`“运输路线地图”。仓库引用经`routeLocations.ts`复用Warehouse列表/详情解析完整地址(warehouse:list);解析失败不加载地图。仅规划路线,不接GPS、不用固定样例,不在车辆主档顶部重复放入口。
|
||||
|
||||
- 内部拼车需求池:`pages/CapacityOffersView.vue`、`pages/CapacityOfferDetailView.vue`、`api/capacityOffers.ts`,车次详情显式打开 `components/CapacityOfferSettingsDialog.vue` 发布余位;独立 `capacity-offer:list/manage/inquiry-*` 权限,销售只操作本人意向。流程与交互唯一契约见 `../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md`,服务端沿 `fleet-capacity-offers-v1.yaml`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
|
||||
- 当前散板需求:正式列表使用`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`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
|
||||
|
||||
- 外接预订可选销售报价:`TripBookingPanel.vue`沿车次作用域`/bookings/quote-candidates`读取候选,不由浏览器推城市或有效日期;来源报价版本与实际成交价分别显示。引用要求quote:list,手填成交价不新增该权限门禁;改变客户/卸货站/币种/计价方式后清除不再匹配的引用,迟到候选不覆盖新表单。确认承运由后端复验版本和有效性,实收调价保留来源快照。
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 海外通用资料原型核对矩阵
|
||||
|
||||
## 散板需求只读展示(2026-10-21)
|
||||
|
||||
依据本轮用户确认:仅保留内部余位需求池,人工联系下单;登记与后续拼车直接隐藏,不增加开关,不删除代码。当前范围唯一见[拼车主契约](../../../../../docs/海外仓业务/内部拼车需求池首版开发契约.md#当前页面范围2026-10-21),覆盖下文此前开放登记的页面结论。
|
||||
|
||||
| 页面/操作 | 当前承载与调整 | 验收 |
|
||||
|---|---|---|
|
||||
| 散板需求列表/查询、分页 | 同一正式路由改读当前派送排车摘要,按排车号展示起运仓、路线、计划发车、车辆容量、已排实托及真实/待确认余位;无登记和旧车次入口,原实现保留 | 本轮8文件115项定向测试及受影响文件类型检查通过;待人工1920×1080、100%截图确认 |
|
||||
| 散板需求详情/查看、重新核对 | 保持独立详情,仅展示运输摘要、联系方式和分段余位;车次号改为纯文本,不能进入旧车次详情;隐藏登记、意向列表、来货及履约窗口,旧登记参数不再打开窗口 | 同上 |
|
||||
|
||||
## 2026-10-20 列表顶部紧凑收尾
|
||||
|
||||
依据为本轮用户整柜业务截图及“所有列表顶部紧凑”的确认;列表公共工具栏和筛选内距由 `adminWorkbench.css` 统一维护。
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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('数量无效')
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
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),
|
||||
})) }
|
||||
}
|
||||
@@ -147,3 +147,13 @@ 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 = { [K in keyof TripLoad]: Numeric }
|
||||
type WireLoad = { palletSpaces: Numeric; weightKg: Numeric | null; volumeCbm: Numeric | null }
|
||||
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: number(value.weightKg), volumeCbm: number(value.volumeCbm) })
|
||||
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) })
|
||||
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) })),
|
||||
|
||||
@@ -2,11 +2,10 @@ import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia } from 'pinia'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
|
||||
import CapacityInquiryDialog from './CapacityInquiryDialog.vue'
|
||||
import CapacityOfferSettingsDialog from './CapacityOfferSettingsDialog.vue'
|
||||
import CapacityOfferDetailView from '../pages/CapacityOfferDetailView.vue'
|
||||
import type { CapacityOffer } from '../capacityOfferTypes'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
@@ -64,11 +63,13 @@ it.each(['inquiry', 'settings'] as const)('keeps the real %s dialog and edited i
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('blocks browser back with the real router, cached detail and native dialog, without duplicating the global prompt', async () => {
|
||||
it('preserves route-leave protection in the retained native inquiry dialog without duplicating the global prompt', async () => {
|
||||
const pinia = createPinia(), registry = useUnsavedWorkStore(pinia), confirms = vi.spyOn(ElMessageBox, 'confirm')
|
||||
// 登记页面入口已隐藏;保留组件自身与真实路由的未保存保护回归。
|
||||
const InquiryHost = defineComponent({ components: { CapacityInquiryDialog }, setup: () => ({ offer, open: ref(false) }), template: '<button @click="open = true">打开保留的登记组件</button><CapacityInquiryDialog v-model="open" :offer="offer" :inquiry="null" :readonly="false" />' })
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/pool', name: 'overseas-master-data-capacity-offers', component: { template: '<p data-testid="pool-list">运力列表</p>' }, meta: { requiresAuth: true } },
|
||||
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: CapacityOfferDetailView, meta: { requiresAuth: true } },
|
||||
{ path: '/pool/:offerNo', name: 'overseas-master-data-capacity-offer-detail', component: InquiryHost, meta: { requiresAuth: true } },
|
||||
{ path: '/outside', name: 'outside', component: { template: '<p>外部页面</p>' }, meta: { workspaceTab: false } },
|
||||
] })
|
||||
// Match the application's intentional policy: cross-workspace switching skips the global prompt.
|
||||
@@ -81,7 +82,7 @@ it('blocks browser back with the real router, cached detail and native dialog, w
|
||||
await router.push('/pool'); await router.isReady()
|
||||
const wrapper = mount(Host, { attachTo: document.body, global: { plugins: [pinia, router, ElementPlus] } }); wrappers.push(wrapper)
|
||||
await router.push('/pool/CO-CLOSE'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get('details summary').trigger('click')
|
||||
const input = wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]')
|
||||
await input.setValue('UI-修复后保留输入'); await flushPromises()
|
||||
@@ -92,15 +93,15 @@ it('blocks browser back with the real router, cached detail and native dialog, w
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(wrapper.get('.app-dialog').isVisible()).toBe(true)
|
||||
await continueEditing()
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offer-detail')
|
||||
expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
|
||||
expect(input.element.value).toBe('UI-修复后保留输入'); expect(getCapacityOffer).not.toHaveBeenCalled()
|
||||
confirms.mockClear(); router.back(); await flushPromises()
|
||||
const message = [...document.querySelectorAll('.el-message-box')].at(-1)!
|
||||
const leave = [...message.querySelectorAll('button')].find(button => button.textContent?.trim() === '放弃并离开') as HTMLButtonElement
|
||||
expect(leave).toBeTruthy(); leave.click(); await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('overseas-master-data-capacity-offers')
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-CLOSE')
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(getCapacityOffer).not.toHaveBeenCalled()
|
||||
await new Promise(resolve => setTimeout(resolve, 50)); await router.push('/pool/CO-CLOSE'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记客户货物')!.trigger('click'); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '打开保留的登记组件')!.trigger('click'); await flushPromises()
|
||||
await wrapper.get<HTMLInputElement>('input[placeholder="填写货物名称(确认承运前必填)"]').setValue('另一次修改'); await flushPromises()
|
||||
confirms.mockClear(); const navigation = router.push('/outside'); await flushPromises()
|
||||
expect(confirms).toHaveBeenCalledOnce(); expect(document.body.textContent).toContain('全局离开确认')
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasMasterDataPermissions as permission } from '../permissions'
|
||||
@@ -97,7 +96,7 @@ onBeforeUnmount(() => { warehouseRequest++; editSession++ })
|
||||
<el-form-item label="可驾驶车型"><el-input v-model="profile.vehicleModels" maxlength="128" placeholder="如 53 尺拖车" /></el-form-item>
|
||||
<LoadErrorState v-if="warehouseError" class="full-width" title="仓库候选加载失败" :message="warehouseError.message" @retry="loadWarehouses" ><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<el-form-item label="语言 / 服务区域" class="full-width"><el-input v-model="profile.languagesAndArea" maxlength="256" placeholder="如 中文、英语 / 洛杉矶区域" /></el-form-item>
|
||||
<h3 class="full-width driver-section-heading">驾驶资质<InlineHelp title="驾驶资质规则"><p>驾驶证在 30 天内到期但仍有效时允许派车并提醒;预计任务结束前到期则禁止。其他证照日期用于档案记录。</p></InlineHelp></h3>
|
||||
<h3 class="full-width">驾驶资质</h3>
|
||||
<el-form-item label="驾照类型"><el-select v-model="form.licenseType" filterable allow-create default-first-option clearable><el-option v-for="kind in ['Class A', 'Class B', 'Class C', '普通驾照']" :key="kind" :label="kind" :value="kind" /></el-select></el-form-item>
|
||||
<el-form-item label="驾照编号"><el-input v-model="form.licenseNumber" maxlength="128" placeholder="请输入驾驶证编号" /></el-form-item>
|
||||
<el-form-item label="证照有效时区" required><el-select v-model="form.licenseZone" filterable allow-create default-first-option placeholder="请选择或输入 IANA 时区"><el-option v-for="zone in zones" :key="zone" :label="zone" :value="zone" /></el-select></el-form-item>
|
||||
@@ -106,12 +105,11 @@ onBeforeUnmount(() => { warehouseRequest++; editSession++ })
|
||||
<el-form-item label="体检到期日"><el-date-picker v-model="profile.medicalExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="培训 / 安全到期日"><el-date-picker v-model="profile.trainingExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="保险到期日"><el-date-picker v-model="profile.insuranceExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<h3 class="full-width driver-section-heading">证照附件<el-tag v-if="current?.licenseFileId" size="small" type="info">已有附件</el-tag><InlineHelp title="驾驶证上传说明"><p>支持 PDF、JPG、PNG,最大 10MiB。上传新驾驶证后需要重新审核;未上传时可先保存档案,补齐驾驶证后才能通过审核。</p></InlineHelp></h3>
|
||||
<h3 class="full-width driver-section-heading">证照附件<el-tag v-if="current?.licenseFileId" size="small" type="info">已有附件</el-tag></h3>
|
||||
<div class="full-width">
|
||||
<label v-if="access.can(permission.driverUpload)">驾驶证<input ref="fileInput" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="saving" @change="selectFile" /></label>
|
||||
<p v-if="selectedFile && current?.licenseFileId" class="density-dialog-note" role="status">更换驾驶证后需重新审核。</p>
|
||||
</div>
|
||||
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充司机档案说明(选填)" /></el-form-item>
|
||||
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function outsourcedBusinessFixture(): OutsourcedBusinessDetail {
|
||||
actualPalletCount: 1, operations: { money: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
|
||||
photoSummary: { loadingCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
|
||||
},
|
||||
dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
|
||||
dispatch: { publishLooseboardDemand: true, dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
|
||||
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function ownedBusinessFixture(): OwnedBusinessDetail {
|
||||
actualPalletCount: 1, operations: { money: null, actualDistanceMiles: null, fuelConsumptionLitresPer100Km: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
|
||||
photoSummary: { loadingCount: 0, fuelCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
|
||||
},
|
||||
dispatch: { dispatchNo: 'DS-OWN-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OWN-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
|
||||
dispatch: { publishLooseboardDemand: true, dispatchNo: 'DS-OWN-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [], exceptions: [] }, documents: { dispatchNo: 'DS-OWN-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
|
||||
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<template #stop="{ row }"><span v-if="matchingStops(row as PalletRow).length === 1">{{ matchingStops(row as PalletRow)[0]?.name }}</span><el-select v-else v-model="row.deliveryIndex" :disabled="!editable || busy || loading || !matchingStops(row as PalletRow).length" placeholder="请先匹配货物目的地"><el-option v-for="stop in matchingStops(row as PalletRow)" :key="stop.index" :value="stop.index" :label="stop.name" /></el-select></template>
|
||||
<template v-if="!embedded" #toolbar-actions><el-button @click="load">{{ planning ? '重新读取容量计划' : '重新读取装载' }}</el-button><el-button v-if="editable" type="primary" :loading="busy" :disabled="loading || !!info?.blockReason || !!destinationIssue || !rows.length" @click="save">{{ planning ? '确认计划容量' : '确认实际装载' }}</el-button></template>
|
||||
</DataTable></div>
|
||||
<table v-if="savedLoad && !stalePlan" class="density-table"><thead><tr><th>行驶区间</th><th>占用托位</th><th>重量(kg)</th><th>体积(m³)</th><th v-if="planning">剩余托位 / kg / m³</th></tr></thead><tbody><tr v-for="segment in segments" :key="segment.fromIndex"><td>{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}</td><td>{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}</td><td>{{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }}</td><td>{{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }}</td><td v-if="planning">{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}</td></tr></tbody></table>
|
||||
<table v-if="savedLoad && !stalePlan" class="density-table"><thead><tr><th>行驶区间</th><th>占用托位</th><th>重量(kg)</th><th>体积(m³)</th><th v-if="planning">剩余托位 / kg / m³</th></tr></thead><tbody><tr v-for="segment in segments" :key="segment.fromIndex"><td>{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}</td><td>{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}</td><td>{{ segment.occupied.weightKg ?? '—' }} / {{ trip.maxLoadKg }}</td><td>{{ segment.occupied.volumeCbm ?? '—' }} / {{ trip.maxVolumeCbm }}</td><td v-if="planning">{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}</td></tr></tbody></table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export { listTripDispatchCandidates, prepareTrip, type TripPreparation, type Dis
|
||||
|
||||
export type { TripPreviousDispatch } from './tripTypes'
|
||||
export { listVehicles } from './api/overseasMasterData'
|
||||
export { listDrivers } from './api/drivers'
|
||||
export { listDrivers, getDriver, downloadDriverLicense } from './api/drivers'
|
||||
export type { DriverDetail } from './driverTypes'
|
||||
export { normalizeTripDetail } from './api/trips'
|
||||
export type { TripDetail, TripAssignment, TripResourceRelease } from './tripTypes'
|
||||
|
||||
@@ -53,11 +53,14 @@ async function load() {
|
||||
try {
|
||||
const value = await getCapacityOffer(String(route.params.offerNo))
|
||||
let checked: CapacityInquiry | null = null
|
||||
// 当前只读展示余位,旧登记深链不再打开登记窗口;原流程保留。
|
||||
/*
|
||||
if (typeof route.query?.inquiry === 'string' && /^[A-Z][A-Z0-9-]{1,63}$/.test(route.query.inquiry)) {
|
||||
const returned = await getCapacityInquiry(route.query.inquiry)
|
||||
if (returned.offerNo !== value.offer.offerNo) throw new Error('登记不属于当前发布')
|
||||
if (current === sequence) { checked = returned; readonly.value = true; confirmMode.value = false; editorOpen.value = true }
|
||||
} else if (editing.value && editorOpen.value) checked = value.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo)
|
||||
*/
|
||||
if (current === sequence) { resetting = true; detail.value = value; if (checked) editing.value = checked; page.value = value.inquiries.page; size.value = value.inquiries.size; queueMicrotask(() => { resetting = false }) }
|
||||
}
|
||||
catch (cause) { if (current === sequence) { detail.value = null; error.value = getApiErrorDetails(cause, '发布详情读取失败') } }
|
||||
@@ -90,18 +93,31 @@ watch([page, size], loadInquiries)
|
||||
onBeforeUnmount(() => { followupSequence++; sequence++; inquirySequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density admin-workbench"><DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" aria-label="运力发布详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回散板需求</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回散板需求</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button>
|
||||
--></div></div></template>
|
||||
<LoadErrorState v-if="error" title="发布详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-loading="loading" :aria-busy="loading"><template v-if="detail">
|
||||
<dl class="density-fields"><div><dt>车次号</dt><dd><el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button><span v-else>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>最晚送仓</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
|
||||
<p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记客户货物:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
|
||||
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记客户货物不占位,调度确认后占位。' }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
|
||||
<dl class="density-fields"><div><dt>车次号</dt><dd><!-- 旧车次详情入口暂时隐藏,原实现保留。
|
||||
<el-button v-if="access.can('platform:fleet:trip:list')" link type="primary" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.offer.tripNo } })">{{ detail.offer.tripNo }}</el-button>
|
||||
--><span>{{ detail.offer.tripNo }}</span></dd></div><div><dt>起运仓</dt><dd>{{ detail.offer.warehouseName }}</dd></div><div><dt>计划发车</dt><dd>{{ offerTime(detail.offer.plannedDepartureAt) }}</dd></div><div><dt>最晚送仓</dt><dd>{{ offerTime(detail.offer.cutoffAt) }}</dd></div><div><dt>调度联系人</dt><dd>{{ detail.offer.contactName || '—' }}</dd></div><div><dt>调度联系方式</dt><dd>{{ detail.offer.contactPhone || '—' }}</dd></div></dl>
|
||||
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<template><p v-if="hasCreatePermission && !detail.offer.canCreateInquiry" role="status">无法登记客户货物:{{ detail.offer.blockReason || '当前发布不接受新意向' }}</p>
|
||||
<p v-else>{{ detail.offer.blockReason || '按所选卸货站核对每段余位;登记客户货物不占位,调度确认后占位。' }}</p></template>
|
||||
--><p v-if="detail.offer.blockReason" role="status">{{ detail.offer.blockReason }}</p><p v-if="detail.offer.remark">{{ detail.offer.remark }}</p>
|
||||
<h2 class="capacity-section-title">分段运力</h2><div class="workbench-table"><DataTable :data="detail.offer.segments" :columns="columns" row-key="fromIndex" empty-text="容量待调度核对"><template #planned="{ row }">{{ loadText(row.planned) }}</template><template #reserved="{ row }">{{ loadText(row.reserved) }}</template><template #remaining="{ row }">{{ detail.offer.capacityKnown ? loadText(row.remaining) : '待调度核对' }}</template></DataTable></div>
|
||||
<h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的登记' }}</h2>
|
||||
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
|
||||
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" />
|
||||
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<template><h2 class="capacity-section-title">{{ access.can('platform:fleet:capacity-offer:manage') ? '本发布全部意向' : '我的登记' }}</h2>
|
||||
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template>
|
||||
</LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
|
||||
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" /></template>
|
||||
-->
|
||||
</template></div>
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
|
||||
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
|
||||
-->
|
||||
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
|
||||
-->
|
||||
</DetailPageLayout></AdminPage></template>
|
||||
<style scoped>.capacity-section-title{font-size:var(--admin-font-section);margin:var(--admin-space-6) 0 var(--admin-space-3)}</style>
|
||||
|
||||
@@ -26,7 +26,7 @@ function mount(component: object, props: Record<string, unknown> = {}): { wrappe
|
||||
const pinia = createPinia()
|
||||
const wrapper = shallowMount(component, { props, global: { plugins: [pinia], directives: { loading: () => undefined }, stubs: {
|
||||
AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout,
|
||||
DataTable: { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/><div v-for="(row, index) in data" :key="index"><slot name="loads" :row="row"/></div><slot/></div>' },
|
||||
DataTable: { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total', 'columns'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="toolbar-end"/><slot name="filters"/><slot name="notices"/><div v-for="(row, index) in data" :key="index"><slot name="actions" :row="row"/><slot name="loads" :row="row"/><slot name="status" :row="row"/><slot name="contact" :row="row"/><slot name="destinations" :row="row"/></div><slot/></div>' },
|
||||
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], template: '<div/>' },
|
||||
CapacityInquiryTable: { name: 'CapacityInquiryTable', props: ['rows', 'total', 'failed'], emits: ['action', 'update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/></div>' },
|
||||
CapacityInquiryDialog: component === CapacityInquiryDialog ? false : true,
|
||||
@@ -38,6 +38,21 @@ function mount(component: object, props: Record<string, unknown> = {}): { wrappe
|
||||
const button = (w: VueWrapper, label: string) => w.findAll('button').find(b => b.text() === label)!
|
||||
beforeEach(() => { state.route.query = {}; vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; state.links.clear(); vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) })
|
||||
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
|
||||
it('hides both legacy trip entries even for managers while retaining read-only offer detail', async () => {
|
||||
state.manage = true
|
||||
state.links.add('platform:fleet:trip:list')
|
||||
const { wrapper: list } = mount(CapacityOffersView); await flushPromises()
|
||||
expect(button(list, '选择车次发布余位')).toBeUndefined()
|
||||
await button(list, '详情').trigger('click'); await flushPromises()
|
||||
expect(state.push).toHaveBeenCalledExactlyOnceWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: offer.offerNo } })
|
||||
state.push.mockClear()
|
||||
const { wrapper: detail } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
expect(detail.text()).toContain(offer.tripNo)
|
||||
expect(button(detail, offer.tripNo)).toBeUndefined()
|
||||
expect(detail.text()).toContain(offer.contactPhone)
|
||||
expect(state.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['DRAFT', 'CONFIRMED', 'RECEIVED', 'BOUND', 'CANCELLED'] as const)('labels %s cargo consistently in the list and detail without treating estimated pallets as actual', async status => {
|
||||
const row: CapacityInquiry = { ...inquiry, status, expectedPalletCount: 7, cargo: { ...inquiry.cargo, packageCount: 12, palletSpaces: 3, weightKg: 80, volumeCbm: 2 } }
|
||||
const label = status === 'RECEIVED' || status === 'BOUND' ? '已核实' : status === 'CANCELLED' ? '记录' : '预报'
|
||||
@@ -59,35 +74,46 @@ it.each(['DRAFT', 'CONFIRMED', 'RECEIVED', 'BOUND', 'CANCELLED'] as const)('labe
|
||||
expect(detail.find('[label="已核实实托数"]').exists()).toBe(false)
|
||||
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
it('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
|
||||
it('keeps the pool read-only and sends one query after resetting from a later page', async () => {
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
|
||||
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('待调度核对')
|
||||
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).exists()).toBe(false)
|
||||
for (const label of ['我的登记', '待匹配散板', '登记客户货物', '待我处理']) expect(button(wrapper, label)).toBeUndefined()
|
||||
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
|
||||
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3); expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
|
||||
await button(wrapper, '我的登记').trigger('click'); await flushPromises()
|
||||
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'MINE' })); expect(api.getCapacityOffer).not.toHaveBeenCalled()
|
||||
const query = wrapper.findComponent({ name: 'QueryToolbar' }); query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
|
||||
expect(api.listCapacityInquiries).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'MINE' }))
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledTimes(3)
|
||||
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
|
||||
})
|
||||
it('ignores an older page response after changing tabs and retains an explicit error state', async () => {
|
||||
|
||||
it('ignores an older pool query response and retains errors with retry', async () => {
|
||||
let resolve!: (value: ReturnType<typeof pageOf<CapacityOffer>>) => void
|
||||
vi.mocked(api.listCapacityOffers).mockReturnValue(new Promise(done => { resolve = done }))
|
||||
const { wrapper } = mount(CapacityOffersView); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
|
||||
vi.mocked(api.listCapacityOffers).mockReturnValueOnce(new Promise(done => { resolve = done }))
|
||||
const { wrapper } = mount(CapacityOffersView)
|
||||
vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([offer], 7))
|
||||
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||
resolve(pageOf([offer], 900)); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('total')).toBe(1)
|
||||
vi.mocked(api.listCapacityInquiries).mockRejectedValue(new Error('读取失败')); wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(7)
|
||||
vi.mocked(api.listCapacityOffers).mockRejectedValueOnce(new Error('读取失败'))
|
||||
wrapper.findComponent({ name: 'QueryToolbar' }).vm.$emit('search'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).props('failed')).toBe(true)
|
||||
wrapper.findComponent({ name: 'LoadErrorState' }).vm.$emit('retry'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
|
||||
})
|
||||
it('reads detail once, permits drafting while capacity is unknown, and never preloads customers or quotes', async () => {
|
||||
|
||||
it('reads only the offer detail and hides registration even with write permission', async () => {
|
||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
expect(api.getCapacityOffer).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||
await button(wrapper, '登记客户货物').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(true)
|
||||
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
|
||||
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||
expect(button(wrapper, '登记客户货物')).toBeUndefined()
|
||||
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).exists()).toBe(false)
|
||||
expect(api.listOfferCustomers).not.toHaveBeenCalled(); expect(api.listOfferQuotes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves nullable estimates and retries the original immutable create request after an uncertain result', async () => {
|
||||
vi.mocked(api.saveCapacityInquiry).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ offer, inquiry, replayed: true })
|
||||
const { wrapper, pinia } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: null, readonly: false }); await flushPromises()
|
||||
@@ -120,12 +146,13 @@ it('retains an uncertain request and route protection after closing and reopenin
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.saveCapacityInquiry).toHaveBeenNthCalledWith(2, offer.offerNo, inquiry.bookingNo, first)
|
||||
})
|
||||
it('keeps sales from confirming even if a stale row advertises the action', async () => {
|
||||
const row = { ...inquiry, actions: { ...inquiry.actions, canConfirm: true } }
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises(); await button(wrapper, '我的登记').trigger('click'); await flushPromises()
|
||||
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'confirm', row); await flushPromises()
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled(); expect(ElMessageBox.confirm).not.toHaveBeenCalled()
|
||||
it('retains pool route details as the only cargo row action for sales', async () => {
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryTable' }).exists()).toBe(false)
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
||||
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads publishing settings only when explicitly opened, not with the trip page', async () => {
|
||||
state.manage = true
|
||||
const { wrapper } = mount(CapacityOfferSettingsDialog, { modelValue: false, trip: { tripNo: 'TR-1', recordVersion: 5 } as FleetTrip }); await flushPromises()
|
||||
@@ -148,24 +175,22 @@ it('retries an uncertain combined save and confirmation with the original payloa
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the create button disabled with a reason after cutoff, while hiding it without write permission', async () => {
|
||||
it('keeps capacity blockers visible without registration prompts after cutoff', async () => {
|
||||
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: { ...offer, canCreateInquiry: false, blockReason: '交货截止已过或不符合当前发车时间' }, inquiries: pageOf([]) })
|
||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
expect(button(wrapper, '登记客户货物').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('无法登记客户货物:交货截止已过')
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
|
||||
state.write = false
|
||||
const readonly = mount(CapacityOfferDetailView); await flushPromises()
|
||||
expect(readonly.wrapper.findAll('button').some(node => node.text() === '登记客户货物')).toBe(false)
|
||||
expect(button(wrapper, '登记客户货物')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('交货截止已过')
|
||||
expect(wrapper.text()).not.toContain('无法登记客户货物')
|
||||
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
|
||||
})
|
||||
it('defaults minimal managers to their TODO inquiries without loading offers, and explicitly switches to available offers', async () => {
|
||||
|
||||
it('defaults managers to the same read-only pool and preserves explicit historical queries', async () => {
|
||||
state.manage = true
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||
expect(api.listCapacityInquiries).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'TODO' }))
|
||||
expect(api.listCapacityOffers).not.toHaveBeenCalled()
|
||||
expect(state.links.size).toBe(0)
|
||||
await button(wrapper, '可拼车次').trigger('click'); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ scope: 'AVAILABLE' }))
|
||||
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
|
||||
for (const label of ['我的登记', '待匹配散板', '待我处理', '登记客户货物']) expect(button(wrapper, label)).toBeUndefined()
|
||||
const query = wrapper.findComponent({ name: 'QueryToolbar' })
|
||||
query.vm.$emit('update:modelValue', { scope: 'ALL' }); await flushPromises(); query.vm.$emit('search'); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'ALL' }))
|
||||
@@ -188,21 +213,6 @@ it('requires an explicit choice for multiple destinations', async () => {
|
||||
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Navigation must retain the exact bound business references and independent read permissions.
|
||||
it.each([CapacityOffersView, CapacityOfferDetailView])('opens the bound trip separately from read-only dispatch detail', async component => {
|
||||
const row = { ...inquiry, dispatchNo: 'DS-BOUND', status: 'BOUND' as const }
|
||||
state.links = new Set(['platform:fleet:trip:list', 'platform:overseas-warehouse:dispatch:list'])
|
||||
const { wrapper } = mount(component); await flushPromises()
|
||||
const dialog = wrapper.findComponent(CapacityInquiryDialog)
|
||||
dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
|
||||
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'DS-BOUND' }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
|
||||
dialog.vm.$emit('action', 'trip', row); await flushPromises()
|
||||
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
||||
state.links.clear(); state.push.mockClear()
|
||||
dialog.vm.$emit('action', 'trip', row); dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
|
||||
expect(state.push).not.toHaveBeenCalled()
|
||||
})
|
||||
it('shows follow-up destinations only with the matching read permission', async () => {
|
||||
const row = { ...inquiry, dispatchNo: 'DS-BOUND', status: 'BOUND' as const }
|
||||
state.links.add('platform:overseas-warehouse:dispatch:list')
|
||||
@@ -318,69 +328,47 @@ it('shows the business identity and exposes the order link only with its list pe
|
||||
await button(wrapper, 'ORDER-1').trigger('click')
|
||||
expect(wrapper.emitted('action')?.[0]).toEqual(['business-order', row])
|
||||
})
|
||||
it('reopens a returned inquiry through an authorized fetch and refuses a different offer', async () => {
|
||||
state.route.query = { inquiry: inquiry.bookingNo }
|
||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
|
||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
|
||||
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
|
||||
expect(editor.props('inquiry').bookingNo).toBe(inquiry.bookingNo)
|
||||
wrapper.unmount()
|
||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, offerNo: 'FO-OTHER' })
|
||||
const rejected = mount(CapacityOfferDetailView).wrapper; await flushPromises()
|
||||
expect(rejected.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
|
||||
expect(rejected.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
|
||||
})
|
||||
it('opens refreshed server guidance after fulfillment succeeds without replaying a write', async () => {
|
||||
it('ignores old inquiry deep links and renders only the offer summary', async () => {
|
||||
state.manage = true
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||
const updated = { ...inquiry, status: 'BOUND' as const, progress: { ...inquiry.progress, primaryAction: 'VIEW', message: '服务端下一待办', owner: '审核 / 仓库' } }
|
||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue(updated)
|
||||
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'bind', inquiry); await flushPromises()
|
||||
wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).vm.$emit('saved'); await flushPromises()
|
||||
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
|
||||
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
|
||||
expect(editor.props('inquiry').progress.message).toBe('服务端下一待办')
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
it('fetches an off-page returned inquiry once per refresh and retries without falling back to stale data', async () => {
|
||||
state.route.query = { inquiry: inquiry.bookingNo }
|
||||
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer, inquiries: pageOf<CapacityInquiry>([], 30) })
|
||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
|
||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
|
||||
expect(editor.props('modelValue')).toBe(true)
|
||||
vi.mocked(api.getCapacityOffer).mockClear(); vi.mocked(api.getCapacityInquiry).mockClear()
|
||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, recordVersion: 7 })
|
||||
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
|
||||
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
|
||||
expect(api.getCapacityInquiry).toHaveBeenCalledExactlyOnceWith(inquiry.bookingNo)
|
||||
expect(editor.props('inquiry').recordVersion).toBe(7)
|
||||
vi.mocked(api.getCapacityInquiry).mockRejectedValueOnce(new Error('读取失败'))
|
||||
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
|
||||
const error = wrapper.findComponent({ name: 'LoadErrorState' })
|
||||
expect(error.exists()).toBe(true)
|
||||
vi.mocked(api.getCapacityOffer).mockClear(); vi.mocked(api.getCapacityInquiry).mockClear()
|
||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, recordVersion: 8 })
|
||||
error.vm.$emit('retry'); await flushPromises()
|
||||
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
|
||||
expect(api.getCapacityInquiry).toHaveBeenCalledExactlyOnceWith(inquiry.bookingNo)
|
||||
expect(editor.props('inquiry').recordVersion).toBe(8)
|
||||
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||
expect(api.getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-1')
|
||||
expect(api.getCapacityInquiry).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain(offer.warehouseName)
|
||||
expect(wrapper.text()).toContain(offer.contactPhone)
|
||||
})
|
||||
|
||||
it('keeps transport, destination, remaining capacity and contact columns for manual follow-up', async () => {
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as { key: string }[]
|
||||
for (const key of ['warehouseName', 'destinations', 'plannedDepartureAt', 'contactName']) expect(columns.some(column => column.key === key)).toBe(true)
|
||||
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows customer looseboard demand in the same pool and passes the original to the selected trip inquiry', async () => {
|
||||
const source = { orderNo: 'LB-SOURCE', customerId: '19', version: 2, origin: 'LA', destination: 'ONT8', productName: '货物', estimatedPalletCount: 3, packageCount: 12, totalWeightKg: 100, totalVolumeCbm: 2, currency: 'USD', originContactName: 'Alice', originContactPhone: '555', remark: null }
|
||||
vi.mocked(api.listLooseboardDemands).mockResolvedValue(pageOf([source]))
|
||||
it('refreshes and retries read-only offer detail without loading an old inquiry', async () => {
|
||||
state.route.query = { inquiry: inquiry.bookingNo }
|
||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
vi.mocked(api.getCapacityOffer).mockClear()
|
||||
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
|
||||
expect(api.getCapacityOffer).toHaveBeenCalledExactlyOnceWith('CO-1')
|
||||
expect(api.getCapacityInquiry).not.toHaveBeenCalled()
|
||||
vi.mocked(api.getCapacityOffer).mockRejectedValueOnce(new Error('读取失败'))
|
||||
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
|
||||
const error = wrapper.findComponent({ name: 'LoadErrorState' })
|
||||
expect(error.exists()).toBe(true)
|
||||
error.vm.$emit('retry'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(false)
|
||||
expect(api.getCapacityInquiry).not.toHaveBeenCalled()
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not load customer looseboard demands or expose matching from the pool', async () => {
|
||||
state.manage = true
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||
await button(wrapper, '待匹配散板').trigger('click'); await flushPromises()
|
||||
expect(api.listLooseboardDemands).toHaveBeenCalledWith({ page: 1, size: 20, keyword: undefined })
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([source])
|
||||
expect(api.getCapacityOffer).not.toHaveBeenCalled()
|
||||
// 用页面实际暴露的匹配动作选择来源,再沿已有登记入口选车。
|
||||
const vm = wrapper.vm as unknown as { matchDemand: (row: typeof source) => void; create: (row: CapacityOffer) => Promise<void> }
|
||||
vm.matchDemand(source); await flushPromises(); await vm.create(offer); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('sourceDemand')).toEqual(source)
|
||||
expect(wrapper.findComponent({ name: 'CapacityInquiryDialog' }).props('offer').offerNo).toBe(offer.offerNo)
|
||||
expect(api.listLooseboardDemands).not.toHaveBeenCalled()
|
||||
expect(button(wrapper, '匹配车次')).toBeUndefined()
|
||||
expect(wrapper.findComponent(CapacityInquiryDialog).exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -20,8 +20,9 @@ import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
|
||||
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||||
const tab = ref<'offers' | 'inquiries' | 'demands'>(manage.value ? 'inquiries' : 'offers')
|
||||
const filters = ref<QueryModel>(manage.value ? { scope: 'TODO' } : {}), applied = ref<QueryModel>({ ...filters.value })
|
||||
// 当前只展示余位需求池;登记、原单匹配及履约模板直接隐藏,原实现保留。
|
||||
const tab = ref<'offers' | 'inquiries' | 'demands'>('offers')
|
||||
const filters = ref<QueryModel>({}), applied = ref<QueryModel>({ ...filters.value })
|
||||
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([])
|
||||
const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true)
|
||||
@@ -56,8 +57,8 @@ let sequence = 0, editorSequence = 0
|
||||
const fields = computed<QueryField[]>(() => tab.value === 'offers' ? [
|
||||
{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 170 }, { key: 'destination', label: '目的站', type: 'input', placeholder: '目的站名称', width: 170 },
|
||||
{ key: 'departureFrom', label: '发车起日', type: 'date', placeholder: '发车起日', width: 170 }, { key: 'departureTo', label: '发车止日', type: 'date', placeholder: '发车止日', width: 170 },
|
||||
{ key: 'canConfirm', label: '接单资格', type: 'select', placeholder: '全部接单资格', width: 150, options: [{ value: 'true', label: '可确认承运' }, { value: 'false', label: '暂不可确认' }] },
|
||||
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '可登记意向', width: 180, options: [{ value: 'AVAILABLE', label: '可登记意向' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
|
||||
{ key: 'canConfirm', label: '余位状态', type: 'select', placeholder: '全部状态', width: 150, options: [{ value: 'true', label: '余位已核对' }, { value: 'false', label: '待核对或不可承运' }] },
|
||||
...(manage.value ? [{ key: 'scope', label: '发布范围', type: 'select', placeholder: '当前发布', width: 180, options: [{ value: 'AVAILABLE', label: '当前发布' }, { value: 'ALL', label: '全部发布(含历史)' }] } as QueryField, { key: 'status', label: '发布状态', type: 'select', placeholder: '全部发布状态', width: 140, options: Object.entries(offerStatusLabels).map(([value, label]) => ({ value, label })) } as QueryField] : []),
|
||||
] : tab.value === 'demands' ? [{ key: 'keyword', label: '需求关键词', type: 'input', placeholder: '原单号 / 起运地 / 目的地', width: 280 }] : [
|
||||
{ key: 'keyword', label: '意向关键词', type: 'input', placeholder: '预订号 / 客户 / 车次', width: 260 },
|
||||
{ key: 'status', label: '意向状态', type: 'select', placeholder: '全部状态', width: 150, options: Object.entries(inquiryStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
@@ -68,7 +69,7 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'warehouseName', label: '起运仓', minWidth: 155 }, { key: 'destinations', label: '目的站与可接余位', minWidth: 310, slotName: 'destinations', singleLine: false },
|
||||
{ key: 'plannedDepartureAt', label: '发车 / 最晚送仓', minWidth: 195, slotName: 'times' },
|
||||
...(manage.value ? [{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false }] : []),
|
||||
{ key: 'status', label: '接单状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
|
||||
{ key: 'status', label: '发布状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
|
||||
])
|
||||
const canCreate = computed(() => manage.value || access.can('platform:fleet:capacity-offer:list') && access.can('platform:fleet:capacity-offer:inquiry-create'))
|
||||
async function create(row: CapacityOffer) {
|
||||
@@ -77,7 +78,7 @@ async function create(row: CapacityOffer) {
|
||||
try { const value = await getCapacityOffer(row.offerNo); if (current !== editorSequence) return; selectedOffer.value = value.offer; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
|
||||
catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '登记信息读取失败') }
|
||||
}
|
||||
function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
|
||||
function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (value !== 'offers' || tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = null
|
||||
try {
|
||||
@@ -129,29 +130,41 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
|
||||
</script>
|
||||
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求">
|
||||
<p v-if="selectedDemand && tab === 'offers'">正在匹配 {{ selectedDemand.orderNo }}:{{ selectedDemand.origin }} → {{ selectedDemand.destination }}。请选择合适车次,保存登记后尚不保位。<el-button link @click="selectedDemand = null">取消选择</el-button></p>
|
||||
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<div v-if="tab === 'demands'" class="workbench-table is-page-surface"><DataTable appearance="integrated" preference-key="fleet-looseboard-demands" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="demands" :columns="demandColumns" :loading="loading" :total="total" row-key="orderNo" empty-text="暂无待匹配散板需求">
|
||||
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="true" class="is-active" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="待匹配散板查询" :loading="loading" @search="search" /></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" @click="matchDemand(row as LooseboardDemand)">匹配车次</el-button></div></template>
|
||||
</DataTable></div>
|
||||
-->
|
||||
<div v-if="tab === 'offers'" class="workbench-table is-page-surface"><DataTable appearance="integrated" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
|
||||
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div></template><template #toolbar-end><el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button></template>
|
||||
<template #toolbar-start><h1>散板需求</h1><!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div>
|
||||
--></template><template #toolbar-end><!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<el-button v-if="manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button>
|
||||
<el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })">选择车次发布余位</el-button>
|
||||
--></template>
|
||||
<template #filters>
|
||||
<div class="integrated-query-group">
|
||||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可拼车次查询" :loading="loading" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
<template #notices><LoadErrorState v-if="error" title="运力列表读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" v-if="canCreate" :disabled="!row.canCreateInquiry" @click="create(row as CapacityOffer)">登记客户货物</el-button><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<el-button class="density-action" v-if="canCreate" :disabled="!row.canCreateInquiry" @click="create(row as CapacityOffer)">登记客户货物</el-button>
|
||||
--><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })">详情</el-button></div></template>
|
||||
<template #identity="{ row }"><div>{{ row.offerNo }}</div><div>{{ row.tripNo }}</div></template>
|
||||
<template #destinations="{ row }"><div v-for="stop in (row as CapacityOffer).destinations" :key="stop.destinationKey">{{ stop.name }} · {{ row.capacityKnown ? loadText(stop.remaining) : '待调度核对' }}</div></template>
|
||||
<template #times="{ row }"><div>发车 {{ offerTime(row.plannedDepartureAt) }}</div><div>最晚送仓 {{ offerTime(row.cutoffAt) }}</div></template>
|
||||
<template #loads="{ row }"><span v-if="!row.segments.length">{{ row.capacityKnown ? '—' : '待调度核对' }}</span><div v-for="segment in (row as CapacityOffer).segments" :key="segment.fromIndex">{{ segment.fromName }} → {{ segment.toName }}<br>计划 {{ loadText(segment.planned) }}<br>预留 {{ loadText(segment.reserved) }}</div></template>
|
||||
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }} · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}<div v-if="row.blockReason">{{ row.blockReason }}</div></template>
|
||||
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }}<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<template> · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}</template>
|
||||
--><div v-if="row.blockReason">{{ row.blockReason }}</div></template>
|
||||
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
|
||||
</DataTable></div>
|
||||
<CapacityInquiryTable v-else-if="tab === 'inquiries'" integrated class="is-page-surface" v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
|
||||
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<CapacityInquiryTable v-if="tab === 'inquiries'" integrated class="is-page-surface" v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
|
||||
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div><span v-if="applied.scope === 'TODO' || applied.scope === 'ALL'" class="workbench-badge">{{ applied.scope === 'TODO' ? '待我处理' : '全部登记' }}</span></template>
|
||||
<template #filters>
|
||||
<div class="integrated-query-group">
|
||||
@@ -160,6 +173,11 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
|
||||
</template>
|
||||
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
|
||||
</CapacityInquiryTable>
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :source-demand="editing ? null : selectedDemand" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
|
||||
-->
|
||||
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
|
||||
-->
|
||||
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :source-demand="editing ? null : selectedDemand" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
|
||||
-->
|
||||
</ListPageLayout></AdminPage></template>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { shallowMount, flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import View from './DispatchCapacityDemandsView.vue'
|
||||
import * as api from '../api/dispatchCapacityDemands'
|
||||
vi.mock('../api/dispatchCapacityDemands', () => ({ listDispatchCapacityDemands: vi.fn() }))
|
||||
const row: api.DispatchCapacityDemand = { dispatchNo: 'PC-TEST', warehouseName: '测试仓', route: '测试仓 → ONT8', plannedDepartureAt: null, vehiclePalletSpaces: 30, scheduledPalletCount: 18, remainingPalletSpaces: null, remainingWeightKg: null, remainingVolumeCbm: null, capacityKnown: false, capacityReason: '占用标准托位、重量及体积待确认' }
|
||||
const result = { records: [row], page: 1, size: 20, total: 1, pages: 1 }
|
||||
const layout = { template: '<div><slot/></div>' }
|
||||
function mount() { return shallowMount(View, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, DataTable: { name: 'DataTable', props: ['data', 'columns', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="toolbar-start"/><slot name="filters"/><slot name="notices"/></div>' }, QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search'], template: '<div/>' }, LoadErrorState: { name: 'LoadErrorState', emits: ['retry'], template: '<div/>' } } } }) }
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.listDispatchCapacityDemands).mockResolvedValue(result) })
|
||||
it('uses the dispatch source and never presents registration, publication or legacy trip navigation', async () => {
|
||||
const wrapper = mount(); await flushPromises()
|
||||
expect(api.listDispatchCapacityDemands).toHaveBeenCalledExactlyOnceWith({ page: 1, size: 20, origin: undefined, destination: undefined })
|
||||
const table = wrapper.getComponent({ name: 'DataTable' })
|
||||
expect(table.props('data')).toEqual([row])
|
||||
const columns = table.props('columns') as { key: string; formatter?: (value: api.DispatchCapacityDemand) => string }[]
|
||||
expect(columns.some(c => c.key === 'actions')).toBe(false)
|
||||
expect(columns.find(c => c.key === 'remainingPalletSpaces')?.formatter?.(row)).toBe('待确认')
|
||||
expect(columns.find(c => c.key === 'remainingPalletSpaces')?.formatter?.({ ...row, capacityKnown: true, remainingPalletSpaces: 12 })).toBe('12')
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('applies warehouse and destination queries and resets pagination only once', async () => {
|
||||
const wrapper=mount(); await flushPromises()
|
||||
wrapper.getComponent({name:'DataTable'}).vm.$emit('update:page',2); await flushPromises()
|
||||
const query=wrapper.getComponent({name:'QueryToolbar'})
|
||||
query.vm.$emit('update:modelValue',{origin:'洛杉矶',destination:'ONT8'}); await flushPromises(); query.vm.$emit('search'); await flushPromises()
|
||||
expect(api.listDispatchCapacityDemands).toHaveBeenCalledTimes(3)
|
||||
expect(api.listDispatchCapacityDemands).toHaveBeenLastCalledWith({page:1,size:20,origin:'洛杉矶',destination:'ONT8'})
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('preserves the query and retries an explicit read error', async () => {
|
||||
vi.mocked(api.listDispatchCapacityDemands).mockRejectedValueOnce(new Error('offline'))
|
||||
const wrapper=mount(); await flushPromises()
|
||||
expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(false)
|
||||
wrapper.getComponent({name:'LoadErrorState'}).vm.$emit('retry'); await flushPromises()
|
||||
expect(wrapper.getComponent({name:'DataTable'}).props('resultsVisible')).toBe(true)
|
||||
expect(api.listDispatchCapacityDemands).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { offerTime } from '../capacityOfferTypes'
|
||||
import { listDispatchCapacityDemands, type DispatchCapacityDemand } from '../api/dispatchCapacityDemands'
|
||||
const rows = ref<DispatchCapacityDemand[]>([]), page = ref(1), size = ref(20), total = ref(0), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ origin: '', destination: '' }), applied = ref({ ...filters.value })
|
||||
const fields: QueryField[] = [{ key: 'origin', label: '起运仓', type: 'input', placeholder: '起运仓名称', width: 190 }, { key: 'destination', label: '目的地', type: 'input', placeholder: '目的地名称', width: 190 }]
|
||||
const amount = (value: number | null | undefined) => value == null ? '—' : String(value)
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'dispatchNo', label: '排车单号', minWidth: 200 }, { key: 'warehouseName', label: '起运仓', minWidth: 160 },
|
||||
{ key: 'route', label: '路线', minWidth: 240, formatter: (row: DispatchCapacityDemand) => row.route || '—' },
|
||||
{ key: 'plannedDepartureAt', label: '计划发车', minWidth: 180, formatter: (row: DispatchCapacityDemand) => offerTime(row.plannedDepartureAt) },
|
||||
{ key: 'vehiclePalletSpaces', label: '车辆容量(托位)', width: 145, align: 'right', formatter: (row: DispatchCapacityDemand) => amount(row.vehiclePalletSpaces) },
|
||||
{ key: 'scheduledPalletCount', label: '已排实托数', width: 120, align: 'right', formatter: (row: DispatchCapacityDemand) => amount(row.scheduledPalletCount) },
|
||||
{ key: 'remainingPalletSpaces', label: '剩余标准托位', width: 140, align: 'right', formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? amount(row.remainingPalletSpaces) : '待确认' },
|
||||
{ key: 'remainingWeightKg', label: '剩余载重(kg)', width: 145, align: 'right', formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? amount(row.remainingWeightKg) : '待确认' },
|
||||
{ key: 'remainingVolumeCbm', label: '剩余容积(m³)', width: 145, align: 'right', formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? amount(row.remainingVolumeCbm) : '待确认' },
|
||||
{ key: 'capacityReason', label: '余位状态', minWidth: 220, formatter: (row: DispatchCapacityDemand) => row.capacityKnown ? '余位已确认' : row.capacityReason || '余位待确认' },
|
||||
]
|
||||
let sequence = 0
|
||||
async function load() {
|
||||
const current = ++sequence; loading.value = true; error.value = null
|
||||
try {
|
||||
const value = await listDispatchCapacityDemands({ page: page.value, size: size.value, origin: String(applied.value.origin || '') || undefined, destination: String(applied.value.destination || '') || undefined })
|
||||
if (current === sequence) { rows.value = value.records; total.value = value.total }
|
||||
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '散板需求加载失败') } }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
function search() { applied.value = { ...filters.value }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求"><div class="workbench-table is-page-surface">
|
||||
<DataTable appearance="integrated" preference-key="dispatch-capacity-demands" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :columns="columns" :data="rows" :total="total" :loading="loading" row-key="dispatchNo" empty-text="暂无已发布的散板需求">
|
||||
<template #toolbar-start><h1>散板需求</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="散板需求查询" :loading="loading" @search="search" /></template>
|
||||
<template #notices><LoadErrorState v-if="error" title="散板需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<template #pagination-left>共 {{ total }} 条散板需求</template>
|
||||
</DataTable>
|
||||
</div></ListPageLayout></AdminPage></template>
|
||||
@@ -5,14 +5,14 @@ export const overseasMasterDataRoutes = [
|
||||
{
|
||||
path: 'overseas-master-data/capacity-offers',
|
||||
name: 'overseas-master-data-capacity-offers',
|
||||
component: () => import('./pages/CapacityOffersView.vue'),
|
||||
meta: { title: '散板需求', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
component: () => import('./pages/DispatchCapacityDemandsView.vue'),
|
||||
meta: { title: '散板需求', description: '查看车次余位与调度联系方式', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/capacity-offers/:offerNo',
|
||||
name: 'overseas-master-data-capacity-offer-detail',
|
||||
component: () => import('./pages/CapacityOfferDetailView.vue'),
|
||||
meta: { title: '散板需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
meta: { title: '散板需求详情', description: '查看车次分段余位与调度联系方式', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'overseas-master-data/fleet-quotes',
|
||||
|
||||
@@ -47,8 +47,8 @@ export interface TripCargo {
|
||||
dispatchNo: string; customerId: string; dispatchItemId: string; warehousePalletId: string | null
|
||||
palletNo: string | null; packages: number; destinationKey: string | null; destinationLabel: string | null
|
||||
}
|
||||
export interface TripMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number; volumeCbm: number; deliveryIndex: number }
|
||||
export interface TripLoad { palletSpaces: number; weightKg: number; volumeCbm: number }
|
||||
export interface TripMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number }
|
||||
export interface TripLoad { palletSpaces: number; weightKg: number | null; volumeCbm: number | null }
|
||||
export interface TripSegment { fromIndex: number; toIndex: number; occupied: TripLoad; remaining: TripLoad }
|
||||
export interface TripConfirmedLoad { manifestHash: string; cargo: TripCargo[]; pallets: TripMeasurement[]; segments: TripSegment[]; operatorId: string; confirmedAt: string }
|
||||
export interface TripPlannedLoad { manifestHash: string; cargo: TripCargo[]; pallets: TripMeasurement[]; segments: TripSegment[]; operatorId: string; confirmedAt: string }
|
||||
|
||||
@@ -22,11 +22,12 @@
|
||||
| 退货预报审核、退货驳回 | `pages/LastMileOrdersView.vue`、`api/lastMileOrders.ts` 的 `reviewReturnOrder` | 尾程订单内 `RETURN_RELABEL + PENDING_REVIEW` 行进入审核,来源退货号取 `sourceOrderNo`;请求 `/platform/v1/last-leg/return-orders/{returnNo}/review` 携带 `X-Customer-Id`,结论 `APPROVE/REJECT`,驳回必填原因。按钮权限入口为 `permissions.ts` 的 `returnOrderReview`;服务端归 `returnreceipt/`,通过后才能签收,不是仓储转运审核或换标完工确认 |
|
||||
| 换标申请审核、待审核按钮与驳回原因 | `pages/RelabelOrdersView.vue`、`pages/RelabelOrderDetailView.vue`、`api/relabelOrders.ts` | 列表审核动作仅在 `PENDING_REVIEW` 显示,动作选择为审核通过/已驳回,驳回原因必填;审核后不再显示审核按钮。回归见 `pages/RelabelOrdersView.test.ts`、`pages/RelabelOrderDetailView.test.ts`;PDA 完工、客户确认与申请审核分别定位,不混用状态入口 |
|
||||
| 排车资源、草稿容器与实际容量 | `components/DispatchResourceFields.vue`、`components/SmartDispatchDialog.vue`、`dispatchContracts.ts` | 车辆/司机分别选择已有档案或临时外配,容量取真实档案或本趟填写的托位、千克及立方米,不按车型名称推算。草稿容器容量不代替车辆实测或装载校验;取证、审核前补齐规则见当前排车正式契约 |
|
||||
| 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写。新增暂扣只在 `INBOUND_COMPLETED` 开放,解除暂扣保持可用;暂扣不阻断入库计划,只限制后续排车和出库。人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
|
||||
| 仓储转运审核、审核通过 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `permissions.ts` 的 `warehouseTransferApprove`;仅 `WAREHOUSE_TRANSFER + PENDING_REVIEW` 行开放。当前弹框仅提交 `APPROVE`,携带该行客户和转运单号;无驳回按钮 |
|
||||
| 转运驳回、REJECT 接口能力 | `api/warehouseTransfers.ts` 的 `reviewWarehouseTransfer` | API 支持 `APPROVE/REJECT` 与 `remark`;不据此推定当前弹框提供驳回操作,界面能力沿上一行组件核对 |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | `api/lastMileOrders.ts`;[入仓计划页面约束](../../../../../docs/业务实现边界.md#admin-入仓计划页面约束),仅计划/状态/请求行为变化时查 |
|
||||
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 排车查询、整车草稿、审核及派送安排 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/SmartDispatchExistingCargo.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
|
||||
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型,状态须覆盖 `WAIT_PICK/待拣货`、`PICKING/拣货中`、`PICKED/已拣货`、`WAIT_OUTBOUND/待出库`、`OUTBOUNDED/已出库` 和 `CANCELLED/已取消`;仅未开始的 `WAIT_PICK/WAIT_OUTBOUND` 允许取消。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
|
||||
| 排车查询、整车草稿、审核与装车资料 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/SmartDispatchExistingCargo.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
|
||||
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;[出库与派送页面约束](../../../../../docs/业务实现边界.md#admin-出库与派送页面约束),候选选择另查下表 |
|
||||
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
|
||||
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
|
||||
| 海外库位维护与状态 | `pages/LocationsView.vue`、`api/locations.ts` | 按海外仓分页或多仓查询库位,支持库区/库位编码、类型和状态筛选;`NORMAL` 库位可编辑、停用或删除,`A0/SHARED_OVERFLOW`、`VIRTUAL` 由服务端事实保护,不能按普通库位修改。接口经 `/platform/v1/warehouse-admin/{warehouseId}/locations`,权限沿 `permissions.ts` 的库位维护/删除入口 |
|
||||
@@ -34,24 +35,33 @@
|
||||
|
||||
## 履约硬边界
|
||||
|
||||
- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。四个作业阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车完成自动生成派送单并直接进入派送中,无独立安排派送或发车确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、原幂等键和完整载荷;未知结果保留原请求核对,成功后另读当前事实。
|
||||
- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。五个作业阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车中可同步上传五张装车照片,确认装车完成且资料齐全自动生成派送单并进入派送中,资料未齐时留在装车后,无独立安排派送或发车确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、幂等键和完整载荷;前端仅请求进行中防连点,成功或失败均立即结束请求态,不持久化未确认请求、不重放旧请求、不因失败锁住后续操作。失败保留表单并读取当前事实,用户下一次点击按当前版本和输入新建请求;成功后另读当前事实。
|
||||
- 智能排车来源支持 `RELABEL`(待出库换标单),按服务端候选的`selection.relabelNo/sourceVersion`提交,不用展示订单号或原入仓号替代换标身份;沿用真实库存、托盘与箱唛,不生成新入库单。客户和仓库归属由服务端核验,不在前端拼接客户子排车。
|
||||
|
||||
- 托盘管理单张打印:`pages/PalletsView.vue` 每行“打印”经 `api/pallets.ts` 的 `getPalletLabelPdf` 下载当前托盘一页 PDF;使用该行仓库 ID 与托盘 ID,权限复用托盘列表。Backend `warehouse-overseas-service` 的 `PalletLabelPrintService` 读取公共托盘;有 FBA 仓码的托盘与国内统一使用 Print `FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm,FBA 仓码、完整托盘号 Code 128、托盘号文字),其他托盘使用 `INBOUND_MARK_V1`。不在前端生成标签、改码或维护第二套模板。
|
||||
- 托盘标签从 `pages/PalletsView.vue` → `api/pallets.ts` 的 `getPalletLabelPdf` 下载服务端 PDF,传该行仓库与托盘 ID,复用列表权限;不在前端生成标签或维护模板、尺寸、编码。模板/接口变化才查 [Print 指引](../../../../../baiyue-firstleg/print/AGENTS.md)的托盘入口及[标签维护](../../../../../docs/打印标签维护/README.md),纯按钮文案不追加打印资料。
|
||||
|
||||
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。整车派送在装车完成事务内自动生成;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
|
||||
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。出库/派送建单时机按[排车现行契约](../../../../../docs/海外仓业务/排车列表统一作业改造契约.md)执行;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
|
||||
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
|
||||
|
||||
## 作业细则按需入口
|
||||
|
||||
仅接口、状态、权限或业务行为变化时读命中段;纯文案、样式沿当前代码,不逐条读取。
|
||||
|
||||
| 任务 | 规则正文 |
|
||||
|---|---|
|
||||
| 排车审核附件 | [对应条款](../../../../../docs/业务实现边界.md#admin-排车审核附件) |
|
||||
| 一件代发出库候选 | [对应条款](../../../../../docs/业务实现边界.md#admin-一件代发出库候选) |
|
||||
| 货物计划与装车命令 | [对应条款](../../../../../docs/业务实现边界.md#admin-货物计划与装车命令) |
|
||||
| 取消与费用展示 | [对应条款](../../../../../docs/业务实现边界.md#admin-取消与费用展示) |
|
||||
| 业务导出 | [对应条款](../../../../../docs/业务实现边界.md#admin-业务导出) |
|
||||
| 装车照片与资料入口 | [对应条款](../../../../../docs/业务实现边界.md#admin-装车照片与资料入口) |
|
||||
|
||||
## 验证
|
||||
|
||||
- 排车调度/理货审核分区仅显示审核、详情;审核弹框的车辆图片、司机照片和驾驶证照片通过既有本车附件接口上传,随 `ReviewCommand.attachments` 保存,不调用资源编辑命令。已有资源与审核附件只读预览,详情合并展示;当前审核权限与服务端 Gate 控制上传及提交,列表查看权限控制读取。
|
||||
- 排车调度/理货审核分区仅显示审核、详情;车辆图片、司机照片和驾驶证照片在维护车辆司机时通过既有本车附件接口上传,随资源编辑命令保存,司机照片使用 `driver.evidence.photoFileIds`。三类照片横向并排、名称显示在照片下方;审核弹框只读预览,合并展示资源照片及历史审核附件。维护权限与服务端 Gate 控制上传及保存,审核权限控制审核提交,列表查看权限控制读取。
|
||||
|
||||
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
|
||||
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
|
||||
- 装车完成通过`completeDispatchLoading`一次提交,结果仅接受`DELIVERING`;原请求恢复不重建单据。未知费用保持未确认,由既有后台费用管理维护,不按金额推断覆盖或重复记费。已有排车加货和移货仅从`SmartDispatchDialog.vue`内的`SmartDispatchExistingCargo.vue`进入;移货必须使用`GET items`返回的真实dispatchItemId及逐项remove Gate;路线仅重排`GET route`返回的既有全部目的地,包含外接点,不能新增或猜测目的地。附件沿`DispatchEvidenceFiles.vue`的本车鉴权上传/读取,上传与主单引用保存分开。
|
||||
- 出库取消提示先解除车次关联,取消后仍须在排车列表释放原排车占用;不提示库存已自动回滚。派送详情费用读取reviewStatus/sourceStatus/voidStatus,同一详情响应展示待财务确认、待财务处理、确认作废,不逐行查询,也不把取消成功视为费用作废。货柜费用面板直接消费同一费用来源接口,待确认时同样禁止调整和重复作废,沿用原页面/弹框承载。
|
||||
- 排车列表车辆信息编辑、BOL PDF 和装车完成从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;列表和详情不另设添加/移除货物入口。货物计划统一由 `components/SmartDispatchDialog.vue` 承载:新建使用候选、草稿及托盘锁,已有排车经 `components/SmartDispatchExistingCargo.vue` 读取实际明细并按版本增减;两类服务端命令不得混用。原加货/移货未知请求也在智能排车恢复。回归为对应组件测试、`pages/DispatchesView.test.ts`、`pages/DispatchDetailView.test.ts` 及 `pages/prototypeContract.test.ts`。
|
||||
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
|
||||
- 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。
|
||||
|
||||
- 尾程订单、库存余额与进销存记录导出统一使用 `../downloads/` 的 `useExportTask` 创建 XLSX 任务;保留当前筛选和业务导出权限,并要求 `platform:download:list`。Lastleg 内部 Provider 契约为 `lastleg-exports-v1.yaml`,业务页面不再请求同步 CSV。
|
||||
- 纯文案/纯展示只核对差异;业务逻辑变化选择直接对应测试,不因文档引用某测试就自动执行。
|
||||
- 转运审核组件/请求逻辑变化:定向选择 `components/WarehouseTransferReviewDialog.test.ts`、`api/warehouseTransfers.test.ts`;入口、权限或状态变化再加 `pages/prototypeContract.test.ts`。
|
||||
- 出库候选/选择变化选 `pages/OutboundsView.selection.test.ts`;状态/取消变化选 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。排车选择受影响组件或详情页测试,入口变化再加原型合同。
|
||||
- 命令从应用根按 `pnpm test src/modules/overseas-warehouse/<目标测试路径>` 运行,并经工作区限流入口;类型关系、模板绑定或模块引用受影响时按应用根选择最小类型检查,不自动升级全量。
|
||||
- 指引/索引修改后从后端根运行一次 `./scripts/agents-index-contract.ps1`;不以离线结果代替真实 HTTP 或人工截图验收。
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# 海外仓原型核对矩阵
|
||||
|
||||
## 排车发布内部散板需求(2026-10-21)
|
||||
|
||||
依据用户确认及 2026-10-22 简化要求,详见拼车主契约当前页面范围。
|
||||
|
||||
| 页面/操作 | 当前承载与最小调整 | 验收 |
|
||||
|---|---|---|
|
||||
| 智能排车/新建空单或选货转换 | 保持原弹框,搜索区增加默认不勾选的“发布公共需求”,统一用于本次生成的所有排车单;移除下方计划时间与备注区,沿用关联排车维护 | 3文件59项定向测试通过;组件及两份测试类型检查通过,规划测试原有VueWrapper辅助函数类型不匹配仍阻断其类型检查;待人工1920×1080、100%截图确认 |
|
||||
| 维护车辆信息/保存及提交审核 | 保持原弹框,回显并保存本车发布选项;与原资源命令同事务,后续提交审核使用新版本 | 沿用此前8文件115项定向测试及受影响文件类型检查结果;本轮未修改,待人工1920×1080、100%截图确认 |
|
||||
|
||||
|
||||
## 2026-10-21 智能排车区域入口替代仓库搜索
|
||||
|
||||
用户明确删除查询区海外仓选择,仅保留“全部/洛杉矶/纽约”。界面移除顶部仓库框,区域筛选直接提交服务端;候选 warehouseId 不传时跨仓数据库分页,首托确定草稿仓并继续同仓安排,已有排车/预约仍显式指定仓库。空单模式在排车设置中保留起运仓,避免无来源时猜测仓库。正式业务边界维护于《排车列表统一作业改造契约》的区域入口确认段。
|
||||
|
||||
自动检查:前端7文件134项中旧区域断言调整后,相关3文件57项复验通过,其余77项结果复用;排车样式合同通过,应用类型检查通过。后端3个精确测试类10项通过,覆盖区域HTTP参数、服务校验、Mapper统计/分页范围及OpenAPI;未运行全量回归。未部署、未访问测试环境数据;需Admin与Lastleg同步发布,视觉与真实HTTP待验收。
|
||||
|
||||
## 2026-10-21 智能排车全屏空间利用
|
||||
|
||||
依据用户截图 `codex-clipboard-df3ac2ac-0fa1-4e30-98e6-e508074c0d66.png` 与 `codex-clipboard-4833c2b8-d6a3-4e34-bb5c-0099a3fc8fcf.png`:区域按钮并入待排订单标题行,移除中栏订单号 65% 宽度上限;全屏新建选货模式沿弹框可用空间分配三栏高度,待排表格取消原 390px/40vh 上限,分页位于下方,三栏各自按内容滚动。退出全屏、空单与已有排车沿用各自布局。计划时间及备注迁移至列表尚处于用户要求的梳理阶段,本轮未删除其填写区;现有列表“维护车辆信息”已支持三字段,计划发车审核前补齐。
|
||||
|
||||
自动检查:受影响布局、选货及排车样式合同共 41 项通过,69 项无关样式用例未执行,未跑全量;先前样式合同整文件运行另发现 DispatchesView 的取货车辆弹框缺少密度类,两项失败与本轮改动无关,未扩散修改。差异检查通过;全屏实际高度及滚动待人工截图确认。
|
||||
|
||||
## 2026-10-21 智能排车查询与三栏统一对齐
|
||||
|
||||
用户确认保留蓝白配色,按最初第二张原型恢复区块与控件位置;最新截图 `codex-clipboard-40f3fcdc-8093-4fd1-8b0e-bd1abb147b3e.png` 确认已有排车查询散落、三栏错位,上轮不构成视觉验收通过。
|
||||
@@ -13,6 +35,10 @@
|
||||
|
||||
本轮追加:用户要求打开时默认第一个起运仓并删除标签;无已有选择、草稿或订单指定仓时采用目录首项并读取候选,空目录保持空值。已有排车按新截图 `codex-clipboard-43654ee8-6c76-4141-bca5-4d108d45b828.png` 合并为可搜索单号的选择器,分页进入下拉底部、货物查询同排,删除重复标签和介绍文字。沿用当前查询API、明确选择后读取清单,不自动选择已有排车。6个文件161项定向回归通过(18.32秒),覆盖默认仓自动加载、空目录、草稿仓保留、下拉搜索分页及原有加货/移除;本轮不跑全量测试。应用类型检查与差异检查通过,真实样式待截图确认。
|
||||
|
||||
加入货物反馈修复(截图 `codex-clipboard-6ef9c642-96b3-4c97-a234-dbfefc5821f8.png`):正常请求暂存也触发待核对警告、重试按钮,候选刷新清空列表且触发整窗遮罩。目标为仅在请求空闲且仍待确认时展示恢复提示;同仓刷新保留不可操作的旧列表直至原子替换,失败或换仓清空,刷新期间拒绝使用旧来源;整窗遮罩只用于初始化。共享候选读取还有预约排车消费者,追加该消费者定向验证,不改其业务逻辑。6个文件110项定向检查通过,包含慢加入、刷新期间列表保留且禁止操作、刷新失败清空、原请求重试和空单创建;未运行全量测试。应用类型检查、差异检查通过;视觉效果待人工截图确认。
|
||||
|
||||
托盘移动与删除反馈(用户截图 `codex-clipboard-3317655c-a74e-4775-b0ff-65aa46ccc4be.png`、`codex-clipboard-2459ecf1-8447-4835-a571-486fd540289a.png`,参考原型 `app.js` 的 `renderDispatchPalletDetails/renderDispatchTrucks`):中栏保留托盘号与箱数,拖拽或点击编号移入选中车辆;按用户“直接移过去”要求,服务端确认加入后从中栏移走,右栏“×”移回,删除每行加入/已加入和目的仓文字。整托关联来源仍可展开,阻断保留“暂不可排车”及可展开原因,候选与整组重复原因去重。非空车辆隐藏删除入口且命令层拦截;首次明确拒绝解除待核对锁,历史已标记明确拒绝的浏览器记录重开时清除锁并恢复原草稿;未知结果及未知后再次拒绝继续保留原请求。自动检查:6文件107项定向测试通过;补充恢复断言后3文件102项(含样式合同)通过,未运行全量。应用类型检查被其他在途模块阻断:ContainersView.vue 行类型、OwnedBusinessFixtures.ts/OutsourcedBusinessFixtures.ts 缺少 exceptions;本轮文件未报告类型错误。差异检查通过,未部署;视觉效果待人工截图确认。换标单轮次/实托/库存来源校验仅确认代码来源,未核实该单据实际数据,不宣称业务阻断已解决。
|
||||
|
||||
自动检查:7个文件179项定向回归通过;`pnpm build`(含应用类型检查)通过。标题共享网格行,三栏内容共同起始,不再使用中栏固定顶部偏移;已有排车自定义内容仍留在同一网格列内。本轮修改的4个测试文件及其组件依赖定向类型检查通过;新增查询断言修正后已有排车23项再次通过。额外全仓`typecheck:all`触及统一入口2GB堆上限,未提高资源限制;扩大到未改动的规划测试时存在既有`VueWrapper`类型报错,未据此宣称全仓测试代码类型检查通过。全量`pnpm test`通过:470文件、4336项(14:34启动,591.74秒)。验证期间三个生产组件哈希未变;测试断言查询方法的最后修正已另跑23项通过。差异空白检查通过。只调整本模块模板和布局,保留蓝白配色及现行排车契约;未部署,视觉验收待1920×1080、100%人工截图确认。
|
||||
|
||||
## 2026-10-21 智能排车截图复核与样式纠偏
|
||||
@@ -27,15 +53,15 @@
|
||||
|
||||
本轮定向7文件178项:排车6文件108项全部通过;共享样式70项中68项通过,2项失败为预约消费者的`<FbaAppointmentFields continuous`属性顺序断言,未改动这些预约实现。`pnpm build`在预约模块`WorkCreateAppointmentForm.vue:63`被`SourceReference.destinationKey`的`null`与`PlanningSourceLookup`不兼容阻断;不据此宣称构建通过,也不扩大范围修复其他在途业务。本轮全量`pnpm test`完成:468文件中465通过、3失败;4324项中4305通过、19失败,另3个未处理错误。失败集中于预约属性顺序断言、预约组件Mock缺少`AppointmentConfirmationCargo`/`canLeave`及其卸载连带错误,未修改对应在途预约代码。排车组件输入在检查期间未变,差异空白检查通过。未部署,视觉仍待1920×1080、100%人工截图确认。
|
||||
|
||||
## 2026-10-21 装车完成直接进入派送中
|
||||
## 2026-10-21 装车后资料与自动派送
|
||||
|
||||
依据用户本轮明确确认“PDA装完货后车就开走”,覆盖早期截图中的装车后等待阶段;沿用原装车完成居中弹框及实载字段,不新增操作入口。
|
||||
依据用户最新提供的排车五阶段截图和“管理资料/装车资料”五张照片截图,替代同日此前装完即派送的确认。规则以排车正式契约为准。
|
||||
|
||||
| 页面/操作 | 当前调整 | 验收 |
|
||||
| 页面/操作 | 截图承载与当前实现 | 验证 |
|
||||
|---|---|---|
|
||||
| 排车列表/阶段与动作 | 显示装车前、装车中、派送中、派送完成;移除人工安排派送、确认发车,路线在派送中可读 | 定向回归通过;1920×1080人工截图待确认 |
|
||||
| 排车详情/装车完成 | 继续使用原装车完成弹框;真实拣货与实载条件保留,单次成功直接进入派送中 | 交互、原请求恢复与阶段校验通过;人工截图待确认 |
|
||||
| 装车完成/异常恢复 | 仅接受最终DELIVERING结果;读详情失败只刷新,不重复创建单据 | 定向回归及类型检查通过 |
|
||||
| 排车列表/装车后页签 | 在装车中与派送中之间显示;计数与分页按服务端AFTER_LOADING查询,行内管理资料进入原居中弹框 | 定向自动检查;待1920×1080、100%人工截图确认 |
|
||||
| 管理资料/装车资料 | 原居中弹框内五行装车照片表格(2026-10-22 简化确认),保留图片预览;装车后默认打开,Admin与PDA共享整车照片;第五张保存后自动进入派送中 | 上传恢复、权限和服务端事务定向检查;待人工截图及PDA真机确认 |
|
||||
| 装车完成/原请求恢复 | 装车结果为AFTER_LOADING;已确认结果重读失败不重复POST,当前派送状态另读 | 定向恢复检查 |
|
||||
|
||||
## 2026-10-21 智能排车恢复10月18日布局
|
||||
|
||||
@@ -401,7 +427,24 @@
|
||||
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 排车列表:调度审核、理货员审核 | 行操作 | 用户本轮确认只保留审核、详情;沿现有列表 | 去掉维护车辆信息及更多菜单,仅显示本环节审核与详情 | 本轮定向回归通过;待人工截图确认 |
|
||||
| 排车审核弹框 | 通过/驳回、上传附件 | 用户附件 codex-clipboard-249614d2-efac-4494-af47-090980981900.png;沿既有弹框 | 车辆图片、司机照片、驾驶证照片三类,原证据只读,本次附件随审核结果保存;未知结果重试原请求 | 本轮定向回归通过;待人工 1920×1080、100% 截图确认 |
|
||||
| 排车审核弹框 | 通过/驳回、预览照片 | 用户附件 codex-clipboard-7867e8a0-a3ae-4a16-bcd5-35291cf12e63.png、codex-clipboard-6baaaa9b-89bd-49fa-ac11-a26b20be7501.png;沿既有弹框 | 三类照片等宽等高区域并排、下方名称、点击预览;底部直接驳回/审核通过,驳回原因必填,去掉结果单选框;未知结果仅重试原请求 | 定向回归通过;待人工 1920×1080、100% 截图确认 |
|
||||
| 排车维护车辆信息 | 上传车辆、司机和驾驶证照片 | 用户确认上传入口属于维护车辆司机 | 三类照片通过资源编辑保存,司机照片独立字段;审核只读,保留历史审核证据 | 定向回归通过;待人工截图确认 |
|
||||
| 排车详情:车辆信息 | 查看审核附件 | 复用既有附件预览 | 汇总原资源与两级审核已保存证据,去重并只读预览 | 本轮定向回归通过;待人工截图确认 |
|
||||
|
||||
本轮自动验证:Admin 5 个定向文件 96 项通过,`pnpm typecheck` 通过;Lastleg/File 8 个定向类 63 项通过,API 路由合同 5 项通过,AGENTS 索引 1232 项通过。未部署,未进行真实页面操作;上线本次变更须包含 Admin、lastleg-service 和 file(新增司机照片文件分类)。
|
||||
|
||||
- 2026-10-21 排车“派送中 → 标记异常”:按用户提供的三类异常、两类退货去向截图补齐选项,沿用居中作业弹框,说明与图片选填,保存后仍派送中;现有排车信息页签回看。自动回归单独报告,1920×1080/100%真实页面待人工截图确认。
|
||||
|
||||
- 2026-10-21 照片维护与审核按钮调整:Admin 4 个业务定向文件共 78 项通过,公共密度样式 70 项通过,类型检查通过;前两次回归发现的提示渲染时序与局部背景样式问题已修正并定向复测。指引索引检查因本地缺少 `D:/project/docs/业务快速索引.md` 无法完成。未部署,真实页面待人工截图确认。
|
||||
|
||||
- 2026-10-21 取货详情:按用户截图在转运单号后展示对应取货地址、联系人和联系电话,缺失值显示“—”;沿现有取货明细接口读取,不跨行复用联系人。`TransportExecutionPanel.test.ts` 6 项通过,含两张转运单地址和联系人分别展示的回归;真实页面待人工截图确认。
|
||||
|
||||
- 2026-10-21 取货装货留痕:列表及执行面板统一使用“上传装货照片”,沿居中弹框展示预报箱数并要求手填实际取货箱数(1—5000),未填数量或未上传照片不能确认;到仓仅展示已保存取货数量,不改写取货事实。数量与照片通过既有版本化命令同次确认,复用服务端操作人和时间历史。执行面板 7 项自动回归通过;真实页面待人工截图确认。
|
||||
|
||||
## 排车管理资料统一入口(2026-10-21)
|
||||
|
||||
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;2026-10-22 用户截图确认移除资料核对、油耗和装车照片改表格;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;嵌入编辑器隐藏资料核对;三个页签仅显示各自资料,装车资料只保留整车装车照片,不再附带BOL及车辆/司机附件,保存资料按钮仅在签收页签显示,切换保留签收草稿;油耗两行、装车照片五行表格,操作列在首列,保留预览、上传/替换和原保存行为;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 4文件27项定向测试及受影响组件与测试源码类型检查通过;页签隔离追加修改后5项定向测试及组件/测试类型检查通过;1920×1080、100%待人工截图确认 |
|
||||
|
||||
| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 |
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { DispatchStage } from '../dispatchContracts'
|
||||
|
||||
export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'] as const
|
||||
export type LoadingPhotoSlot = typeof loadingPhotoSlots[number]
|
||||
export interface LoadingPhoto { slot: LoadingPhotoSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
|
||||
export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] }
|
||||
export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string }
|
||||
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents`
|
||||
export async function getLoadingDocuments(no: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(path(no)))
|
||||
}
|
||||
export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string) {
|
||||
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/${slot}/uploads`, form))
|
||||
}
|
||||
export async function recoverLoadingPhoto(no: string, key: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/uploads/${encodeURIComponent(key)}`))
|
||||
}
|
||||
export async function bindLoadingPhoto(no: string, slot: LoadingPhotoSlot, body: BindLoadingPhoto) {
|
||||
return unwrapApiResponse(await http.put<ApiEnvelope<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(`${path(no)}/${slot}`, body))
|
||||
}
|
||||
export async function readLoadingPhoto(no: string, photo: LoadingPhoto) {
|
||||
const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
|
||||
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对')
|
||||
return blob
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type FuelSlot = 'FUEL_START' | 'FUEL_END'
|
||||
export interface VehiclePhoto { slot: FuelSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null }
|
||||
export interface VehicleDocuments { dispatchNo: string; recordVersion: string; editable: boolean; photos: VehiclePhoto[] }
|
||||
export interface BindVehiclePhoto { recordVersion: string; idempotencyKey: string; fileId: string }
|
||||
const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/vehicle-documents`
|
||||
export async function getVehicleDocuments(no: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<VehicleDocuments>, ApiEnvelope<VehicleDocuments>>(path(no)))
|
||||
}
|
||||
export async function uploadVehiclePhoto(no: string, slot: FuelSlot, file: File, key: string) {
|
||||
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<VehiclePhoto>, ApiEnvelope<VehiclePhoto>>(`${path(no)}/${slot}/uploads`, form))
|
||||
}
|
||||
export async function recoverVehiclePhoto(no: string, key: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<VehiclePhoto>, ApiEnvelope<VehiclePhoto>>(`${path(no)}/uploads/${encodeURIComponent(key)}`))
|
||||
}
|
||||
export async function bindVehiclePhoto(no: string, slot: FuelSlot, body: BindVehiclePhoto) {
|
||||
return unwrapApiResponse(await http.put<ApiEnvelope<VehicleDocuments>, ApiEnvelope<VehicleDocuments>>(`${path(no)}/${slot}`, body))
|
||||
}
|
||||
export async function readVehiclePhoto(no: string, photo: VehiclePhoto) {
|
||||
const blob = await http.get<Blob, Blob>(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
|
||||
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对')
|
||||
return blob
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import * as api from './dispatches'
|
||||
import { blankVehicleMaintenance } from '../dispatchVehicleMaintenance'
|
||||
import { candidate, command, detail, loadingPreview, page } from '../dispatchTestFixtures'
|
||||
import type { CreateDispatch, DispatchBatchAllocation, DraftSelection, LoadingComplete, ReasonCommand, ReviewCommand, UpdateDispatch, Version, VersionCommand } from '../dispatchContracts'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }))
|
||||
@@ -14,11 +15,33 @@ beforeEach(() => {
|
||||
vi.mocked(http.put).mockResolvedValue({ data: command('EDIT') })
|
||||
vi.mocked(http.delete).mockResolvedValue({ data: draft() })
|
||||
})
|
||||
it('reads and writes original dispatch appointment allocations with lossless versions', async () => {
|
||||
const value = { dispatchNo: 'PC/1', recordVersion: '9007199254740993', items: [], edit: { allowed: true, blockCode: null, blockReason: null } }
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ data: value })
|
||||
expect(await api.getDispatchAppointmentAllocations('PC/1')).toEqual(value)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/appointment-allocations')
|
||||
const body = { recordVersion: 0, idempotencyKey: 'allocation-key', dispatchItemIds: ['11'], batchAllocations: [{ planPalletId: '21', batchKey: 'FBA_APPOINTMENT:9', customerId: '101', sourceKey: 'ORDER:1', destinationKey: 'FBA:ACY2', packageCount: 10, recordVersion: 0 }] }
|
||||
await api.saveDispatchAppointmentAllocations('PC/1', body)
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/appointment-allocations', body)
|
||||
})
|
||||
|
||||
describe('native whole-vehicle dispatch API', () => {
|
||||
it('round-trips this trip maintenance data and its vehicle photo references without altering actual departure', async () => {
|
||||
const maintenance = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', fleetEmail: 'fleet@example.test', appointmentNo: 'APPT-1' }
|
||||
const value = detail()
|
||||
value.vehicleInfo.maintenance = maintenance
|
||||
value.vehicleInfo.vehicle!.evidenceFileIds = ['9007199254740993']
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ data: value })
|
||||
const saved = await api.getDispatch('PC/1')
|
||||
expect(saved.vehicleInfo.maintenance).toEqual(maintenance)
|
||||
expect(saved.vehicleInfo.vehicle!.evidenceFileIds).toEqual(['9007199254740993'])
|
||||
await api.updateDispatch('PC/1', { recordVersion: saved.recordVersion, idempotencyKey: 'maintenance-key', vehicle: saved.vehicleInfo.vehicle, driver: saved.vehicleInfo.driver, maintenance, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, publishLooseboardDemand: true })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', expect.objectContaining({ maintenance, vehicle: expect.objectContaining({ evidenceFileIds: ['9007199254740993'] }) }))
|
||||
expect(vi.mocked(http.put).mock.calls[0]![1]).not.toHaveProperty('departedAt')
|
||||
})
|
||||
it.each([0, '0', '9007199254740993'])('preserves authoritative batch version %s across direct create, add cargo and grouped conversion', async recordVersion => {
|
||||
const allocation: DispatchBatchAllocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }
|
||||
const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] }
|
||||
const create: CreateDispatch = { publishLooseboardDemand: true, warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] }
|
||||
await api.createDispatch(create); await api.addDispatchCargo('PC/1', { ...body, cargo: create.cargo, batchAllocations: [allocation] }); await api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'container', dispatch: create }] })
|
||||
expect(vi.mocked(http.post).mock.calls[0]![1]).toBe(create)
|
||||
expect(vi.mocked(http.post).mock.calls[1]![1]).toMatchObject({ batchAllocations: [{ recordVersion }] })
|
||||
@@ -26,7 +49,7 @@ describe('native whole-vehicle dispatch API', () => {
|
||||
})
|
||||
it.each([undefined, null, -1, '', 'bad', 9007199254740992])('rejects missing or invalid nested batch version %s before any write or candidate handoff', async value => {
|
||||
const allocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion: value } as unknown as DispatchBatchAllocation
|
||||
const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] }
|
||||
const create: CreateDispatch = { publishLooseboardDemand: true, warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] }
|
||||
await expect(Promise.resolve().then(() => api.createDispatch(create))).rejects.toThrow('重新读取候选')
|
||||
await expect(Promise.resolve().then(() => api.addDispatchCargo('PC/1', { ...body, cargo: create.cargo, batchAllocations: [allocation] }))).rejects.toThrow('重新读取候选')
|
||||
await expect(Promise.resolve().then(() => api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'container', dispatch: create }] }))).rejects.toThrow('重新读取候选')
|
||||
@@ -36,7 +59,7 @@ describe('native whole-vehicle dispatch API', () => {
|
||||
})
|
||||
it('rejects inconsistent original versions of the same batch across conversion groups', () => {
|
||||
const allocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion: '9007199254740993' }
|
||||
const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [allocation] }
|
||||
const create: CreateDispatch = { publishLooseboardDemand: true, warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [allocation] }
|
||||
expect(() => api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'A', dispatch: create }, { draftContainerId: 'B', dispatch: { ...create, batchAllocations: [{ ...allocation, recordVersion: '9007199254740994' }] } }] })).toThrow('同一预约批次的版本不一致')
|
||||
expect(http.post).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -71,7 +94,7 @@ describe('native whole-vehicle dispatch API', () => {
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'], page: 2, size: 20, warehouseTab: 'NYC' }, paramsSerializer: { indexes: null } })
|
||||
})
|
||||
it('creates an empty real dispatch without an artificial customer or cargo', async () => {
|
||||
const request: CreateDispatch = { idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] }
|
||||
const request: CreateDispatch = { publishLooseboardDemand: true, idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] }
|
||||
await api.createDispatch(request); expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', request)
|
||||
})
|
||||
it.each(['cancel', 'remove', 'review', 'loading-review', 'return', 'loading', 'add', 'submit', 'draft-container', 'draft-select', 'draft-release', 'draft-convert', 'draft-snapshot'])('rejects missing/null/negative versions before %s requests and accepts zero', async action => {
|
||||
@@ -97,7 +120,7 @@ describe('native whole-vehicle dispatch API', () => {
|
||||
expect(http.post).not.toHaveBeenCalled(); await invoke(0); expect(vi.mocked(http.post).mock.calls[0]![1]).toEqual(expect.objectContaining({ recordVersion: 0 }))
|
||||
})
|
||||
it('updates vehicle resources and timing with the explicit dispatch version', async () => {
|
||||
const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null }
|
||||
const request: UpdateDispatch = { publishLooseboardDemand: true, ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null }
|
||||
await api.updateDispatch('PC/1', request); expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', request)
|
||||
await expect(api.updateDispatch('PC/1', { ...request, recordVersion: undefined as unknown as Version })).rejects.toThrow('版本')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
|
||||
import type { AppointmentAllocations, AppointmentAllocationCommand } from '../dispatchContracts'
|
||||
import type { MarkDispatchException, DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
|
||||
import { requireVersion, requireBatchVersions, type AddCargo, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts'
|
||||
import { normalizeCandidateList, normalizeConvertedDraft, normalizeDispatchCommand, normalizeDispatchDetail, normalizeDispatchDraft, normalizeDispatchItems, normalizeDispatchPage, normalizeEvidenceFile, normalizeLoadingPreview, type CandidateListWire, type CommandResultWire, type ConvertedDraftWire, type DispatchDetailWire, type DispatchDraftWire, type DispatchEvidenceFileWire, type DispatchItemsWire, type DispatchPageWire, type LoadingPreviewWire } from '../dispatchWire'
|
||||
export type { DispatchBatchReference, DispatchBatchAllocation } from '../dispatchContracts'
|
||||
@@ -17,6 +18,19 @@ export async function listDispatches(query: DispatchQuery) {
|
||||
}
|
||||
export const getDispatch = (dispatchNo: string) => get<DispatchDetailWire>(path(dispatchNo)).then(normalizeDispatchDetail)
|
||||
export const getDispatchItems = (no: string) => get<DispatchItemsWire>(`${path(no)}/items`).then(normalizeDispatchItems)
|
||||
export async function getDispatchAppointmentAllocations(no: string) {
|
||||
const value = await get<AppointmentAllocations>(`${path(no)}/appointment-allocations`)
|
||||
requireVersion(value.recordVersion)
|
||||
value.items.forEach(item => { requireBatchVersions(item.allocations); item.sources.forEach(source => source.options.forEach(option => requireVersion(option.recordVersion))) })
|
||||
return value
|
||||
}
|
||||
export const saveDispatchAppointmentAllocations = (no: string, body: AppointmentAllocationCommand) => {
|
||||
versioned(body); requireBatchVersions(body.batchAllocations)
|
||||
return postAppointmentAllocations(no, body)
|
||||
}
|
||||
async function postAppointmentAllocations(no: string, body: AppointmentAllocationCommand) {
|
||||
return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(`${path(no)}/appointment-allocations`, body)))
|
||||
}
|
||||
export const getDispatchRoute = (no: string) => get<DispatchRoute>(`${path(no)}/route`)
|
||||
export async function reorderDispatchRoute(no: string, body: DispatchRouteCommand) { return normalizeDispatchCommand(unwrapApiResponse(await http.put<ApiEnvelope<CommandResultWire>, ApiEnvelope<CommandResultWire>>(`${path(no)}/route`, versioned(body)))) }
|
||||
const evidencePath = (no: string, kind: DispatchEvidenceKind, id?: string) => `${path(no)}/evidence/${kind}${id ? `/${encodeURIComponent(id)}` : ''}`
|
||||
@@ -37,6 +51,7 @@ export const submitDispatchReview = (no: string, body: VersionCommand & { remark
|
||||
export const reviewDispatch = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/dispatch-review`, versioned(body))
|
||||
export const reviewDispatchLoading = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/loading-review`, versioned(body))
|
||||
export const returnDispatchToReview = (no: string, body: ReasonCommand) => postCommand(`${path(no)}/return-to-review`, versioned(body))
|
||||
export const markDispatchException = (no: string, body: MarkDispatchException) => postCommand(`${path(no)}/exceptions`, versioned(body))
|
||||
export const completeDispatchLoading = (no: string, body: LoadingComplete) => postCommand(`${path(no)}/loading-complete`, versioned(body))
|
||||
export const getDispatchBolPdf = (no: string) => http.get<Blob, Blob>(`${path(no)}/prints/bol`, { responseType: 'blob' })
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { assignTransport } from './transportTasks'
|
||||
vi.mock('@/shared/api/http', () => ({ http: { post: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }))
|
||||
vi.mock('@/modules/overseas-master-data', () => ({ normalizeTripDetail: (value: unknown) => value }))
|
||||
beforeEach(() => vi.resetAllMocks())
|
||||
const body = { recordVersion: 4, vehicleId: '1', driverId: '2', reason: '保存照片', reviewPhotos: { vehicleFileIds: ['21'], driverPhotoFileIds: ['22'], licenseFileIds: [] }, masterLicenseFileId: '33' }
|
||||
it('does not submit photo changes when the backend has not supplied the dispatch version', () => {
|
||||
expect(() => assignTransport('PC1', body)).toThrow('照片保存版本')
|
||||
expect(http.post).not.toHaveBeenCalled()
|
||||
})
|
||||
it('preserves zero dispatch version and opaque photo IDs in the assignment', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { dispatchNo: 'PC1', taskType: 'PICKUP', dispatchRecordVersion: '1', resources: { vehicle: { evidenceFileIds: ['21'] } }, execution: {}, pickups: [] } })
|
||||
const result = await assignTransport('PC1', { ...body, dispatchRecordVersion: 0 })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/transport-tasks/PC1/assign', { ...body, dispatchRecordVersion: 0 })
|
||||
expect(result.resources?.vehicle?.evidenceFileIds).toEqual(['21'])
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeTripDetail, type TripDetail, type PickupTask, type PickupCandidate, type PickupEvidence, type PickupPlan, type TripAssignment, type TripResourceRelease } from '@/modules/overseas-master-data'
|
||||
import type { Resources, ReviewAttachments, Version } from '../dispatchContracts'
|
||||
|
||||
export type TransportType = 'PICKUP' | 'LOCAL_TRUCK'
|
||||
export const transportLabels = { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }
|
||||
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; completionEvidence: PickupEvidence[] }
|
||||
export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null }
|
||||
export type TransportAssignment = TripAssignment & { dispatchRecordVersion?: Version; reviewPhotos?: ReviewAttachments; masterLicenseFileId?: string | null }
|
||||
export interface TruckCandidate { customerId: string; orderNo: string; origin: string; originAddress: string; destination: string; destinationAddress: string; palletCount: number; packageCount: number }
|
||||
export interface TruckPlan { customerId: string; orderNo: string; warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; idempotencyKey: string }
|
||||
type WireDetail = Omit<TransportDetail, 'execution'> & { execution: Parameters<typeof normalizeTripDetail>[0] }
|
||||
@@ -17,7 +19,10 @@ export const pickupCandidates = () => get<{ records: PickupCandidate[]; total: n
|
||||
export const truckCandidates = () => get<TruckCandidate[]>(`${base}/truck-candidates`)
|
||||
export const createTransportPickup = (body: PickupPlan) => post<WireDetail>(`${base}/pickup`, body).then(normalize)
|
||||
export const createTransportTruck = (body: TruckPlan) => post<WireDetail>(`${base}/truck`, body).then(normalize)
|
||||
export const assignTransport = (number: string, body: TripAssignment) => post<WireDetail>(`${path(number)}/assign`, body).then(normalize)
|
||||
export const assignTransport = (number: string, body: TransportAssignment) => {
|
||||
if (body.reviewPhotos && body.dispatchRecordVersion == null) throw new Error('未读取到照片保存版本,请更新本地后端并刷新后重试')
|
||||
return post<WireDetail>(`${path(number)}/assign`, body).then(normalize)
|
||||
}
|
||||
export const transportOccupancy = (number: string, kind: 'VEHICLE' | 'DRIVER', resourceIds: string[]) => resourceIds.length ? post<Array<{ resourceId: string; occupiedTripNo: string | null; available: boolean }>>(`${path(number)}/resource-occupancy`, { kind, resourceIds }) : Promise.resolve([])
|
||||
export const cancelTransport = (number: string, body: object) => post(`${path(number)}/cancel`, body)
|
||||
export const releaseTransport = (number: string, body: TripResourceRelease) => post(`${path(number)}/release`, body)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { appointmentFormError, appointmentPayload, createAppointmentForm } from './appointmentForm'
|
||||
|
||||
it('allows an undated arrangement without inventing confirmation or loading facts', () => {
|
||||
it('requires an existing external confirmation for a new platform registration', () => {
|
||||
const form = createAppointmentForm()
|
||||
expect(appointmentFormError(form)).toContain('外部约号与确认时间')
|
||||
expect(appointmentPayload(form)).toMatchObject({ plannedDate: null, plannedAt: null, appointmentAt: null, confirmationNo: null, appointmentStatus: 'SUCCESS' })
|
||||
form.confirmationNo = 'EXT-CONFIRMED'
|
||||
form.appointmentAt = '2026-10-25T09:30:00'
|
||||
expect(appointmentFormError(form)).toBeNull()
|
||||
expect(appointmentPayload(form)).toMatchObject({ plannedDate: null, plannedAt: null, appointmentAt: null, confirmationNo: null, appointmentStatus: 'PENDING' })
|
||||
expect(appointmentPayload(form)).not.toHaveProperty('expectedLoadingAt')
|
||||
})
|
||||
it('retains an already confirmed repeated local time using its stored UTC instant', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface AppointmentForm {
|
||||
}
|
||||
export function createAppointmentForm(detail?: FbaAppointmentDto): AppointmentForm {
|
||||
return { plannedDate: detail?.plannedDate || '', plannedAt: detail?.plannedAt || '', plannedEndAt: detail?.plannedEndAt || '', confirmationNo: detail?.confirmationNo || '',
|
||||
appointmentAt: detail?.appointmentAt || '', appointmentEndAt: detail?.appointmentEndAt || '', appointmentStatus: (detail?.appointmentStatus || 'PENDING') as AppointmentForm['appointmentStatus'],
|
||||
appointmentAt: detail?.appointmentAt || '', appointmentEndAt: detail?.appointmentEndAt || '', appointmentStatus: (detail?.appointmentStatus || 'SUCCESS') as AppointmentForm['appointmentStatus'],
|
||||
appointmentRemark: detail?.appointmentRemark || '', startOffset: confirmedOffset(detail?.appointmentAt, detail?.appointmentStartUtc), endOffset: confirmedOffset(detail?.appointmentEndAt, detail?.appointmentEndUtc) }
|
||||
}
|
||||
function confirmedOffset(local?: string | null, utc?: string | null): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
@@ -12,7 +12,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, downloadDeliveryDocument, type DeliveryDocuments, type DeliveryDocumentCategory, type DeliveryDocumentFile } from '../api/deliveryDocuments'
|
||||
|
||||
const props = defineProps<{ deliveryNo: string; customerId: string }>()
|
||||
const props = defineProps<{ deliveryNo: string; customerId: string; embedded?: boolean; categories?: DeliveryDocumentCategory[] }>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -28,8 +28,12 @@ const error = ref<ApiErrorDetails | null>(null)
|
||||
const conflict = ref(false)
|
||||
const canEdit = computed(() => access.can(permission.deliveryDocuments) && Boolean(snapshot.value?.allowedActions.includes('MANAGE_DOCUMENTS')))
|
||||
const busy = computed(() => saving.value || uploading.value)
|
||||
const uploadedCount = computed(() => draft.value.filter(item => item.files.length > 0).length)
|
||||
const uploadedCount = computed(() => visibleGroups.value.filter(item => item.files.length > 0).length)
|
||||
const visibleGroups = computed(() => draft.value.filter(item => !props.categories || props.categories.includes(item.category)))
|
||||
const dirty = computed(() => Boolean(snapshot.value) && (bolNo.value !== (snapshot.value?.bolNo || '') || draft.value.some(group => JSON.stringify(group.files.map(file => file.fileId)) !== JSON.stringify(snapshot.value?.categories.find(item => item.category === group.category)?.files.map(file => file.fileId) || []))))
|
||||
defineExpose({ busy, dirty, canEdit, loading, conflict, save })
|
||||
let sequence = 0
|
||||
onBeforeUnmount(() => { sequence++ })
|
||||
function isCurrent(current: number, deliveryNo: string, customerId: string) { return current === sequence && open.value && props.deliveryNo === deliveryNo && props.customerId === customerId }
|
||||
let attempt: { body: string; key: string } | null = null
|
||||
async function load() {
|
||||
@@ -97,23 +101,27 @@ async function save() {
|
||||
saving.value = true; error.value = null
|
||||
const current = sequence; const { deliveryNo, customerId } = props
|
||||
try {
|
||||
await saveDeliveryDocuments(deliveryNo, customerId, { ...body, idempotencyKey: attempt.key })
|
||||
const result = await saveDeliveryDocuments(deliveryNo, customerId, { ...body, idempotencyKey: attempt.key })
|
||||
if (!isCurrent(current, deliveryNo, customerId)) return
|
||||
const hasReceipt = body.documents.some(item => item.category === 'RECEIPT' && item.fileIds.length > 0)
|
||||
open.value = false; emit('saved'); ElMessage.success(hasReceipt ? '签收文件已保存,派送单已自动完成' : '派送资料已保存')
|
||||
if (props.embedded) {
|
||||
snapshot.value = result; bolNo.value = result.bolNo || ''; attempt = null
|
||||
draft.value = deliveryDocumentCategories.map(item => ({ ...item, files: (result.categories.find(group => group.category === item.category)?.files || []).map(file => ({ ...file })) }))
|
||||
} else open.value = false
|
||||
emit('saved'); ElMessage.success(hasReceipt ? '签收文件已保存,派送单已自动完成' : '派送资料已保存')
|
||||
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) { error.value = getApiErrorDetails(cause, '派送资料保存失败'); conflict.value = error.value.code === 'A0409' } }
|
||||
finally { if (current === sequence) saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="open" :title="'派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
|
||||
<component :is="embedded ? 'div' : AppDialog" class="admin-density-dialog admin-workbench is-dialog" v-model="open" :title="embedded ? undefined : '派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
|
||||
<div class="density-dialog-sections">
|
||||
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :loading="busy || loading" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/3 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
|
||||
<section v-if="!embedded" class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/' + visibleGroups.length + ' 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
|
||||
<template v-if="snapshot">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="BOL资料登记"><h3 class="density-dialog-section__title">BOL资料</h3><el-form class="density-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" /><span v-else>{{ bolNo || '—' }}</span></el-form-item></td></tr></tbody></table></el-form></section>
|
||||
<section v-for="group in draft" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category">
|
||||
<section v-if="!categories || categories.includes('BOL')" class="density-dialog-section" data-tone="work" aria-label="BOL资料登记"><h3 class="density-dialog-section__title">BOL资料</h3><el-form class="density-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" /><span v-else>{{ bolNo || '—' }}</span></el-form-item></td></tr></tbody></table></el-form></section>
|
||||
<section v-for="group in visibleGroups" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category">
|
||||
<header><h3 class="density-dialog-section__title">{{ group.label }}</h3><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
|
||||
<p v-if="!group.files.length" class="density-dialog-note">尚未上传文件</p>
|
||||
<table v-else class="density-table document-files"><thead><tr><th class="document-file-actions">操作</th><th>文件名称</th><th class="document-file-time">上传时间</th><th class="document-file-state">保存状态</th></tr></thead><tbody>
|
||||
@@ -125,7 +133,7 @@ async function save() {
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import ElementPlus, { ElInputNumber } from 'element-plus'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DispatchAppointmentAllocationsDialog from './DispatchAppointmentAllocationsDialog.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import type { AppointmentAllocations } from '../dispatchContracts'
|
||||
import { command } from '../dispatchTestFixtures'
|
||||
|
||||
const access = vi.hoisted(() => ({ can: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.can }) }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatchAppointmentAllocations: vi.fn(), saveDispatchAppointmentAllocations: vi.fn() }))
|
||||
function facts(): AppointmentAllocations {
|
||||
return { dispatchNo: 'PC/1', recordVersion: '0', edit: { allowed: true, blockCode: null, blockReason: null }, items: ['11', '12'].map((id, index) => ({
|
||||
dispatchItemId: id, planPalletId: `${index + 21}`, customerId: '101', customerName: '客户甲', palletNo: `PALLET-${index + 1}`, allocations: [],
|
||||
sources: [{ sourceKey: 'ORDER:1', orderNo: 'WC-1', inboundCode: 'IN-1', destinationKey: 'FBA:ACY2', packageCount: 10, blockReasons: [],
|
||||
options: [{ batchKey: 'FBA_APPOINTMENT:9', batchNo: 'YY-9', recordVersion: '0', availablePackages: 20, confirmationNo: 'CONF-9', appointmentAt: '2026-10-26T10:00:00', appointmentEndAt: null, timezone: 'America/New_York' }] }],
|
||||
})) }
|
||||
}
|
||||
async function open() {
|
||||
const wrapper = mount(DispatchAppointmentAllocationsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises(); return wrapper
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); access.can = true
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(facts())
|
||||
vi.mocked(api.saveDispatchAppointmentAllocations).mockResolvedValue(command('EDIT'))
|
||||
})
|
||||
it('links both pallets to the confirmed appointment on the original dispatch with explicit version zero', async () => {
|
||||
const wrapper = await open()
|
||||
expect(wrapper.text()).toContain('WC-1'); expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).toContain('America/New_York')
|
||||
expect(wrapper.text()).not.toContain('ORDER:1')
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledTimes(1)
|
||||
expect(api.saveDispatchAppointmentAllocations).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: '0', dispatchItemIds: ['11', '12'],
|
||||
batchAllocations: [expect.objectContaining({ planPalletId: '21', packageCount: 10, recordVersion: '0' }), expect.objectContaining({ planPalletId: '22', packageCount: 10, recordVersion: '0' })] }))
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
||||
})
|
||||
it('counts a shared source quota once across pallets and rejects incomplete allocation', async () => {
|
||||
const value = facts(); value.items.forEach(item => { item.sources[0]!.options[0]!.availablePackages = 15 })
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
|
||||
const wrapper = await open(); const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.props('confirmDisabled')).toBe(true); expect(wrapper.text()).toContain('本票剩余可排量不足')
|
||||
wrapper.findAllComponents(ElInputNumber)[0]!.vm.$emit('update:modelValue', 5); await flushPromises()
|
||||
expect(dialog.props('confirmDisabled')).toBe(true); expect(wrapper.text()).toContain('须完整分配 10 箱')
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(api.saveDispatchAppointmentAllocations).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('preserves edited quantities after rejection and retries with current versions and a new command key', async () => {
|
||||
const value = facts(); value.items = value.items.slice(0, 1)
|
||||
value.items[0]!.sources[0]!.options.push({ ...value.items[0]!.sources[0]!.options[0]!, batchKey: 'FBA_APPOINTMENT:10', batchNo: 'YY-10' })
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
|
||||
const wrapper = await open(); const inputs = wrapper.findAllComponents(ElInputNumber)
|
||||
inputs[0]!.vm.$emit('update:modelValue', 6); inputs[1]!.vm.$emit('update:modelValue', 4); await flushPromises()
|
||||
const latest = structuredClone(value); latest.recordVersion = '2'; latest.items[0]!.sources[0]!.options.forEach(option => { option.recordVersion = '3' })
|
||||
vi.mocked(api.saveDispatchAppointmentAllocations).mockRejectedValueOnce(new Error('版本已变化'))
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(latest)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.findAllComponents(ElInputNumber).map(input => input.props('modelValue'))).toEqual([6, 4])
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
const calls = vi.mocked(api.saveDispatchAppointmentAllocations).mock.calls
|
||||
expect(calls).toHaveLength(2); expect(calls[1]![1].idempotencyKey).not.toBe(calls[0]![1].idempotencyKey)
|
||||
expect(calls[1]![1]).toMatchObject({ recordVersion: '2', batchAllocations: [{ recordVersion: '3', packageCount: 6 }, { recordVersion: '3', packageCount: 4 }] })
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('retains an unavailable former choice after a failed save until the user clears it', async () => {
|
||||
const wrapper = await open()
|
||||
vi.mocked(api.saveDispatchAppointmentAllocations).mockRejectedValueOnce(new Error('预约已变化'))
|
||||
const latest = facts(); latest.items[0]!.sources[0]!.options = []
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(latest)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('已失效'); expect(wrapper.findAllComponents(ElInputNumber)[0]!.props('modelValue')).toBe(10)
|
||||
expect(wrapper.findComponent(AppDialog).props('confirmDisabled')).toBe(true); wrapper.unmount()
|
||||
})
|
||||
it.each(['permission', 'gate', 'unconfirmed'] as const)('does not save when blocked by %s', async mode => {
|
||||
const value = facts()
|
||||
if (mode === 'permission') access.can = false
|
||||
if (mode === 'gate') value.edit = { allowed: false, blockCode: 'NOT_ELIGIBLE', blockReason: '本车已有真实扫码' }
|
||||
if (mode === 'unconfirmed') value.items[0]!.sources[0] = { ...value.items[0]!.sources[0]!, options: [], blockReasons: ['请先确认送仓时间'] }
|
||||
vi.mocked(api.getDispatchAppointmentAllocations).mockResolvedValue(value)
|
||||
const wrapper = await open(); expect(wrapper.findComponent(AppDialog).props('confirmDisabled')).toBe(true)
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.saveDispatchAppointmentAllocations).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user