Compare commits

...
200 changed files with 5821 additions and 850 deletions
+7 -6
View File
@@ -2,13 +2,14 @@
## 快速读取顺序
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。
Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件任务按需读取 `../../docs/统一认证与服务通信契约.md` 对应章节。
1. 由“模块快速路由”选最小模块并读最近 `AGENTS.md`,按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不扫描全部 `src/`。
2. 视觉、布局/交互承载变更或真实页面验收先读 `../../docs/Admin-Client前端原型还原验收规则.md`;需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发。
样式优化另读 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”“新模块接入规则”,保留已确认密度、彩色页签、业务语义及承载。国内仓、客户管理为样板;新增菜单按本轮范围显式接入,货柜入口见 `../../docs/Admin货柜管理样式核对记录.md`。
蓝白样式接入见 [样板指南](../../docs/界面参考/蓝白卡片式工作台风格参考.md)。
3. 标准列表/公共组件读 `src/shared/components/AGENTS.md`;HTTP、Token/刷新读 `src/shared/api/AGENTS.md`;Finance 读 `src/modules/finance/AGENTS.md`;海外仓全流程先读 `../../docs/海外仓业务/README.md`。
4. 平台/客户层级及权限读 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件按需读 `../../docs/统一认证与服务通信契约.md` 对应章节。
普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。
单端仅读本指引、最近模块指引及命中代码;跨服务状态、权限或接口边界不明才加后端契约。预算遵循根指引,先读适用规则、定向查业务表,跳过无关专题。
## 项目定位与技术基线
+18 -2
View File
@@ -8,6 +8,22 @@ import {
} from './adminNavigation'
describe('adminNavigation', () => {
it('keeps outsourced read access separate from owned access and write-only permissions', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:list']))
const vehicle = read.find(node => node.key === 'vehicle-management')
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/outsourced-business'])
const write = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:edit', 'platform:fleet:outsourced-business:upload']))
expect(getNavigationLeaves(write).some(node => node.path === '/overseas-master-data/outsourced-business')).toBe(false)
})
it('places owned-business under vehicle management and keeps edit/upload from granting page access', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:owned-business:list']))
const vehicle = read.find(node => node.key === 'vehicle-management')
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/owned-business'])
const writeOnly = filterAdminNavigation(adminNavigation, new Set([
'platform:fleet:owned-business:edit', 'platform:fleet:owned-business:upload',
]))
expect(getNavigationLeaves(writeOnly).some(node => node.path === '/overseas-master-data/owned-business')).toBe(false)
})
it('opens all self-operated read pages with list permission and keeps sync alone insufficient', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:list']))
expect(getNavigationLeaves(read).map(item => item.path)).toEqual([
@@ -31,7 +47,7 @@ describe('adminNavigation', () => {
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
{ title: '预约管理', children: ['预约列表'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '出车记录'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配业务', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
@@ -40,7 +56,7 @@ describe('adminNavigation', () => {
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(13)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(56)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
+2
View File
@@ -134,6 +134,8 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
{ key: 'fleet-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' },
{ key: 'fleet-outsourced-business', title: '外配业务', icon: Truck, path: '/overseas-master-data/outsourced-business', permission: 'platform:fleet:outsourced-business:list' },
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true },
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
],
@@ -1,3 +1,4 @@
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import AppointmentBatchForm from './AppointmentBatchForm.vue'
@@ -13,6 +14,7 @@ vi.mock('./batchApi', async original => ({ ...await original<typeof import('./ba
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkItems: vi.fn() }))
let guard: LeaveGuard
function render() { return mount(AppointmentBatchForm, { props: { batchKey: 'FBA_APPOINTMENT:1' }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType || \'button\'" :disabled="disabled"><slot /></button>' },
LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true, CargoVerificationForm: true,
} } }) }
@@ -176,7 +178,7 @@ it('retries a new destination without exposing the prior batch or retaining its
expect(wrapper.text()).not.toContain('AP-001'); expect(wrapper.find('form').exists()).toBe(false)
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('AP-CURRENT')
expect((wrapper.findAll('input[type="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
expect((wrapper.findAll('input[data-picker-kind="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('0')
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:2')
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
@@ -121,10 +121,10 @@ onMounted(() => load())
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
<div v-if="!context.sources.length" aria-label="原批次货物快照"><p>当前批次无分批资格,以下为原预约保留的货物资料。</p><div v-for="(item, index) in context.appointment.items" :key="item.id" class="allocation-row"><b>来源 {{ index + 1 }} · {{ item.inboundCode || '原票业务号未提供' }}</b><p>{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div></div>
<div class="batch-modes"><el-button :disabled="saving || !!pending || !context.allowedActions.includes('SPLIT')" @click="changeMode('split')">分成两次送仓</el-button><el-button :disabled="saving || !!pending || !context.allowedActions.includes('MERGE')" @click="changeMode('merge')">同仓合并安排</el-button></div>
<form @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
<form class="density-form" @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
<div v-for="(source, index) in context.sources" :key="allocationKey(source)" class="allocation-row"><b>来源 {{ index + 1 }} · {{ sourceLabel(source) }}</b><p>本批 {{ source.packageCount ?? '待核实' }} 箱 · 原箱数 {{ source.originalPackages ?? '未知' }}</p><p>货件:{{ source.shipmentReferences.join('、') || '待核实' }}</p><label v-if="mode === 'split'">分到第二批的箱数<input v-model="form.moved[allocationKey(source)]" type="number" min="0" :max="source.packageCount ?? undefined" step="1" :aria-label="`来源${index + 1}分出箱数`"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)">核实本票资料</el-button></div>
<template v-if="mode === 'merge'"><label>查找另一批工作事项<input v-model="keyword" placeholder="预约号、批次号、柜号" maxlength="80" @keydown.enter.prevent="searchCandidates()"></label><el-button :loading="candidateLoading" @click="searchCandidates()">查询批次</el-button><LoadErrorState v-if="candidateError" title="合并候选不可用" :message="candidateError.message" @retry="searchCandidates(candidatePage)" /><div v-for="candidate in candidates?.records.filter(item => item.batchKey && item.batchKey !== activeKey)" :key="candidate.workItemKey" class="batch-candidate"><span>{{ candidate.title }} · {{ candidate.destinationName || '目的仓待核实' }}</span><el-button :disabled="candidateLoading" @click="chooseOther(candidate.batchKey!)">核对并选择</el-button></div><p v-if="candidates && !candidates.records.length">本页未查询到工作事项。</p><div v-if="candidates && candidates.total > 8" class="batch-modes"><el-button :disabled="candidatePage === 1 || candidateLoading" @click="searchCandidates(candidatePage - 1)">上一页</el-button><span>第 {{ candidatePage }} 页</span><el-button :disabled="candidatePage * 8 >= candidates.total || candidateLoading" @click="searchCandidates(candidatePage + 1)">下一页</el-button></div><p v-if="other">已核对:{{ other.appointment.appointmentNo }},{{ other.sources.length }} 条来源;提交时再次逐来源校验。</p></template>
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<input v-model="form.firstDate" type="date"></label><label v-if="mode === 'split'">第二批目标日期(可不填)<input v-model="form.nextDate" type="date"></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" /></label>
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<el-date-picker v-model="form.firstDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label v-if="mode === 'split'">第二批目标日期(可不填)<el-date-picker v-model="form.nextDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" /></label>
</fieldset><LoadErrorState v-if="saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></form>
</template>
</template>
@@ -62,10 +62,13 @@ it('keeps the arrival context and typed local time across native fullscreen and
expect(dialog.props('width')).toBe('520px')
expect(dialog.get('[aria-label="到仓业务信息"]').text()).toContain('TEST-CONT 计划到仓')
expect(dialog.text()).not.toContain(warehouse.sourceContainerBookingId)
await dialog.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
const picker = dialog.getComponent({ name: 'ElDatePicker' })
expect(picker.props()).toMatchObject({ type: 'datetime', valueFormat: 'YYYY-MM-DDTHH:mm', format: 'YYYY-MM-DD HH:mm' })
expect(dialog.getComponent({ name: 'ElForm' }).props('labelWidth')).toBe('150px')
picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((dialog.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
expect(picker.props('modelValue')).toBe('2026-10-22T09:30')
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String))
})
@@ -73,7 +76,7 @@ it('locks the real arrival input after an unknown outcome and reuses its immutab
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
await dialog.get('input').setValue('2026-10-22T09:30')
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(dialog.get('input').attributes('disabled')).toBeDefined()
expect(dialog.text()).toContain('上次提交结果尚未确认')
@@ -81,6 +84,17 @@ it('locks the real arrival input after an unknown outcome and reuses its immutab
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
})
it('clears the Element arrival picker without saving an empty required time', async () => {
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
const picker = dialog.getComponent({ name: 'ElDatePicker' })
expect(dialog.find('input[type="datetime-local"]').exists()).toBe(false)
picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
picker.vm.$emit('update:modelValue', null); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(saveWarehouseArrival).not.toHaveBeenCalled()
})
it('keeps complete long cargo names accessible in the selectable table', async () => {
const wrapper = await open()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
@@ -1,3 +1,4 @@
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { onBeforeRouteLeave } from 'vue-router'
@@ -17,6 +18,7 @@ const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
export function renderSchedule() {
return mount(AppointmentCalendarView, { global: { stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
AdminPage: { template: '<main><slot /></main>' },
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode', 'canCreate'], template: '<section data-calendar />' },
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
@@ -88,10 +90,10 @@ describe('simplified appointment entry', () => {
wrapper.unmount()
})
it('does not request unreadable domains and describes the incomplete scope', async () => {
routing.denied.add('platform:fleet:trip:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
routing.denied.add('platform:overseas-warehouse:dispatch:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
const wrapper = renderSchedule(); await flushPromises()
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('缺少车次读取权限')
expect(wrapper.text()).toContain('缺少排车读取权限')
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
wrapper.unmount()
})
@@ -109,7 +111,7 @@ describe('simplified appointment entry', () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('提交结果尚未确认')
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
@@ -143,4 +145,17 @@ describe('simplified appointment entry', () => {
expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper.unmount()
})
it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => {
routing.denied.add('platform:fleet:trip:list')
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [{ sourceType: 'DISPATCH', dispatchNo: 'DS/1', status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: '美国仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-21T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [], incompleteReasons: [] }], unresolvedRecords: [] })
const wrapper = renderSchedule(); await flushPromises()
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch')
expect(listTripCalendar).toHaveBeenCalledOnce()
expect(event.sourcePath).toBe('/overseas-warehouse/dispatches/DS%2F1')
panel.vm.$emit('select', '2026-10-21', 'week')
panel.vm.$emit('open', event); await flushPromises()
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
wrapper.unmount()
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, defineAsyncComponent, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
@@ -64,12 +65,12 @@ async function load(next = range.value) {
} catch (cause) { fail('计划到仓读取失败', cause) }
})(),
(async () => {
if (!access.can('platform:fleet:trip:list')) { notices.value.push('缺少车次读取权限,当前未显示排车安排。'); return }
if (!access.can('platform:overseas-warehouse:dispatch:list')) { notices.value.push('缺少排车读取权限,当前未显示排车安排。'); return }
try {
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
if (request !== generation) return
trips.value = result.events
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 个车次缺少起运仓或时区资料,请到车辆管理核对。`)
if (result.unresolved.length) notices.value.push(`${result.unresolved.length} 条排车资料不完整,请到排车列表核对。`)
} catch (cause) { fail('排车读取失败', cause) }
})(),
])
@@ -180,10 +181,10 @@ onBeforeUnmount(() => { ++generation; controller?.abort() })
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
</section>
<p class="data-notice">自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p>
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
<el-form class="density-form" label-position="left" label-width="110px" :disabled="saving || unknownResult">
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><el-input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" /></el-form-item></td></tr></tbody></table>
<el-form class="density-form" label-position="left" label-width="150px" :disabled="saving || unknownResult">
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></el-form-item></td></tr></tbody></table>
</el-form>
</section>
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
@@ -134,7 +134,7 @@ onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsReque
<tr><th scope="row">确认开始时差</th><td>{{ createdForm.startOffset || '—' }}</td><th scope="row">确认结束时差</th><td>{{ createdForm.endOffset || '—' }}</td></tr>
<tr><th scope="row">{{ createdForm.appointmentStatus === 'FAILED' ? '原因' : '备注' }}</th><td colspan="3">{{ createdForm.appointmentRemark || '—' }}</td></tr>
</tbody></table>
<p class="density-dialog-note">预约保存后仍需在原业务安排车辆,日历中的排车以真实车次为准。</p>
<p class="density-dialog-note">预约已保存,请继续在原业务安排车辆。</p>
</section>
<div v-if="canWrite" class="density-actions density-actions--footer"><el-button @click="again">继续新增预约</el-button></div>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -53,7 +54,7 @@ onMounted(() => load())
</script>
<template>
<section aria-label="货物核实资料">
<h3>核实货物资料</h3><p>未约成也可保存;只用于预约核实,保留原订单资料。</p>
<h3>核实货物资料<InlineHelp title="核实资料说明"><p>未约成也可保存;只用于预约核实,保留原订单资料。</p></InlineHelp></h3>
<LoadErrorState v-if="error" title="核实资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在读取货物资料…</p>
<template v-if="context">
@@ -1,5 +1,59 @@
# 预约工作日历逐操作矩阵
## 2026-10-21 排车候选分页
| 页面/组件 | 操作与承载依据 | 本次调整 | 验收 |
|---|---|---|---|
| WorkDispatchForm | 保留既有预约事项深链内的表单、批次范围与整车草稿流程;现行排车契约第17项候选分页 | 表下分页仅请求当前页;说明总数为关联计划候选数,表格仅显示本批货物;跨页保留选择和原批次版本,完整组/来源版本变化阻止建单 | 定向自动检查单独记录;1920×1080、100%待人工截图确认 |
## 2026-10-20 日期时间组件统一(当前确认)
依据:用户截图 `codex-clipboard-ed442cca-1bd2-43d3-87e8-6671e5bcc508.png`,以及随后明确要求排查并优化同类原生日期输入。保留现有业务入口与承载方式,使用 Element Plus 日期/时间控件及已有公共表单样式。
| 页面/组件 | 操作与承载 | 本次调整 | 验收 |
|---|---|---|---|
| AppointmentCalendarView | 调整计划到仓,原520px居中弹框及全屏能力 | 1处日期时间选择器;分钟精度沿用原请求格式;标签宽度150px,说明图标与标签并排,控件沿用公共满宽样式 | 真实浏览器检查通过:日期面板、选值、空值禁用、全屏保值、未保存保护及放弃返回;未提交业务保存 |
| WorkReceivingPanel | 原业务深链中的客户计划、后送与跟进,原表单 | 5处日期/日期时间输入;日期与秒精度的当地时间分别保存;调整开始/结束时段后仍清除原时差依据 | 自动检查通过;待人工截图确认 |
| WorkCommandForm | 跟进、收货要求,原表单 | 3处日期、2处时间输入;跨日结束日期显式保留;不推断次日、不转换地点时区 | 自动检查通过;待人工截图确认 |
| AppointmentBatchForm | 原分批/合并表单 | 2处可空目标日期,保留跨批分配、版本、幂等与原写权限 | 自动检查通过;待人工截图确认 |
共4组件13处。日期使用 `el-date-picker`,时间使用 `el-time-picker`;保留原权限、处理中及未知结果锁定、清空与未保存保护。新增控件显式接收原禁用条件,宽度复用 `density-form`,不新增全局日期控件外观规则。
自动验证:受影响7个测试文件88项通过;类型检查与生产构建通过。全量回归执行464个文件4203项,4201项通过,两项旧公共样式断言(标签宽度、追加样式类)更新后,该样式文件65项全部复验通过。后台业务模板原生日期/时间控件残留检查通过。本轮未部署TEST,未进行真实HTTP或人工页面截图验收。
补充浏览器验收:用户明确要求打开页面后,使用已发布TEST页面,页面报告1920×1080、DPR1、visual scale1。TSTU5446539的默认计划、Element日期面板、快捷选值、全屏保值、清空禁用、未保存保护与放弃返回通过;原计划仍2026-10-01,未点击保存。MSCU7264017现已有入库计划,按冻结时间显示且无调整入口。本轮仅覆盖日历与调整到仓弹框,其余三表单仍待逐页操作;输入09:30的最终确认未单独完成,不计通过。证据见 `.logs/appointment-element-release-20261020/ui/result.json` 及同目录截图。
## 2026-10-20 蓝白工作台续批(写权已协调)
本次续批修改文件(相对本模块):
- `AppointmentBatchForm.vue`
- `AppointmentCalendarPanel.vue`
- `AppointmentCalendarView.vue`
- `AppointmentCreatePanel.vue`
- `ReceivingScope.vue`
- `SourceAppointmentPanel.vue`
- `TripAppointmentPanel.vue`
- `WorkDetailPanel.vue`
- `WorkReceivingPanel.vue`
总控已核实预约交付及排车只读预审状态,本轮授权续接展示;逐文件按当前内容快照核对后写入。依据蓝白样板与本矩阵既有逐操作承载。
| 实际入口 | 操作/承载与冻结结论 | 验收 |
|---|---|---|
| 预约日历主页面及原业务深链的实际表单、工作详情;保留四类、月周日期与原弹框承载 | 只接入主题、公共列表/面板和局部旧外观清理;尺寸、字段、事件、权限、请求及业务计算保留 | 续批定向回归通过;待人工1920×1080、100%截图确认 |
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
当前无正式入口引用的11个旧面板保留原样:AppointmentCancellationDialog、AppointmentCancellationHistory、AppointmentCancellationRecords、AppointmentCargoPanel、AppointmentCargoTabs、AppointmentContainerPanel、AppointmentExternalCandidates、AppointmentFollowupDialog、AppointmentImpactDialog、AppointmentInboundCodes、WorkCalendarPanel。不是新增或恢复入口;TripAppointmentPanel仍由正式车次详情引用,已纳入本次续批。
## 2026-10-20 排车入口收敛
用户指出预约日历仍跳旧车次详情。排车标签改用服务端关联真实整车主单返回的排车单号,打开 `/overseas-warehouse/dispatches/:dispatchNo` 独立排车详情;返回按钮显示“返回预约日历”并保留原日期与月/周视图。无合法日历返回参数时继续走原列表或拼车登记返回逻辑,外部返回地址拒绝。读取权限与排车列表一致,不要求旧车次列表权限;独立旧车次、取消排车主单不进入活动日历,不按TR号猜DS号。原四类、地点日期和实际发车归属规则保持。自动检查与发布/真实HTTP分别记录,1920×1080、100%人工操作截图待确认。
自动验证:相关3文件42项、Admin全量453文件4086项、类型检查与生产构建通过;后端最终常规Maven流程完成生产/测试源码编译,`FleetTripCalendarServiceTest`及真实H2查询的`FleetTripCalendarMapperTest`共9项通过,覆盖真实排车号与内部车次号不同、独立旧车次/取消主单/取货排除、跨日期和查询上限。Gateway路由合同5项通过。均经共享`local-check.ps1`执行;早先其他自有业务在途编译错误及失效生成物尝试不作为通过证据,以最终常规Maven回归为准。接口路径复用、无网关配置或数据库结构变更,匹配发布单元为Admin与Lastleg。
## 2026-10-19 四类日历恢复(当前结论)
依据:用户本轮确认四类与日期规则,以及截图 `C:/Users/BY-OY/AppData/Local/Temp/codex-clipboard-840bcc07-6b54-4afa-9dc7-666d09ff04f2.png`。当前结论覆盖下文旧工作台首页/明细承载描述;原业务写入规则仍按正式业务契约。
@@ -215,3 +269,5 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
| 刷新后沿用已有FBA排车单 | 当前对话指定沿用原DS;`WorkExistingDispatchForm.vue` 的既有原排车列表和 `TripPreparationDialog` | 保留事项弹框,显示本批原排车单;继续用原安排车次弹框,不重复选托盘或创建 | `WorkDispatchForm.vue` 先读当前批次 `receiving.transport`,再按原排车单 `appointmentBatches` 校验批次/客户;已绑定车次直接查看,关系读取失败不显示创建入口;剩余实物仅显式选择后读取。待人工截图确认 |
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。预约页面没有额外整页面板,当前双面板结构保持,不给左右面板增加该modifier。
@@ -1,3 +1,4 @@
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkCommandForm from './WorkCommandForm.vue'
@@ -13,6 +14,7 @@ vi.mock('./workApi', async original => ({ ...await original<typeof import('./wor
let guard: LeaveGuard
function render(action: WorkAction = followAction, detail = workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) })) {
return mount(WorkCommandForm, { props: { detail, action, refreshing: false }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType" :disabled="disabled"><slot /></button>' },
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}</div>' }, ApiErrorMeta: true,
} } })
@@ -79,7 +81,7 @@ const requirementAction: WorkAction = { code: 'SAVE_REQUIREMENT', label: '登记
it('preserves an explicit cross-day local interval in the requirement request', async () => {
const wrapper = render(requirementAction)
await wrapper.findAll('textarea')[1]!.setValue('跨日收货窗口')
const dates = wrapper.findAll('input[type=date]'), times = wrapper.findAll('input[type=time]')
const dates = wrapper.findAll('input[data-picker-kind=date]'), times = wrapper.findAll('input[data-picker-kind=time]')
await dates[0]!.setValue('2026-10-15'); await times[0]!.setValue('23:00')
await dates[1]!.setValue('2026-10-16'); await times[1]!.setValue('01:00')
await wrapper.get('form').trigger('submit'); await flushPromises()
@@ -93,7 +95,7 @@ it.each([
])('rejects incomplete or non-increasing interval %s %s %s without inferring a next day', async (endDate, endTime, startTime) => {
const wrapper = render(requirementAction)
await wrapper.findAll('textarea')[1]!.setValue('核对时间')
const dates = wrapper.findAll('input[type=date]'), times = wrapper.findAll('input[type=time]')
const dates = wrapper.findAll('input[data-picker-kind=date]'), times = wrapper.findAll('input[data-picker-kind=time]')
await dates[0]!.setValue('2026-10-15'); await times[0]!.setValue(startTime)
await dates[1]!.setValue(endDate); await times[1]!.setValue(endTime)
await wrapper.get('form').trigger('submit'); await flushPromises()
@@ -102,7 +104,7 @@ it.each([
it('saves a date-only requirement with all time fields null', async () => {
const wrapper = render(requirementAction)
await wrapper.findAll('textarea')[1]!.setValue('具体时段待回复')
await wrapper.findAll('input[type=date]')[0]!.setValue('2026-10-15')
await wrapper.findAll('input[data-picker-kind=date]')[0]!.setValue('2026-10-15')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ payload: expect.objectContaining({ target: { date: '2026-10-15', startTime: null, endDate: null, endTime: null, timezone: 'America/Los_Angeles' } }) }))
wrapper.unmount()
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
@@ -86,14 +87,14 @@ async function save() {
</script>
<template>
<form class="density-form work-form" @submit.prevent="save">
<h3>{{ action.label }}</h3>
<h3>{{ action.label }}<InlineHelp v-if="command === 'SAVE_FOLLOWUP'" title="跟进日期说明"><p>跟进日期不代替作业日期;未完成事项仍保留在待办中。</p></InlineHelp><InlineHelp v-if="command === 'RESOLVE_REVIEW'" title="核对结果说明"><p>原安排需要改期时,请先处理原安排,再记录核对结果。</p></InlineHelp></h3>
<p v-if="!allowed">当前仅可查看。此操作需要有效的服务端资格和对应操作权限。</p>
<p v-if="missingContext" role="alert">{{ missingContext }}</p>
<fieldset :disabled="!allowed || refreshing || saving || !!pending">
<template v-if="command === 'CONFIRM_ROUTE'"><label>运输方式<select v-model="form.flow"><option value="TRANSFER">先入海外仓,再分批送仓</option><option value="DIRECT">整柜直接送目的仓</option></select></label><label>路线说明<textarea v-model="form.reason" maxlength="1000" /></label></template>
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" /></label><label>下次跟进日期(可留空)<input v-model="form.followupDate" type="date"></label><p>跟进日期不代替作业日期,未完成事项仍保留在待办中。</p></template>
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" /></label><p>若原安排需要改期,请先处理原安排,再记录核对结果。</p></template>
<template v-else-if="command === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<input v-model="form.date" type="date"></label><div class="time-fields"><label>开始时段(可留空)<input v-model="form.startTime" type="time"></label><label>结束日期(可留空)<input v-model="form.endDate" type="date"></label><label>结束时段(可留空)<input v-model="form.endTime" type="time"></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" /></label></template>
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" /></label><label>下次跟进日期(可留空)<el-date-picker v-model="form.followupDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label></template>
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" /></label></template>
<template v-else-if="command === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<el-date-picker v-model="form.date" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><div class="time-fields"><label>开始时段(可留空)<el-time-picker v-model="form.startTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label><label>结束日期(可留空)<el-date-picker v-model="form.endDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>结束时段(可留空)<el-time-picker v-model="form.endTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" /></label></template>
<template v-else-if="command === 'HANDOFF_RETURN'"><p>转交保留原预约和责任记录;接手方确认前持续跟进。</p><label>接手方类型<select v-model="form.recipientKind" @change="changeRecipient"><option value="EXTERNAL">外部车队 / 联系人</option><option value="SUPPLIER">已登记海外车队</option><option value="USER">当前关联负责人</option></select></label><label v-if="form.recipientKind === 'EXTERNAL'">接手方名称<input v-model="form.recipientName" required maxlength="200"></label><template v-else-if="form.recipientKind === 'SUPPLIER'"><label>查找海外车队<input v-model="supplierKeyword" maxlength="80" @keydown.enter.prevent="searchSuppliers"></label><el-button :loading="supplierLoading" @click="searchSuppliers">查询车队</el-button><LoadErrorState v-if="supplierError" title="还空车队不可用" :message="supplierError.message" @retry="searchSuppliers" /><label>接手车队<select v-model="form.recipientId" required :disabled="supplierLoading || !!supplierError"><option value="" disabled>请选择已登记海外车队</option><option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">{{ supplier.name }}</option></select></label><p v-if="supplierTotal > suppliers.length">请输入更完整名称缩小查询范围。</p></template><label v-else>接手负责人<select v-model="form.recipientId" required><option value="" disabled>请选择</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>转交原因<textarea v-model="form.reason" required maxlength="1000" /></label></template>
<template v-else-if="command === 'ACCEPT_RETURN'"><p>原责任方:{{ detail.returnHandoff?.from.name || '—' }} → 接手方:{{ detail.returnHandoff?.to.name || '—' }}</p><label>接手反馈依据<textarea v-model="form.note" :required="detail.returnHandoff?.to.kind !== 'USER'" maxlength="1000" /></label><p>确认接手只记录责任,实际还空仍按原业务操作。</p></template>
</fieldset>
@@ -48,7 +48,7 @@ beforeEach(() => {
natives = [candidate()]; draft = { draftToken: 'server-owned-token', recordVersion: 0, status: 'ACTIVE', warehouseId: 'origin-1', containers: [], sources: [], expiresAt: '2026-10-20T00:00:00Z' }
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key))
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] }))
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ total: 1, page: 1, size: 20, pages: 1, candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft))
vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) })
vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) })
@@ -58,7 +58,7 @@ beforeEach(() => {
})
it('creates the whole dispatch when native Long candidate quantities arrive as strings and batch quantities are Integers', async () => {
const actual = await vi.importActual<typeof import('../overseas-warehouse/api/dispatches')>('../overseas-warehouse/api/dispatches')
const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data: { candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } })))
const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data: { total: 1, page: 1, size: 20, pages: 1, candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } })))
vi.mocked(listDispatchCandidates).mockImplementation(actual.listDispatchCandidates)
const wrapper = render()
try {
@@ -98,7 +98,7 @@ it('uses a server-owned draft, explicit zero versions, native source hashes and
})
it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => {
const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] })
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups
expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount()
})
@@ -112,12 +112,12 @@ it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo
const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5')
wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value)))
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] })
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount()
})
it('fails closed on incomplete native candidates, missing versions, and cross-warehouse batches', async () => {
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.text()).toContain('未完整返回'); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.get('input').attributes('disabled')).toBeDefined(); missing.unmount()
it('accepts partial pages but fails closed on missing versions and cross-warehouse batches', async () => {
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ total: 1, page: 1, size: 20, pages: 1, candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.text()).toContain('来源版本不完整'); missing.unmount()
vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value })
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount()
})
@@ -160,3 +160,38 @@ it('shows batch-level blockers even without rows and refuses duplicate physical
const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount()
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount()
})
it('keeps original batch allocations and selected cargo while paging through associated plan candidates', async () => {
const first = row(), second = row('9007199254740994', 'customer-b')
natives = [candidate(first), candidate(second)]
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [first, second], blockReasons: [] })
vi.mocked(listDispatchCandidates).mockImplementation(async query => ({ total: 21, page: query.page!, size: query.size!, pages: 2, candidates: [clone(natives[query.page === 2 ? 1 : 0]!)], complete: false, snapshotAt: '' }))
const wrapper = render()
try {
await flushPromises(); expect(wrapper.findAll('input[type=checkbox]')).toHaveLength(1)
await wrapper.get('input[type=checkbox]').setValue(true)
const state = wrapper.vm as unknown as { changeCandidatePage(page: number): void }
state.changeCandidatePage(2); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
expect(listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 20, warehouseTab: 'ALL', warehouseId: 'origin-1' }))
expect(getBatchDispatchCandidates).toHaveBeenCalledTimes(1)
expect(listDispatchCandidates).toHaveBeenCalledTimes(2)
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...first.allocations, ...second.allocations])
} finally { wrapper.unmount() }
})
it('rejects a selected batch source whose group version changed on another candidate page', async () => {
vi.mocked(listDispatchCandidates).mockImplementation(async query => {
const value = clone(natives[0]!)
if (query.page === 2) value.selectionGroup.groupVersion = 'new-group-version'
return { total: 21, page: query.page!, size: query.size!, pages: 2, candidates: [value], complete: false, snapshotAt: '' }
})
const wrapper = render()
try {
await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
;(wrapper.vm as unknown as { changeCandidatePage(page: number): void }).changeCandidatePage(2)
await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('选货组组成已变化')
expect(createDispatchDraft).not.toHaveBeenCalled()
} finally { wrapper.unmount() }
})
@@ -4,7 +4,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -21,14 +21,20 @@ type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; di
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error, listDispatchCandidates)
const { page: candidatePageNumber, size: candidatePageSize, total: candidateTotal } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const selectedRows = ref<Record<string, BatchRow>>({})
let receivingContexts: Awaited<ReturnType<typeof getReceivingContext>>[] = []
let nativeGeneration = 0
const capacity = ref(30), result = ref<DispatchDetail | null>(null), pending = ref<Preparation | null>(null), corrupted = ref(false)
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.key)))
const chosen = computed(() => selected.value.map(key => selectedRows.value[key]).filter((row): row is BatchRow => Boolean(row)))
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
@@ -43,7 +49,7 @@ function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encod
async function load() {
if (!readable.value || pending.value || corrupted.value) return
const current = ++generation, batches = [...new Set(props.batchKeys)]
loading.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []
working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++
try {
if (!batches.length || batches.some(key => !/^FBA_APPOINTMENT:\d+$/.test(key))) throw new DispatchInputError('请从真实预约批次进入排车,不能重组其他业务批次')
const receiving = await Promise.all(batches.map(key => getReceivingContext(key)))
@@ -66,16 +72,24 @@ async function load() {
if (current !== generation) return
if (batchCandidates.some((context, index) => context.batchKey !== batches[index])) throw new DispatchInputError('实物候选与当前预约批次不一致')
requireBatchVersions(batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)))
contexts.value = batchCandidates
const planIds = [...new Set(batchCandidates.flatMap(context => context.candidates.map(row => row.planId)))]
const native = planIds.length ? await listDispatchCandidates({ warehouseId: warehouseId.value, planIds }) : { candidates: [], complete: true }
if (current !== generation) return
requireBatchVersions([...batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)), ...native.candidates.flatMap(row => row.appointmentBatches)])
if (native.complete !== true) throw new DispatchInputError('本仓原生候选未完整返回,不能按部分结果建单')
rows.value = batchCandidates.flatMap((context, index) => context.candidates.map(row => {
const matches = native.candidates.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
contexts.value = batchCandidates; receivingContexts = receiving
await loadCandidatePage(true)
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
finally { if (current === generation) working.value = false }
}
async function loadCandidatePage(autoSelect = false) {
const sequence = ++nativeGeneration, current = generation
const planIds = [...new Set(contexts.value.flatMap(context => context.candidates.map(row => row.planId)))]
if (!planIds.length) { candidatePage.reset(); rows.value = []; return }
await candidatePage.load({ warehouseId: warehouseId.value, planIds, page: candidatePageNumber.value, size: candidatePageSize.value, warehouseTab: 'ALL' })
if (sequence !== nativeGeneration || current !== generation) return
if (error.value) { rows.value = []; return }
try {
requireBatchVersions(candidatePage.candidates.value.flatMap(row => row.appointmentBatches))
rows.value = contexts.value.flatMap((context, index) => context.candidates.map(row => {
const matches = candidatePage.candidates.value.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
const candidate = matches.length === 1 ? matches[0]! : null
const sources = receiving[index]!.batch.items
const sources = receivingContexts[index]!.batch.items
const blocks = [...context.blockReasons, ...row.blockReasons]
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) }
@@ -84,20 +98,25 @@ async function load() {
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
return { ...row, key: `${context.batchKey}:${row.customerId}:${row.planPalletId}`, batchKey: context.batchKey, native: candidate, blockReasons: [...new Set(blocks)] }
}))
if (candidateTotal.value > candidatePageSize.value) rows.value = rows.value.filter(row => row.native)
for (const row of rows.value) if (row.native && rows.value.filter(other => other.native?.sourceKey === row.native!.sourceKey).length > 1) row.blockReasons.push('同一实物出现在多条批次候选,请先核对分配范围')
const eligible = rows.value.filter(row => !reasons(row).length)
selected.value = batches.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
baseline.value = JSON.stringify(selected.value)
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
finally { if (current === generation) loading.value = false }
if (autoSelect && candidateTotal.value <= candidatePageSize.value) {
const eligible = rows.value.filter(row => !reasons(row).length)
selected.value = props.batchKeys.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
selectedRows.value = Object.fromEntries(eligible.filter(row => selected.value.includes(row.key)).map(row => [row.key, clone(row)]))
baseline.value = JSON.stringify(selected.value)
}
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
}
function changeCandidatePage(page: number) { candidatePageNumber.value = page; void loadCandidatePage() }
function changeCandidateSize(size: number) { candidatePageSize.value = size; candidatePageNumber.value = 1; void loadCandidatePage() }
function select(row: BatchRow, event: Event) {
const input = event.target as HTMLInputElement
if (!writable.value || unresolved.value || loading.value || error.value || reasons(row).length) { input.checked = selected.value.includes(row.key); return }
if (!input.checked) selected.value = selected.value.filter(key => key !== row.key)
else if (!selected.value.includes(row.key)) {
if (physicalCount([...chosen.value, row]) > capacity.value) ElMessage.warning('已达到本组计划托位上限,请核对实际托盘')
else selected.value.push(row.key)
else { selectedRows.value[row.key] = clone(row); selected.value.push(row.key) }
}
input.checked = selected.value.includes(row.key)
}
@@ -153,6 +172,7 @@ async function save() {
ensureOwner()
if (!pending.value) {
if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist()
}
saving.value = true
@@ -200,7 +220,7 @@ async function releaseRejected() {
}
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
onMounted(load)
onBeforeUnmount(() => { generation++ })
onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() })
</script>
<template>
<section class="work-form" aria-label="按预约批次创建整车排车">
@@ -218,6 +238,8 @@ onBeforeUnmount(() => { generation++ })
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
<table class="density-table"><thead><tr><th>选择</th><th>客户</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.key}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.key)" :disabled="!writable || unresolved || reasons(row).length > 0" @change="select(row, $event)"></td><td>{{ row.native?.customerName || '客户待核实' }}</td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : '计划托盘' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table><p v-if="!rows.length">当前批次尚无可排实物。</p>
<el-pagination aria-label="批次关联候选分页" :current-page="candidatePageNumber" :page-size="candidatePageSize" :page-sizes="[10, 20, 50]" :total="candidateTotal" :disabled="unresolved || loading" layout="total, sizes, prev, pager, next" @update:current-page="changeCandidatePage" @update:page-size="changeCandidateSize" />
<p>上方总数为关联计划的候选数;当前页展示其中属于本批次的实物。已选 {{ chosen.length }} 条(含其他页)。</p>
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
</template>
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
@@ -15,9 +15,10 @@ beforeEach(() => { vi.clearAllMocks(); state.canRead = true; vi.mocked(getContai
it('opens a real plan in the exact customer scope while leaving missing plans unavailable', async () => {
const wrapper = render(); await flushPromises()
expect(getContainerPlanProgress).toHaveBeenCalledExactlyOnceWith('9007199254740993')
await wrapper.findAll('button')[0]!.trigger('click')
const plans = wrapper.findAll('button').filter(item => item.text() === '查看原接仓记录')
await plans[0]!.trigger('click')
expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/inbound-orders/IP-001?customerId=9007199254740999']])
expect(wrapper.findAll('button')[1]!.attributes('disabled')).toBeDefined()
expect(plans[1]!.attributes('disabled')).toBeDefined()
expect(wrapper.text()).not.toContain('internal-plan'); expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
})
it('does not read the container source without its original permission', async () => {
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -42,7 +43,7 @@ async function load() { if (!readable.value) return; loading.value = true; error
onMounted(load)
</script>
<template>
<section aria-label="本柜接仓计划"><h3>选择本柜入仓计划</h3><p>实际收货与卸柜由仓库原作业流程登记;此处进入对应客户的原入库单查看。</p><p v-if="!readable">读取本柜计划需要尾程订单列表权限。</p>
<section aria-label="本柜接仓计划"><h3>选择本柜入仓计划<InlineHelp title="接仓计划说明"><p>实际收货与卸柜由仓库原作业流程登记;此处进入对应客户的原入库单查看。</p></InlineHelp></h3><p v-if="!readable">读取本柜计划需要尾程订单列表权限。</p>
<LoadErrorState v-if="error" title="本柜入仓计划读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="detail && !error"><p>{{ detail.summary.containerNo || '柜号待核实' }} · {{ detail.summary.overseasWarehouseName || '接仓仓库待核实' }}</p><p v-if="detail.summary.blockingReason">{{ detail.summary.blockingReason }}</p><table class="density-table"><thead><tr><th>操作</th><th>入库单号</th><th>入仓码 / 订单号</th></tr></thead><tbody><tr v-for="row in detail.orders" :key="`${row.customerId}:${row.orderId}`"><td><el-button :disabled="saving || !!pending || !row.planNo || !row.customerId || !row.planValid || !access.can('platform:overseas-warehouse:inbound-order:list')" @click="emit('external', `/overseas-warehouse/inbound-orders/${encodeURIComponent(row.planNo!)}?customerId=${encodeURIComponent(row.customerId)}`)">查看原接仓记录</el-button></td><td>{{ row.planNo || '尚未生成有效计划' }}</td><td>{{ row.inboundCode || row.orderNo || '—' }}</td></tr></tbody></table><p v-if="!detail.orders.length">本柜尚无可读取的入仓来源。</p></template>
<el-button v-if="writable && !preview && !pending" :loading="previewLoading" @click="loadPreview">核对本柜入库计划预览</el-button>
@@ -1,3 +1,4 @@
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
@@ -26,6 +27,7 @@ function agreement(status = 'ACTIVE'): api.ReceivingAgreement { return { agreeme
function pendingChange(): api.ReceivingPending { return { changeKey: 'CHANGE-1', version: 1, status: 'WAITING', intent: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a', quantities: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', retainedPackages: 0, expectedSourceVersion: 'source-v3', expectedVerificationVersion: 2 }], laterDate: null, ownerId: 'owner-related', nextFollowupDate: '2026-10-21', reason: '本客户后送', transportVersion: 'transport-v1' }, originalMembers: [members()[0]!], originalAgreements: [agreement()], followup: { ownerId: 'owner-related', ownerName: '已核实负责人', nextFollowupDate: '2026-10-21' } } }
function result(status = 'APPLIED'): api.ReceivingResult { return { changeKey: null, status, batches: [context().batch], agreements: [], steps: [{ step: 'BOOK_RECEIVING', status: 'SUCCESS', targetKey: 'GROUP-1', message: '已保存真实预约' }], nextActions: ['REVIEW_TRANSPORT'] } }
function render() { return mount(WorkReceivingPanel, { props: { batchKey: 'FBA_APPOINTMENT:1', canWrite: true, owners: [{ id: 'owner-related', name: '关联负责人' }] }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
ElButton: { props: ['disabled', 'nativeType'], template: '<button :disabled="disabled" :type="nativeType || \'button\'"><slot /></button>' },
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div data-booking-fields />' },
LoadErrorState: { props: ['message', 'showRetry'], template: '<section role="alert">{{ message }}<button v-if="showRetry !== false" @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true,
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
@@ -263,7 +264,7 @@ onMounted(async () => { await load(); if (props.initialCustomerId && customers.v
<ReceivingScope :members="[]" :transport="context.transport" />
<el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">登记当前未覆盖货物的正式预约</el-button>
</template>
<form v-else @submit.prevent="save()">
<form v-else class="density-form" @submit.prevent="save()">
<fieldset :disabled="locked || !formWritable">
<template v-if="mode === 'refresh'">
<h4>{{ customerName(customer) || '—' }} · 核对本客户最新货物资料</h4><p>采用后仅更新本客户的资料快照,本批箱数和其他客户的确认保持不变;保存核实资料不会自动执行采用。</p>
@@ -274,19 +275,19 @@ onMounted(async () => { await load(); if (props.initialCustomerId && customers.v
<el-button :disabled="locked || cargoLoading" @click="loadCargo">重新读取最新资料</el-button>
</template>
<template v-if="mode === 'plan'">
<h4>{{ customerName(customer) || '—' }} · 本客户普通计划</h4>
<h4>{{ customerName(customer) || '—' }} · 本客户普通计划<InlineHelp title="客户计划说明"><p>目标日期可留空;清空日期和时段会清除本客户目标,不取消货物需求。负责人及下次跟进请在本客户事项的“记录回复与跟进”维护。</p></InlineHelp></h4>
<p v-for="item in customerItems" :key="item.id">{{ item.inboundCode || '—' }} · {{ item.packageCount ?? '箱数待核实' }}</p>
<p>{{ planTimezone || '目的仓时区待核实' }} · 只保存本客户计划,其他客户的确认保持不变。</p>
<p v-if="!planEligible(customer)" role="status">本客户资格已变化,暂不能保存普通计划,请返回范围概览核对。</p>
<label>目标送仓日期<input v-model="planForm.plannedDate" type="date" aria-label="客户计划日期"></label>
<label>计划开始时段<input v-model="planForm.plannedAt" @change="planForm.startOffset = ''" type="datetime-local" step="1" aria-label="客户计划开始"></label>
<label>计划结束时段<input v-model="planForm.plannedEndAt" @change="planForm.endOffset = ''" type="datetime-local" step="1" aria-label="客户计划结束"></label>
<label>目标送仓日期<el-date-picker v-model="planForm.plannedDate" type="date" aria-label="客户计划日期" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label>
<label>计划开始时段<el-date-picker v-model="planForm.plannedAt" @change="planForm.startOffset = ''" type="datetime" aria-label="客户计划开始" :disabled="locked || !formWritable" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></label>
<label>计划结束时段<el-date-picker v-model="planForm.plannedEndAt" @change="planForm.endOffset = ''" type="datetime" aria-label="客户计划结束" :disabled="locked || !formWritable" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></label>
<details><summary>计划时段的夏令时信息</summary><p>当地时钟回拨导致时刻重复时,请按仓库确认填写对应时差。跳时期间不存在的当地时刻不可用,请选择有效时间。</p><label>计划开始时差<input v-model="planForm.startOffset" aria-label="客户计划开始时差" placeholder="如 -07:00"></label><label>计划结束时差<input v-model="planForm.endOffset" aria-label="客户计划结束时差" placeholder="如 -08:00"></label></details>
<label>本次联系结果<select v-model="planForm.appointmentStatus" aria-label="客户计划状态"><option value="PENDING">等待对方确认</option><option value="FAILED">未约成</option></select></label>
<label>{{ planForm.appointmentStatus === 'FAILED' ? '未约成原因' : '联系备注' }}<textarea v-model="planForm.appointmentRemark" maxlength="1000" aria-label="客户计划备注" /></label>
<p>目标日期可留空;清空日期和时段会清除本客户目标,不取消货物需求。负责人及下次跟进请在本客户事项的“记录回复与跟进”维护。</p>
</template>
<template v-if="mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数"></label></div><label>后送目标日期<input v-model="adjustment.laterDate" type="date"></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<input v-model="adjustment.followup" type="date"></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></template>
<template v-if="mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数"></label></div><label>后送目标日期<el-date-picker v-model="adjustment.laterDate" type="date" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<el-date-picker v-model="adjustment.followup" type="date" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></template>
<template v-if="mode === 'resolve' && change"><h4>仓库回复:本次客户调整</h4><ReceivingScope :members="change.originalMembers" :transport="context.transport" /><p v-for="(quantity, index) in change.intent.quantities" :key="sourceKey(quantity)">货物 {{ index + 1 }} · 本次先送 {{ quantity.retainedPackages }} 箱</p><p>负责人 {{ change.followup.ownerName || '—' }} · 跟进 {{ change.followup.nextFollowupDate || '待安排' }}</p><p>后送日期 {{ change.intent.laterDate || '未定' }} · {{ change.intent.reason }}</p><p v-for="agreement in change.originalAgreements" :key="agreement.agreementKey">原约号 {{ agreement.details.confirmationNo || '—' }} · {{ agreement.details.appointmentAt || '时段未定' }} · {{ agreement.timezone || '时区待核实' }}</p><label>仓库同意保留量和移出量的回复依据<textarea v-model="evidence" maxlength="1000" aria-label="仓库回复依据" /></label></template>
<template v-if="group"><h4>{{ mode === 'book' ? '整组重约当前范围' : '整份预约当前范围' }}</h4><ReceivingScope :members="mode === 'book' ? selectedBookMembers : group.agreement.members" :transport="group.transport" /><div v-for="(excluded, index) in group.excludedMembers" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除范围 {{ index + 1 }} · {{ excluded.member.appointmentNo }}:{{ excluded.reason }}</div></template>
<template v-if="mode === 'book'"><p>未取得确认时,可先按客户保存目标或未约成原因。</p><el-button v-for="member in bookMembers.filter(row => row.batchKey === batchKey && planEligible(row.customerId))" :key="member.customerId" :disabled="locked || !edit" @click="openPlan(member.customerId)">{{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }} · 保存目标 / 未约成</el-button><p v-if="!group && cargoIncomplete" role="status">货物数量或来源资料待核实,当前不能登记正式预约。</p><p v-if="bookingScopeChanged" role="status">当前成员范围已变化,请核对上方范围后重新填写预约,原输入不会自动套用给其他客户。<el-button :disabled="locked" @click="resetBookingScope">按当前范围重新填写</el-button></p><template v-if="!group"><p v-for="(excluded, index) in context.executionScope.members.filter(row => row.completed || !row.canRebook)" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除客户范围 {{ index + 1 }}:{{ excluded.reason || (excluded.completed ? '已完成' : '当前不可重新预约') }}</p></template><ReceivingScope v-if="!group" :members="selectedBookMembers" :transport="context.transport" /><LoadErrorState v-if="scopeError" title="所选客户执行资格读取失败" :message="scopeError.message" :loading="scopeLoading" @retry="refreshSelectedScope" /><p v-if="scopeLoading">正在核对所选客户执行资格…</p><p v-if="!scopeLoading && !scopeError && selectedBookMembers.length && !selectedExecutionAllowed" role="status">所选客户执行资格不完整或不可办理,请核对后重新选择。</p><p v-for="(eligibility, index) in selectedExecution?.members.filter(row => row.completed || !row.canRebook) || []" :key="index" role="status">所选范围 {{ index + 1 }}:{{ eligibility.reason || '当前不可重新预约' }}</p><div aria-label="本次预约客户"><h4>选择本次取得确认的客户</h4><label v-for="(member, index) in bookMembers" :key="memberKey(member)"><input v-model="selectedMemberKeys" @change="refreshSelectedScope" :value="memberKey(member)" type="checkbox" :aria-label="`选择预约客户 ${index + 1}`">{{ member.appointmentNo }} · {{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }}</label><p v-if="!selectedBookMembers.length">请至少选择一个客户。</p></div><label><input v-model="separate" type="checkbox">所选客户分别登记预约</label><p>共同预约须由仓库明确确认覆盖当前全部所选客户和货量;重复填写同一约号不能代替共同预约。</p><template v-for="(row, index) in (bookingScopeChanged ? [] : bookingRows)" :key="index"><h4>{{ separate ? `${row.member?.appointmentNo} · ${row.member?.customerName || '—'} · 客户范围 ${index + 1}` : '一份共同预约' }}</h4><FbaAppointmentFields :model="bookingForms[row.index]!" :timezone="group?.agreement.timezone || context.batch.timezone" :disabled="locked || !edit" /><label>仓库确认覆盖依据<textarea v-model="bookingEvidence[row.index]" maxlength="1000" aria-label="预约覆盖依据" /></label></template></template>
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { projectContainerSchedule } from './schedule'
import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
vi.mock('@/shared/api/http', async original => ({
...await original<typeof import('@/shared/api/http')>(),
http: { get: vi.fn(), post: vi.fn() },
}))
const range = { from: '2026-09-28', to: '2026-11-02' }
const bookingId = '9007199254740993'
// 使用真实接口的结构:Long 数量序列化为字符串,空时间字段不输出。
const wire = {
sourceContainerBookingId: bookingId, containerNo: 'CALENDAR-CONT',
warehouseName: '接仓仓库', warehouseCode: 'US-W', version: 0, planCount: '0', canEdit: true,
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'OK', data: {
containers: [{ containerBookingId: bookingId, cabinetNo: 'D-CALENDAR', containerNo: 'CALENDAR-CONT',
vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null,
arrivalAt: '2026-10-20T23:00:00', arrivalType: 'ESTIMATED' }],
emptyReturns: [], emptyReturnsReadable: true,
} })
})
async function readSchedule(overrides: Record<string, unknown> = {}) {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'OK', data: [{ ...wire, ...overrides }] })
const sources = await getCalendarSources(range)
const warehouses = await getWarehouseArrivals(range, [bookingId])
return { warehouses, event: projectContainerSchedule(sources, warehouses, range, true).find(row => row.type === 'warehouse') }
}
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 })
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' })
expect(event?.fields).toContainEqual({ label: '实际到仓', value: '—' })
})
it('keeps the authoritative frozen plan instead of recalculating from arrival', async () => {
const { warehouses, event } = await readSchedule({ planCount: '2', plannedAt: '2026-10-25T09:30:00', canEdit: false })
expect(warehouses[0]?.planCount).toBe(2)
expect(event).toMatchObject({ date: '2026-10-25', time: '09:30', canEdit: false, subtitle: '已有入库计划,日期已冻结' })
})
it('keeps a manually maintained time when there is no inbound plan', async () => {
const { event } = await readSchedule({ version: 1, plannedAt: '2026-10-24T08:00:00' })
expect(event).toMatchObject({ date: '2026-10-24', time: '08:00', subtitle: '人工维护' })
})
it('does not calculate a default when existing plans have no unified time', async () => {
const { event } = await readSchedule({ planCount: '2', canEdit: false })
expect(event?.date).toBeNull()
})
it('keeps conflicting authoritative versions unresolved even with zero plans', async () => {
const { event } = await readSchedule({ version: -1, canEdit: false })
expect(event).toMatchObject({ date: null, canEdit: false, subtitle: '柜内计划时间不一致,待核实' })
})
it.each([undefined, null, '', 'invalid', '-1', '1.5', '9007199254740992', -1, Infinity])(
'rejects invalid or unsafe plan count %j rather than treating it as no plans', async planCount => {
await expect(readSchedule({ planCount })).rejects.toThrow('入库计划数量无效')
},
)
})
@@ -31,15 +31,35 @@ export interface WarehouseArrival {
canEdit: boolean
}
export interface ScheduleRange { from: string; to: string }
type WarehouseArrivalWire = Omit<WarehouseArrival, 'planCount' | 'plannedAt' | 'actualAt'> & {
planCount: string | number
plannedAt?: string | null
actualAt?: string | null
}
/** 在接口边界统一长整型数量,未知数量不能被当成零触发默认日期。 */
function normalizeWarehouseArrival(row: WarehouseArrivalWire): WarehouseArrival {
const count = row.planCount
if (typeof count !== 'number' && (typeof count !== 'string' || !/^(0|[1-9]\d*)$/.test(count))) {
throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
}
const planCount = Number(count)
if (!Number.isSafeInteger(planCount) || planCount < 0) {
throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
}
return { ...row, planCount, plannedAt: row.plannedAt ?? null, actualAt: row.actualAt ?? null }
}
export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
return unwrapApiResponse(await http.get<ApiEnvelope<CalendarSources>, ApiEnvelope<CalendarSources>>(
'/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
))
}
export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
return unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrival[]>, ApiEnvelope<WarehouseArrival[]>>(
const rows = unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrivalWire[]>, ApiEnvelope<WarehouseArrivalWire[]>>(
'/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
))
return rows.map(normalizeWarehouseArrival)
}
export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
@@ -0,0 +1,8 @@
const input = {
props: ['modelValue', 'disabled', 'type', 'format', 'valueFormat'],
emits: ['update:modelValue', 'change'],
template: `<input data-temporal-picker :data-picker-kind="type || 'time'" :value="modelValue" :disabled="disabled" @input="$emit('update:modelValue', $event.target.value); $emit('change', $event.target.value)">`,
}
export const DatePickerStub = { name: 'ElDatePicker', ...input }
export const TimePickerStub = { name: 'ElTimePicker', ...input }
@@ -0,0 +1,36 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { parse } from 'vue/compiler-sfc'
import { expect, it } from 'vitest'
/** 持续检查后台业务模板,避免再次用原生日期控件替代 Element 公共控件。 */
it('uses Element date and time pickers throughout Admin business templates', () => {
const root = join(process.cwd(), 'src/modules')
const violations: string[] = []
function inspect(content: DocumentFragment, file: string) {
for (const input of content.querySelectorAll('input, el-input')) {
const type = input.getAttribute('type') || ''
if (/^(date|time|month|week|datetime-local)$/.test(type)) {
violations.push(`${relative(root, file)} ${input.tagName.toLowerCase()} type=${type}`)
}
}
// 条件和插槽模板的内容位于独立的 DocumentFragment 中。
for (const template of content.querySelectorAll('template')) inspect(template.content, file)
}
function visit(directory: string) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const file = join(directory, entry.name)
if (entry.isDirectory()) visit(file)
else if (entry.name.endsWith('.vue')) {
const source = parse(readFileSync(file, 'utf8')).descriptor.template?.content
if (source) {
const template = document.createElement('template')
template.innerHTML = source
inspect(template.content, file)
}
}
}
}
visit(root)
expect(violations).toEqual([])
})
@@ -3,16 +3,24 @@ import { listTripCalendar, projectTripCalendar, tripQueryWindow, type TripCalend
import { http } from '@/shared/api/http'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data }))
const range = { from: '2026-10-01', to: '2026-11-01' }
function trip(tripNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
return { sourceType: 'TRIP_PLAN', tripNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
function trip(dispatchNo: string, plannedDepartureAt: string, originTimezone = 'America/Los_Angeles'): TripCalendarEntry {
return { sourceType: 'DISPATCH', dispatchNo, status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'US-W', originWarehouseName: 'US warehouse', originCountryCode: 'US', originTimezone, originWarehouseStatus: 'ACTIVE', plannedDepartureAt, expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{name:'US warehouse'}, {name:'FBA'}], incompleteReasons: [] }
}
describe('location-based trip calendar', () => {
it('routes the canonical dispatch number to dispatch detail and does not invent a link without it', () => {
const row = trip('DS/REAL', '2026-10-10T12:00:00Z')
const result = projectTripCalendar({ records: [row], unresolvedRecords: [] }, range)
expect(result.events[0]).toMatchObject({ key: 'dispatch-DS/REAL', title: 'DS/REAL 排车', sourcePath: '/overseas-warehouse/dispatches/DS%2FREAL' })
const missing = projectTripCalendar({ records: [{ ...row, dispatchNo: '' }], unresolvedRecords: [] }, range)
expect(missing.events).toEqual([])
expect(missing.unresolved[0]!.incompleteReasons).toContain('DISPATCH_REFERENCE_UNKNOWN')
})
it('counts a shared trip once and excludes cancelled arrangements', () => {
const shared = trip('TR-SHARED', '2026-10-10T12:00:00Z')
const cancelled = { ...trip('TR-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
const shared = trip('DS-SHARED', '2026-10-10T12:00:00Z')
const cancelled = { ...trip('DS-CANCELLED', '2026-10-10T12:00:00Z'), status: 'CANCELLED' }
const result = projectTripCalendar({ records: [shared, shared, cancelled], unresolvedRecords: [] }, range)
expect(result.events).toHaveLength(1)
expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'TR-SHARED 排车' })
expect(result.events[0]).toMatchObject({ type: 'dispatch', title: 'DS-SHARED 排车' })
})
it('queries one padded UTC window for 42 location dates without frontend page scanning', async () => {
const localRange = {from:'2026-09-28',to:'2026-11-09'}
@@ -25,23 +33,23 @@ describe('location-based trip calendar', () => {
it('includes the first and last local day and removes padded rows outside each origin date range', () => {
const records=[trip('LA-before','2026-10-01T06:30:00Z'),trip('NY-start','2026-10-01T06:30:00Z','America/New_York'),trip('LA-end','2026-11-01T06:30:00Z'),trip('NY-after','2026-11-01T06:30:00Z','America/New_York')]
const projected=projectTripCalendar({records,unresolvedRecords:[]},range)
expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['trip-NY-start','2026-10-01','02:30'],['trip-LA-end','2026-10-31','23:30']])
expect(projected.events.map(event=>[event.key,event.date,event.time])).toEqual([['dispatch-NY-start','2026-10-01','02:30'],['dispatch-LA-end','2026-10-31','23:30']])
})
it('does not guess a slot for absent, fixed-offset, invalid or browser-unsupported time zones', () => {
const unknown={...trip('unknown','2026-10-10T12:00:00Z'),originTimezone:null,incompleteReasons:['ORIGIN_TIMEZONE_UNKNOWN']}
const result=projectTripCalendar({records:[trip('offset','2026-10-10T12:00:00Z','UTC-5'),trip('bad','2026-10-10T12:00:00Z','Mars/Base'),trip('naive','2026-10-10T12:00:00')],unresolvedRecords:[unknown]},range)
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.tripNo)).toEqual(['unknown','offset','bad','naive'])
expect(result.events).toEqual([]);expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['unknown','offset','bad','naive'])
})
it('uses actual local departure through DST changes while retaining the original plan for comparison', () => {
const row={...trip('TR-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
const events=projectTripCalendar({records:[row,trip('TR-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
const row={...trip('DS-DST','2026-11-01T05:30:00Z','America/New_York'),departedAt:'2026-11-02T05:00:00Z',stops:Array.from({length:20},(_,i)=>({name:`Stop ${i}`}))}
const events=projectTripCalendar({records:[row,trip('DS-DST-2','2026-11-01T06:30:00Z','America/New_York')],unresolvedRecords:[]},{from:'2026-11-01',to:'2026-11-08'}).events
expect(events.map(event=>event.time)).toEqual(['00:00','01:30'])
expect(events[0]).toMatchObject({date:'2026-11-02',title:'TR-DST 实际发车'})
expect(events[0]).toMatchObject({date:'2026-11-02',title:'DS-DST 实际发车'})
expect(events[0]!.fields).toContainEqual({label:'计划出发(UTC)',value:'2026-11-01T05:30:00Z'})
expect(events[0]!.fields).toContainEqual({label:'路线',value:'Stop 0 → Stop 1 → Stop 2 等20站'})
})
it('keeps executed trips in the actual month after replanning and excludes their old plan month', () => {
const executed = {...trip('TR-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
const executed = {...trip('DS-ACTUAL', '2026-10-31T12:00:00Z'), departedAt:'2026-11-01T12:00:00Z'}
expect(projectTripCalendar({records:[executed],unresolvedRecords:[]}, range).events).toEqual([])
const november = {from:'2026-11-01',to:'2026-12-01'}
const event = projectTripCalendar({records:[executed],unresolvedRecords:[]}, november).events[0]
@@ -50,9 +58,9 @@ describe('location-based trip calendar', () => {
expect(projectTripCalendar({records:[changed],unresolvedRecords:[]},november).events[0]).toMatchObject({date:event!.date,time:event!.time})
})
it('does not fall back to the plan when an actual departure cannot be resolved', () => {
const invalid = {...trip('TR-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
const invalid = {...trip('DS-INVALID', '2026-10-15T12:00:00Z'), departedAt:'2026-10-16T12:00:00'}
const result = projectTripCalendar({records:[invalid],unresolvedRecords:[]},range)
expect(result.events).toEqual([])
expect(result.unresolved.map(row=>row.tripNo)).toEqual(['TR-INVALID'])
expect(result.unresolved.map(row=>row.dispatchNo)).toEqual(['DS-INVALID'])
})
})
@@ -2,8 +2,8 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { ScheduleEvent } from './schedule'
export interface TripCalendarEntry {
sourceType: 'TRIP_PLAN'
tripNo: string
sourceType: 'DISPATCH'
dispatchNo: string
status: string
originWarehouseId: string | null
originWarehouseCode: string | null
@@ -23,6 +23,7 @@ export const tripIssueLabels: Record<string, string> = {
ORIGIN_WAREHOUSE_UNKNOWN: '起运仓资料缺失', ORIGIN_COUNTRY_UNKNOWN: '起运国家待核实',
ORIGIN_TIMEZONE_UNKNOWN: '起运仓时区待核实', ROUTE_INCOMPLETE: '路线资料不完整',
LOCAL_DATE_UNRESOLVED: '无法确定当地日期',
DISPATCH_REFERENCE_UNKNOWN: '排车单号缺失',
}
export function tripIssues(row: TripCalendarEntry) {
return row.incompleteReasons.map(reason => tripIssueLabels[reason] || '资料待核实').join(';')
@@ -56,7 +57,13 @@ function localStamp(value: string | null, zone: string | null) {
export function projectTripCalendar(result: TripCalendarResult, range: { from: string; to: string }) {
const events: ScheduleEvent[] = [], unresolved = [...result.unresolvedRecords]
const labels: Record<string, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
for (const row of new Map(result.records.map(row => [row.tripNo, row])).values()) {
const seen = new Set<string>()
for (const row of result.records) {
if (!row.dispatchNo?.trim()) {
unresolved.push({ ...row, incompleteReasons: [...row.incompleteReasons, 'DISPATCH_REFERENCE_UNKNOWN'] }); continue
}
if (seen.has(row.dispatchNo)) continue
seen.add(row.dispatchNo)
if (row.status === 'CANCELLED') continue
// Executed work stays on its actual local date even if the plan changes later.
const occurrence = localStamp(row.departedAt ?? row.plannedDepartureAt, row.originTimezone)
@@ -66,11 +73,11 @@ export function projectTripCalendar(result: TripCalendarResult, range: { from: s
if (occurrence.date < range.from || occurrence.date >= range.to) continue
const actual = localStamp(row.departedAt, row.originTimezone)
const driverEnd = localStamp(row.expectedDriverEndAt, row.originTimezone)
events.push({ key: `trip-${row.tripNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
title: `${row.tripNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
sourcePath: `/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`,
events.push({ key: `dispatch-${row.dispatchNo}`, type: 'dispatch', date: occurrence.date, time: occurrence.time,
title: `${row.dispatchNo} ${row.departedAt ? '实际发车' : '排车'}`, subtitle: row.departedAt ? '按起运仓当地实际发车时间展示' : '海外仓送往 FBA/客户仓的车辆安排',
sourcePath: `/overseas-warehouse/dispatches/${encodeURIComponent(row.dispatchNo)}`,
fields: [
{ label: '车次状态', value: labels[row.status] || '待核实' },
{ label: '运输状态', value: labels[row.status] || '待核实' },
{ label: '起运仓', value: row.originWarehouseName || row.originWarehouseCode },
{ label: '地点时区', value: row.originTimezone },
{ label: '计划出发(UTC)', value: row.plannedDepartureAt },
@@ -197,8 +197,8 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
</section>
<el-alert v-if="!detail.success" type="error" :closable="false" show-icon :title="detail.errorMessage || '操作失败,请查看诊断信息'" />
<div class="audit-snapshots">
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作前快照</h3><p class="density-dialog-note">操作执行前保留的业务数据</p><pre class="density-code-block">{{ formatSnapshot(detail.beforeData) }}</pre></section>
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作结果快照</h3><p class="density-dialog-note">操作执行后返回或保存的业务数据</p><pre class="density-code-block">{{ formatSnapshot(detail.afterData) }}</pre></section>
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作前快照</h3><pre class="density-code-block">{{ formatSnapshot(detail.beforeData) }}</pre></section>
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">操作结果快照</h3><pre class="density-code-block">{{ formatSnapshot(detail.afterData) }}</pre></section>
</div>
</div>
</AppDrawer>
@@ -298,7 +298,7 @@ onMounted(load)
<section class="container-customs surface-panel admin-density" aria-label="整柜报关">
<OperationErrorToast :details="actionError" />
<header class="customs-header">
<div><h2>整柜报关</h2><p>资料、回执与完税文件按柜管理。</p></div>
<div><h2>整柜报关</h2></div>
<div v-if="customs" class="customs-actions">
<el-tag :type="!customs.hasActualCargo ? 'info' : customs.status === 'COMPLETED' ? 'success' : customs.status === 'WAITING_CUSTOMER_DOCUMENTS' ? 'danger' : 'warning'">{{ customs.hasActualCargo ? statusText[customs.status] : '无需报关' }}</el-tag>
<el-button v-if="canManage && allBuy" plain :loading="actionLoading" @click="exportAllBuy">导出买单 XLSX</el-button>
@@ -338,7 +338,7 @@ onMounted(load)
</tbody></table>
</div>
<div v-if="['DECLARING', 'COMPLETED'].includes(customs.status) && brokerSubmitted" class="compact-table-wrap declaration-batches-wrap">
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3><p>查看申报批次及海关处理结果。</p></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
<div class="declaration-batches-heading"><div><h3>正式申报与海关结果</h3></div><el-button v-if="canManage && !openDeclarationBatch" size="small" type="primary" :loading="actionLoading" @click="createFormalDeclaration">创建正式申报批次</el-button></div>
<el-empty v-if="!declarationBatches.length" description="尚未创建正式申报批次" :image-size="48" />
<div v-else class="declaration-batch-list">
<article v-for="batch in declarationBatches" :key="batch.id" class="declaration-batch-card">
@@ -40,7 +40,7 @@ describe('尾程付款凭证', () => {
it('新增连续表切换付款字段和全屏后保留输入,保存仍使用真实集装箱号', async () => {
const wrapper = await setup(false, true)
await wrapper.get('button').trigger('click')
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click')
await flushPromises()
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('新增尾程费用'))!
@@ -86,7 +86,7 @@ describe('尾程付款凭证', () => {
it('只有柜次号仍不能维护费用,保留补录真实集装箱号门禁', async () => {
const wrapper = await setup(false, true, null)
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
expect(wrapper.findAll('button').find(button => button.text() === '新增费用')!.attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('尚未补录真实集装箱号')
expect(mocks.create).not.toHaveBeenCalled()
wrapper.unmount()
@@ -103,7 +103,7 @@ describe('尾程付款凭证', () => {
it('显示上传文件名,允许再次上传替换和移除', async () => {
const wrapper = await setup()
await wrapper.get('button').trigger('click')
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click')
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
state.form.settlementMode = 'DIRECT_PAID'
await flushPromises()
@@ -121,7 +121,7 @@ describe('尾程付款凭证', () => {
it('上传中禁止保存,客户切换后忽略旧客户上传结果', async () => {
const wrapper = await setup()
await wrapper.get('button').trigger('click')
await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click')
const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState
state.form.settlementMode = 'DIRECT_PAID'
await flushPromises()
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -165,7 +166,7 @@ onMounted(load)
<template>
<div class="last-leg-fees admin-density">
<header class="last-leg-fees__header"><div><h2>{{ title }}</h2><p>按真实货柜展示港口、堆存、修箱、吊机和码头拖车费用,金额按币种分别列示。</p></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
<header class="last-leg-fees__header"><div><h2>{{ title }}<InlineHelp title="费用统计说明"><p>按真实货柜展示港口、堆存、修箱、吊机和码头拖车费用,金额按币种分别列示。</p></InlineHelp></h2></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
<el-alert v-if="canView && !canEdit && !canReview" type="info" :closable="false" title="当前账号仅有海外段费用查看权限。" />
<el-alert v-if="!containerNo" type="warning" :closable="false" title="当前货柜尚未补录真实集装箱号,暂不能新增尾程费用。" />
<el-alert v-if="!canView" type="warning" :closable="false" title="当前账号无权查看尾程计费。" />
@@ -27,14 +27,12 @@ describe('ContainerSimulationCreate opted-in form', () => {
await flushPromises()
expect(createContainerSimulation).not.toHaveBeenCalled()
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
expect(fields).toHaveLength(4)
expect(fields).toHaveLength(3)
fields.find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '8')
await flushPromises()
expect(listWarehouseLogisticsChannels).toHaveBeenCalledExactlyOnceWith('8')
fields.find(field => field.props('prop') === 'channelGroupCode')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'MATSON_CLX')
await flushPromises()
const specification = fields.find(field => field.props('label') === '统一规格')!
expect(specification.find('input').exists()).toBe(!dense)
dialog.vm.$emit('confirm')
await flushPromises()
expect(createContainerSimulation).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref } from 'vue'
import { Plus } from '@lucide/vue'
import { Container, Plus } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listWarehouseLogisticsChannels, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
@@ -9,7 +9,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification } from '@/shared/domain/containerSpecifications'
import { createContainerSimulation } from '../api/platformContainerLoading'
import type { ContainerSimulationCreateRequest } from '../types'
import { SMART_LOADING_CHANNEL_GROUPS } from '../smartLoadingChannelGroups'
@@ -95,14 +95,54 @@ async function saveSimulation() {
<template>
<el-button type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button>
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
<section :class="{ 'density-dialog-section': dense }" data-tone="work"> <el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" :label-position="dense ? 'left' : 'top'" :label-width="dense ? '120px' : undefined" class="simulation-form" :class="{ 'density-form': dense }"><template v-if="dense"><table :class="{ 'density-table': dense, 'density-table--editable': dense }" aria-label="模拟柜资料"><tbody><tr><td><el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item></td><td><el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item></td><td><el-form-item label="统一规格"><span>{{ `${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg` }}</span><small :class="{ 'density-dialog-note': dense }">{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item></td></tr></tbody></table></template><template v-else><el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><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="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item><el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item><el-form-item label="统一规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /><small>{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item></template></el-form></section>
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
<AppDialog
v-model="simulationOpen"
class="simulation-create-dialog"
:class="{ 'admin-density-dialog': dense, 'admin-density-dialog--comfortable': dense }"
title="新增模拟柜"
width="min(800px, calc(100vw - 32px))"
confirm-text="创建模拟柜"
cancel-text="取消"
:confirm-loading="simulationSaving"
:confirm-disabled="warehouseLoading || simulationChannelsLoading"
@confirm="saveSimulation"
>
<template #header>
<div class="simulation-heading"><span class="simulation-heading__icon"><Container :size="20" aria-hidden="true" /></span><span>新增模拟柜</span></div>
</template>
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
<div class="simulation-fields">
<el-form-item label="装柜仓库" prop="warehouseId">
<el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse">
<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="channelGroupCode">
<el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道">
<el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" />
</el-select>
</el-form-item>
<el-form-item label="柜型" prop="containerType" class="simulation-type">
<el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))">
<el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" />
</el-select>
</el-form-item>
</div>
</el-form>
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
</template>
<style scoped>
.simulation-form :deep(.el-select), .simulation-form :deep(.el-input-number) { width: 100%; }
.simulation-heading { display: flex; align-items: center; gap: 10px; font-size: var(--admin-font-title, 16px); }
.simulation-heading__icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.simulation-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; row-gap: 8px; }
.simulation-fields > * { min-width: 0; }
.simulation-form :deep(.el-select) { width: 100%; }
.simulation-type { grid-column: 1 / -1; }
.simulation-type :deep(.el-select) { max-width: calc(50% - 12px); }
@media (max-width: 600px) {
.simulation-fields { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.simulation-type :deep(.el-select) { max-width: none; }
}
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
@@ -706,7 +707,7 @@ onMounted(async () => {
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
<header class="outbound-panel__header"><div><h2>国内出仓门禁<InlineHelp title="离仓核验规则"><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></InlineHelp></h2></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
<template v-else-if="outboundDetail">
@@ -752,12 +753,12 @@ onMounted(async () => {
<AppDialog class="admin-density-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" 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" 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">
<p class="density-dialog-note">按真实装柜来源逐票登记签收;全部来源完成后核验还柜资格。海外仓接仓仍使用原仓库收货流程。</p>
<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><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></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"><table class="density-table density-table--editable container-workflow-table" aria-label="本票直送签收"><tbody><tr><td><el-form-item label="实际卸货当地时间" required><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></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></section>
<section v-if="directSelected" class="density-dialog-section" :data-tone="!canWriteDirect || directSelected.actualAt ? 'source' : 'work'"><h3 class="density-dialog-section__title">{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="left" label-width="160px" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><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></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" v-model="terminalOpen" :title="containerDialogTitle('提柜安排与执行', detail)" width="780px" fullscreenable :show-confirm="false" :before-close="closeTerminal">
@@ -821,7 +821,6 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<style scoped>
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.container-editor-table { table-layout: fixed; }.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
@media(max-width:720px){.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
@media(max-width:720px){.container-editor-table :is(tbody,tr,td){display:block}}
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import '@/modules/customers/customerDensity.css'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
@@ -398,7 +399,7 @@ onBeforeUnmount(stopInitializationPolling)
<div v-else-if="initializationReady" v-loading="loading" class="loading-state" />
<section v-if="initializationReady && canChargeView" class="rule-card charge-rule-card">
<header class="rule-card-heading"><div><div class="rule-title-row"><h2>箱规按权重系数计算</h2><el-checkbox :model-value="defaultCalculationMode === 'WEIGHT'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('WEIGHT', Boolean($event))">设为默认规则</el-checkbox></div><p class="section-hint">勾选后客户下单数据才会按该规则调整;未勾选时默认使用实测原尺寸(不调整)。</p></div></header>
<header class="rule-card-heading"><div><div class="rule-title-row"><h2>箱规按权重系数计算<InlineHelp title="默认规则说明"><p>勾选后客户下单数据才会按该规则调整;未勾选时默认使用实测原尺寸(不调整)。</p></InlineHelp></h2><el-checkbox :model-value="defaultCalculationMode === 'WEIGHT'" :disabled="!canEdit || defaultModeSaving" @change="selectDefaultCalculationMode('WEIGHT', Boolean($event))">设为默认规则</el-checkbox></div></div></header>
<el-table v-loading="chargeLoading" :data="chargeDisplayRows" border stripe empty-text="暂无系数配置">
<el-table-column prop="boxTypeName" label="箱规类型" min-width="110" />
<el-table-column label="内径尺寸(长×宽×高 cm)" min-width="240"><template #default="{ row }">{{ row.dimension ? formatDimension(row.dimension.innerLengthCm, row.dimension.innerWidthCm, row.dimension.innerHeightCm) : '—' }}</template></el-table-column>
@@ -414,8 +415,8 @@ onBeforeUnmount(stopInitializationPolling)
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="editorVisible" :title="customerDialogTitle('编辑箱规尺寸 / 计算规则', { tenantCode: customerCode, tenantName: customerName })" fullscreenable width="min(1200px, calc(100vw - 28px))" :confirm-loading="editorSaving || chargeSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
<el-form ref="editorForm" :model="form" label-position="top" class="density-form density-dialog-sections">
<section class="density-dialog-section" data-tone="work">
<h3 class="density-dialog-section__title">按箱规尺寸计算</h3>
<p class="density-dialog-note">计算公式:按长、宽、高匹配箱规;长、宽、高各加 2X(X 为单边加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
<h3 class="density-dialog-section__title">按箱规尺寸计算<InlineHelp title="箱规计算公式"><p>计算公式:按长、宽、高匹配箱规;长、宽、高各加 2X(X 为单边加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p></InlineHelp></h3>
<div class="editor-table-wrap">
<table class="density-table density-table--editable editor-table" aria-label="箱规尺寸规则填写"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>单边加宽(cm)</th><th>备注</th></tr></thead><tbody>
<tr v-for="row in form.rules" :key="row.boxType"><th scope="row">{{ row.boxTypeName }}</th><td><el-input-number v-model="row.innerLengthCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.innerWidthCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.innerHeightCm" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.edgeAdditionCm" :min="0.001" :max="100" :precision="3" :controls="false" placeholder="请输入边长加值" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr>
@@ -423,7 +424,7 @@ onBeforeUnmount(stopInitializationPolling)
</div>
<div class="rule-save-row density-actions density-actions--footer"><el-button type="primary" :loading="editorSaving" @click="saveConfig">保存规则</el-button></div>
</section>
<section v-if="canChargeView" class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">箱规按权重系数计算</h3><p class="density-dialog-note">长、宽、高任一项超出当前挡位时进入下一挡;命中规格的一个系数统一应用于长、宽、高。</p><div class="charge-rule-editor-wrap"><table class="density-table density-table--editable charge-rule-editor" aria-label="权重系数规则填写"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead><tbody><tr v-for="row in chargeRows" :key="`combined-${row.boxType}`"><th scope="row">{{ BOX_NAMES[row.boxType] }}</th><td><el-input-number v-model="row.length" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.width" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.height" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" placeholder="请输入权重系数" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" placeholder="请输入重量" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr></tbody></table></div><div class="rule-save-row density-actions density-actions--footer"><el-button type="primary" :loading="chargeSaving" @click="saveChargeConfig">保存系数规则</el-button></div></section>
<section v-if="canChargeView" class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">箱规按权重系数计算<InlineHelp title="系数匹配规则"><p>长、宽、高任一项超出当前挡位时进入下一挡;命中规格的一个系数统一应用于长、宽、高。</p></InlineHelp></h3><div class="charge-rule-editor-wrap"><table class="density-table density-table--editable charge-rule-editor" aria-label="权重系数规则填写"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead><tbody><tr v-for="row in chargeRows" :key="`combined-${row.boxType}`"><th scope="row">{{ BOX_NAMES[row.boxType] }}</th><td><el-input-number v-model="row.length" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径长" /></td><td><el-input-number v-model="row.width" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径宽" /></td><td><el-input-number v-model="row.height" :min="0.001" :precision="3" :controls="false" placeholder="请输入内径高" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" placeholder="请输入权重系数" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" placeholder="请输入重量" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="请输入备注(选填)" /></td></tr></tbody></table></div><div class="rule-save-row density-actions density-actions--footer"><el-button type="primary" :loading="chargeSaving" @click="saveChargeConfig">保存系数规则</el-button></div></section>
</el-form>
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="editorSaving || chargeSaving" @click="closeEditor">取消</el-button></div></template>
</AppDialog>
@@ -99,7 +99,9 @@ describe('CustomerDetailView', () => {
expect(document.body.textContent).toContain('企业管理员')
expect(document.body.textContent).toContain('公司联系电话')
expect(document.body.textContent).toContain('登录手机号')
expect(document.body.textContent).toContain('修改会影响登录')
expect(document.querySelector('.user-panel')?.textContent).not.toContain('修改会影响登录')
document.querySelector<HTMLButtonElement>('[aria-label="查看账号信息说明"]')!.focus()
await vi.waitFor(() => expect(document.body.textContent).toContain('修改会影响登录'))
expect(document.querySelector('.contact-panel .info-grid--three')).not.toBeNull()
expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id)
expect(document.body.textContent).toContain('已替换')
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import '@/modules/customers/customerDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
@@ -253,7 +254,7 @@ onBeforeUnmount(() => {
<template v-if="activeTab === 'basic'">
<div class="basic-layout">
<section class="detail-panel company-panel">
<div class="panel-title"><div><h2>企业基本信息</h2><p>工商档案与合作配置</p></div><Building2 :size="19" /></div>
<div class="panel-title"><div><h2>企业基本信息</h2></div><Building2 :size="19" /></div>
<dl class="info-grid density-fields">
<div><dt>公司名称</dt><dd>{{ displayText(customer.legalName || customer.tenantName) }}</dd></div>
<div><dt>客户昵称</dt><dd>{{ displayText(customer.customerNickname) }}</dd></div>
@@ -275,7 +276,7 @@ onBeforeUnmount(() => {
<div v-else class="section-empty"><FileCheck2 :size="22" /><strong>暂无资质文件</strong><span>客户尚未上传企业资质</span></div>
</section>
<section class="detail-panel contact-panel">
<div class="panel-title"><div><h2>联系与合作信息</h2><p>客户联络及内部跟进信息</p></div><UserRound :size="19" /></div>
<div class="panel-title"><div><h2>联系与合作信息</h2></div><UserRound :size="19" /></div>
<dl class="info-grid info-grid--three density-fields">
<div><dt>联系人</dt><dd>{{ displayText(customer.contactName) }}</dd></div><div><dt>公司联系电话</dt><dd>{{ displayText(customer.contactPhone) }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ displayText(customer.contactEmail) }}</dd></div><div><dt>联系微信</dt><dd>{{ displayText(customer.contactWechat) }}</dd></div>
@@ -285,7 +286,7 @@ onBeforeUnmount(() => {
</dl>
</section>
<section class="detail-panel user-panel">
<div class="panel-title"><div><h2>企业用户</h2><p>登录手机号属于账号信息;请在成员/账号管理中单独维护,修改会影响登录</p></div><UsersRound :size="19" /></div>
<div class="panel-title"><div><h2>企业用户<InlineHelp title="账号信息说明"><p>登录手机号属于账号信息;请在成员/账号管理中单独维护,修改会影响登录</p></InlineHelp></h2></div><UsersRound :size="19" /></div>
<div v-if="enterpriseMembers.length" class="user-table-wrap">
<table><thead><tr><th>账号</th><th>成员类型</th><th>登录手机号</th><th>邮箱</th><th>角色</th><th>状态</th></tr></thead>
<tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)">
@@ -268,7 +268,9 @@ describe('CustomersView', () => {
'货代资质证书选填',
'银行开户许可证选填',
])
expect(document.body.textContent).toContain('营业执照和18位统一社会信用代码为必填业务项')
expect(document.querySelector('.qualification-section')?.textContent).not.toContain('营业执照和18位统一社会信用代码为必填业务项')
document.querySelector<HTMLButtonElement>('[aria-label="查看资质提交规则"]')!.focus()
await vi.waitFor(() => expect(document.body.textContent).toContain('营业执照和18位统一社会信用代码为必填业务项'))
})
it('creates a customer with a business license and no legal-person ID', async () => {
@@ -320,6 +322,8 @@ describe('CustomersView', () => {
tableButtonByText('编辑')?.click()
await flushPromises()
await wrapper.get('[aria-label="查看企业联系信息说明"]').trigger('focus')
await vi.waitFor(() => expect(document.body.textContent).toContain('不会修改企业管理员登录手机号'))
const phone = wrapper.get('input[placeholder="请输入公司联系电话"]')
expect(phone.attributes('disabled')).toBeUndefined()
await phone.setValue('0755-12345678')
@@ -331,7 +335,6 @@ describe('CustomersView', () => {
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
expect(document.body.textContent).toContain('客户资料已保存')
expect(document.body.textContent).not.toContain('尚未提交审核')
expect(document.body.textContent).toContain('不会修改企业管理员登录手机号')
expect(customersSource).not.toContain('联系电话(登录用户名)')
expect(customersSource.indexOf('if (!failures.length) editorOpen.value = false')).toBeLessThan(customersSource.indexOf('await loadCustomers()'))
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import '@/modules/customers/customerDensity.css'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
@@ -1086,7 +1087,7 @@ onMounted(() => { void loadCustomers() })
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="144px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">基础资料</h3>
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /><small class="field-hint">创建后仍可在客户资料中修改。</small></el-form-item></td></tr>
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td></tr>
<tr><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td></tr>
<tr><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="2"><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>
@@ -1094,7 +1095,7 @@ onMounted(() => { void loadCustomers() })
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">联系与商务</h3><p class="density-dialog-note">仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p>
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">联系与商务<InlineHelp title="企业联系信息说明"><p>仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p></InlineHelp></h3>
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td></tr>
<tr><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td></tr>
@@ -1103,7 +1104,7 @@ onMounted(() => { void loadCustomers() })
</tbody></table>
</section>
<section class="density-dialog-section qualification-section" data-tone="work"><h3 class="density-dialog-section__title">企业认证资料</h3><p class="density-dialog-note">营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<section class="density-dialog-section qualification-section" 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" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
<div class="qualification-table" role="table" aria-label="客户资质文件">
@@ -1121,7 +1122,7 @@ onMounted(() => { void loadCustomers() })
<div class="qualification-validity" role="cell"><el-date-picker v-model="draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploading || (draft.uploaded && !draft.file)" /></div>
</div>
</div>
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p>
</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>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { AlertTriangle, ArrowLeft, CircleCheck, Download, FileSpreadsheet, RefreshCw, Ship, Warehouse } from '@lucide/vue'
@@ -473,7 +474,7 @@ onMounted(() => {
</section>
<section v-if="isPending && canEdit" class="mapping-section">
<header class="section-heading"><div><h2><Warehouse :size="18" />仓库映射</h2><p>来源编码必须匹配启用的平台公共仓库编码。</p></div></header>
<header class="section-heading"><div><h2><Warehouse :size="18" />仓库映射<InlineHelp title="仓库映射规则"><p>来源编码必须匹配启用的平台公共仓库编码。</p></InlineHelp></h2></div></header>
<el-alert
v-if="nonMappingBlockingIssues.length"
title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
@@ -512,7 +513,7 @@ onMounted(() => {
</section>
<section class="detail-section">
<header class="section-heading"><div><h2><AlertTriangle :size="18" />解析与校验</h2><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></div></header>
<header class="section-heading"><div><h2><AlertTriangle :size="18" />解析与校验<InlineHelp title="价格表确认规则"><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></InlineHelp></h2></div></header>
<el-empty v-if="!detail.issues.length" description="未发现解析问题" :image-size="72" />
<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">
<el-table-column label="级别" width="90"><template #default="{ row }"><el-tag :type="(row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? 'danger' : 'warning'" size="small">{{ (row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? '阻断' : '警告' }}</el-tag></template></el-table-column>
@@ -523,7 +524,7 @@ onMounted(() => {
</section>
<section class="detail-section channel-preview">
<header class="section-heading"><div><h2><Ship :size="18" />按渠道预览</h2><p>显示上传 Excel 的解析结果,确认页不提供在线改价。</p></div></header>
<header class="section-heading"><div><h2><Ship :size="18" />按渠道预览</h2></div></header>
<el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" />
<template v-else>
<el-tabs v-model="selectedChannelCode" class="channel-tabs density-element-tabs">
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { FileSpreadsheet, FileUp } from '@lucide/vue'
@@ -397,10 +398,10 @@ onMounted(() => {
@confirm="submitUpload"
>
<div class="density-dialog-sections">
<el-alert title="上传后先展示解析结果和仓库映射;确认后只替换同一报价范围的当前生效版本。客户专属报价生效后,计价时优先于平台公共报价。" type="info" :closable="false" show-icon />
<el-form class="density-form" label-position="left" label-width="90px" :disabled="uploading">
<table class="density-table density-table--editable" aria-label="上传价格表资料"><tbody>
<tr><td><el-form-item label="报价范围"><el-select v-model="uploadCustomerId" filterable :disabled="uploading" aria-label="上传报价范围"><el-option label="平台公共报价" value="0" /><el-option v-for="customer in customerOptions" :key="String(customer.id)" :label="`客户专属 · ${customer.tenantName || '未命名客户'}`" :value="String(customer.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="报价范围"><template #label><span>报价范围<InlineHelp title="报价上传规则"><p>上传后先展示解析结果和仓库映射;确认后只替换同一报价范围的当前生效版本。客户专属报价生效后,计价时优先于平台公共报价。</p></InlineHelp></span></template><el-select v-model="uploadCustomerId" filterable :disabled="uploading" aria-label="上传报价范围"><el-option label="平台公共报价" value="0" /><el-option v-for="customer in customerOptions" :key="String(customer.id)" :label="`客户专属 · ${customer.tenantName || '未命名客户'}`" :value="String(customer.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="价格表文件">
<input ref="uploadInput" class="visually-hidden-input" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" aria-label="选择 XLSX 船期价格表" :disabled="uploading" @change="selectUploadFile" />
<div class="upload-file-control"><el-button :disabled="uploading" @click="uploadInput?.click()"><FileSpreadsheet :size="16" />选择 XLSX 文件</el-button><span class="upload-file-name">{{ uploadFile?.name || '尚未选择文件' }}</span></div>
@@ -19,7 +19,7 @@ import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { firstLegBusinessTypeLabel } from '@/shared/utils/firstLegBusinessLabels'
import { listPlatformInbounds } from '../core/api'
import type { InboundOrderSummary } from '../core/types'
import type { InboundOrderSummary, InboundPageResult } from '../core/types'
import InboundMeasurementDialog from '../measurement/InboundMeasurementDialog.vue'
import MeasurementReviewPanel from '../review/MeasurementReviewPanel.vue'
import { approveMeasurement, getMeasurementReviewDetail } from '../review/api'
@@ -33,7 +33,11 @@ type FilterModel = QueryModel & {
expectedArrivalRange: string[]
}
const props = defineProps<{ mode: InboundTaskMode; title?: string }>()
const props = defineProps<{
mode: InboundTaskMode
title?: string
taskLoader?: (pagination: { page: number; size: number }) => Promise<InboundPageResult>
}>()
const emit = defineEmits<{ changed: [] }>()
const router = useRouter()
const access = useAccessPreviewStore()
@@ -143,6 +147,7 @@ function dateParam(value: string | undefined, end = false) {
}
async function loadOptions() {
if (props.taskLoader) return
const serial = ++optionsRequestSerial
customerLoadError.value = null
try {
@@ -169,7 +174,7 @@ async function loadTasks() {
loadError.value = null
const applied = appliedFilters.value
try {
const response = await listPlatformInbounds(applied.customerId || undefined, buildInboundTaskQuery(props.mode, {
const response = props.taskLoader ? await props.taskLoader({ page: page.value, size: pageSize.value }) : await listPlatformInbounds(applied.customerId || undefined, buildInboundTaskQuery(props.mode, {
page: page.value,
size: pageSize.value,
warehouseId: applied.warehouseId || undefined,
@@ -328,6 +333,12 @@ function clearSelectedInbound() {
reviewNavigationLoading.value = false
}
function reload() {
if (page.value === 1) return loadTasks()
page.value = 1
}
defineExpose({ reload })
watch([page, pageSize], () => { void loadTasks() })
onMounted(() => { void loadOptions(); void loadTasks() })
onActivated(() => {
@@ -363,6 +374,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
>
<template #toolbar-start><h1>{{ title || (isReview ? '测量数据审核' : '订单测量') }}</h1></template>
<template #filters>
<slot name="filters" :loading="loading">
<slot name="status-tabs" />
<QueryToolbar
v-model="filters"
@@ -373,6 +385,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
@search="applyFilters"
@reset="resetFilters"
/>
</slot>
</template>
<template #notices><template v-if="customerLoadError || loadError">
<div class="state-stack">
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { onBeforeUnmount, ref, watch } from 'vue'
import { Printer, RefreshCw } from '@lucide/vue'
import { ElMessage } from 'element-plus'
@@ -88,9 +89,9 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1 })
<section class="dropship-print-panel admin-density" aria-labelledby="dropship-print-title">
<header class="print-heading">
<div>
<h3 id="dropship-print-title">一件代发托盘打印</h3>
<p>测量审核生成托盘码后,在电脑端打印并贴到空托盘,再交由 PDA 扫码组托。</p>
<p>箱数与 SKU 种类数按整托统计,包含合托的其他入仓码;相同 SKU 编码只计一种,不代表商品件数。</p>
<h3 id="dropship-print-title">一件代发托盘打印<InlineHelp title="托盘打印说明"><p>测量审核生成托盘码后,在电脑端打印并贴到空托盘,再交由 PDA 扫码组托。</p><p>箱数与 SKU 种类数按整托统计,包含合托的其他入仓码;相同 SKU 编码只计一种,不代表商品件数。</p></InlineHelp></h3>
</div>
<div class="print-actions">
<el-button :disabled="loading || Boolean(printing)" @click="loadPallets"><RefreshCw :size="15" />刷新</el-button>
+2 -2
View File
@@ -438,9 +438,9 @@ defineExpose({ reload: loadFee })
</header>
<AppDialog v-model="trialOpen" title="头程费用试算" width="min(760px, calc(100vw - 28px))" :confirm-loading="trialLoading" confirm-text="开始试算" @confirm="submitTrial">
<el-alert title="重量和体积各选择一项;默认选择实际测量数据。" type="info" :closable="false" show-icon />
<div class="trial-source-grid">
<section><h4>计费重量(单选)</h4><el-radio-group v-model="trial.weightSource"><el-radio v-for="item in sourceOptions" :key="`weight-${item.value}`" :value="item.value" :disabled="item.weight <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.weight) }} KG</strong></el-radio></el-radio-group></section>
<section><h4>计费重量(单选)<InlineHelp title="试算取值说明"><p>重量和体积各选择一项;默认选择实际测量数据。</p></InlineHelp></h4><el-radio-group v-model="trial.weightSource"><el-radio v-for="item in sourceOptions" :key="`weight-${item.value}`" :value="item.value" :disabled="item.weight <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.weight) }} KG</strong></el-radio></el-radio-group></section>
<section><h4>计费体积(单选)</h4><el-radio-group v-model="trial.volumeSource"><el-radio v-for="item in sourceOptions" :key="`volume-${item.value}`" :value="item.value" :disabled="item.volume <= 0"><span>{{ item.label }}</span><strong>{{ formatNumber(item.volume, 6) }} CBM</strong></el-radio></el-radio-group></section>
</div>
</AppDialog>
+2
View File
@@ -20,3 +20,5 @@ export type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './fee
export { downloadWeightImage } from './weight/api/weightOcr'
export type { InboundMeasurementDetail } from './measurement/types'
export type { InboundHoldCommand, InboundHoldDetail, InboundHoldEvidenceUpload, InboundHoldHistoryEntry } from './core/types'
export type { InboundOrderSummary, InboundPageResult } from './core/types'
@@ -69,7 +69,7 @@ describe('MeasurementReviewPanel', () => {
const toolbar = wrapper.get('.review-toolbar')
expect(toolbar.text()).toContain('待审核')
expect(toolbar.text()).toContain('修改重量')
expect(toolbar.text()).toContain('通过并进入待分柜')
expect(toolbar.findAll('button').map(button => button.text())).toContain('通过')
expect(toolbar.text()).toContain('刷新')
expect(toolbar.text()).toContain('客户:柏悦客户')
expect(toolbar.text()).toContain('渠道:海运')
@@ -99,10 +99,10 @@ describe('MeasurementReviewPanel', () => {
const warning = vi.spyOn(ElMessage, 'warning')
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001', fulfillmentType: 'ONE_CLICK_DROPSHIP' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).not.toContain('通过并进入待分柜')
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待一件代发组托'))!.trigger('click')
expect(wrapper.text()).not.toContain('通过并进入')
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
await flushPromises()
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('进入待一件代发组托'), '确认测量审核通过', expect.objectContaining({ confirmButtonText: '通过并进入待一件代发组托' }))
expect(confirmation).toHaveBeenCalledWith(expect.stringContaining('进入待一件代发组托'), '确认测量审核通过', expect.objectContaining({ confirmButtonText: '通过' }))
expect(initialPricingSucceeded ? success : warning).toHaveBeenCalledWith(expect.stringContaining('入库单已进入待一件代发组托'))
expect(approveMeasurement).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('changed')).toHaveLength(1)
@@ -209,7 +209,7 @@ describe('MeasurementReviewPanel', () => {
await wrapper.get('.mock-weight-change').trigger('click')
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click')
await wrapper.findAll('button').find((button) => button.text() === '通过')?.trigger('click')
await flushPromises()
expect(getMeasurementReviewDetail).toHaveBeenCalledTimes(2)
@@ -227,7 +227,7 @@ describe('MeasurementReviewPanel', () => {
await flushPromises()
expect(wrapper.find('.review-toolbar__actions').exists()).toBe(false)
expect(wrapper.get('.review-toolbar__context').text()).toContain('客户:柏悦客户')
expect(wrapper.vm.approvalButtonLabel).toBe('通过并进入待分柜')
expect(wrapper.vm.approvalButtonLabel).toBe('通过')
wrapper.vm.enableWeightCorrection()
await flushPromises()
expect(wrapper.getComponent({ name: 'WeightOcrPanel' }).props('correctionMode')).toBe(true)
@@ -245,7 +245,7 @@ describe('MeasurementReviewPanel', () => {
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const approveButton = wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!
const approveButton = wrapper.findAll('button').find((button) => button.text() === '通过')!
await approveButton.trigger('click')
await approveButton.trigger('click')
@@ -263,12 +263,12 @@ describe('MeasurementReviewPanel', () => {
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!.trigger('click')
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
await flushPromises()
expect(wrapper.emitted('changed')).toBeUndefined()
expect(wrapper.find('.review-panel').exists()).toBe(true)
expect(wrapper.findAll('button').some((button) => button.text().includes('通过并进入待分柜'))).toBe(true)
expect(wrapper.findAll('button').some((button) => button.text() === '通过')).toBe(true)
})
it('blocks approval while the order detail navigation is loading', async () => {
@@ -279,7 +279,7 @@ describe('MeasurementReviewPanel', () => {
})
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))!.trigger('click')
await wrapper.findAll('button').find((button) => button.text() === '通过')!.trigger('click')
expect(approveMeasurement).not.toHaveBeenCalled()
})
@@ -49,7 +49,7 @@ const canReview = computed(() => access.can('firstleg:inbound:measurement-review
const customerId = computed(() => props.customerId || props.tenantId || '')
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
const approvalTargetLabel = computed(() => props.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? '待一件代发组托' : '待分柜')
const approvalButtonLabel = computed(() => `通过并进入${approvalTargetLabel.value}`)
const approvalButtonLabel = computed(() => '通过')
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
const displayCustomerName = computed(() => detail.value?.companyName?.trim() || props.customerName?.trim() || '客户未命名')
@@ -308,7 +308,7 @@ watch([planId, warehouseId], () => {
</AppDialog>
<AppDialog v-model="remarkOpen" class="remarks-dialog admin-density-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">
<el-alert type="info" :closable="false" show-icon title="客户备注来自客户下单且不可修改;其余备注可维护" />
<section class="density-dialog-section" data-tone="work">
<el-form class="density-form">
<table class="density-table density-table--editable preload-editor-table" aria-label="预装明细备注"><tbody>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
@@ -379,8 +380,8 @@ onMounted(() => {
<el-form-item label="客服电话"><el-input v-model.trim="form.servicePhone" maxlength="32" placeholder="例如 95338" /></el-form-item>
<el-form-item label="官方网站" prop="website"><el-input v-model.trim="form.website" maxlength="255" placeholder="https://www.example.com" /></el-form-item>
</div>
<el-form-item label="轨迹查询地址模板"><el-input v-model.trim="form.trackingUrlTemplate" maxlength="500" placeholder="https://example.com/track/{trackingNo}" /></el-form-item>
<p class="form-help">可使用 <code>{trackingNo}</code> 作为快递单号占位符;一期不包含轨迹订阅和自动回传。</p>
<el-form-item label="轨迹查询地址模板"><template #label><span>轨迹查询地址模板<InlineHelp title="轨迹地址模板说明"><p>可使用 <code>{trackingNo}</code> 作为快递单号占位符。</p></InlineHelp></span></template><el-input v-model.trim="form.trackingUrlTemplate" maxlength="500" placeholder="https://example.com/track/{trackingNo}" /></el-form-item>
</FormSection>
<FormSection class="density-form-section" title="备注"><el-form-item label="内部说明"><el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用范围、服务限制或其他说明" /></el-form-item></FormSection>
</el-form>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, onActivated, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -163,10 +164,10 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
<section><h3>差异处置记录</h3><DataTable :data="detail.physical.dispositions" :columns="dispositionColumns" row-key="id" empty-text="暂无差异处置" /></section>
<section><h3>逐箱装货清单 · {{ detail.physical.packingStatus === 'CONFIRMED' ? '已确认' : detail.physical.packingStatus === 'DRAFT' ? '草稿' : '未建立' }}<span v-if="detail.physical.packingVersion">(第 {{ detail.physical.packingVersion }} 版)</span></h3><DataTable :data="detail.physical.cartons" :columns="cartonColumns" row-key="id" empty-text="暂无箱明细" /></section>
<section v-if="detail.transport.domesticCargo.length"><h3>国内包装与可寄货物核对</h3><DataTable :data="detail.transport.domesticCargo" :columns="cartonColumns" row-key="cartonId" /></section>
<section v-for="group in labelGroups" :key="group.leg"><h3>{{ group.title }}最近保存面单 · 第 {{ group.value?.version }} 版</h3><dl v-if="group.value" class="density-fields"><div class="wide"><dt>寄件信息</dt><dd>{{ group.value.sender.name }} · {{ group.value.sender.phone }} · {{ group.value.sender.address }}</dd></div><div class="wide"><dt>收件信息</dt><dd>{{ group.value.receiver.name }} · {{ group.value.receiver.phone }} · {{ group.value.receiver.address }}</dd></div></dl><DataTable :data="group.value?.labels || []" :columns="labelColumns" row-key="cartonId" /></section>
<section v-for="group in labelGroups" :key="group.leg"><h3>{{ group.title }}最近保存面单 · 第 {{ group.value?.version }} 版<InlineHelp title="面单核对说明"><p>包装或实收变化后须重新核对面单;是否可交寄以节点校验结果为准。</p></InlineHelp></h3><dl v-if="group.value" class="density-fields"><div class="wide"><dt>寄件信息</dt><dd>{{ group.value.sender.name }} · {{ group.value.sender.phone }} · {{ group.value.sender.address }}</dd></div><div class="wide"><dt>收件信息</dt><dd>{{ group.value.receiver.name }} · {{ group.value.receiver.phone }} · {{ group.value.receiver.address }}</dd></div></dl><DataTable :data="group.value?.labels || []" :columns="labelColumns" row-key="cartonId" /></section>
<section><h3>两段实际交寄</h3><DataTable :data="detail.customerView.shipments" :columns="shipmentColumns" empty-text="暂无实际交寄记录" /></section>
<section><h3>费用与资金结果</h3><DataTable :data="detail.customerView.fees" :columns="feeColumns" row-key="feeNo" empty-text="暂无核定费用" /></section>
<section><h3>费用更正记录</h3><DataTable :data="detail.feeCorrections" :columns="correctionColumns" row-key="correctionNo" empty-text="暂无费用目标更正" /></section>
<section><h3>费用与资金结果<InlineHelp title="费用与资金说明"><p>样品货款与实际费用独立结算;预估国际运费仅冻结。资金结果和费用来源关联分别核对。</p></InlineHelp></h3><DataTable :data="detail.customerView.fees" :columns="feeColumns" row-key="feeNo" empty-text="暂无核定费用" /></section>
<section><h3>费用更正记录<InlineHelp title="费用更正说明"><p>本表记录核定目标、差额及未实扣费用的替代版本;实际扣款、退款结果见费用与资金明细。</p></InlineHelp></h3><DataTable :data="detail.feeCorrections" :columns="correctionColumns" row-key="correctionNo" empty-text="暂无费用目标更正" /></section>
<section><h3>事实说明与阻断</h3><DataTable :data="detail.controls.notes" :columns="noteColumns" row-key="id" empty-text="暂无事实说明或阻断" /></section>
<section><h3>剩余货物去向核对</h3><DataTable :data="detail.controls.remaining" :columns="remainingColumns" empty-text="当前无待处置的剩余货物" /></section>
<section v-if="detail.controls.closures.length"><h3>异常关闭记录</h3><DataTable :data="detail.controls.closures" :columns="closureColumns" row-key="id" /><DataTable :data="detail.controls.closureItems" :columns="closureItemColumns" row-key="id" empty-text="关闭时无剩余实物" /></section>
@@ -1,5 +1,7 @@
# 海外通用资料与车辆管理
- 自有业务:`pages/OwnedBusinessView.vue`、`OwnedBusinessDetailView.vue`及`api/ownedBusiness.ts`沿`fleet-owned-business-v1.yaml`,是原排车装车后的自有车辆运营视图,不按货源分类或另建履约流程;共同规则见`../../../../../docs/海外仓业务/自有业务首版开发契约.md`。详情三个页签,运营登记1080px居中、照片760px居中;金额/油耗单位、附加与排车版本分离,`owned-business:list/edit/upload`独立,统计沿完整计划发车日期筛选。外配页面本批不实现。
- 运输路线地图:`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`;首版所有货到我仓上车,未确认意向不占位,列表不逐行请求详情。
@@ -1,5 +1,18 @@
# 海外通用资料原型核对矩阵
## 自有业务准备核对(2026-10-20,主规则已确认)
用户当前要求逐页处理,先看自有业务;HTTP 扩展由既有排车后端对话独立负责。本节记录原型与已确认规则的映射,不授权外配页面改造。自有与外配均为原排车装车后的车辆归属视图,不另建任务或履约流程;自有车增加运营细项,外配管理细度后续确认。视觉沿当前 Admin 高密度规范,业务与操作承载依据用户提供的 `C:/Users/BY-OY/Documents/WXWork/1688854456873012/Cache/File/2026-10/悦/悦/新版.html` 及本轮截图。
| 页面 | 操作 | 原型证据 | 承载与字段 | 当前实现及待处理 |
|---|---|---|---|---|
| 自有业务列表 | 统计、筛选、查询/重置 | `page-vehicle-own-business`,8772行;`renderVehicleBusiness` 22794行、`resetVehicleBusiness` 22827行 | 独立页面;四项统计为排车数、装车完成、派送中、派送完成;计划发车日期范围、排车单号/车牌/FBA仓、状态、仓库筛选 | 已注册源码菜单和列表/详情路由;统计跟随同一全部筛选结果,不用当前页行数;不保留原型写死日期。页面实现和环境验收分别核对 |
| 自有业务列表 | 任务明细 | 同页面表头及渲染函数 | 预约号、排车单号、车牌、司机、提货仓、目的仓、派送仓、油耗、托数、总价/单价、最晚/实际装车完成时间、预约/实际到达时间、状态;正式业务操作列按全局规则放第一列 | 原型动态改表头造成重复托数及仓库列错位,不作为正式字段定义;按我们自己的车辆归类,装车完成后三阶段可见;整车总金额除实际物理托数得单价,同车多仓及预约行内展开 |
| 自有业务 | 查看明细 | `page-vehicle-own-business-detail` 8779行;`openOwnBusinessDetail` 22836行 | 独立详情页及返回入口;任务与车辆信息、路线货物与凭证、操作记录三个页签 | 不是排车详情四页签;不能直接复用旧车次八页签替代;原型驾驶证、路线、GPS/ETA、时间线等含固定演示值,须逐项映射真实来源,缺失不得伪造 |
| 自有业务 | 上传照片、填写实际迈数 | `modal-own-business-images` 8791行;最终生效的 `openOwnBusinessImages` 23082行覆盖22842行同名函数;`switchOwnBusinessImageTab` 23080行、`saveOwnBusinessMileage` 23083行 | 760px居中弹窗;装车完成、油耗图片两页签,逐框JPG/PNG;油耗页包含实际迈数;底部关闭 | 原型仅内存/FileReader保存,存在示例图片占位;真实文件归属、资料版本、保存语义及迈数单位需沿正式提供者核对,不能把关闭或上传文件本身当作确认装车 |
用户已明确:按我们自己的车辆归类;装车完成后展示三个阶段;后台登记一次整车总金额,单价为总金额除实际物理托数;一张排车单一行,多仓及预约行内展开;实到时间、里程和油耗由后台补录,照片按原型上传。计划发车日期作为查询口径,统计随筛选变化;新增“登记运营信息”居中弹窗,照片仍独立弹窗。共同业务及三方写入边界唯一见工作区 `docs/海外仓业务/自有业务首版开发契约.md`,金额不擅自改称收入或成本,不因原型演示值自行新增业务事实。当前源码和自动检查不代表真实页面已验收。
## 运输路线地图
本轮最新要求:使用已有起点、目的地展示规划路线,后续另接GPS。唯一组件为`components/TransportRouteDialog.vue`,经`index.ts`公开导出;无固定样例、无定位采集、不产生业务记录。
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getOutsourcedBusiness, listOutsourcedBusiness, listOutsourcedBusinessHistory, saveOutsourcedBusinessOperations, uploadOutsourcedBusinessPhoto, bindOutsourcedBusinessPhoto, getOutsourcedBusinessPhotoContent } from './outsourcedBusiness'
import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures'
import type { OutsourcedBusinessPhoto, OutsourcedBusinessPhotoSlot, OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
vi.mock('@/shared/api/http', async importOriginal => {
const actual = await importOriginal<typeof import('@/shared/api/http')>()
return { ...actual, http: { get: vi.fn(), put: vi.fn(), post: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }
})
beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/outsourced-business'
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('outsourced business HTTP contract', () => {
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
const detail = outsourcedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
const query = { page: 1, size: 20, plannedDepartureFrom: '2026-10-20T00:00:00Z', plannedDepartureTo: '2026-10-21T00:00:00Z', keyword: 'FBA-1' }
const value = await listOutsourcedBusiness(query)
expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query })
expect(value.summary.dispatchCount).toBe(41); expect(value.records).toHaveLength(1); expect(value.records[0]!.actualPalletCount).toBe(1)
})
it('encodes the dispatch business number and preserves auxiliary/main versions independently', async () => {
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = '0'; detail.row.dispatchRecordVersion = '9007199254740993'
detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
vi.mocked(http.get).mockResolvedValue({ data: detail })
expect((await getOutsourcedBusiness('DS/1')).row.dispatchRecordVersion).toBe('9007199254740993')
expect(http.get).toHaveBeenCalledWith(`${base}/DS%2F1`, { params: undefined })
detail.dispatch.dispatchNo = 'another'; await expect(getOutsourcedBusiness('DS/1')).rejects.toThrow('不一致')
})
it('does not convert money text, calculate a client unit price or submit a dispatch version', async () => {
const body: OutsourcedBusinessSave = { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null }
const detail = outsourcedBusinessFixture(); detail.row.operations.money = body.operations.money
vi.mocked(http.put).mockResolvedValue({ data: detail })
const saved = await saveOutsourcedBusinessOperations('DS-OUT-1', body)
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS-OUT-1/operations`, body)
expect(body.operations.money!.amount).toBe('9999999999999999.99'); expect('dispatchRecordVersion' in body).toBe(false)
expect(saved.row.operations.money!.amount).toBe('9999999999999999.99')
})
it('keeps upload and versioned file binding separate with the same prepared file identity', async () => {
vi.mocked(http.post).mockResolvedValue({ data: photo })
const file = new File(['image'], 'loading.png', { type: 'image/png' })
await uploadOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', file, 'upload-key')
const [url, form] = vi.mocked(http.post).mock.calls[0]!
expect(url).toBe(`${base}/DS%2F1/photos/LOADING_SCENE/uploads`); expect((form as FormData).get('file')).toBe(file); expect((form as FormData).get('idempotencyKey')).toBe('upload-key')
const detail = outsourcedBusinessFixture(); detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
vi.mocked(http.put).mockResolvedValue({ data: detail })
await bindOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS%2F1/photos/LOADING_SCENE`, { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
})
it('retains the two history streams and reads historical files only at their explicit revision', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ eventSource: 'DISPATCH', recordVersion: null, dispatchRecordVersion: '9', operations: null, photos: [] }, { eventSource: 'OPERATIONS', recordVersion: '1', dispatchRecordVersion: null, operations: outsourcedBusinessFixture().row.operations, photos: [] }], page: '1', size: '20', total: '2' } })
const value = await listOutsourcedBusinessHistory('DS/1', 1, 20)
expect(value.records.map(record => [record.eventSource, record.recordVersion, record.dispatchRecordVersion])).toEqual([['DISPATCH',null,'9'],['OPERATIONS','1',null]])
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
await getOutsourcedBusinessPhotoContent('DS/1', photo, '1')
expect(http.get).toHaveBeenLastCalledWith(`${base}/DS%2F1/photos/LOADING_SCENE/files/${photo.fileId}/content`, { responseType: 'blob', params: { revisionVersion: '1' } })
})
it('never renders JSON errors or empty bytes as a photo', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({ code: 'A0403', message: '拒绝读取', requestId: 'request', traceId: 'trace' })], { type: 'application/json' }))
await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toMatchObject({ code: 'A0403', requestId: 'request', traceId: 'trace' })
vi.mocked(http.get).mockResolvedValue(new Blob([], { type: 'image/png' })); await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toThrow('为空')
await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1','LOADING_SCENE',new File(['text'],'bad.txt',{type:'text/plain'}),'key')).rejects.toThrow('JPG')
})
it.each(['2', null])('accepts authoritative external vehicle identity %s without looking up the current master', async vehicleId => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle.vehicleId = vehicleId; detail.row.vehicle.carrierName = null
vi.mocked(http.get).mockResolvedValue({ data: detail })
const value = await getOutsourcedBusiness('DS-OUT-1')
expect(value.row.vehicle).toMatchObject({ vehicleId, ownership: 'DISPATCHED', carrierName: null })
expect(http.get).toHaveBeenCalledTimes(1)
})
it('rejects owned vehicle facts and unsupported fuel or mileage writes before any request', async () => {
const detail = outsourcedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership: 'OWNED' } } } })
await expect(getOutsourcedBusiness('DS-OUT-1')).rejects.toThrow('来源或阶段')
for (const field of ['actualDistanceMiles','fuelConsumptionLitresPer100Km','actualFuelLitres']) {
const body = { recordVersion:'0', idempotencyKey:'original-key', operations:{ ...detail.row.operations, [field]:'1' }, reason:null }
await expect(saveOutsourcedBusinessOperations('DS-OUT-1', body)).rejects.toThrow('不支持里程或油耗')
}
expect(http.put).not.toHaveBeenCalled()
for (const slot of ['FUEL_START','FUEL_END']) {
await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,new File(['image'],'loading.png',{type:'image/png'}),'upload-key')).rejects.toThrow('不支持该照片分类')
await expect(bindOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})).rejects.toThrow('不支持该照片分类')
}
expect(http.post).not.toHaveBeenCalled(); expect(http.put).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,74 @@
import axios from 'axios'
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessPhotoRoles, type OutsourcedBusinessOperations, type OutsourcedBusinessRow, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessSave, type OutsourcedBusinessBind, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessCommand, type OutsourcedBusinessHistory, type OutsourcedBusinessPage } from '../outsourcedBusinessTypes'
const base = '/platform/v1/last-leg/fleet/outsourced-business'
const path = (no: string) => `${base}/${encodeURIComponent(no)}`
async function get<T>(url: string, params?: object): Promise<T> { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
function count(value: unknown): number { const text = outsourcedBusinessVersion(value); const result = Number(text); if (!Number.isSafeInteger(result)) throw new Error('运营记录数量超出精确范围'); return result }
function decimal(value: string | null, scale: number, precision: number): string | null {
if (value == null) return null
if (typeof value !== 'string' || outsourcedBusinessDecimal(value, scale, precision) !== value) throw new Error('运营金额或测量必须使用精确十进制字符串')
return value
}
function operations(value: OutsourcedBusinessOperations): OutsourcedBusinessOperations {
if ('actualDistanceMiles' in value || 'fuelConsumptionLitresPer100Km' in value || 'actualFuelLitres' in value) throw new Error('外配运营信息不支持里程或油耗')
if (value.money && !['CNY','USD'].includes(value.money.currency)) throw new Error('运营金额币种无效')
const amount = value.money ? decimal(value.money.amount, 2, 18) : null
if (value.money && amount == null) throw new Error('承运金额与币种必须同时登记')
return { money: value.money ? { currency: value.money.currency, amount: amount! } : null, arrivals: value.arrivals }
}
function requireSlot(slot: OutsourcedBusinessPhotoSlot) { if (!outsourcedBusinessPhotoRoles.some(role => role.slot === slot)) throw new Error('外配业务不支持该照片分类') }
function row(value: OutsourcedBusinessRow): OutsourcedBusinessRow {
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'DISPATCHED') throw new Error('外配排车来源或阶段不完整,请重新读取')
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: outsourcedBusinessVersion(value.recordVersion), dispatchRecordVersion: outsourcedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount) } }
}
function detail(value: OutsourcedBusinessDetail): OutsourcedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
function command(value: OutsourcedBusinessCommand): OutsourcedBusinessCommand { return { ...value, recordVersion: value.recordVersion == null ? null : outsourcedBusinessVersion(value.recordVersion) } }
export async function listOutsourcedBusiness(query: OutsourcedBusinessQuery): Promise<OutsourcedBusinessList> {
const value = await get<OutsourcedBusinessList>(base, query)
return { ...value, records: value.records.map(row), page: count(value.page), size: count(value.size), total: count(value.total), summary: { dispatchCount: count(value.summary.dispatchCount), afterLoadingCount: count(value.summary.afterLoadingCount), deliveringCount: count(value.summary.deliveringCount), deliveredCount: count(value.summary.deliveredCount) } }
}
export async function getOutsourcedBusiness(no: string) { const value = detail(await get<OutsourcedBusinessDetail>(path(no))); if (value.row.dispatchNo !== no) throw new Error('运营资料与当前排车业务号不一致'); return value }
export async function listOutsourcedBusinessHistory(no: string, page: number, size: number): Promise<OutsourcedBusinessPage<OutsourcedBusinessHistory>> {
const value = await get<OutsourcedBusinessPage<OutsourcedBusinessHistory>>(`${path(no)}/history`, { page, size })
return { ...value, records: value.records.map(record => ({ ...record, operations: record.operations ? operations(record.operations) : null, recordVersion: record.recordVersion == null ? null : outsourcedBusinessVersion(record.recordVersion), dispatchRecordVersion: record.dispatchRecordVersion == null ? null : outsourcedBusinessVersion(record.dispatchRecordVersion) })), page: count(value.page), size: count(value.size), total: count(value.total) }
}
export const getOutsourcedBusinessCommand = (no: string, key: string) => get<OutsourcedBusinessCommand>(`${path(no)}/commands/${encodeURIComponent(key)}`).then(command)
export async function saveOutsourcedBusinessOperations(no: string, body: OutsourcedBusinessSave) {
outsourcedBusinessVersion(body.recordVersion)
operations(body.operations)
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OutsourcedBusinessDetail>, ApiEnvelope<OutsourcedBusinessDetail>>(`${path(no)}/operations`, body)))
if (value.row.dispatchNo !== no) throw new Error('运营保存结果与原排车单不一致')
return value
}
export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) {
requireSlot(slot)
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
const value = unwrapApiResponse(await http.post<ApiEnvelope<OutsourcedBusinessPhoto>, ApiEnvelope<OutsourcedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
return value
}
export async function bindOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, body: OutsourcedBusinessBind) {
requireSlot(slot)
outsourcedBusinessVersion(body.recordVersion)
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OutsourcedBusinessDetail>, ApiEnvelope<OutsourcedBusinessDetail>>(`${path(no)}/photos/${slot}`, body)))
if (value.row.dispatchNo !== no) throw new Error('照片绑定结果与原排车单不一致')
return value
}
async function downloadError(payload: unknown, status: number) {
let value = payload
if (payload instanceof Blob && payload.type.includes('json')) { try { value = JSON.parse(await payload.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') } }
if (value && typeof value === 'object' && 'code' in value) { const error = value as ApiEnvelope<unknown>; throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId) }
}
export async function getOutsourcedBusinessPhotoContent(no: string, photo: OutsourcedBusinessPhoto, revisionVersion?: string): Promise<Blob> {
requireSlot(photo.slot)
if (revisionVersion != null && BigInt(outsourcedBusinessVersion(revisionVersion)) < 1n) throw new Error('历史图片版本必须大于零')
try {
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } })
await downloadError(blob, 200)
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
return blob
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
}
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getOwnedBusiness, listOwnedBusiness, listOwnedBusinessHistory, saveOwnedBusinessOperations, uploadOwnedBusinessPhoto, bindOwnedBusinessPhoto, getOwnedBusinessPhotoContent } from './ownedBusiness'
import { ownedBusinessFixture } from '../components/OwnedBusinessFixtures'
import type { OwnedBusinessPhoto, OwnedBusinessSave } from '../ownedBusinessTypes'
vi.mock('@/shared/api/http', async importOriginal => {
const actual = await importOriginal<typeof import('@/shared/api/http')>()
return { ...actual, http: { get: vi.fn(), put: vi.fn(), post: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }
})
beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/owned-business'
const photo: OwnedBusinessPhoto = { slot: 'FUEL_START', category: 'FUEL', fileId: '9007199254740993', fileName: 'fuel.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('owned business HTTP contract', () => {
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
const detail = ownedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
const query = { page: 1, size: 20, plannedDepartureFrom: '2026-10-20T00:00:00Z', plannedDepartureTo: '2026-10-21T00:00:00Z', keyword: 'FBA-1' }
const value = await listOwnedBusiness(query)
expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query })
expect(value.summary.dispatchCount).toBe(41); expect(value.records).toHaveLength(1); expect(value.records[0]!.actualPalletCount).toBe(1)
})
it('encodes the dispatch business number and preserves auxiliary/main versions independently', async () => {
const detail = ownedBusinessFixture(); detail.row.recordVersion = '0'; detail.row.dispatchRecordVersion = '9007199254740993'
detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
vi.mocked(http.get).mockResolvedValue({ data: detail })
expect((await getOwnedBusiness('DS/1')).row.dispatchRecordVersion).toBe('9007199254740993')
expect(http.get).toHaveBeenCalledWith(`${base}/DS%2F1`, { params: undefined })
detail.dispatch.dispatchNo = 'another'; await expect(getOwnedBusiness('DS/1')).rejects.toThrow('不一致')
})
it('does not convert money text, calculate a client unit price or submit a dispatch version', async () => {
const body: OwnedBusinessSave = { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...ownedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' }, fuelConsumptionLitresPer100Km: '18.600' }, reason: null }
const detail = ownedBusinessFixture(); detail.row.operations.money = body.operations.money
vi.mocked(http.put).mockResolvedValue({ data: detail })
const saved = await saveOwnedBusinessOperations('DS-OWN-1', body)
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS-OWN-1/operations`, body)
expect(body.operations.money!.amount).toBe('9999999999999999.99'); expect('dispatchRecordVersion' in body).toBe(false)
expect(saved.row.operations.money!.amount).toBe('9999999999999999.99')
})
it('keeps upload and versioned file binding separate with the same prepared file identity', async () => {
vi.mocked(http.post).mockResolvedValue({ data: photo })
const file = new File(['image'], 'fuel.png', { type: 'image/png' })
await uploadOwnedBusinessPhoto('DS/1', 'FUEL_START', file, 'upload-key')
const [url, form] = vi.mocked(http.post).mock.calls[0]!
expect(url).toBe(`${base}/DS%2F1/photos/FUEL_START/uploads`); expect((form as FormData).get('file')).toBe(file); expect((form as FormData).get('idempotencyKey')).toBe('upload-key')
const detail = ownedBusinessFixture(); detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
vi.mocked(http.put).mockResolvedValue({ data: detail })
await bindOwnedBusinessPhoto('DS/1', 'FUEL_START', { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS%2F1/photos/FUEL_START`, { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
})
it('retains the two history streams and reads historical files only at their explicit revision', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ eventSource: 'DISPATCH', recordVersion: null, dispatchRecordVersion: '9', operations: null, photos: [] }, { eventSource: 'OPERATIONS', recordVersion: '1', dispatchRecordVersion: null, operations: ownedBusinessFixture().row.operations, photos: [] }], page: '1', size: '20', total: '2' } })
const value = await listOwnedBusinessHistory('DS/1', 1, 20)
expect(value.records.map(record => [record.eventSource, record.recordVersion, record.dispatchRecordVersion])).toEqual([['DISPATCH',null,'9'],['OPERATIONS','1',null]])
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
await getOwnedBusinessPhotoContent('DS/1', photo, '1')
expect(http.get).toHaveBeenLastCalledWith(`${base}/DS%2F1/photos/FUEL_START/files/${photo.fileId}/content`, { responseType: 'blob', params: { revisionVersion: '1' } })
})
it('never renders JSON errors or empty bytes as a photo', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({ code: 'A0403', message: '拒绝读取', requestId: 'request', traceId: 'trace' })], { type: 'application/json' }))
await expect(getOwnedBusinessPhotoContent('DS-OWN-1', photo)).rejects.toMatchObject({ code: 'A0403', requestId: 'request', traceId: 'trace' })
vi.mocked(http.get).mockResolvedValue(new Blob([], { type: 'image/png' })); await expect(getOwnedBusinessPhotoContent('DS-OWN-1', photo)).rejects.toThrow('为空')
await expect(uploadOwnedBusinessPhoto('DS-OWN-1','FUEL_START',new File(['text'],'bad.txt',{type:'text/plain'}),'key')).rejects.toThrow('JPG')
})
})
@@ -0,0 +1,66 @@
import axios from 'axios'
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
import { ownedBusinessDecimal, ownedBusinessVersion, type OwnedBusinessOperations, type OwnedBusinessRow, type OwnedBusinessDetail, type OwnedBusinessList, type OwnedBusinessQuery, type OwnedBusinessSave, type OwnedBusinessBind, type OwnedBusinessPhoto, type OwnedBusinessPhotoSlot, type OwnedBusinessCommand, type OwnedBusinessHistory, type OwnedBusinessPage } from '../ownedBusinessTypes'
const base = '/platform/v1/last-leg/fleet/owned-business'
const path = (no: string) => `${base}/${encodeURIComponent(no)}`
async function get<T>(url: string, params?: object): Promise<T> { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
function count(value: unknown): number { const text = ownedBusinessVersion(value); const result = Number(text); if (!Number.isSafeInteger(result)) throw new Error('运营记录数量超出精确范围'); return result }
function decimal(value: string | null, scale: number, precision: number): string | null {
if (value == null) return null
if (typeof value !== 'string' || ownedBusinessDecimal(value, scale, precision) !== value) throw new Error('运营金额或测量必须使用精确十进制字符串')
return value
}
function operations(value: OwnedBusinessOperations): OwnedBusinessOperations {
if (value.money && !['CNY','USD'].includes(value.money.currency)) throw new Error('运营金额币种无效')
return { ...value, money: value.money ? { ...value.money, amount: decimal(value.money.amount, 2, 18)! } : null, actualDistanceMiles: decimal(value.actualDistanceMiles,3,12), fuelConsumptionLitresPer100Km: decimal(value.fuelConsumptionLitresPer100Km,3,12) }
}
function row(value: OwnedBusinessRow): OwnedBusinessRow {
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'OWNED') throw new Error('自有排车来源或阶段不完整,请重新读取')
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: ownedBusinessVersion(value.recordVersion), dispatchRecordVersion: ownedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount), fuelCount: count(value.photoSummary.fuelCount) } }
}
function detail(value: OwnedBusinessDetail): OwnedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
function command(value: OwnedBusinessCommand): OwnedBusinessCommand { return { ...value, recordVersion: value.recordVersion == null ? null : ownedBusinessVersion(value.recordVersion) } }
export async function listOwnedBusiness(query: OwnedBusinessQuery): Promise<OwnedBusinessList> {
const value = await get<OwnedBusinessList>(base, query)
return { ...value, records: value.records.map(row), page: count(value.page), size: count(value.size), total: count(value.total), summary: { dispatchCount: count(value.summary.dispatchCount), afterLoadingCount: count(value.summary.afterLoadingCount), deliveringCount: count(value.summary.deliveringCount), deliveredCount: count(value.summary.deliveredCount) } }
}
export async function getOwnedBusiness(no: string) { const value = detail(await get<OwnedBusinessDetail>(path(no))); if (value.row.dispatchNo !== no) throw new Error('运营资料与当前排车业务号不一致'); return value }
export async function listOwnedBusinessHistory(no: string, page: number, size: number): Promise<OwnedBusinessPage<OwnedBusinessHistory>> {
const value = await get<OwnedBusinessPage<OwnedBusinessHistory>>(`${path(no)}/history`, { page, size })
return { ...value, records: value.records.map(record => ({ ...record, operations: record.operations ? operations(record.operations) : null, recordVersion: record.recordVersion == null ? null : ownedBusinessVersion(record.recordVersion), dispatchRecordVersion: record.dispatchRecordVersion == null ? null : ownedBusinessVersion(record.dispatchRecordVersion) })), page: count(value.page), size: count(value.size), total: count(value.total) }
}
export const getOwnedBusinessCommand = (no: string, key: string) => get<OwnedBusinessCommand>(`${path(no)}/commands/${encodeURIComponent(key)}`).then(command)
export async function saveOwnedBusinessOperations(no: string, body: OwnedBusinessSave) {
ownedBusinessVersion(body.recordVersion)
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OwnedBusinessDetail>, ApiEnvelope<OwnedBusinessDetail>>(`${path(no)}/operations`, body)))
if (value.row.dispatchNo !== no) throw new Error('运营保存结果与原排车单不一致')
return value
}
export async function uploadOwnedBusinessPhoto(no: string, slot: OwnedBusinessPhotoSlot, file: File, key: string) {
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
const value = unwrapApiResponse(await http.post<ApiEnvelope<OwnedBusinessPhoto>, ApiEnvelope<OwnedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
return value
}
export async function bindOwnedBusinessPhoto(no: string, slot: OwnedBusinessPhotoSlot, body: OwnedBusinessBind) {
ownedBusinessVersion(body.recordVersion)
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OwnedBusinessDetail>, ApiEnvelope<OwnedBusinessDetail>>(`${path(no)}/photos/${slot}`, body)))
if (value.row.dispatchNo !== no) throw new Error('照片绑定结果与原排车单不一致')
return value
}
async function downloadError(payload: unknown, status: number) {
let value = payload
if (payload instanceof Blob && payload.type.includes('json')) { try { value = JSON.parse(await payload.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') } }
if (value && typeof value === 'object' && 'code' in value) { const error = value as ApiEnvelope<unknown>; throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId) }
}
export async function getOwnedBusinessPhotoContent(no: string, photo: OwnedBusinessPhoto, revisionVersion?: string): Promise<Blob> {
if (revisionVersion != null && BigInt(ownedBusinessVersion(revisionVersion)) < 1n) throw new Error('历史图片版本必须大于零')
try {
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: ownedBusinessVersion(revisionVersion) } })
await downloadError(blob, 200)
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
return blob
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
}
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, nextTick, onBeforeUnmount, reactive, ref, toRaw, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -112,12 +113,12 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
<strong>本目的站可接余位</strong>
<p v-if="selectedCapacity">{{ selectedCapacity.palletSpaces }} 托位 · {{ selectedCapacity.weightKg }} kg · {{ selectedCapacity.volumeCbm }} m³</p>
<p v-else>待调度核对,当前没有可靠的可接余位。</p>
<p>托位、重量、体积须同时满足;确认时由系统重新核验,不代表已经保位。</p>
</section>
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
<section v-if="inquiry" aria-label="本票处理进度"><strong>{{ inquiry.customerName }} · {{ inquiry.destinationLabel }}</strong><p>车次:{{ inquiry.tripNo }} · 登记:{{ inquiry.bookingNo }}</p><strong>{{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section>
<p v-else>先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。</p>
<p v-if="!inquiry">业务类型:{{ form.businessType === 'US_TRUCK' ? '本土卡车' : '本土散板' }},可在“确认承运前待补资料”中调整。</p>
<p v-if="offer">计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}</p>
<p v-if="inquiry?.actions.blockReason">{{ inquiry.actions.blockReason }}</p>
@@ -128,11 +129,11 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
</div></details>
</section>
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
<el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
<el-form-item label="委托客户" required><template #label><span>委托客户<InlineHelp title="登记货物说明"><p>先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。</p></InlineHelp></span></template><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="lock || offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>{{ cargoLabel === '已核实' ? '已核实货量与预计实托数' : readonly ? `${cargoLabel}货量` : '已知货量(可先填写,未知可留空)' }}</summary><div class="capacity-inquiry-form">
<el-form-item :label="`${cargoLabel}箱数`"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
<el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
<el-form-item label="预计实托数"><template #label><span>预计实托数<InlineHelp title="容量核验说明"><p>托位、重量、体积须同时满足;确认时由系统重新核验,不代表已经保位。</p></InlineHelp></span></template><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
<el-form-item :label="`${cargoLabel}标准托位`"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
<el-form-item :label="`${cargoLabel}重量(kg)`"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item :label="`${cargoLabel}体积(m³)`"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
@@ -120,15 +121,15 @@ onBeforeUnmount(() => { generation++ })
<LoadErrorState v-if="error" title="发布操作未完成" :message="error.message" @retry="pending ? execute() : load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p>{{ trip.tripNo }} · {{ offer ? `${offer.offerNo} · ${offerStatusLabels[offer.status]}` : '尚未建立发布' }}</p><p v-if="offer?.blockReason && offer.status !== 'DRAFT'">{{ offer.blockReason }}</p><p v-if="pending">上次结果尚未确定,原请求已保留,请重试核对。</p>
<template v-if="loaded && open && (!offer || offer.actions.canEdit)">
<h3>本车货物与余位</h3>
<p>卸货站已按货物匹配。核对下方数据,确认发布时一并保存容量;多个目的地的先后顺序请在车次计划中确认。</p>
<h3>本车货物与余位<InlineHelp title="容量发布说明"><p>卸货站已按货物匹配。核对下方数据,确认发布时一并保存容量;多个目的地的先后顺序请在车次计划中确认。</p></InlineHelp></h3>
<el-button v-if="canPlan && planner?.destinationIssue" :disabled="busy || !!pending" @click="matchExistingStops">按货物补齐卸货站</el-button>
<TripLoadingPanel ref="planner" :trip="currentTrip" :editable="canPlan && !busy && !pending" planning embedded @changed="planChanged" />
</template>
<p v-if="!canPlan">已有有效容量可直接发布;需要修改容量时请联系有车次编辑权限的调度。</p>
<el-button v-if="offer?.status === 'OPEN'" :disabled="busy || !!pending" type="primary" link @click="open = false; router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: offer.offerNo } })">查看需求池发布</el-button>
<el-form v-loading="loading" label-position="top" class="density-form offer-settings-form" :disabled="busy || loading || !!pending || !writable || !!offer && !offer.actions.canEdit">
<el-form-item label="交货截止时间" required><el-date-picker v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择交货截止时间" /><span>默认与计划发车时间一致,可提前截止。</span></el-form-item><details class="offer-settings-wide"><summary>联系人与交货说明(选填)</summary><div class="offer-settings-form"><el-form-item label="公开给销售的调度联系人"><el-input v-model="form.contactName" maxlength="128" placeholder="填写公开给销售的联系人(选填)" /></el-form-item><el-form-item label="公开联系方式"><el-input v-model="form.contactPhone" maxlength="128" placeholder="填写公开电话或其他联系方式(选填)" /></el-form-item><el-form-item label="公开说明" class="offer-settings-wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="补充交货要求或运力说明(选填)" /></el-form-item></div></details>
<el-form-item label="交货截止时间" required><template #label><span>交货截止时间<InlineHelp title="交货截止时间说明"><p>默认与计划发车时间一致,可提前截止。</p></InlineHelp></span></template><el-date-picker v-model="form.cutoffAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="选择交货截止时间" /></el-form-item><details class="offer-settings-wide"><summary>联系人与交货说明(选填)</summary><div class="offer-settings-form"><el-form-item label="公开给销售的调度联系人"><el-input v-model="form.contactName" maxlength="128" placeholder="填写公开给销售的联系人(选填)" /></el-form-item><el-form-item label="公开联系方式"><el-input v-model="form.contactPhone" maxlength="128" placeholder="填写公开电话或其他联系方式(选填)" /></el-form-item><el-form-item label="公开说明" class="offer-settings-wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" placeholder="补充交货要求或运力说明(选填)" /></el-form-item></div></details>
</el-form>
<div v-if="offer && writable" class="density-actions"><el-button v-if="offer.actions.canPause" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" @click="command('pause')">暂停接单</el-button><el-button v-if="offer.actions.canClose" :disabled="busy || loading || !loaded || !!pending || guard.dirty.value" type="danger" plain @click="command('close')">关闭发布</el-button></div>
</AppDialog></template>
@@ -4,6 +4,7 @@ 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'
@@ -95,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">驾驶资质</h3>
<h3 class="full-width driver-section-heading">驾驶资质<InlineHelp title="驾驶资质规则"><p>驾驶证在 30 天内到期但仍有效时允许派车并提醒;预计任务结束前到期则禁止。其他证照日期用于档案记录。</p></InlineHelp></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>
@@ -104,11 +105,10 @@ 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>
<p class="full-width form-hint">驾驶证在 30 天内到期但仍有效时允许派车并提醒;预计任务结束前到期则禁止。其他证照日期用于档案记录。</p>
<h3 class="full-width">证照附件</h3>
<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>
<div class="full-width">
<label v-if="access.can(permission.driverUpload)">驾驶证(PDF / JPG / PNG,最大 10MiB)<input ref="fileInput" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="saving" @change="selectFile" /></label>
<p class="form-hint">{{ selectedFile?.name || (current?.licenseFileId ? '已有驾驶证附件;上传新文件后需要重新审核。' : '可先保存档案,补齐驾驶证后才能通过审核。') }}</p>
<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>
@@ -120,5 +120,5 @@ onBeforeUnmount(() => { warehouseRequest++; editSession++ })
.full-width { grid-column: 1 / -1; }
.driver-form h3 { margin: 0; }
.driver-form :deep(.el-select), .driver-form :deep(.el-date-editor) { width: 100%; }
.form-hint { margin: var(--admin-space-3) 0; color: var(--cc-muted); }
.driver-section-heading { display: flex; align-items: center; gap: var(--admin-space-2); }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, reactive, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -65,7 +66,7 @@ async function save() {
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="报价金额">
<h3 class="density-dialog-section__title">报价金额</h3>
<h3 class="density-dialog-section__title">报价金额<InlineHelp title="报价金额说明"><p>留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。</p></InlineHelp></h3>
<table class="density-table density-table--editable fleet-quote-form"><tbody>
<tr><td><el-form-item label="整车自提 / 每单"><el-input-number :model-value="form.selfPickupPrice" @update:model-value="form.selfPickupPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item></td><td><el-form-item label="整车派送 / 每单"><el-input-number :model-value="form.fullDeliveryPrice" @update:model-value="form.fullDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="散板派送 / 每托"><el-input-number :model-value="form.looseDeliveryPrice" @update:model-value="form.looseDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item></td></tr>
@@ -79,7 +80,7 @@ async function save() {
</tbody></table>
</section>
</el-form>
<p class="density-dialog-note">留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。</p>
<template #footer="{close}"><div class="density-actions density-actions--footer"><el-button :disabled="saving" @click="close">关闭</el-button><el-button v-if="!readonly" type="primary" :loading="saving" @click="save">{{ uncertain ? '按原请求重试' : '保存报价' }}</el-button></div></template>
</AppDialog></template>
<style scoped>
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/outsourcedBusiness'
import { outsourcedBusinessFixture, outsourcedBusinessCommandFixture } from './OutsourcedBusinessFixtures'
import { acceptOutsourcedBusinessCommand, newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, outsourcedBusinessPendingKey } from './OutsourcedBusinessCommandRecovery'
import type { OutsourcedBusinessPhoto, OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
beforeEach(() => sessionStorage.clear())
const no = 'DS-OUT-1'
function operation() : OutsourcedBusinessSave { return { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null } }
function client() {
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null }
return { ...api, getOutsourcedBusiness: vi.fn(async () => current), getOutsourcedBusinessCommand: vi.fn(async () => outsourcedBusinessCommandFixture()), saveOutsourcedBusinessOperations: vi.fn(async () => current), bindOutsourcedBusinessPhoto: vi.fn(async () => current), uploadOutsourcedBusinessPhoto: vi.fn(async () => photo) }
}
describe('outsourced business exact command recovery', () => {
it('persists the complete original payload, actor and zero auxiliary version', () => {
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation())
persistOutsourcedBusinessPending('actor', no, 'operations', pending)
expect(restoreOutsourcedBusinessPending('actor', no, 'operations')).toEqual(pending)
expect(restoreOutsourcedBusinessPending('another', no, 'operations')).toBeNull()
const raw = JSON.parse(sessionStorage.getItem(outsourcedBusinessPendingKey('actor',no,'operations'))!)
raw.actor = 'another'; sessionStorage.setItem(outsourcedBusinessPendingKey('actor',no,'operations'),JSON.stringify(raw))
expect(() => restoreOutsourcedBusinessPending('actor',no,'operations')).toThrow('损坏')
})
it('looks up an unknown write first and retries only the unchanged original body on explicit not-found', async () => {
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()); pending.attempted = true
const service = client(), persist = vi.fn()
service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
await recoverOutsourcedBusinessCommand(pending,persist,() => {},service)
expect(service.getOutsourcedBusinessCommand).toHaveBeenCalledWith(no,'original-key')
expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledExactlyOnceWith(no,operation())
expect(pending.body).toEqual(operation()); expect(pending.body?.recordVersion).toBe('0')
})
it('retains a proven success through failed fresh reads without writing it a second time', async () => {
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()), service = client()
service.getOutsourcedBusiness.mockRejectedValueOnce(new Error('read offline'))
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('read offline')
expect(pending.confirmed?.recordVersion).toBe('1')
await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)
expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledTimes(1); expect(service.getOutsourcedBusinessCommand).not.toHaveBeenCalled()
})
it('never retransmits a file for an unknown upload, including command not-found', async () => {
const pending = newOutsourcedBusinessPending('actor',no,'UPLOAD_PHOTO',null,'LOADING_SCENE'); pending.attempted = true
const service = client(); service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('禁止重复上传')
expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(pending.key).toBeTruthy()
})
it('keeps the prepared file and exact binding key through unknown binding and later replacement', async () => {
const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:'2026-10-20T10:01:00Z' }
const pending = newOutsourcedBusinessPending('actor',no,'BIND_PHOTO',{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId},photo.slot)
pending.attempted = true
const service = client(); const bound = outsourcedBusinessFixture(); bound.row.recordVersion='1'; bound.photos=[photo]
const original = outsourcedBusinessCommandFixture({action:'BIND_PHOTO',idempotencyKey:'bind-key',result:bound})
service.getOutsourcedBusinessCommand.mockResolvedValue(original)
const current = outsourcedBusinessFixture();current.row.recordVersion='2';current.photos=[{...photo,fileId:'9007199254740994'}]
service.getOutsourcedBusiness.mockResolvedValue(current)
const result = await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)
expect(result.detail?.photos[0]?.fileId).toBe('9007199254740994')
expect(pending.body).toEqual({recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})
expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled();expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled()
})
it('refuses wrong command identities, stale reads, definite rejection retries and scope changes', async () => {
const pending = newOutsourcedBusinessPending('actor',no,'SAVE_OPERATIONS',operation()), service=client()
expect(() => acceptOutsourcedBusinessCommand(pending,outsourcedBusinessCommandFixture({dispatchNo:'another'}))).toThrow('不一致')
pending.command=outsourcedBusinessCommandFixture({recordVersion:'9007199254740993'})
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('早于')
pending.rejected=true;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('明确拒绝')
pending.rejected=false;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {throw new Error('scope changed')},service)).rejects.toThrow('scope changed')
expect(service.saveOutsourcedBusinessOperations).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,89 @@
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/outsourcedBusiness'
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessAction, type OutsourcedBusinessBind, type OutsourcedBusinessCommand, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
export interface OutsourcedBusinessPending {
actor: string; dispatchNo: string; action: OutsourcedBusinessAction; key: string; slot?: OutsourcedBusinessPhotoSlot
body: OutsourcedBusinessSave | OutsourcedBusinessBind | null; attempted: boolean; command: OutsourcedBusinessCommand | null; confirmed: { recordVersion: string | null; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto } | null; rejected: boolean
}
export const outsourcedBusinessPendingKey = (actor: string, no: string, area: string) => `outsourced-business:${encodeURIComponent(actor)}:${encodeURIComponent(no)}:${area}`
export function restoreOutsourcedBusinessPending(actor: string, no: string, area: string): OutsourcedBusinessPending | null {
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, no, area))
if (!raw) return null
const pending = JSON.parse(raw) as OutsourcedBusinessPending
if (pending.actor !== actor || pending.dispatchNo !== no || !pending.key || !['SAVE_OPERATIONS','UPLOAD_PHOTO','BIND_PHOTO'].includes(pending.action)) throw new Error('原运营请求记录损坏,请保留现场核对')
if (pending.action !== 'SAVE_OPERATIONS' && !outsourcedBusinessPhotoRoles.some(role => role.slot === pending.slot) || pending.action !== 'UPLOAD_PHOTO' && (!pending.body || pending.body.idempotencyKey !== pending.key)) throw new Error('原运营请求内容不完整,请保留现场核对')
return pending
}
export function persistOutsourcedBusinessPending(actor: string, no: string, area: string, pending: OutsourcedBusinessPending | null) {
if (!actor || !no || pending && (pending.actor !== actor || pending.dispatchNo !== no)) throw new Error('运营请求身份已变化')
const key = outsourcedBusinessPendingKey(actor, no, area)
if (pending) sessionStorage.setItem(key, JSON.stringify(pending)); else sessionStorage.removeItem(key)
}
export function newOutsourcedBusinessPending(actor: string, no: string, action: OutsourcedBusinessAction, body: OutsourcedBusinessPending['body'], slot?: OutsourcedBusinessPhotoSlot): OutsourcedBusinessPending {
if (!actor || !no) throw new Error('当前运营作业身份缺失')
const key = body?.idempotencyKey || crypto.randomUUID()
return { actor, dispatchNo: no, action, key, slot, body: body ? structuredClone(body) : null, attempted: false, command: null, confirmed: null, rejected: false }
}
export function acceptOutsourcedBusinessResult(pending: OutsourcedBusinessPending, result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto) {
if (pending.action === 'UPLOAD_PHOTO') {
if (!('fileId' in result) || result.slot !== pending.slot || result.category !== 'LOADING' || typeof result.fileId !== 'string' || !/^[1-9]\d*$/.test(result.fileId) || !['image/jpeg','image/png'].includes(result.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
pending.confirmed = { recordVersion: null, result }
} else {
if (!('row' in result) || result.row.dispatchNo !== pending.dispatchNo) throw new Error('运营保存结果与原排车单不一致')
pending.confirmed = { recordVersion: outsourcedBusinessVersion(result.row.recordVersion), result }
}
}
export function acceptOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, command: OutsourcedBusinessCommand) {
if (command.dispatchNo !== pending.dispatchNo || command.action !== pending.action || command.idempotencyKey !== pending.key) throw new Error('返回结果与原运营请求不一致,请保留原请求核对')
pending.command = command
if (command.state === 'REJECTED') pending.rejected = true
if (command.state !== 'SUCCEEDED') throw new Error(command.state === 'REJECTED' ? '原请求已明确拒绝,请核对原因后重新登记' : '原请求结果尚未确定,请保留原请求继续核对')
if (!command.result) throw new Error('原运营命令的成功结果不完整,请继续核对')
acceptOutsourcedBusinessResult(pending, command.result)
if (pending.action !== 'UPLOAD_PHOTO' && (command.recordVersion == null || command.recordVersion !== pending.confirmed!.recordVersion)) throw new Error('原运营命令版本与结果不一致')
}
export async function recoverOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, persist: () => void, ensureScope: () => void, client = api): Promise<{ detail?: OutsourcedBusinessDetail; photo?: OutsourcedBusinessPhoto }> {
ensureScope()
if (pending.rejected) throw new Error('原请求已明确拒绝,请先按明确拒绝结果重新核对')
if (pending.command?.state === 'SUCCEEDED') acceptOutsourcedBusinessCommand(pending, pending.command)
const previous = pending.attempted
if (!pending.confirmed) {
let original: OutsourcedBusinessCommand | null = null
if (previous) {
try { original = await client.getOutsourcedBusinessCommand(pending.dispatchNo, pending.key) }
catch (cause) { if (!(cause instanceof ApiResponseError) || !['404','A0404','HTTP_404'].includes(cause.code)) throw cause }
ensureScope()
}
if (original) { try { acceptOutsourcedBusinessCommand(pending, original) } finally { persist() } }
else {
if (pending.action === 'UPLOAD_PHOTO') throw new Error('图片上传结果未知,禁止重复上传;请继续核对原上传请求')
pending.attempted = true; persist()
try {
const result = pending.action === 'SAVE_OPERATIONS'
? await client.saveOutsourcedBusinessOperations(pending.dispatchNo, pending.body as OutsourcedBusinessSave)
: await client.bindOutsourcedBusinessPhoto(pending.dispatchNo, pending.slot!, pending.body as OutsourcedBusinessBind)
ensureScope(); acceptOutsourcedBusinessResult(pending, result); persist()
} catch (cause) {
ensureScope()
if (!previous && cause instanceof ApiResponseError && ['A0400','A0403','A0404','A0409','400','403','404','409','422'].includes(cause.code)) { pending.rejected = true; persist() }
throw cause
}
}
}
const confirmed = pending.confirmed!
if (pending.action === 'UPLOAD_PHOTO') {
const photo = confirmed.result as OutsourcedBusinessPhoto | null
if (!photo || photo.slot !== pending.slot || photo.category !== 'LOADING' || typeof photo.fileId !== 'string' || !/^[1-9]\d*$/.test(photo.fileId) || !['image/jpeg','image/png'].includes(photo.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
return { photo }
}
const current = await client.getOutsourcedBusiness(pending.dispatchNo); ensureScope()
if (current.row.dispatchNo !== pending.dispatchNo || confirmed.recordVersion == null || BigInt(outsourcedBusinessVersion(current.row.recordVersion)) < BigInt(outsourcedBusinessVersion(confirmed.recordVersion))) throw new Error('当前运营版本早于原操作结果,请继续核对')
if (pending.action === 'BIND_PHOTO') {
const matches = (photo: OutsourcedBusinessPhoto) => photo.slot === pending.slot && photo.fileId === (pending.body as OutsourcedBusinessBind).fileId && Boolean(photo.savedAt)
const original = confirmed.result
const originalPhotos = original && 'row' in original ? original.photos : original && 'fileId' in original ? [original] : []
if (!current.photos.some(matches) && !originalPhotos.some(matches)) throw new Error('图片绑定结果尚未确认,请保留原文件和请求')
}
return { detail: current }
}
@@ -0,0 +1,36 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
defineProps<{ dispatchNo?: string; statusText?: string; loading?: boolean }>()
const activeTab = defineModel<'task' | 'route' | 'history'>('activeTab', { default: 'task' })
const emit = defineEmits<{ back: [] }>()
</script>
<template>
<AdminPage class="admin-density" spacing="compact">
<DetailPageLayout aria-label="外配业务详情">
<template #header>
<div class="outsourced-business-detail-heading">
<div><strong>外配业务</strong><span class="outsourced-business-reference">{{ dispatchNo || '—' }}</span><span v-if="statusText">{{ statusText }}</span></div>
<div class="density-actions"><slot name="actions" /><el-button class="density-action" @click="emit('back')">返回外配业务</el-button></div>
</div>
</template>
<slot name="state" />
<div v-loading="loading" :aria-busy="loading">
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
<el-tab-pane name="task" label="任务与车辆信息"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span>任务与车辆信息</template><slot v-if="activeTab === 'task'" name="task" /></el-tab-pane>
<el-tab-pane name="route" label="路线货物与凭证"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span>路线货物与凭证</template><slot v-if="activeTab === 'route'" name="route" /></el-tab-pane>
<el-tab-pane name="history" label="操作记录"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span>操作记录</template><slot v-if="activeTab === 'history'" name="history" /></el-tab-pane>
</el-tabs>
</div>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.outsourced-business-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
.outsourced-business-detail-heading > div { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
.outsourced-business-reference { overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,20 @@
import type { OutsourcedBusinessCommand, OutsourcedBusinessDetail } from '../outsourcedBusinessTypes'
export function outsourcedBusinessFixture(): OutsourcedBusinessDetail {
const review = { status: 'APPROVED' as const, reason: null, reviewerId: null, reviewedAt: '2026-10-20T10:00:00Z', reviewRound: '1', basisHash: null, invalidatedReason: null }
return {
row: {
dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', plannedDepartureAt: '2026-10-20T10:00:00Z', loadingCompletedAt: '2026-10-20T09:00:00Z', departedAt: null, deliveryCompletedAt: null,
origin: { warehouseId: '1', warehouseCode: 'WH-1', warehouseName: '起运仓' }, vehicle: { vehicleId: '2', plate: 'OUT-1', ownership: 'DISPATCHED', carrierName: '测试外配承运商' }, driver: { name: null, phone: null },
destinations: [{ destinationKey: 'destination-1', warehouseCode: 'FBA-1', warehouseName: '目的仓', address: null, appointments: [{ appointmentNo: 'AP-1', confirmationNo: null, scheduledAt: null, allocatedPackages: '2' }], actualArrivedAt: null }],
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: [] },
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: [],
}
}
export function outsourcedBusinessCommandFixture(overrides: Partial<OutsourcedBusinessCommand> = {}): OutsourcedBusinessCommand {
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = overrides.recordVersion ?? '1'
return { dispatchNo: 'DS-OUT-1', action: 'SAVE_OPERATIONS', idempotencyKey: 'original-key', state: 'SUCCEEDED', recordVersion: '1', replayed: false, result: detail, errorCode: null, retryAllowed: false, ...overrides }
}
@@ -0,0 +1,48 @@
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import OutsourcedBusinessDetailLayout from './OutsourcedBusinessDetailLayout.vue'
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
const Pane = defineComponent({ props: ['name', 'label'], template: '<article><slot /></article>' })
const Dialog = defineComponent({ props: ['width', 'showConfirm', 'beforeClose'], template: '<div><slot /></div>' })
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, AppDialog: Dialog, ElTabs: Tabs, ElTabPane: Pane, ElButton: Surface }, directives: { loading: () => {} } }
describe('OutsourcedBusiness presentation boundaries', () => {
it('mounts only the selected one of the three independent detail regions', async () => {
const wrapper = mount(OutsourcedBusinessDetailLayout, { global, props: { dispatchNo: 'DS-OUT-1' }, slots: { task: '<p>task-facts</p>', route: '<p>route-facts</p>', history: '<p>history-facts</p>' } })
expect(wrapper.findAllComponents(Pane).map(pane => pane.props('label'))).toEqual(['任务与车辆信息', '路线货物与凭证', '操作记录'])
expect(wrapper.text()).toContain('task-facts'); expect(wrapper.text()).not.toContain('history-facts')
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'history'); await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('history-facts'); expect(wrapper.text()).not.toContain('task-facts')
})
it('preserves only five approved photo roles without demo images or uploaded claims', () => {
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证'])
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
})
it('blocks a close while busy and delegates unsaved-result decisions to the owner', async () => {
const guard = vi.fn(), done = vi.fn()
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true, busy: true, beforeClose: guard } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(guard).not.toHaveBeenCalled(); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ busy: false }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
const done = vi.fn(), guard = vi.fn()
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ beforeClose: guard }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,16 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
</script>
<template>
<AdminPage class="admin-density" spacing="compact">
<slot name="statistics" />
<ListPageLayout aria-label="外配业务任务列表">
<template v-if="$slots.filters" #toolbar><slot name="filters" /></template>
<template v-if="$slots.state" #state><slot name="state" /></template>
<slot />
</ListPageLayout>
</AdminPage>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
const props = defineProps<{
dispatchNo?: string
busy?: boolean
saveDisabled?: boolean
pending?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{ save: [] }>()
function close(done: () => void) {
if (props.busy) return
if (props.beforeClose) return props.beforeClose(done)
if (!props.pending) done()
}
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" :confirm-text="pending ? '核对并重试原请求' : '保存登记'" :before-close="close" :close-on-press-escape="!busy" @confirm="emit('save')">
<p class="outsourced-business-operation-reference">排车单号 {{ dispatchNo || '—' }}</p>
<slot name="state" />
<fieldset class="outsourced-business-operation-section"><legend>整车承运金额</legend><slot name="amount" /><p>整车只登记一次;单价采用系统返回的承运金额与实际物理托数计算结果。</p></fieldset>
<fieldset class="outsourced-business-operation-section"><legend>逐目的地实际到达时间</legend><slot name="arrivals" /></fieldset>
<p>登记运营信息不推进排车或派送状态;照片在独立上传照片窗口中处理。</p>
</AppDialog>
</template>
<style scoped>
.outsourced-business-operation-reference { overflow-wrap: anywhere; }
.outsourced-business-operation-section { min-width: 0; margin: 0 0 var(--admin-space-4); padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
.outsourced-business-operation-section legend { padding: 0 var(--admin-space-2); font-weight: 600; }
</style>
@@ -0,0 +1,59 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import OutsourcedBusinessOperationsEditor from './OutsourcedBusinessOperationsEditor.vue'
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
const calls = vi.hoisted(() => ({ save: vi.fn(), detail: vi.fn(), command: vi.fn(), canEdit: true }))
vi.mock('../api/outsourcedBusiness', () => ({ saveOutsourcedBusinessOperations: calls.save, getOutsourcedBusiness: calls.detail, getOutsourcedBusinessCommand: calls.command }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'actor' } }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => calls.canEdit }) }))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const Frame = defineComponent({ props: ['modelValue','busy','saveDisabled','pending'], emits: ['save','update:modelValue'], setup(props,{ slots,emit }) { return () => h('div', [...Object.values(slots).map(slot => slot?.()), h('button',{ 'data-save':'',disabled: props.busy || props.saveDisabled,onClick: () => emit('save') },'save')]) } })
const Control = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' })
const Field = defineComponent({ props: ['label'], template: '<label>{{label}}<slot /></label>' })
const global = { stubs: { OutsourcedBusinessOperationsDialog: Frame, ElForm: { template:'<div><slot /></div>' }, ElFormItem: Field, ElInput: Control, ElSelect: Control, ElOption: true, ElDatePicker: Control, LoadErrorState: { props:['message'],template:'<div>{{message}}</div>' }, ApiErrorMeta: true } }
beforeEach(() => {
vi.clearAllMocks(); sessionStorage.clear(); calls.canEdit = true
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
calls.save.mockResolvedValue(current); calls.detail.mockResolvedValue(current)
calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
})
describe('outsourced operations registration', () => {
it('sends explicit zero and destination arrival with auxiliary zero, without fuel or mileage fields', async () => {
const wrapper = mount(OutsourcedBusinessOperationsEditor,{ global,props:{modelValue:true,detail:outsourcedBusinessFixture()} }); await flushPromises()
const currency = wrapper.findAll('label').find(field => field.text() === '币种')!.find('input')
const amount = wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input')
await currency.setValue('USD'); await amount.setValue('0.00')
await wrapper.findAll('input').at(-1)!.setValue('2026-10-20T12:00:00+08:00')
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
const body = calls.save.mock.calls[0]![1]
expect(body).toEqual({ recordVersion:'0',idempotencyKey:expect.any(String),operations:{money:{currency:'USD',amount:'0.00'},arrivals:[{destinationKey:'destination-1',actualArrivedAt:'2026-10-20T12:00:00+08:00'}]},reason:null })
expect(calls.save).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('里程'); wrapper.unmount()
})
it('clears money as null and retains exactly the original request after an unknown save', async () => {
const detail = outsourcedBusinessFixture(); detail.row.operations.money = {currency:'CNY',amount:'123.45'}
calls.save.mockRejectedValueOnce(new TypeError('network interrupted'))
const wrapper = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail}}); await flushPromises()
await wrapper.findAll('label').find(field => field.text() === '币种')!.find('input').setValue('')
await wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input').setValue('')
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
const original = JSON.parse(JSON.stringify(calls.save.mock.calls[0]![1]))
expect(original.operations.money).toBeNull(); expect(wrapper.emitted('saved')).toBeUndefined()
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
expect(calls.command).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',original.idempotencyKey)
expect(calls.save).toHaveBeenNthCalledWith(2,'DS-OUT-1',original); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('does not write for an unchanged form or a user without edit permission', async () => {
const first = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
await first.find('[data-save]').trigger('click'); await flushPromises(); expect(calls.save).not.toHaveBeenCalled(); first.unmount()
calls.canEdit = false
const readonly = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
expect(readonly.find('[data-save]').attributes('disabled')).toBeDefined(); expect(calls.save).not.toHaveBeenCalled(); readonly.unmount()
})
})
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
import { newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessMoney, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { getOutsourcedBusiness } from '../api/outsourcedBusiness'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), pending = ref<OutsourcedBusinessPending | null>(null), error = ref<ApiErrorDetails | null>(null), corrupted = ref(false)
const form = reactive({ currency: '', amount: '', reason: '', arrivals: {} as Record<string,string> })
const active = computed(() => open.value || Boolean(pending.value))
const registration = useUnsavedWorkRegistration({ key: `outsourced-business-operations:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务运营登记'), isOpen: active, model: reactive({ form, pending }), requiredPermission: computed(() => permission.outsourcedBusinessEdit) })
let actor = '', number = '', baseline = ''
const writable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessEdit) && props.detail?.row.actions.edit.allowed === true)
const locked = computed(() => busy.value || Boolean(pending.value))
function persist() { persistOutsourcedBusinessPending(actor, number, 'operations', pending.value) }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('操作身份或排车单已变化,请重新打开') }
watch(busy, value => emit('busy', value))
watch(open, async value => {
if (!value || !props.detail) return
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo; error.value = null; corrupted.value = false
try {
pending.value = restoreOutsourcedBusinessPending(actor, number, 'operations')
const operations = pending.value?.action === 'SAVE_OPERATIONS' ? (pending.value.body as OutsourcedBusinessSave).operations : props.detail.row.operations
Object.assign(form, { currency: operations.money?.currency || '', amount: operations.money?.amount ?? '', reason: pending.value ? (pending.value.body as OutsourcedBusinessSave).reason || '' : '', arrivals: Object.fromEntries(props.detail.row.destinations.map(destination => [destination.destinationKey, operations.arrivals.find(arrival => arrival.destinationKey === destination.destinationKey)?.actualArrivedAt || ''])) })
baseline = JSON.stringify(form); await nextTick(); if (!pending.value) registration.captureBaseline()
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '运营资料恢复失败') }
}, { immediate: true })
function payload(): OutsourcedBusinessSave {
const amount = outsourcedBusinessDecimal(form.amount, 2, 18)
let money: OutsourcedBusinessMoney | null = null
if (amount != null) { if (!['CNY','USD'].includes(form.currency)) throw new Error('请选择金额币种'); money = { currency: form.currency as OutsourcedBusinessMoney['currency'], amount } }
else if (form.currency) throw new Error('金额和币种请成对填写,清空时同时清空两项')
const arrivals = props.detail!.row.destinations.map(destination => {
const value = form.arrivals[destination.destinationKey] || null
if (value && (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))) throw new Error('实际到达时间必须包含有效时区')
return { destinationKey: destination.destinationKey, actualArrivedAt: value }
})
return { recordVersion: outsourcedBusinessVersion(props.detail!.row.recordVersion), idempotencyKey: crypto.randomUUID(), operations: { money, arrivals }, reason: form.reason.trim() || null }
}
async function save() {
if (busy.value || !writable.value || !props.detail) return
busy.value = true; error.value = null
try {
ensureScope()
if (!pending.value) { if (JSON.stringify(form) === baseline) { open.value = false; return }; pending.value = newOutsourcedBusinessPending(actor, number, 'SAVE_OPERATIONS', payload()); persist() }
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.detail) throw new Error('运营登记结果未确认')
pending.value = null; persist(); registration.captureBaseline(); emit('saved', result.detail); open.value = false; ElMessage.success('运营信息已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '运营登记结果尚未确认') }
finally { busy.value = false }
}
async function beforeClose(done: () => void) {
if (busy.value) return
if (pending.value || JSON.stringify(form) !== baseline) {
try { await ElMessageBox.confirm(pending.value ? '原请求将保留,重新打开后可核对原结果。是否关闭?' : '放弃尚未保存的运营信息?', '关闭运营登记', { customClass: 'admin-density-confirm', confirmButtonText: pending.value ? '保留请求并关闭' : '放弃修改', cancelButtonText: '继续编辑' }) } catch { return }
}
done()
}
async function discardRejected() {
if (!pending.value?.rejected || busy.value) return
busy.value = true
try { const current = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', current); error.value = null }
catch (cause) { error.value = getApiErrorDetails(cause, '当前运营版本核对失败') }
finally { busy.value = false }
}
</script>
<template>
<OutsourcedBusinessOperationsDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :pending="Boolean(pending)" :save-disabled="!writable" :before-close="beforeClose" @save="save">
<template #state><LoadErrorState v-if="error" title="运营登记未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原请求已保留,字段锁定;核对结果前不会建立新请求。</p><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝结果重新编辑</el-button><p v-if="!writable">{{ detail?.row.actions.edit.blockReason || '没有运营登记权限' }}</p></template>
<template #amount><el-form label-position="top" class="outsourced-business-form"><el-form-item label="币种"><el-select v-model="form.currency" clearable :disabled="locked"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select></el-form-item><el-form-item label="整车承运金额"><el-input v-model="form.amount" :disabled="locked" placeholder="未登记;允许明确填写0" /></el-form-item><el-form-item label="更正说明"><el-input v-model="form.reason" :disabled="locked" maxlength="1000" /></el-form-item></el-form></template>
<template #arrivals><div v-for="destination in detail?.row.destinations || []" :key="destination.destinationKey" class="outsourced-business-arrival"><span>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}<small v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}</small></span><el-date-picker v-model="form.arrivals[destination.destinationKey]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" :disabled="locked" placeholder="实际到达时间,未登记留空" /></div><p>时间按浏览器本地时区输入,提交时保留时区。</p></template>
</OutsourcedBusinessOperationsDialog>
</template>
<style scoped>
.outsourced-business-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-4); }
.outsourced-business-arrival { display: grid; grid-template-columns: minmax(0,1fr) 280px; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-3); }
.outsourced-business-arrival small { display: block; }
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
import { outsourcedBusinessPhotoRoles } from '../outsourcedBusinessTypes'
const props = defineProps<{
dispatchNo?: string
busy?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
function close(done: () => void) {
if (props.busy) return
if (props.beforeClose) return props.beforeClose(done)
done()
}
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}</p>
<slot name="state" />
<p>每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</p>
<div class="outsourced-business-photo-grid">
<section v-for="photo in outsourcedBusinessPhotoRoles" :key="photo.slot" class="outsourced-business-photo-slot" :aria-label="photo.label">
<h3>{{ photo.label }}</h3><slot :name="photo.viewSlot"><p>暂无照片</p></slot>
</section>
</div>
</AppDialog>
</template>
<style scoped>
.outsourced-business-photo-reference { overflow-wrap: anywhere; }
.outsourced-business-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
.outsourced-business-photo-slot { min-width: 0; padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
.outsourced-business-photo-slot h3 { margin: 0 0 var(--admin-space-3); font-size: inherit; }
</style>
@@ -0,0 +1,64 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import OutsourcedBusinessPhotosEditor from './OutsourcedBusinessPhotosEditor.vue'
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
import type { OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes'
const calls=vi.hoisted(()=>({upload:vi.fn(),bind:vi.fn(),command:vi.fn(),detail:vi.fn(),content:vi.fn(),actor:{user:{id:'actor'}},canUpload:true}))
vi.mock('../api/outsourcedBusiness',()=>({uploadOutsourcedBusinessPhoto:calls.upload,bindOutsourcedBusinessPhoto:calls.bind,getOutsourcedBusinessCommand:calls.command,getOutsourcedBusiness:calls.detail,getOutsourcedBusinessPhotoContent:calls.content}))
vi.mock('@/core/auth/auth',()=>({useAuthStore:()=>calls.actor}))
vi.mock('@/core/access/accessPreview',()=>({useAccessPreviewStore:()=>({can:()=>calls.canUpload})}))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration',()=>({useUnsavedWorkRegistration:()=>({captureBaseline:vi.fn()})}))
vi.mock('element-plus',()=>({ElMessage:{success:vi.fn()},ElMessageBox:{confirm:vi.fn()}}))
const Frame=defineComponent({props:['modelValue','busy','beforeClose'],setup(_props,{slots}){return()=>h('div',Object.values(slots).map(slot=>slot?.()))}})
const Button=defineComponent({props:['disabled'],template:'<button :disabled="disabled"><slot /></button>'})
const global={stubs:{OutsourcedBusinessPhotosDialog:Frame,ElButton:Button,LoadErrorState:{props:['message'],template:'<div>{{message}}<slot name="details" /></div>'},ApiErrorMeta:true}}
const photo:OutsourcedBusinessPhoto={slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null}
async function choose(wrapper:ReturnType<typeof mount>){const input=wrapper.find('input[type="file"]');Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['image'],'loading.png',{type:'image/png'})]});await input.trigger('change');await flushPromises()}
beforeEach(()=>{
vi.clearAllMocks();sessionStorage.clear();calls.actor.user.id='actor';calls.canUpload=true
calls.upload.mockResolvedValue(photo);calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
const current=outsourcedBusinessFixture();current.row.recordVersion='1';current.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
calls.bind.mockResolvedValue(current);calls.detail.mockResolvedValue(current)
})
afterEach(() => vi.unstubAllGlobals())
describe('outsourced business per-slot photo save',()=>{
it('retains prepared file and original binding request through a failed bind without retransmitting the image',async()=>{
calls.bind.mockRejectedValueOnce(new TypeError('network interrupted'))
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('尚未确认绑定保存');expect(wrapper.emitted('saved')).toBeUndefined()
const original={...calls.bind.mock.calls[0]![2]}
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(2)
expect(calls.bind.mock.calls[1]![2]).toEqual(original);expect(original.fileId).toBe(photo.fileId);expect(original.recordVersion).toBe('0')
expect(wrapper.emitted('saved')).toHaveLength(1);wrapper.unmount()
})
it('does not offer a new upload after unknown upload or fabricate a saved photo',async()=>{
calls.upload.mockRejectedValue(new TypeError('network interrupted'))
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).not.toHaveBeenCalled();expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined();expect(wrapper.text()).toContain('禁止重复上传');wrapper.unmount()
})
it('keeps saved metadata readable without upload permission and fetches image bytes only on explicit preview',async()=>{
calls.canUpload=false
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
expect(wrapper.find('input[type="file"]').exists()).toBe(false);expect(wrapper.text()).toContain('已保存');expect(calls.content).not.toHaveBeenCalled()
wrapper.unmount()
})
it('reads a saved loading photo only on click and releases its URL on closing',async()=>{
const create=vi.fn(()=> 'blob:outsourced-loading'),revoke=vi.fn()
vi.stubGlobal('URL',class extends URL { static createObjectURL=create; static revokeObjectURL=revoke })
calls.canUpload=false;calls.content.mockResolvedValue(new Blob(['image'],{type:'image/png'}))
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
expect(calls.content).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises()
expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading')
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
})
})
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot } from '../outsourcedBusinessTypes'
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, uploadOutsourcedBusinessPhoto } from '../api/outsourcedBusiness'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { acceptOutsourcedBusinessResult, newOutsourcedBusinessPending, outsourcedBusinessPendingKey, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null), pending = ref<OutsourcedBusinessPending | null>(null), prepared = ref<OutsourcedBusinessPhoto | null>(null), corrupted = ref(false)
const urls = ref<Partial<Record<OutsourcedBusinessPhotoSlot,string>>>({}), reading = ref<OutsourcedBusinessPhotoSlot | null>(null)
const uploadable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
useUnsavedWorkRegistration({ key: `outsourced-business-photos:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务照片作业'), isOpen: computed(() => open.value || Boolean(pending.value) || Boolean(prepared.value)), model: reactive({ pending, prepared }), requiredPermission: computed(() => permission.outsourcedBusinessUpload) })
let actor = '', number = '', generation = 0
function savedPhoto(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
function persist() { persistOutsourcedBusinessPending(actor, number, 'photos', pending.value) }
function persistPrepared() {
const key = outsourcedBusinessPendingKey(actor, number, 'prepared-photo')
if (prepared.value) sessionStorage.setItem(key, JSON.stringify(prepared.value)); else sessionStorage.removeItem(key)
}
watch(busy, value => emit('busy', value))
watch(open, value => {
generation++; release(); reading.value = null; error.value = null; corrupted.value = false
if (!value || !props.detail) return
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo
try {
pending.value = restoreOutsourcedBusinessPending(actor, number, 'photos')
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, number, 'prepared-photo'))
prepared.value = raw ? JSON.parse(raw) as OutsourcedBusinessPhoto : null
if (prepared.value && (!prepared.value.fileId || !outsourcedBusinessPhotoRoles.some(role => role.slot === prepared.value!.slot))) throw new Error('原上传文件资料损坏,请保留现场核对')
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '照片作业恢复失败') }
}, { immediate: true })
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
onBeforeUnmount(() => { generation++; release() })
async function preview(slot: OutsourcedBusinessPhotoSlot) {
const photo = savedPhoto(slot), current = generation
if (!photo || reading.value) return
reading.value = slot; error.value = null
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
finally { if (current === generation) reading.value = null }
}
async function bindPrepared() {
if ((!prepared.value && !pending.value) || !props.detail || !uploadable.value) return
if (!pending.value) {
const body = { recordVersion: outsourcedBusinessVersion(props.detail.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: prepared.value!.fileId }
pending.value = newOutsourcedBusinessPending(actor, number, 'BIND_PHOTO', body, prepared.value!.slot); persist()
}
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.detail) throw new Error('照片绑定尚未确认')
pending.value = null; persist(); prepared.value = null; persistPrepared(); emit('saved', result.detail); ElMessage.success('照片已绑定保存')
}
async function recover() {
if (busy.value || !uploadable.value) return
busy.value = true; error.value = null
try {
ensureScope()
if (pending.value?.action === 'UPLOAD_PHOTO') {
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.photo) throw new Error('原上传文件未确认')
prepared.value = result.photo; persistPrepared(); pending.value = null; persist()
}
await bindPrepared()
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '照片结果尚未确认') }
finally { busy.value = false }
}
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || pending.value || prepared.value || !uploadable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
busy.value = true; error.value = null
try {
ensureScope(); pending.value = newOutsourcedBusinessPending(actor, number, 'UPLOAD_PHOTO', null, slot); pending.value.attempted = true; persist()
const uploaded = await uploadOutsourcedBusinessPhoto(number, slot, file, pending.value.key)
ensureScope(); acceptOutsourcedBusinessResult(pending.value, uploaded); persist()
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.photo) throw new Error('真实上传文件未确认')
prepared.value = result.photo; persistPrepared(); pending.value = null; persist(); await bindPrepared()
} catch (cause) {
if (pending.value?.action === 'UPLOAD_PHOTO' && cause instanceof ApiResponseError && ['A0400','A0403','A0404','400','403','404','422'].includes(cause.code)) { pending.value.rejected = true; persist() }
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '图片上传或绑定结果尚未确认')
} finally { busy.value = false }
}
async function discardRejected() {
if (busy.value || !pending.value?.rejected) return
busy.value = true
try { const latest = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', latest); error.value = null }
catch (cause) { error.value = getApiErrorDetails(cause, '当前照片版本读取失败') }
finally { busy.value = false }
}
async function beforeClose(done: () => void) {
if (busy.value) return
if (pending.value || prepared.value) {
try { await ElMessageBox.confirm('未完成的原上传或绑定记录将保留,重新打开后继续核对;关闭不会保存尚未绑定的照片。是否关闭?', '关闭照片窗口', { customClass: 'admin-density-confirm', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
}
done()
}
</script>
<template>
<OutsourcedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原{{ pending.action === 'UPLOAD_PHOTO' ? '上传' : '绑定' }}请求已保留,不会重新上传文件。</p><p v-if="prepared">{{ prepared.fileName }} 已上传,尚未确认绑定保存。</p><el-button v-if="pending || prepared" :loading="busy" :disabled="!uploadable" @click="recover">核对原结果并继续绑定</el-button><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝重新核对,保留已上传文件</el-button><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="outsourced-business-photo" />
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
<p v-else>暂无已保存照片</p>
<label v-if="uploadable" class="outsourced-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy || Boolean(pending) || Boolean(prepared)" @change="upload(role.slot, $event)" /></label>
</template>
</OutsourcedBusinessPhotosDialog>
</template>
<style scoped>
.outsourced-business-photo { display: block; width: 100%; max-height: 160px; object-fit: contain; }
.outsourced-business-upload { display: block; margin-top: var(--admin-space-3); }
.outsourced-business-upload input { display: block; width: 100%; margin-top: var(--admin-space-2); }
</style>
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/ownedBusiness'
import { ownedBusinessFixture, ownedBusinessCommandFixture } from './OwnedBusinessFixtures'
import { acceptOwnedBusinessCommand, newOwnedBusinessPending, persistOwnedBusinessPending, restoreOwnedBusinessPending, recoverOwnedBusinessCommand, ownedBusinessPendingKey } from './OwnedBusinessCommandRecovery'
import type { OwnedBusinessPhoto, OwnedBusinessSave } from '../ownedBusinessTypes'
beforeEach(() => sessionStorage.clear())
const no = 'DS-OWN-1'
function operation() : OwnedBusinessSave { return { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...ownedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null } }
function client() {
const current = ownedBusinessFixture(); current.row.recordVersion = '1'
const photo: OwnedBusinessPhoto = { slot:'FUEL_START',category:'FUEL',fileId:'9007199254740993',fileName:'fuel.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null }
return { ...api, getOwnedBusiness: vi.fn(async () => current), getOwnedBusinessCommand: vi.fn(async () => ownedBusinessCommandFixture()), saveOwnedBusinessOperations: vi.fn(async () => current), bindOwnedBusinessPhoto: vi.fn(async () => current), uploadOwnedBusinessPhoto: vi.fn(async () => photo) }
}
describe('owned business exact command recovery', () => {
it('persists the complete original payload, actor and zero auxiliary version', () => {
const pending = newOwnedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation())
persistOwnedBusinessPending('actor', no, 'operations', pending)
expect(restoreOwnedBusinessPending('actor', no, 'operations')).toEqual(pending)
expect(restoreOwnedBusinessPending('another', no, 'operations')).toBeNull()
const raw = JSON.parse(sessionStorage.getItem(ownedBusinessPendingKey('actor',no,'operations'))!)
raw.actor = 'another'; sessionStorage.setItem(ownedBusinessPendingKey('actor',no,'operations'),JSON.stringify(raw))
expect(() => restoreOwnedBusinessPending('actor',no,'operations')).toThrow('损坏')
})
it('looks up an unknown write first and retries only the unchanged original body on explicit not-found', async () => {
const pending = newOwnedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()); pending.attempted = true
const service = client(), persist = vi.fn()
service.getOwnedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
await recoverOwnedBusinessCommand(pending,persist,() => {},service)
expect(service.getOwnedBusinessCommand).toHaveBeenCalledWith(no,'original-key')
expect(service.saveOwnedBusinessOperations).toHaveBeenCalledExactlyOnceWith(no,operation())
expect(pending.body).toEqual(operation()); expect(pending.body?.recordVersion).toBe('0')
})
it('retains a proven success through failed fresh reads without writing it a second time', async () => {
const pending = newOwnedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()), service = client()
service.getOwnedBusiness.mockRejectedValueOnce(new Error('read offline'))
await expect(recoverOwnedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('read offline')
expect(pending.confirmed?.recordVersion).toBe('1')
await recoverOwnedBusinessCommand(pending,() => {},() => {},service)
expect(service.saveOwnedBusinessOperations).toHaveBeenCalledTimes(1); expect(service.getOwnedBusinessCommand).not.toHaveBeenCalled()
})
it('never retransmits a file for an unknown upload, including command not-found', async () => {
const pending = newOwnedBusinessPending('actor',no,'UPLOAD_PHOTO',null,'FUEL_START'); pending.attempted = true
const service = client(); service.getOwnedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
await expect(recoverOwnedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('禁止重复上传')
expect(service.uploadOwnedBusinessPhoto).not.toHaveBeenCalled(); expect(service.bindOwnedBusinessPhoto).not.toHaveBeenCalled(); expect(pending.key).toBeTruthy()
})
it('keeps the prepared file and exact binding key through unknown binding and later replacement', async () => {
const photo: OwnedBusinessPhoto = { slot:'FUEL_START',category:'FUEL',fileId:'9007199254740993',fileName:'fuel.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:'2026-10-20T10:01:00Z' }
const pending = newOwnedBusinessPending('actor',no,'BIND_PHOTO',{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId},photo.slot)
pending.attempted = true
const service = client(); const bound = ownedBusinessFixture(); bound.row.recordVersion='1'; bound.photos=[photo]
const original = ownedBusinessCommandFixture({action:'BIND_PHOTO',idempotencyKey:'bind-key',result:bound})
service.getOwnedBusinessCommand.mockResolvedValue(original)
const current = ownedBusinessFixture();current.row.recordVersion='2';current.photos=[{...photo,fileId:'9007199254740994'}]
service.getOwnedBusiness.mockResolvedValue(current)
const result = await recoverOwnedBusinessCommand(pending,() => {},() => {},service)
expect(result.detail?.photos[0]?.fileId).toBe('9007199254740994')
expect(pending.body).toEqual({recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})
expect(service.uploadOwnedBusinessPhoto).not.toHaveBeenCalled();expect(service.bindOwnedBusinessPhoto).not.toHaveBeenCalled()
})
it('refuses wrong command identities, stale reads, definite rejection retries and scope changes', async () => {
const pending = newOwnedBusinessPending('actor',no,'SAVE_OPERATIONS',operation()), service=client()
expect(() => acceptOwnedBusinessCommand(pending,ownedBusinessCommandFixture({dispatchNo:'another'}))).toThrow('不一致')
pending.command=ownedBusinessCommandFixture({recordVersion:'9007199254740993'})
await expect(recoverOwnedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('早于')
pending.rejected=true;await expect(recoverOwnedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('明确拒绝')
pending.rejected=false;await expect(recoverOwnedBusinessCommand(pending,() => {},() => {throw new Error('scope changed')},service)).rejects.toThrow('scope changed')
expect(service.saveOwnedBusinessOperations).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,89 @@
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/ownedBusiness'
import { ownedBusinessPhotoRoles, ownedBusinessVersion, type OwnedBusinessAction, type OwnedBusinessBind, type OwnedBusinessCommand, type OwnedBusinessDetail, type OwnedBusinessPhoto, type OwnedBusinessPhotoSlot, type OwnedBusinessSave } from '../ownedBusinessTypes'
export interface OwnedBusinessPending {
actor: string; dispatchNo: string; action: OwnedBusinessAction; key: string; slot?: OwnedBusinessPhotoSlot
body: OwnedBusinessSave | OwnedBusinessBind | null; attempted: boolean; command: OwnedBusinessCommand | null; confirmed: { recordVersion: string | null; result: OwnedBusinessDetail | OwnedBusinessPhoto } | null; rejected: boolean
}
export const ownedBusinessPendingKey = (actor: string, no: string, area: string) => `owned-business:${encodeURIComponent(actor)}:${encodeURIComponent(no)}:${area}`
export function restoreOwnedBusinessPending(actor: string, no: string, area: string): OwnedBusinessPending | null {
const raw = sessionStorage.getItem(ownedBusinessPendingKey(actor, no, area))
if (!raw) return null
const pending = JSON.parse(raw) as OwnedBusinessPending
if (pending.actor !== actor || pending.dispatchNo !== no || !pending.key || !['SAVE_OPERATIONS','UPLOAD_PHOTO','BIND_PHOTO'].includes(pending.action)) throw new Error('原运营请求记录损坏,请保留现场核对')
if (pending.action !== 'SAVE_OPERATIONS' && !ownedBusinessPhotoRoles.some(role => role.slot === pending.slot) || pending.action !== 'UPLOAD_PHOTO' && (!pending.body || pending.body.idempotencyKey !== pending.key)) throw new Error('原运营请求内容不完整,请保留现场核对')
return pending
}
export function persistOwnedBusinessPending(actor: string, no: string, area: string, pending: OwnedBusinessPending | null) {
if (!actor || !no || pending && (pending.actor !== actor || pending.dispatchNo !== no)) throw new Error('运营请求身份已变化')
const key = ownedBusinessPendingKey(actor, no, area)
if (pending) sessionStorage.setItem(key, JSON.stringify(pending)); else sessionStorage.removeItem(key)
}
export function newOwnedBusinessPending(actor: string, no: string, action: OwnedBusinessAction, body: OwnedBusinessPending['body'], slot?: OwnedBusinessPhotoSlot): OwnedBusinessPending {
if (!actor || !no) throw new Error('当前运营作业身份缺失')
const key = body?.idempotencyKey || crypto.randomUUID()
return { actor, dispatchNo: no, action, key, slot, body: body ? structuredClone(body) : null, attempted: false, command: null, confirmed: null, rejected: false }
}
export function acceptOwnedBusinessResult(pending: OwnedBusinessPending, result: OwnedBusinessDetail | OwnedBusinessPhoto) {
if (pending.action === 'UPLOAD_PHOTO') {
if (!('fileId' in result) || result.slot !== pending.slot || typeof result.fileId !== 'string' || !/^[1-9]\d*$/.test(result.fileId) || !['image/jpeg','image/png'].includes(result.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
pending.confirmed = { recordVersion: null, result }
} else {
if (!('row' in result) || result.row.dispatchNo !== pending.dispatchNo) throw new Error('运营保存结果与原排车单不一致')
pending.confirmed = { recordVersion: ownedBusinessVersion(result.row.recordVersion), result }
}
}
export function acceptOwnedBusinessCommand(pending: OwnedBusinessPending, command: OwnedBusinessCommand) {
if (command.dispatchNo !== pending.dispatchNo || command.action !== pending.action || command.idempotencyKey !== pending.key) throw new Error('返回结果与原运营请求不一致,请保留原请求核对')
pending.command = command
if (command.state === 'REJECTED') pending.rejected = true
if (command.state !== 'SUCCEEDED') throw new Error(command.state === 'REJECTED' ? '原请求已明确拒绝,请核对原因后重新登记' : '原请求结果尚未确定,请保留原请求继续核对')
if (!command.result) throw new Error('原运营命令的成功结果不完整,请继续核对')
acceptOwnedBusinessResult(pending, command.result)
if (pending.action !== 'UPLOAD_PHOTO' && (command.recordVersion == null || command.recordVersion !== pending.confirmed!.recordVersion)) throw new Error('原运营命令版本与结果不一致')
}
export async function recoverOwnedBusinessCommand(pending: OwnedBusinessPending, persist: () => void, ensureScope: () => void, client = api): Promise<{ detail?: OwnedBusinessDetail; photo?: OwnedBusinessPhoto }> {
ensureScope()
if (pending.rejected) throw new Error('原请求已明确拒绝,请先按明确拒绝结果重新核对')
if (pending.command?.state === 'SUCCEEDED') acceptOwnedBusinessCommand(pending, pending.command)
const previous = pending.attempted
if (!pending.confirmed) {
let original: OwnedBusinessCommand | null = null
if (previous) {
try { original = await client.getOwnedBusinessCommand(pending.dispatchNo, pending.key) }
catch (cause) { if (!(cause instanceof ApiResponseError) || !['404','A0404','HTTP_404'].includes(cause.code)) throw cause }
ensureScope()
}
if (original) { try { acceptOwnedBusinessCommand(pending, original) } finally { persist() } }
else {
if (pending.action === 'UPLOAD_PHOTO') throw new Error('图片上传结果未知,禁止重复上传;请继续核对原上传请求')
pending.attempted = true; persist()
try {
const result = pending.action === 'SAVE_OPERATIONS'
? await client.saveOwnedBusinessOperations(pending.dispatchNo, pending.body as OwnedBusinessSave)
: await client.bindOwnedBusinessPhoto(pending.dispatchNo, pending.slot!, pending.body as OwnedBusinessBind)
ensureScope(); acceptOwnedBusinessResult(pending, result); persist()
} catch (cause) {
ensureScope()
if (!previous && cause instanceof ApiResponseError && ['A0400','A0403','A0404','A0409','400','403','404','409','422'].includes(cause.code)) { pending.rejected = true; persist() }
throw cause
}
}
}
const confirmed = pending.confirmed!
if (pending.action === 'UPLOAD_PHOTO') {
const photo = confirmed.result as OwnedBusinessPhoto | null
if (!photo || photo.slot !== pending.slot || typeof photo.fileId !== 'string' || !/^[1-9]\d*$/.test(photo.fileId) || !['image/jpeg','image/png'].includes(photo.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
return { photo }
}
const current = await client.getOwnedBusiness(pending.dispatchNo); ensureScope()
if (current.row.dispatchNo !== pending.dispatchNo || confirmed.recordVersion == null || BigInt(ownedBusinessVersion(current.row.recordVersion)) < BigInt(ownedBusinessVersion(confirmed.recordVersion))) throw new Error('当前运营版本早于原操作结果,请继续核对')
if (pending.action === 'BIND_PHOTO') {
const matches = (photo: OwnedBusinessPhoto) => photo.slot === pending.slot && photo.fileId === (pending.body as OwnedBusinessBind).fileId && Boolean(photo.savedAt)
const original = confirmed.result
const originalPhotos = original && 'row' in original ? original.photos : original && 'fileId' in original ? [original] : []
if (!current.photos.some(matches) && !originalPhotos.some(matches)) throw new Error('图片绑定结果尚未确认,请保留原文件和请求')
}
return { detail: current }
}
@@ -0,0 +1,36 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
defineProps<{ dispatchNo?: string; statusText?: string; loading?: boolean }>()
const activeTab = defineModel<'task' | 'route' | 'history'>('activeTab', { default: 'task' })
const emit = defineEmits<{ back: [] }>()
</script>
<template>
<AdminPage class="admin-density" spacing="compact">
<DetailPageLayout aria-label="自有业务详情">
<template #header>
<div class="owned-business-detail-heading">
<div><strong>自有业务</strong><span class="owned-business-reference">{{ dispatchNo || '—' }}</span><span v-if="statusText">{{ statusText }}</span></div>
<div class="density-actions"><slot name="actions" /><el-button class="density-action" @click="emit('back')">返回自有业务</el-button></div>
</div>
</template>
<slot name="state" />
<div v-loading="loading" :aria-busy="loading">
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
<el-tab-pane name="task" label="任务与车辆信息"><slot v-if="activeTab === 'task'" name="task" /></el-tab-pane>
<el-tab-pane name="route" label="路线货物与凭证"><slot v-if="activeTab === 'route'" name="route" /></el-tab-pane>
<el-tab-pane name="history" label="操作记录"><slot v-if="activeTab === 'history'" name="history" /></el-tab-pane>
</el-tabs>
</div>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.owned-business-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
.owned-business-detail-heading > div { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
.owned-business-reference { overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,20 @@
import type { OwnedBusinessCommand, OwnedBusinessDetail } from '../ownedBusinessTypes'
export function ownedBusinessFixture(): OwnedBusinessDetail {
const review = { status: 'APPROVED' as const, reason: null, reviewerId: null, reviewedAt: '2026-10-20T10:00:00Z', reviewRound: '1', basisHash: null, invalidatedReason: null }
return {
row: {
dispatchNo: 'DS-OWN-1', stage: 'AFTER_LOADING', plannedDepartureAt: '2026-10-20T10:00:00Z', loadingCompletedAt: '2026-10-20T09:00:00Z', departedAt: null, deliveryCompletedAt: null,
origin: { warehouseId: '1', warehouseCode: 'WH-1', warehouseName: '起运仓' }, vehicle: { vehicleId: '2', plate: 'OWN-1', ownership: 'OWNED' }, driver: { name: null, phone: null },
destinations: [{ destinationKey: 'destination-1', warehouseCode: 'FBA-1', warehouseName: '目的仓', address: null, appointments: [{ appointmentNo: 'AP-1', confirmationNo: null, scheduledAt: null, allocatedPackages: '2' }], actualArrivedAt: null }],
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: [] },
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: [],
}
}
export function ownedBusinessCommandFixture(overrides: Partial<OwnedBusinessCommand> = {}): OwnedBusinessCommand {
const detail = ownedBusinessFixture(); detail.row.recordVersion = overrides.recordVersion ?? '1'
return { dispatchNo: 'DS-OWN-1', action: 'SAVE_OPERATIONS', idempotencyKey: 'original-key', state: 'SUCCEEDED', recordVersion: '1', replayed: false, result: detail, errorCode: null, retryAllowed: false, ...overrides }
}
@@ -0,0 +1,48 @@
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import OwnedBusinessDetailLayout from './OwnedBusinessDetailLayout.vue'
import OwnedBusinessPhotosDialog from './OwnedBusinessPhotosDialog.vue'
import OwnedBusinessOperationsDialog from './OwnedBusinessOperationsDialog.vue'
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
const Pane = defineComponent({ props: ['name', 'label'], template: '<article><slot /></article>' })
const Dialog = defineComponent({ props: ['width', 'showConfirm', 'beforeClose'], template: '<div><slot /></div>' })
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, AppDialog: Dialog, ElTabs: Tabs, ElTabPane: Pane, ElButton: Surface }, directives: { loading: () => {} } }
describe('OwnedBusiness presentation boundaries', () => {
it('mounts only the selected one of the three independent detail regions', async () => {
const wrapper = mount(OwnedBusinessDetailLayout, { global, props: { dispatchNo: 'DS-OWN-1' }, slots: { task: '<p>task-facts</p>', route: '<p>route-facts</p>', history: '<p>history-facts</p>' } })
expect(wrapper.findAllComponents(Pane).map(pane => pane.props('label'))).toEqual(['任务与车辆信息', '路线货物与凭证', '操作记录'])
expect(wrapper.text()).toContain('task-facts'); expect(wrapper.text()).not.toContain('history-facts')
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'history'); await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('history-facts'); expect(wrapper.text()).not.toContain('task-facts')
})
it('preserves seven real prototype photo roles without demo images or uploaded claims', () => {
const wrapper = mount(OwnedBusinessPhotosDialog, { global, props: { modelValue: true } })
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证', '起始油耗表盘', '结束油耗表盘'])
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
})
it('blocks a close while busy and delegates unsaved-result decisions to the owner', async () => {
const guard = vi.fn(), done = vi.fn()
const wrapper = mount(OwnedBusinessPhotosDialog, { global, props: { modelValue: true, busy: true, beforeClose: guard } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(guard).not.toHaveBeenCalled(); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ busy: false }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
const done = vi.fn(), guard = vi.fn()
const wrapper = mount(OwnedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ beforeClose: guard }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,16 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
</script>
<template>
<AdminPage class="admin-density" spacing="compact">
<slot name="statistics" />
<ListPageLayout aria-label="自有业务任务列表">
<template v-if="$slots.filters" #toolbar><slot name="filters" /></template>
<template v-if="$slots.state" #state><slot name="state" /></template>
<slot />
</ListPageLayout>
</AdminPage>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
const props = defineProps<{
dispatchNo?: string
busy?: boolean
saveDisabled?: boolean
pending?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{ save: [] }>()
function close(done: () => void) {
if (props.busy) return
if (props.beforeClose) return props.beforeClose(done)
if (!props.pending) done()
}
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" :confirm-text="pending ? '核对并重试原请求' : '保存登记'" :before-close="close" :close-on-press-escape="!busy" @confirm="emit('save')">
<p class="owned-business-operation-reference">排车单号 {{ dispatchNo || '—' }}</p>
<slot name="state" />
<fieldset class="owned-business-operation-section"><legend>整车金额</legend><slot name="amount" /><p>整车只登记一次;单价采用系统返回的总金额与实际物理托数计算结果。</p></fieldset>
<fieldset class="owned-business-operation-section"><legend>逐目的地实际到达时间</legend><slot name="arrivals" /></fieldset>
<fieldset class="owned-business-operation-section"><legend>实际里程与油耗</legend><slot name="measurements" /></fieldset>
<p>登记运营信息不推进排车或派送状态;照片在独立上传照片窗口中处理。</p>
</AppDialog>
</template>
<style scoped>
.owned-business-operation-reference { overflow-wrap: anywhere; }
.owned-business-operation-section { min-width: 0; margin: 0 0 var(--admin-space-4); padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
.owned-business-operation-section legend { padding: 0 var(--admin-space-2); font-weight: 600; }
</style>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OwnedBusinessOperationsDialog from './OwnedBusinessOperationsDialog.vue'
import { newOwnedBusinessPending, persistOwnedBusinessPending, restoreOwnedBusinessPending, recoverOwnedBusinessCommand, type OwnedBusinessPending } from './OwnedBusinessCommandRecovery'
import { ownedBusinessDecimal, ownedBusinessVersion, type OwnedBusinessDetail, type OwnedBusinessMoney, type OwnedBusinessSave } from '../ownedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { getOwnedBusiness } from '../api/ownedBusiness'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OwnedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OwnedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), pending = ref<OwnedBusinessPending | null>(null), error = ref<ApiErrorDetails | null>(null), corrupted = ref(false)
const form = reactive({ currency: '', amount: '', actualDistanceMiles: '', fuelConsumptionLitresPer100Km: '', reason: '', arrivals: {} as Record<string,string> })
const active = computed(() => open.value || Boolean(pending.value))
const registration = useUnsavedWorkRegistration({ key: `owned-business-operations:${getCurrentInstance()?.uid}`, label: computed(() => '自有业务运营登记'), isOpen: active, model: reactive({ form, pending }), requiredPermission: computed(() => permission.ownedBusinessEdit) })
let actor = '', number = '', baseline = ''
const writable = computed(() => !corrupted.value && access.can(permission.ownedBusinessEdit) && props.detail?.row.actions.edit.allowed === true)
const locked = computed(() => busy.value || Boolean(pending.value))
function persist() { persistOwnedBusinessPending(actor, number, 'operations', pending.value) }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('操作身份或排车单已变化,请重新打开') }
watch(busy, value => emit('busy', value))
watch(open, async value => {
if (!value || !props.detail) return
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo; error.value = null; corrupted.value = false
try {
pending.value = restoreOwnedBusinessPending(actor, number, 'operations')
const operations = pending.value?.action === 'SAVE_OPERATIONS' ? (pending.value.body as OwnedBusinessSave).operations : props.detail.row.operations
Object.assign(form, { currency: operations.money?.currency || '', amount: operations.money?.amount ?? '', actualDistanceMiles: operations.actualDistanceMiles ?? '', fuelConsumptionLitresPer100Km: operations.fuelConsumptionLitresPer100Km ?? '', reason: pending.value ? (pending.value.body as OwnedBusinessSave).reason || '' : '', arrivals: Object.fromEntries(props.detail.row.destinations.map(destination => [destination.destinationKey, operations.arrivals.find(arrival => arrival.destinationKey === destination.destinationKey)?.actualArrivedAt || ''])) })
baseline = JSON.stringify(form); await nextTick(); if (!pending.value) registration.captureBaseline()
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '运营资料恢复失败') }
}, { immediate: true })
function payload(): OwnedBusinessSave {
const amount = ownedBusinessDecimal(form.amount, 2, 18)
let money: OwnedBusinessMoney | null = null
if (amount != null) { if (!['CNY','USD'].includes(form.currency)) throw new Error('请选择金额币种'); money = { currency: form.currency as OwnedBusinessMoney['currency'], amount } }
else if (form.currency) throw new Error('金额和币种请成对填写,清空时同时清空两项')
const arrivals = props.detail!.row.destinations.map(destination => {
const value = form.arrivals[destination.destinationKey] || null
if (value && (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))) throw new Error('实际到达时间必须包含有效时区')
return { destinationKey: destination.destinationKey, actualArrivedAt: value }
})
return { recordVersion: ownedBusinessVersion(props.detail!.row.recordVersion), idempotencyKey: crypto.randomUUID(), operations: { money, actualDistanceMiles: ownedBusinessDecimal(form.actualDistanceMiles, 3, 12), fuelConsumptionLitresPer100Km: ownedBusinessDecimal(form.fuelConsumptionLitresPer100Km, 3, 12), arrivals }, reason: form.reason.trim() || null }
}
async function save() {
if (busy.value || !writable.value || !props.detail) return
busy.value = true; error.value = null
try {
ensureScope()
if (!pending.value) { if (JSON.stringify(form) === baseline) { open.value = false; return }; pending.value = newOwnedBusinessPending(actor, number, 'SAVE_OPERATIONS', payload()); persist() }
const result = await recoverOwnedBusinessCommand(pending.value, persist, ensureScope)
if (!result.detail) throw new Error('运营登记结果未确认')
pending.value = null; persist(); registration.captureBaseline(); emit('saved', result.detail); open.value = false; ElMessage.success('运营信息已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '运营登记结果尚未确认') }
finally { busy.value = false }
}
async function beforeClose(done: () => void) {
if (busy.value) return
if (pending.value || JSON.stringify(form) !== baseline) {
try { await ElMessageBox.confirm(pending.value ? '原请求将保留,重新打开后可核对原结果。是否关闭?' : '放弃尚未保存的运营信息?', '关闭运营登记', { customClass: 'admin-density-confirm', confirmButtonText: pending.value ? '保留请求并关闭' : '放弃修改', cancelButtonText: '继续编辑' }) } catch { return }
}
done()
}
async function discardRejected() {
if (!pending.value?.rejected || busy.value) return
busy.value = true
try { const current = await getOwnedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', current); error.value = null }
catch (cause) { error.value = getApiErrorDetails(cause, '当前运营版本核对失败') }
finally { busy.value = false }
}
</script>
<template>
<OwnedBusinessOperationsDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :pending="Boolean(pending)" :save-disabled="!writable" :before-close="beforeClose" @save="save">
<template #state><LoadErrorState v-if="error" title="运营登记未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原请求已保留,字段锁定;核对结果前不会建立新请求。</p><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝结果重新编辑</el-button><p v-if="!writable">{{ detail?.row.actions.edit.blockReason || '没有运营登记权限' }}</p></template>
<template #amount><el-form label-position="top" class="owned-business-form"><el-form-item label="币种"><el-select v-model="form.currency" clearable :disabled="locked"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select></el-form-item><el-form-item label="整车总金额"><el-input v-model="form.amount" :disabled="locked" placeholder="未登记;允许明确填写0" /></el-form-item><el-form-item label="更正说明"><el-input v-model="form.reason" :disabled="locked" maxlength="1000" /></el-form-item></el-form></template>
<template #arrivals><div v-for="destination in detail?.row.destinations || []" :key="destination.destinationKey" class="owned-business-arrival"><span>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}<small v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}</small></span><el-date-picker v-model="form.arrivals[destination.destinationKey]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" :disabled="locked" placeholder="实际到达时间,未登记留空" /></div><p>时间按浏览器本地时区输入,提交时保留时区。</p></template>
<template #measurements><el-form label-position="top" class="owned-business-form"><el-form-item label="实际里程(mile)"><el-input v-model="form.actualDistanceMiles" :disabled="locked" placeholder="未登记" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input v-model="form.fuelConsumptionLitresPer100Km" :disabled="locked" placeholder="手填耗油率,未登记留空" /></el-form-item></el-form></template>
</OwnedBusinessOperationsDialog>
</template>
<style scoped>
.owned-business-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-4); }
.owned-business-arrival { display: grid; grid-template-columns: minmax(0,1fr) 280px; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-3); }
.owned-business-arrival small { display: block; }
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
const props = defineProps<{
dispatchNo?: string
busy?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
const activeTab = defineModel<'loading' | 'fuel'>('activeTab', { default: 'loading' })
// These are prototype presentation slots; the public file categories are not yet frozen.
const loadingPhotos = [
{ slot: 'loading-scene', label: '装车现场' },
{ slot: 'loading-list', label: '装车清单' },
{ slot: 'pre-departure-check', label: '出车前检查' },
{ slot: 'driver-license', label: '驾驶证附件' },
{ slot: 'delivery-receipt', label: '签收凭证' },
]
const fuelPhotos = [{ slot: 'fuel-start', label: '起始油耗表盘' }, { slot: 'fuel-end', label: '结束油耗表盘' }]
function close(done: () => void) {
if (props.busy) return
if (props.beforeClose) return props.beforeClose(done)
done()
}
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
<p class="owned-business-photo-reference">排车单号 {{ dispatchNo || '—' }}</p>
<slot name="state" />
<p>每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</p>
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
<el-tab-pane name="loading" label="装车完成">
<div class="owned-business-photo-grid">
<section v-for="photo in loadingPhotos" :key="photo.slot" class="owned-business-photo-slot" :aria-label="photo.label">
<h3>{{ photo.label }}</h3><slot :name="photo.slot"><p>暂无照片</p></slot>
</section>
</div>
</el-tab-pane>
<el-tab-pane name="fuel" label="油耗图片">
<div class="owned-business-photo-grid">
<section v-for="photo in fuelPhotos" :key="photo.slot" class="owned-business-photo-slot" :aria-label="photo.label">
<h3>{{ photo.label }}</h3><slot :name="photo.slot"><p>暂无照片</p></slot>
</section>
</div>
</el-tab-pane>
</el-tabs>
</AppDialog>
</template>
<style scoped>
.owned-business-photo-reference { overflow-wrap: anywhere; }
.owned-business-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
.owned-business-photo-slot { min-width: 0; padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
.owned-business-photo-slot h3 { margin: 0 0 var(--admin-space-3); font-size: inherit; }
</style>
@@ -0,0 +1,52 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import OwnedBusinessPhotosEditor from './OwnedBusinessPhotosEditor.vue'
import { ownedBusinessFixture } from './OwnedBusinessFixtures'
import type { OwnedBusinessPhoto } from '../ownedBusinessTypes'
const calls=vi.hoisted(()=>({upload:vi.fn(),bind:vi.fn(),command:vi.fn(),detail:vi.fn(),content:vi.fn(),actor:{user:{id:'actor'}},canUpload:true}))
vi.mock('../api/ownedBusiness',()=>({uploadOwnedBusinessPhoto:calls.upload,bindOwnedBusinessPhoto:calls.bind,getOwnedBusinessCommand:calls.command,getOwnedBusiness:calls.detail,getOwnedBusinessPhotoContent:calls.content}))
vi.mock('@/core/auth/auth',()=>({useAuthStore:()=>calls.actor}))
vi.mock('@/core/access/accessPreview',()=>({useAccessPreviewStore:()=>({can:()=>calls.canUpload})}))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration',()=>({useUnsavedWorkRegistration:()=>({captureBaseline:vi.fn()})}))
vi.mock('element-plus',()=>({ElMessage:{success:vi.fn()},ElMessageBox:{confirm:vi.fn()}}))
const Frame=defineComponent({props:['modelValue','busy','beforeClose'],setup(_props,{slots}){return()=>h('div',Object.values(slots).map(slot=>slot?.()))}})
const Button=defineComponent({props:['disabled'],template:'<button :disabled="disabled"><slot /></button>'})
const global={stubs:{OwnedBusinessPhotosDialog:Frame,ElButton:Button,LoadErrorState:{props:['message'],template:'<div>{{message}}<slot name="details" /></div>'},ApiErrorMeta:true}}
const photo:OwnedBusinessPhoto={slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null}
async function choose(wrapper:ReturnType<typeof mount>){const input=wrapper.find('input[type="file"]');Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['image'],'loading.png',{type:'image/png'})]});await input.trigger('change');await flushPromises()}
beforeEach(()=>{
vi.clearAllMocks();sessionStorage.clear();calls.actor.user.id='actor';calls.canUpload=true
calls.upload.mockResolvedValue(photo);calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
const current=ownedBusinessFixture();current.row.recordVersion='1';current.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
calls.bind.mockResolvedValue(current);calls.detail.mockResolvedValue(current)
})
describe('owned business per-slot photo save',()=>{
it('retains prepared file and original binding request through a failed bind without retransmitting the image',async()=>{
calls.bind.mockRejectedValueOnce(new TypeError('network interrupted'))
const wrapper=mount(OwnedBusinessPhotosEditor,{global,props:{modelValue:true,detail:ownedBusinessFixture()}});await choose(wrapper)
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('尚未确认绑定保存');expect(wrapper.emitted('saved')).toBeUndefined()
const original={...calls.bind.mock.calls[0]![2]}
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(2)
expect(calls.bind.mock.calls[1]![2]).toEqual(original);expect(original.fileId).toBe(photo.fileId);expect(original.recordVersion).toBe('0')
expect(wrapper.emitted('saved')).toHaveLength(1);wrapper.unmount()
})
it('does not offer a new upload after unknown upload or fabricate a saved photo',async()=>{
calls.upload.mockRejectedValue(new TypeError('network interrupted'))
const wrapper=mount(OwnedBusinessPhotosEditor,{global,props:{modelValue:true,detail:ownedBusinessFixture()}});await choose(wrapper)
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).not.toHaveBeenCalled();expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined();expect(wrapper.text()).toContain('禁止重复上传');wrapper.unmount()
})
it('keeps saved metadata readable without upload permission and fetches image bytes only on explicit preview',async()=>{
calls.canUpload=false
const detail=ownedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OwnedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
expect(wrapper.find('input[type="file"]').exists()).toBe(false);expect(wrapper.text()).toContain('已保存');expect(calls.content).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OwnedBusinessPhotosDialog from './OwnedBusinessPhotosDialog.vue'
import { ownedBusinessPhotoRoles, ownedBusinessVersion, type OwnedBusinessDetail, type OwnedBusinessPhoto, type OwnedBusinessPhotoSlot } from '../ownedBusinessTypes'
import { getOwnedBusiness, getOwnedBusinessPhotoContent, uploadOwnedBusinessPhoto } from '../api/ownedBusiness'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { acceptOwnedBusinessResult, newOwnedBusinessPending, ownedBusinessPendingKey, persistOwnedBusinessPending, restoreOwnedBusinessPending, recoverOwnedBusinessCommand, type OwnedBusinessPending } from './OwnedBusinessCommandRecovery'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OwnedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OwnedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null), pending = ref<OwnedBusinessPending | null>(null), prepared = ref<OwnedBusinessPhoto | null>(null), corrupted = ref(false)
const urls = ref<Partial<Record<OwnedBusinessPhotoSlot,string>>>({}), reading = ref<OwnedBusinessPhotoSlot | null>(null)
const uploadable = computed(() => !corrupted.value && access.can(permission.ownedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
useUnsavedWorkRegistration({ key: `owned-business-photos:${getCurrentInstance()?.uid}`, label: computed(() => '自有业务照片作业'), isOpen: computed(() => open.value || Boolean(pending.value) || Boolean(prepared.value)), model: reactive({ pending, prepared }), requiredPermission: computed(() => permission.ownedBusinessUpload) })
let actor = '', number = '', generation = 0
function savedPhoto(slot: OwnedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
function persist() { persistOwnedBusinessPending(actor, number, 'photos', pending.value) }
function persistPrepared() {
const key = ownedBusinessPendingKey(actor, number, 'prepared-photo')
if (prepared.value) sessionStorage.setItem(key, JSON.stringify(prepared.value)); else sessionStorage.removeItem(key)
}
watch(busy, value => emit('busy', value))
watch(open, value => {
generation++; release(); reading.value = null; error.value = null; corrupted.value = false
if (!value || !props.detail) return
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo
try {
pending.value = restoreOwnedBusinessPending(actor, number, 'photos')
const raw = sessionStorage.getItem(ownedBusinessPendingKey(actor, number, 'prepared-photo'))
prepared.value = raw ? JSON.parse(raw) as OwnedBusinessPhoto : null
if (prepared.value && (!prepared.value.fileId || !ownedBusinessPhotoRoles.some(role => role.slot === prepared.value!.slot))) throw new Error('原上传文件资料损坏,请保留现场核对')
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '照片作业恢复失败') }
}, { immediate: true })
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
onBeforeUnmount(() => { generation++; release() })
async function preview(slot: OwnedBusinessPhotoSlot) {
const photo = savedPhoto(slot), current = generation
if (!photo || reading.value) return
reading.value = slot; error.value = null
try { const blob = await getOwnedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
finally { if (current === generation) reading.value = null }
}
async function bindPrepared() {
if ((!prepared.value && !pending.value) || !props.detail || !uploadable.value) return
if (!pending.value) {
const body = { recordVersion: ownedBusinessVersion(props.detail.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: prepared.value!.fileId }
pending.value = newOwnedBusinessPending(actor, number, 'BIND_PHOTO', body, prepared.value!.slot); persist()
}
const result = await recoverOwnedBusinessCommand(pending.value, persist, ensureScope)
if (!result.detail) throw new Error('照片绑定尚未确认')
pending.value = null; persist(); prepared.value = null; persistPrepared(); emit('saved', result.detail); ElMessage.success('照片已绑定保存')
}
async function recover() {
if (busy.value || !uploadable.value) return
busy.value = true; error.value = null
try {
ensureScope()
if (pending.value?.action === 'UPLOAD_PHOTO') {
const result = await recoverOwnedBusinessCommand(pending.value, persist, ensureScope)
if (!result.photo) throw new Error('原上传文件未确认')
prepared.value = result.photo; persistPrepared(); pending.value = null; persist()
}
await bindPrepared()
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '照片结果尚未确认') }
finally { busy.value = false }
}
async function upload(slot: OwnedBusinessPhotoSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || pending.value || prepared.value || !uploadable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
busy.value = true; error.value = null
try {
ensureScope(); pending.value = newOwnedBusinessPending(actor, number, 'UPLOAD_PHOTO', null, slot); pending.value.attempted = true; persist()
const uploaded = await uploadOwnedBusinessPhoto(number, slot, file, pending.value.key)
ensureScope(); acceptOwnedBusinessResult(pending.value, uploaded); persist()
const result = await recoverOwnedBusinessCommand(pending.value, persist, ensureScope)
if (!result.photo) throw new Error('真实上传文件未确认')
prepared.value = result.photo; persistPrepared(); pending.value = null; persist(); await bindPrepared()
} catch (cause) {
if (pending.value?.action === 'UPLOAD_PHOTO' && cause instanceof ApiResponseError && ['A0400','A0403','A0404','400','403','404','422'].includes(cause.code)) { pending.value.rejected = true; persist() }
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '图片上传或绑定结果尚未确认')
} finally { busy.value = false }
}
async function discardRejected() {
if (busy.value || !pending.value?.rejected) return
busy.value = true
try { const latest = await getOwnedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', latest); error.value = null }
catch (cause) { error.value = getApiErrorDetails(cause, '当前照片版本读取失败') }
finally { busy.value = false }
}
async function beforeClose(done: () => void) {
if (busy.value) return
if (pending.value || prepared.value) {
try { await ElMessageBox.confirm('未完成的原上传或绑定记录将保留,重新打开后继续核对;关闭不会保存尚未绑定的照片。是否关闭?', '关闭照片窗口', { customClass: 'admin-density-confirm', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
}
done()
}
</script>
<template>
<OwnedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原{{ pending.action === 'UPLOAD_PHOTO' ? '上传' : '绑定' }}请求已保留,不会重新上传文件。</p><p v-if="prepared">{{ prepared.fileName }} 已上传,尚未确认绑定保存。</p><el-button v-if="pending || prepared" :loading="busy" :disabled="!uploadable" @click="recover">核对原结果并继续绑定</el-button><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝重新核对,保留已上传文件</el-button><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
<template v-for="role in ownedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="owned-business-photo" />
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
<p v-else>暂无已保存照片</p>
<label v-if="uploadable" class="owned-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy || Boolean(pending) || Boolean(prepared)" @change="upload(role.slot, $event)" /></label>
</template>
</OwnedBusinessPhotosDialog>
</template>
<style scoped>
.owned-business-photo { display: block; width: 100%; max-height: 160px; object-fit: contain; }
.owned-business-upload { display: block; margin-top: var(--admin-space-3); }
.owned-business-upload input { display: block; width: 100%; margin-top: var(--admin-space-2); }
</style>
@@ -25,7 +25,7 @@ describe('transport route address resolution', () => {
expect(wrapper.text()).toContain('目的地地址:' + addresses.ABE3)
expect(resolveRouteLocation).toHaveBeenCalledTimes(2)
const iframe = wrapper.get('iframe').element
await wrapper.get('button').trigger('click')
await wrapper.findAll('button').find(button => button.text() === '重新加载地图')!.trigger('click')
expect(wrapper.get('iframe').element).not.toBe(iframe)
expect(resolveRouteLocation).toHaveBeenCalledTimes(2)
await wrapper.setProps({ modelValue: false })
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -51,11 +52,11 @@ const tooLong = computed(() => mapUrl.value.length > 2048 || externalUrl.value.l
<AppDialog v-model="open" class="admin-density-dialog" title="查看路线"
width="min(1100px, 96vw)" align-center :show-confirm="false" cancel-text="关闭">
<section v-if="open" class="route-map" aria-label="起点至目的地路线">
<strong>{{ props.origin.label || '起点未填写' }} → {{ selected?.label || '目的地未填写' }}</strong>
<strong>{{ props.origin.label || '起点未填写' }} → {{ selected?.label || '目的地未填写' }}<InlineHelp title="路线地图说明"><p>按起点和目的地规划驾车路线,不显示实时位置或实际行驶轨迹。</p></InlineHelp></strong>
<el-select v-if="props.destinations.length > 1" v-model="destinationIndex" aria-label="选择目的仓">
<el-option v-for="(item, index) in props.destinations" :key="index" :label="item.label" :value="index" />
</el-select>
<p class="route-note">根据起点与目的地地址规划驾车路线,尚未接入 GPS,不显示车辆实时位置或实际行驶轨迹。</p>
<p v-if="loading" role="status">正在查询仓库详细地址…</p>
<LoadErrorState v-else-if="error" title="无法加载路线" :message="error.message" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-else-if="tooLong" role="status">地点信息过长,请核对起点和目的地地址后重试。</p>
@@ -64,7 +65,7 @@ const tooLong = computed(() => mapUrl.value.length > 2048 || externalUrl.value.l
referrerpolicy="no-referrer" />
</template>
<div v-if="ready && !tooLong" class="route-help">
<span>地图需联网加载。如未显示,可重新加载或在新窗口打开。</span>
<el-button @click="mapRevision++">重新加载地图</el-button>
<el-link :href="externalUrl" target="_blank" rel="noopener noreferrer" type="primary">在新窗口打开地图</el-link>
</div>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -147,10 +148,10 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
</script>
<template>
<div>
<p>询价不占用车辆空间;确认承运后预留容量。实收时核对运费,转派送时按确认报价登记应收,付款状态以财务为准。</p>
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button></template>
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button><InlineHelp title="外接预订说明"><p>询价不占位,确认承运后预留容量。实收时核对运费,转派送后按确认报价登记应收,付款状态以财务为准。</p></InlineHelp></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="!row.offerNo && planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="!row.offerNo && planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="canConfirmReceipt(row as TripBooking)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
@@ -177,9 +178,9 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
<p v-if="quoteSource">来源:{{ quoteSource.quoteNo }} · 版本 {{ quoteSource.recordVersion }} · {{ quoteSource.originCity }} → {{ quoteSource.destinationCode }} · 适用日期 {{ quoteSource.effectiveOn }}<br>来源单价:{{ quoteSource.quotedUnitPrice.toFixed(2) }} {{ quoteSource.currency }} / {{ quoteSource.priceBasis === 'PALLET' ? '托位' : '单' }} · 有效期 {{ quoteSource.validFrom }} 至 {{ quoteSource.validTo }}</p>
<p v-else>{{ quoteRef ? `已选报价 ${quoteRef.quoteNo} · 版本 ${quoteRef.recordVersion}` : '手工询价,尚未选择报价库来源。' }}</p>
<template v-if="!readOnly && canReadQuotes">
<el-button :disabled="quoteLocked || !canQueryQuotes || quoteLoading" @click="queryQuotes()">查询报价库</el-button>
<el-button :disabled="quoteLocked || !canQueryQuotes || quoteLoading" @click="queryQuotes()">查询报价库</el-button><InlineHelp title="报价来源说明"><p>来源报价与实际约定价格分别保存;确认承运时重新核对报价版本与适用日期。可选择手工询价。</p></InlineHelp>
<el-button v-if="quoteRef" :disabled="quoteLocked" @click="clearQuote">改为手工询价</el-button>
<p v-if="!canQueryQuotes">选择客户、FBA卸货站及CNY或USD后可查询;其他场景可继续填写手工价格。</p>
<p v-if="!canQueryQuotes">查询报价需客户、FBA卸货站和 CNY/USD。</p>
<el-select v-if="candidates.length" data-testid="booking-quote-candidate" :model-value="quoteRef ? sourceKey(quoteRef) : ''" :disabled="quoteLocked || quoteLoading" placeholder="选择报价后带入单价,可另行议价" @update:model-value="selectQuote">
<el-option v-for="item in candidates" :key="sourceKey(item)" :value="sourceKey(item)" :label="`${item.quoteNo} · ${item.quotedUnitPrice.toFixed(2)} ${item.currency} · ${item.validFrom} 至 ${item.validTo}`" />
</el-select>
@@ -187,7 +188,7 @@ onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTi
<div v-if="quotePages > 1"><el-button :disabled="quoteLoading || quotePage <= 1" @click="queryQuotes(quotePage - 1)">上一页</el-button> {{ quotePage }} / {{ quotePages }} <el-button :disabled="quoteLoading || quotePage >= quotePages" @click="queryQuotes(quotePage + 1)">下一页</el-button></div>
<p v-if="quoteError" role="alert">{{ quoteError.message }} <ApiErrorMeta :details="quoteError" /></p>
</template>
<p v-if="quoteRef">来源报价与实际约定价格分别保存;确认承运时会重新核对报价版本与适用日期。</p>
</section>
<p>约定运费:{{ quotedAmount == null ? '待报价' : `${quotedAmount.toFixed(2)} ${form.currency || ''}` }}。保存询价不会占位,确认承运时核对本车剩余容量。</p>
</AppDialog>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -139,7 +140,7 @@ onBeforeUnmount(() => { scopeGeneration++; generation++; historyGeneration++ })
<section aria-label="车次直接成本">
<p v-if="!allowed">暂无车次成本查看权限。</p>
<template v-else>
<p class="cost-note">按实际费用登记,金额按币种分别汇总。运营分摊用于单趟分析,不代表已付款,也不生成客户账单。</p>
<LoadErrorState v-if="error" title="车次成本加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="summary">
<p v-if="!summary.totals.length" class="cost-note">尚未登记直接成本。</p>
@@ -154,7 +155,7 @@ onBeforeUnmount(() => { scopeGeneration++; generation++; historyGeneration++ })
</div>
</template>
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="costNo" empty-text="暂无直接成本记录">
<template #toolbar-actions><el-button :disabled="loading || saving" @click="load">刷新</el-button><el-button v-if="access.can(permission.tripCostCreate)" type="primary" :disabled="opening || saving" @click="beginEdit(null)">登记成本</el-button></template>
<template #toolbar-actions><el-button :disabled="loading || saving" @click="load">刷新</el-button><el-button v-if="access.can(permission.tripCostCreate)" type="primary" :disabled="opening || saving" @click="beginEdit(null)">登记成本</el-button><InlineHelp title="直接成本说明"><p>按实际费用登记,各币种分别汇总。运营分摊用于单趟分析,不代表已付款,也不生成客户账单。</p></InlineHelp></template>
<template #actions="{ row }"><RowActions :actions="actions()" @action="$event === 'edit' ? beginEdit(row as TripCost) : showHistory(row as TripCost)" /></template>
<template #category="{ row }">{{ categories[row.details.category as CostCategory] }}</template>
<template #amount="{ row }">{{ row.details.currency }} {{ row.details.amount.toFixed(2) }}</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { onBeforeUnmount, reactive, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -86,19 +87,19 @@ watch([tab, page, size], () => { clearPreview(); if (tab.value === 'history') vo
<el-tabs v-model="tab" class="density-element-tabs density-contained-tabs"><el-tab-pane label="实测记录" name="measured">
<p v-if="uncertain">上次保存结果未确认,将按原请求重试。</p>
<el-form v-if="record" label-position="top" class="density-form record-form" :disabled="readonly || !record || loading || saving || uploading || uncertain">
<el-form-item label="本趟实际里程(mile)"><el-input-number :model-value="form.actualDistanceMiles" @update:model-value="form.actualDistanceMiles = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际英里数" /></el-form-item>
<el-form-item label="本趟实际里程(mile)"><template #label><span>本趟实际里程(mile)<InlineHelp title="实测记录说明"><p>未填写与明确零值分别保存。实测记录不改车辆累计里程、货物履约或财务费用。</p></InlineHelp></span></template><el-input-number :model-value="form.actualDistanceMiles" @update:model-value="form.actualDistanceMiles = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际英里数" /></el-form-item>
<el-form-item label="本趟实际油耗(L)"><el-input-number :model-value="form.actualFuelLitres" @update:model-value="form.actualFuelLitres = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际用油升数" /></el-form-item>
<el-form-item label="备注" class="full"><el-input :model-value="form.remark || ''" @update:model-value="form.remark = $event || null" type="textarea" maxlength="1000" placeholder="选填,补充实测情况" /></el-form-item>
<el-form-item v-if="!readonly" label="更正说明(选填)" class="full"><el-input v-model="reason" maxlength="1000" placeholder="选填,留空使用标准更正说明" /></el-form-item>
<el-form-item label="实测凭证(选填)" class="full">
<el-form-item label="实测凭证(选填)" class="full"><template #label><span>实测凭证(选填)<InlineHelp title="实测凭证说明"><p>PDF、JPG、PNG,单个不超过10MiB,最多20个;不上传也可保存。</p></InlineHelp></span></template>
<input v-if="!readonly" data-testid="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" aria-label="选择实测凭证" :disabled="!record || loading || saving || uploading || uncertain || uploadUncertain || form.evidenceFiles.length >= 20" @change="chooseFile" />
<p>PDF、JPG、PNG,单个不超过10MiB,最多20个;不上传也可保存。</p>
</el-form-item>
</el-form>
<ul v-if="form.evidenceFiles.length" class="evidence-list"><li v-for="file in form.evidenceFiles" :key="file.fileId"><el-button :disabled="previewLoading || !savedFileIds.has(file.fileId)" @click="preview(file)">{{ file.fileName }}</el-button><span v-if="!savedFileIds.has(file.fileId)">(保存后可预览)</span><el-button v-if="!readonly" :disabled="saving || uploading || uncertain" @click="removeFile(file.fileId)">移除引用</el-button></li></ul>
<p v-if="uploading">正在上传 {{ uploadName }}…</p>
<p v-if="uploadUncertain">{{ uploadName }} 的上传结果未确认,可能已上传但尚未绑定。请先核对,不要重复上传。<el-button :disabled="uploading" @click="omitUpload">暂不绑定此附件</el-button></p>
<p>未填写与明确零值分别保存。实测记录不改车辆累计里程、货物履约或财务费用。</p>
</el-tab-pane><el-tab-pane label="更正历史" name="history"><DataTable v-model:page="page" v-model:page-size="size" :data="history" :columns="columns" :total="total" :loading="loading" row-key="recordVersion" empty-text="暂无实测更正历史"><template #distance="{ row: item }">{{ item.details.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row: item }">{{ item.details.actualFuelLitres ?? '—' }}</template><template #remark="{ row: item }">{{ item.details.remark || '—' }}</template><template #evidence="{ row: item }"><el-button v-for="file in item.details.evidenceFiles" :key="file.fileId" :disabled="previewLoading" @click="preview(file)">{{ file.fileName }}</el-button><span v-if="!item.details.evidenceFiles.length">—</span></template></DataTable></el-tab-pane></el-tabs>
<section v-if="previewUrl && previewFile" aria-label="实测凭证预览"><el-button @click="clearPreview">关闭预览</el-button><iframe v-if="previewFile.contentType === 'application/pdf'" :src="previewUrl" :title="previewFile.fileName" class="evidence-preview" /><img v-else :src="previewUrl" :alt="previewFile.fileName" class="evidence-preview" /></section>
<template #footer><el-button :disabled="saving" @click="open = false">关闭</el-button><el-button v-if="!readonly && tab === 'measured'" type="primary" :loading="saving" :disabled="!record || loading || uploading || uploadUncertain" @click="save">{{ uncertain ? '按原请求重试' : '保存记录' }}</el-button></template>
@@ -68,7 +68,7 @@ onBeforeUnmount(() => { generation++ })
<el-form-item label="起运仓" required><el-input v-if="trip" :model-value="trip.warehouseName" readonly /><el-select v-else v-model="form.warehouseId" filterable placeholder="选择海外起运仓"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :value="warehouse.id" :label="`${warehouse.name}(${warehouse.code})`" /></el-select></el-form-item>
<el-form-item label="计划发车时间" required><el-date-picker v-model="form.plannedDepartureAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="司机预计任务结束" required><el-date-picker v-model="form.expectedDriverEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item>
<div class="full-width"><h3>路线站点</h3><p>先建立计划,关联货物后可在详情中匹配实际卸货站。</p>
<div class="full-width"><h3>路线站点</h3>
<div v-for="(stop, index) in stops" :key="index" class="trip-stop-row"><span>{{ index === 0 ? '起点' : `第 ${index} 站` }}</span><el-input v-model="stop.name" placeholder="站点名称" /><el-input v-model="stop.address" placeholder="地址(选填)" /><el-button :disabled="index === 0 || stops.length <= 2" @click="stops.splice(index, 1)">移除</el-button></div>
<el-button @click="stops.push({ name: '' })">添加站点</el-button>
</div>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
@@ -69,9 +70,9 @@ onBeforeUnmount(() => { generation++; searchSequence++ })
<LoadErrorState v-if="error" title="安排未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p>{{ pending ? submittedSummary?.warehouseName : warehouseName }} · 已选 {{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.length }} 张排车单:{{ (pending ? submittedSummary?.dispatchNos : dispatchNos)?.join('、') }}</p>
<p v-if="pending">提交结果尚未确定,请保留原请求重试,不另建车次。</p>
<template v-if="result"><p>已安排到 {{ result.trip.tripNo }} · {{ result.trip.stops.map(s => s.name).join(' → ') }}</p><p>多目的地请先在车次中确认行驶顺序,再核对容量发布余位。安排成功不代表已审核或发车。</p><el-button v-if="access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: result.trip.tripNo } })">查看车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:list')" type="primary" @click="publishOpen = true">核对容量并发布余位</el-button></template>
<template v-if="result"><p>已安排到 {{ result.trip.tripNo }} · {{ result.trip.stops.map(s => s.name).join(' → ') }}</p><el-button v-if="access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: result.trip.tripNo } })">查看车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage') && access.can('platform:fleet:trip:list')" type="primary" @click="publishOpen = true">核对容量并发布余位</el-button></template>
<el-form v-else label-position="top" class="density-form preparation-form" :disabled="busy || !!pending">
<el-form-item label="安排方式"><el-radio-group v-model="form.mode"><el-radio value="existing">加入已有车次</el-radio><el-radio v-if="canCreate" value="new">新建车次</el-radio></el-radio-group></el-form-item>
<el-form-item label="安排方式"><template #label><span>安排方式<InlineHelp title="安排车次说明"><p>多目的地请先在车次中确认行驶顺序,再核对容量发布余位。安排成功不代表已审核或发车。</p></InlineHelp></span></template><el-radio-group v-model="form.mode"><el-radio value="existing">加入已有车次</el-radio><el-radio v-if="canCreate" value="new">新建车次</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="form.mode === 'existing'" label="本仓计划中车次" required><el-select v-model="form.tripNo" filterable remote :remote-method="(k: string) => search('trip', k)" :loading="loading" placeholder="按车次、车牌或司机搜索" @focus="search('trip')"><el-option v-for="t in trips" :key="t.tripNo" :value="t.tripNo" :label="`${t.tripNo} · ${t.vehiclePlate || '待分配车辆'} · ${t.stops.map(s => s.name).join(' → ')}`" /></el-select></el-form-item>
<template v-else>
<el-form-item label="计划发车时间" required><el-date-picker v-model="form.departure" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -117,17 +118,17 @@ onBeforeUnmount(() => { editSession++ })
<el-form-item v-if="costRecord" :label="recordType === 'REPAIR' ? '维修项目' : '救援项目'" required><el-input v-model="form.incident.item" maxlength="256" placeholder="请输入具体项目" /></el-form-item>
<el-form-item v-if="costRecord" :label="recordType === 'REPAIR' ? '维修地点' : '救援地点'" required><el-input v-model="form.incident.place" maxlength="512" placeholder="请输入实际地点" /></el-form-item>
<el-form-item label="事件描述" class="full-width" required><el-input v-model="form.incident.description" type="textarea" :rows="3" maxlength="2000" placeholder="请说明事件及实际处理情况" /></el-form-item>
<el-form-item v-if="costRecord" label="费用(申报金额)"><el-input-number v-model="form.incident.reportedCost" :min="0" :precision="2" placeholder="未确定时留空" /></el-form-item>
<el-form-item v-if="costRecord" label="费用(申报金额)"><template #label><span>费用(申报金额)<InlineHelp title="申报费用说明"><p>此处保存申报费用;保存后可从记录行“财务”核对并提交公司成本。处理完成不会标记为已入账或已付款。</p></InlineHelp></span></template><el-input-number v-model="form.incident.reportedCost" :min="0" :precision="2" placeholder="未确定时留空" /></el-form-item>
<el-form-item v-if="costRecord" label="币种" :required="form.incident.reportedCost != null"><el-select v-model="form.incident.currency" filterable allow-create default-first-option clearable placeholder="请选择或输入三位币种"><el-option v-for="currency in ['USD','CNY','CAD','EUR','GBP','MXN']" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item>
<el-form-item label="处理状态"><el-select v-model="form.incident.processingStatus"><el-option v-for="(label, key) in processingLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
<p v-if="costRecord" class="care-hint full-width">此处保存申报费用;保存后可从记录行“财务”核对并提交公司成本。处理完成不会标记为已入账或已付款。</p>
</template>
<el-form-item v-if="meterRecord" label="核验状态"><el-select v-model="form.verificationStatus"><el-option label="待核验" value="PENDING" /><el-option label="已核验" value="VERIFIED" /></el-select></el-form-item>
<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 v-if="current && canEdit" label="更正说明(选填)" class="full-width"><el-input v-model="reason" maxlength="1000" placeholder="需要补充时填写" /></el-form-item>
</el-form>
<label v-if="meterRecord && access.can(permission.vehicleRecordUpload)" class="file-picker">附件图片(JPG / PNG,每张最大 10MiB)<input ref="input" type="file" accept="image/png,image/jpeg" multiple :disabled="saving || !!pendingCreate" @change="selectFiles" /></label>
<p v-if="meterRecord" class="care-hint">已保存 {{ current?.files.length || 0 }} 张,待上传 {{ selected.length }} 张。记录使用所选单位,不自动修改车辆公里数。</p>
<p v-if="meterRecord" class="care-hint">已保存 {{ current?.files.length || 0 }} 张,待上传 {{ selected.length }} 张。</p>
</AppDialog>
</template>
<style scoped>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@lucide/vue'
@@ -193,7 +194,7 @@ onBeforeUnmount(() => { request++; operationEpoch++; supplierRequest++; disposed
<VehicleCareEditor v-model="editorOpen" :vehicle-id="vehicleId" :plate="plate" :record-type="recordType" :record="editing" @saved="saved" />
<AppDialog v-model="financeOpen" title="公司车辆成本" width="620px" class="admin-density-dialog" confirm-text="提交财务"
:show-confirm="canSubmitFinance || !!pendingFinance" :confirm-loading="financeSaving" :confirm-disabled="ledgerBusy || !access.can(vehicleCareFinanceSubmitPermission)" @confirm="submitFinance">
<p>按公司成本登记,不生成客户应收。财务接收、入账和付款分别确认。</p>
<p v-if="financeTarget">{{ financeTarget.recordNo }} · {{ financeTarget.vehiclePlate }} · 申报费用 {{ financeTarget.details.incident?.currency || '—' }} {{ financeTarget.details.incident?.reportedCost?.toFixed(2) ?? '—' }}</p>
<p v-if="financeStatus">{{ vehicleCareFinanceLabels[financeStatus.status] }}</p>
<template v-if="financeStatus?.finance">
@@ -204,7 +205,7 @@ onBeforeUnmount(() => { request++; operationEpoch++; supplierRequest++; disposed
<p v-if="pendingFinance">提交结果待核对;重试使用原请求,不重复登记。</p>
<el-form v-if="canSubmitFinance || pendingFinance" label-position="top" :disabled="financeSaving || !!pendingFinance">
<el-form-item label="供应商" required><el-select v-model="supplierId" filterable remote :remote-method="searchSuppliers" :loading="suppliersLoading" placeholder="搜索启用的供应商" @focus="searchSuppliers()"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="费用业务日期" required><el-date-picker v-model="businessDate" type="date" value-format="YYYY-MM-DD" placeholder="用于确定原币折算月份" /></el-form-item>
<el-form-item label="费用业务日期" required><template #label><span>费用业务日期<InlineHelp title="公司成本说明"><p>按公司成本登记,不生成客户应收。财务接收、入账和付款分别确认。</p></InlineHelp></span></template><el-date-picker v-model="businessDate" type="date" value-format="YYYY-MM-DD" placeholder="用于确定原币折算月份" /></el-form-item>
</el-form>
<p v-if="access.can(vehicleCareFinanceSubmitPermission) && !access.can('platform:supplier:list')">提交公司成本还需供应商查询权限。</p>
<VehicleCareFinanceLedger v-if="ledgerCostNo" :cost-no="ledgerCostNo" :active="financeOpen" @changed="loadFinance" @busy="ledgerBusy = $event" />
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessText, outsourcedBusinessPhotoRoles } from './outsourcedBusinessTypes'
describe('outsourced business decimal and version boundary', () => {
it('supports exactly the five approved loading photo slots', () => {
expect(outsourcedBusinessPhotoRoles.map(role => role.slot)).toEqual(['LOADING_SCENE','LOADING_LIST','PRE_DEPARTURE_CHECK','DRIVER_LICENSE','RECEIPT'])
expect(outsourcedBusinessPhotoRoles.every(role => role.category === 'LOADING')).toBe(true)
})
it('preserves the maximum decimal money text and distinguishes empty from explicit zero', () => {
expect(outsourcedBusinessDecimal('9999999999999999.99', 2, 18)).toBe('9999999999999999.99')
expect(outsourcedBusinessDecimal('0.00', 2, 18)).toBe('0.00'); expect(outsourcedBusinessDecimal(' ', 2, 18)).toBeNull()
expect(() => outsourcedBusinessDecimal('10000000000000000.00', 2, 18)).toThrow()
for (const value of ['-1','1e3','1.001','01','NaN','Infinity']) expect(() => outsourcedBusinessDecimal(value, 2, 18)).toThrow()
expect(outsourcedBusinessDecimal('123.456', 3, 12)).toBe('123.456')
})
it('keeps version zero and large string identities without accepting unsafe numeric versions', () => {
expect(outsourcedBusinessVersion(0)).toBe('0'); expect(outsourcedBusinessVersion('9007199254740993')).toBe('9007199254740993')
for (const value of [undefined,null,-1,0.5,Number.MAX_SAFE_INTEGER + 1,'','1e3']) expect(() => outsourcedBusinessVersion(value)).toThrow()
expect(outsourcedBusinessText(null)).toBe('—'); expect(outsourcedBusinessText(0)).toBe('0')
})
})
@@ -0,0 +1,45 @@
import type { DispatchDetail } from '@/modules/overseas-warehouse'
export type OutsourcedBusinessStage = 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
export type OutsourcedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEPARTURE_CHECK' | 'DRIVER_LICENSE' | 'RECEIPT'
export interface OutsourcedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
export interface OutsourcedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
export interface OutsourcedBusinessOperations { money: OutsourcedBusinessMoney | null; arrivals: OutsourcedBusinessArrival[] }
export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OutsourcedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
export interface OutsourcedBusinessRow {
dispatchNo: string; stage: OutsourcedBusinessStage; plannedDepartureAt: string | null; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
origin: { warehouseId: string; warehouseCode: string; warehouseName: string }
vehicle: { vehicleId: string | null; plate: string | null; ownership: 'DISPATCHED'; carrierName: string | null }; driver: { name: string | null; phone: string | null }
destinations: OutsourcedBusinessDestination[]; actualPalletCount: number | null; operations: OutsourcedBusinessOperations; unitPrice: string | null; recordVersion: string; dispatchRecordVersion: string
photoSummary: { loadingCount: number }; actions: { edit: OutsourcedBusinessGate; upload: OutsourcedBusinessGate }; sourceIssues: string[]
}
export interface OutsourcedBusinessGate { allowed: boolean; blockReason: string | null }
export interface OutsourcedBusinessQuery { page?: number; size?: number; plannedDepartureFrom?: string; plannedDepartureTo?: string; keyword?: string; stage?: OutsourcedBusinessStage; warehouseId?: string }
export interface OutsourcedBusinessSummary { dispatchCount: number; afterLoadingCount: number; deliveringCount: number; deliveredCount: number }
export interface OutsourcedBusinessPage<T> { records: T[]; page: number; size: number; total: number }
export interface OutsourcedBusinessList extends OutsourcedBusinessPage<OutsourcedBusinessRow> { summary: OutsourcedBusinessSummary }
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] }
export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null }
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] }
export const outsourcedBusinessStages: Record<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' },
]
export function outsourcedBusinessVersion(value: unknown): string {
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
return String(value)
}
export function outsourcedBusinessDecimal(value: string, scale: number, precision: number): string | null {
const text = value.trim()
if (!text) return null
if (!new RegExp(`^(?:0|[1-9]\\d*)(?:\\.\\d{1,${scale}})?$`).test(text) || text.split('.')[0]!.length > precision - scale) throw new Error(`请输入不超过${scale}位小数的有效非负数`)
return text
}
export function outsourcedBusinessText(value: unknown): string { return value == null || value === '' ? '—' : String(value) }
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { ownedBusinessDecimal, ownedBusinessVersion, ownedBusinessText } from './ownedBusinessTypes'
describe('owned business decimal and version boundary', () => {
it('preserves the maximum decimal money text and distinguishes empty from explicit zero', () => {
expect(ownedBusinessDecimal('9999999999999999.99', 2, 18)).toBe('9999999999999999.99')
expect(ownedBusinessDecimal('0.00', 2, 18)).toBe('0.00'); expect(ownedBusinessDecimal(' ', 2, 18)).toBeNull()
expect(() => ownedBusinessDecimal('10000000000000000.00', 2, 18)).toThrow()
for (const value of ['-1','1e3','1.001','01','NaN','Infinity']) expect(() => ownedBusinessDecimal(value, 2, 18)).toThrow()
expect(ownedBusinessDecimal('123.456', 3, 12)).toBe('123.456')
})
it('keeps version zero and large string identities without accepting unsafe numeric versions', () => {
expect(ownedBusinessVersion(0)).toBe('0'); expect(ownedBusinessVersion('9007199254740993')).toBe('9007199254740993')
for (const value of [undefined,null,-1,0.5,Number.MAX_SAFE_INTEGER + 1,'','1e3']) expect(() => ownedBusinessVersion(value)).toThrow()
expect(ownedBusinessText(null)).toBe('—'); expect(ownedBusinessText(0)).toBe('0')
})
})
@@ -0,0 +1,45 @@
import type { DispatchDetail } from '@/modules/overseas-warehouse'
export type OwnedBusinessStage = 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
export type OwnedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEPARTURE_CHECK' | 'DRIVER_LICENSE' | 'RECEIPT' | 'FUEL_START' | 'FUEL_END'
export interface OwnedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
export interface OwnedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
export interface OwnedBusinessOperations { money: OwnedBusinessMoney | null; actualDistanceMiles: string | null; fuelConsumptionLitresPer100Km: string | null; arrivals: OwnedBusinessArrival[] }
export interface OwnedBusinessPhoto { slot: OwnedBusinessPhotoSlot; category: 'LOADING' | 'FUEL'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OwnedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
export interface OwnedBusinessRow {
dispatchNo: string; stage: OwnedBusinessStage; plannedDepartureAt: string; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
origin: { warehouseId: string; warehouseCode: string; warehouseName: string }
vehicle: { vehicleId: string; plate: string; ownership: 'OWNED' }; driver: { name: string | null; phone: string | null }
destinations: OwnedBusinessDestination[]; actualPalletCount: number | null; operations: OwnedBusinessOperations; unitPrice: string | null; recordVersion: string; dispatchRecordVersion: string
photoSummary: { loadingCount: number; fuelCount: number }; actions: { edit: OwnedBusinessGate; upload: OwnedBusinessGate }; sourceIssues: string[]
}
export interface OwnedBusinessGate { allowed: boolean; blockReason: string | null }
export interface OwnedBusinessQuery { page?: number; size?: number; plannedDepartureFrom?: string; plannedDepartureTo?: string; keyword?: string; stage?: OwnedBusinessStage; warehouseId?: string }
export interface OwnedBusinessSummary { dispatchCount: number; afterLoadingCount: number; deliveringCount: number; deliveredCount: number }
export interface OwnedBusinessPage<T> { records: T[]; page: number; size: number; total: number }
export interface OwnedBusinessList extends OwnedBusinessPage<OwnedBusinessRow> { summary: OwnedBusinessSummary }
export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OwnedBusinessPhoto[] }
export interface OwnedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OwnedBusinessOperations; reason: string | null }
export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
export interface OwnedBusinessCommand { dispatchNo: string; action: OwnedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OwnedBusinessDetail | OwnedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
export interface OwnedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OwnedBusinessOperations | null; photos: OwnedBusinessPhoto[] }
export const ownedBusinessStages: Record<OwnedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
export const ownedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
export const ownedBusinessPhotoRoles: { slot: OwnedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' | 'FUEL' }[] = [
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' }, { slot: 'FUEL_START', viewSlot: 'fuel-start', label: '起始油耗表盘', category: 'FUEL' }, { slot: 'FUEL_END', viewSlot: 'fuel-end', label: '结束油耗表盘', category: 'FUEL' },
]
export function ownedBusinessVersion(value: unknown): string {
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
return String(value)
}
export function ownedBusinessDecimal(value: string, scale: number, precision: number): string | null {
const text = value.trim()
if (!text) return null
if (!new RegExp(`^(?:0|[1-9]\\d*)(?:\\.\\d{1,${scale}})?$`).test(text) || text.split('.')[0]!.length > precision - scale) throw new Error(`请输入不超过${scale}位小数的有效非负数`)
return text
}
export function ownedBusinessText(value: unknown): string { return value == null || value === '' ? '—' : String(value) }
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import OutsourcedBusinessDetailLayout from '../components/OutsourcedBusinessDetailLayout.vue'
import OutsourcedBusinessOperationsEditor from '../components/OutsourcedBusinessOperationsEditor.vue'
import OutsourcedBusinessPhotosEditor from '../components/OutsourcedBusinessPhotosEditor.vue'
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, listOutsourcedBusinessHistory } from '../api/outsourcedBusiness'
import { outsourcedBusinessActionLabels, outsourcedBusinessStages, outsourcedBusinessText as text, outsourcedBusinessPhotoRoles, type OutsourcedBusinessDetail, type OutsourcedBusinessHistory, type OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const dispatchNo = computed(() => typeof route.params.dispatchNo === 'string' ? route.params.dispatchNo : '')
const detail = ref<OutsourcedBusinessDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), activeTab = ref<'task'|'route'|'history'>('task')
const editorOpen = ref(false), photosOpen = ref(false), busy = ref(false)
const history = ref<OutsourcedBusinessHistory[]>([]), historyPage = ref(1), historySize = ref(20), historyTotal = ref(0), historyLoading = ref(false), historyError = ref<ApiErrorDetails | null>(null), previewError = ref<ApiErrorDetails | null>(null)
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
const previewUrls = new Set<string>()
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
const taskFields = computed(() => {
const row = detail.value?.row
return [['计划发车', time(row?.plannedDepartureAt)], ['装车完成', time(row?.loadingCompletedAt)], ['实际发车', time(row?.departedAt)], ['派送完成', time(row?.deliveryCompletedAt)], ['车牌', text(row?.vehicle.plate)], ['司机', text(row?.driver.name)], ['司机电话', text(row?.driver.phone)], ['提货仓库', text(row?.origin.warehouseName)], ['实际物理托数', text(row?.actualPalletCount)], ['整车承运金额', row?.operations.money ? `${row.operations.money.currency} ${row.operations.money.amount}` : '未登记'], ['单价', row?.unitPrice == null ? '—' : `${row.operations.money?.currency || ''} ${row.unitPrice}`], ['承运商', text(row?.vehicle.carrierName)]]
})
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
function destinationLabel(key: string) { const value = detail.value?.row.destinations.find(destination => destination.destinationKey === key); return value?.warehouseCode || value?.warehouseName || value?.address || '—' }
function photoLabel(photo: OutsourcedBusinessPhoto) { return outsourcedBusinessPhotoRoles.find(role => role.slot === photo.slot)?.label || '照片' }
async function load() {
if (!active) return
const request = ++generation, no = dispatchNo.value; loading.value = true; error.value = null
try { if (!no || !access.can(permission.outsourcedBusinessList)) throw new Error('缺少排车业务号或查看权限'); const value = await getOutsourcedBusiness(no); if (active && request === generation) { if (value.row.dispatchNo !== no) throw new Error('当前运营资料与排车业务号不一致'); detail.value = value; if (activeTab.value === 'history') void loadHistory() } }
catch (cause) { if (active && request === generation) { detail.value = null; error.value = getApiErrorDetails(cause, '外配业务详情读取失败') } }
finally { if (request === generation) loading.value = false }
}
async function loadHistory() {
if (!active || activeTab.value !== 'history' || !detail.value) return
const request = ++historyGeneration, no = dispatchNo.value; historyLoading.value = true; historyError.value = null
try { const value = await listOutsourcedBusinessHistory(no, historyPage.value, historySize.value); if (active && request === historyGeneration && no === dispatchNo.value) { history.value = value.records; historyTotal.value = value.total } }
catch (cause) { if (request === historyGeneration) historyError.value = getApiErrorDetails(cause, '操作记录读取失败') }
finally { if (request === historyGeneration) historyLoading.value = false }
}
async function preview(photo: OutsourcedBusinessPhoto, revision: string | null) {
const no = dispatchNo.value, request = generation; previewError.value = null
if (revision == null) { previewError.value = { message: '历史照片缺少运营资料版本,请重新读取' }; return }
const windowRef = window.open('', '_blank')
if (!windowRef) { previewError.value = { message: '请允许本站打开图片预览窗口' }; return }
try { const blob = await getOutsourcedBusinessPhotoContent(no, photo, revision); if (!active || request !== generation || no !== dispatchNo.value) { windowRef.close(); return }; const url = URL.createObjectURL(blob); previewUrls.add(url); windowRef.location.href = url }
catch (cause) { windowRef.close(); previewError.value = getApiErrorDetails(cause, '历史照片读取失败') }
}
function saved(value: OutsourcedBusinessDetail) { if (value.row.dispatchNo !== dispatchNo.value) return; detail.value = value; if (activeTab.value === 'history') void loadHistory() }
watch([activeTab,historyPage,historySize], () => { if (active) void loadHistory() })
watch(dispatchNo, () => { generation++; historyGeneration++; detail.value = null; history.value = []; historyTotal.value = 0; activeTab.value = 'task'; editorOpen.value = false; photosOpen.value = false; if (active) void load() })
onMounted(load)
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
onDeactivated(() => { active = false; generation++; historyGeneration++; loading.value = false; historyLoading.value = false; editorOpen.value = false; photosOpen.value = false })
onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previewUrls.forEach(url => URL.revokeObjectURL(url)) })
</script>
<template>
<OutsourcedBusinessDetailLayout v-model:active-tab="activeTab" :dispatch-no="detail?.row.dispatchNo || dispatchNo" :status-text="detail ? outsourcedBusinessStages[detail.row.stage] : ''" :loading="loading" @back="router.push('/overseas-master-data/outsourced-business')">
<template #actions><el-button v-if="detail && access.can(permission.outsourcedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.outsourcedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
<template #state><LoadErrorState v-if="error" title="外配业务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
<template #task><dl v-if="detail" class="density-fields outsourced-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="outsourced-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable><section class="outsourced-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ outsourcedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车承运金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></template>
</OutsourcedBusinessDetailLayout>
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
<OutsourcedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
</template>
<style scoped>
.outsourced-business-fields { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); padding: var(--admin-space-4); }
.outsourced-business-fields > div { min-width: 0; overflow-wrap: anywhere; }
.outsourced-business-route { padding: var(--admin-space-4); border-bottom: 1px solid var(--admin-table-border); }
.outsourced-business-route h3 { font-size: inherit; margin: 0 0 var(--admin-space-3); }
</style>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import QueryToolbar, { type QueryField, type QueryModel, type QueryOption } from '@/shared/components/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OutsourcedBusinessListLayout from '../components/OutsourcedBusinessListLayout.vue'
import OutsourcedBusinessOperationsEditor from '../components/OutsourcedBusinessOperationsEditor.vue'
import OutsourcedBusinessPhotosEditor from '../components/OutsourcedBusinessPhotosEditor.vue'
import { getOutsourcedBusiness, listOutsourcedBusiness } from '../api/outsourcedBusiness'
import { outsourcedBusinessStages, outsourcedBusinessText as text, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessRow, type OutsourcedBusinessStage } from '../outsourcedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
const router = useRouter(), access = useAccessPreviewStore()
const result = ref<OutsourcedBusinessList | null>(null), page = ref(1), size = ref(20), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ plannedRange: [], keyword: '', stage: '', warehouseId: '' })
const warehouseOptions = ref<QueryOption[]>([]), warehouseError = ref<ApiErrorDetails | null>(null)
const detail = ref<OutsourcedBusinessDetail | null>(null), editorOpen = ref(false), photosOpen = ref(false), opening = ref(false), mutating = ref(false)
let applied: Omit<OutsourcedBusinessQuery,'page'|'size'> = {}, generation = 0, openGeneration = 0, active = true, initialActivation = true
const fields = computed<QueryField[]>(() => [
{ key: 'plannedRange', type: 'daterange', label: '计划发车日期', width: 270, props: { valueFormat: 'YYYY-MM-DD', 'aria-label': '计划发车日期范围' } },
{ key: 'keyword', type: 'input', label: '关键词', width: 230, placeholder: '排车单号 / 车牌 / 目的仓', props: { maxlength: 128 } },
{ key: 'stage', type: 'select', label: '阶段', width: 140, options: Object.entries(outsourcedBusinessStages).map(([value,label]) => ({ value,label })) },
{ key: 'warehouseId', type: 'select', label: '提货仓库', width: 180, options: warehouseOptions.value, props: { disabled: !access.can('platform:warehouse:list') } },
])
const summary = computed(() => [
['排车数', result.value?.summary.dispatchCount], ['装车完成', result.value?.summary.afterLoadingCount], ['派送中', result.value?.summary.deliveringCount], ['派送完成', result.value?.summary.deliveredCount],
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
{ key: 'carrier', label: '承运商', minWidth: 150, slotName: 'carrier' },
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
{ key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 285, slotName: 'destinations' },
{ prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
{ key: 'money', label: '整车承运金额 / 单价', minWidth: 170, slotName: 'money' },
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
{ key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' },
]
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
function rowActions(row: OutsourcedBusinessRow): RowActionItem[] {
return [
{ key: 'detail', label: '查看明细' },
{ key: 'operations', label: '登记运营信息', visible: access.can(permission.outsourcedBusinessEdit), disabled: opening.value || mutating.value || !row.actions.edit.allowed },
{ key: 'photos', label: access.can(permission.outsourcedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片', disabled: opening.value || mutating.value },
]
}
function act(row: OutsourcedBusinessRow, key: string) {
if (key === 'detail') void router.push(`/overseas-master-data/outsourced-business/${encodeURIComponent(row.dispatchNo)}`)
else if (key === 'operations' || key === 'photos') void openAction(row, key)
}
async function load() {
if (!active) return
const request = ++generation
loading.value = true; error.value = null
try {
if (!access.can(permission.outsourcedBusinessList)) throw new Error('没有外配业务查看权限')
const value = await listOutsourcedBusiness({ ...applied, page: page.value, size: size.value })
if (active && request === generation) result.value = value
} catch (cause) { if (active && request === generation) { result.value = null; error.value = getApiErrorDetails(cause, '外配业务读取失败') } }
finally { if (request === generation) loading.value = false }
}
function search() {
try {
const range = filters.value.plannedRange
const dates = Array.isArray(range) ? range : []
if (dates.length && (dates.length !== 2 || !dayjs(String(dates[0])).isValid() || !dayjs(String(dates[1])).isValid() || dayjs(String(dates[1])).isBefore(dayjs(String(dates[0]))))) throw new Error('请选择有效的计划发车日期范围')
applied = { keyword: String(filters.value.keyword || '').trim() || undefined, stage: (filters.value.stage || undefined) as OutsourcedBusinessStage | undefined, warehouseId: String(filters.value.warehouseId || '') || undefined,
plannedDepartureFrom: dates.length ? dayjs(String(dates[0])).startOf('day').toISOString() : undefined, plannedDepartureTo: dates.length ? dayjs(String(dates[1])).add(1,'day').startOf('day').toISOString() : undefined }
if (page.value !== 1) page.value = 1; else void load()
} catch (cause) { error.value = getApiErrorDetails(cause, '筛选条件无效') }
}
async function loadWarehouses() {
if (!access.can('platform:warehouse:list')) return
try { const values = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }); warehouseOptions.value = values.map(value => ({ value: String(value.id), label: value.warehouseName })) }
catch (cause) { warehouseError.value = getApiErrorDetails(cause, '仓库目录读取失败,可继续使用其他筛选') }
}
async function openAction(row: OutsourcedBusinessRow, kind: 'operations' | 'photos') {
if (opening.value || mutating.value) return
const code = kind === 'operations' ? permission.outsourcedBusinessEdit : permission.outsourcedBusinessList
if (!access.can(code) || kind === 'operations' && !row.actions.edit.allowed) return
const request = ++openGeneration; opening.value = true; error.value = null
try { const value = await getOutsourcedBusiness(row.dispatchNo); if (!active || request !== openGeneration) return; detail.value = value; if (kind === 'operations') editorOpen.value = true; else photosOpen.value = true }
catch (cause) { if (request === openGeneration) error.value = getApiErrorDetails(cause, '当前运营资料读取失败') }
finally { if (request === openGeneration) opening.value = false }
}
function saved(value: OutsourcedBusinessDetail) { detail.value = value; void load() }
watch([page,size], () => { if (active) void load() })
onMounted(() => { void load(); void loadWarehouses() })
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
onDeactivated(() => { active = false; generation++; openGeneration++; loading.value = false; opening.value = false; editorOpen.value = false; photosOpen.value = false })
onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
</script>
<template>
<OutsourcedBusinessListLayout>
<template #statistics><section class="outsourced-business-statistics" aria-label="完整筛选范围外配业务统计" :aria-busy="loading"><div v-for="item in summary" :key="String(item[0])"><span>{{ item[0] }}</span><strong>{{ item[1] ?? '—' }}</strong></div></section></template>
<DataTable appearance="integrated" :stripe="false" v-model:page="page" v-model:page-size="size" :data="result?.records || []" :columns="columns" :total="result?.total || 0" :loading="loading" row-key="dispatchNo" preference-key="outsourced-business-table" empty-text="当前筛选范围暂无已装车的外配车辆排车单">
<template #toolbar-start><h1>外配业务</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="外配业务筛选" preference-key="outsourced-business-query" :loading="loading" @search="search" /><small>日期和时间按浏览器本地时区显示;统计覆盖同一完整筛选范围。</small></template>
<template #notices><LoadErrorState v-if="error" title="外配业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
<template #actions="{ row }"><RowActions :actions="rowActions(row as OutsourcedBusinessRow)" text-actions @action="act(row as OutsourcedBusinessRow, $event)" /></template>
<template #carrier="{ row }">{{ text(row.vehicle.carrierName) }}</template>
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="outsourced-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="outsourced-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约 {{ time(appointment.scheduledAt) }}</div><div>实到 {{ time(destination.actualArrivedAt) }}</div></div></template>
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}<br>单价 {{ row.unitPrice ?? '—' }}</template><span v-else>未登记</span></template>
<template #plannedDeparture="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #loaded="{ row }">{{ time(row.loadingCompletedAt) }}</template><template #stage="{ row }">{{ outsourcedBusinessStages[row.stage as OutsourcedBusinessStage] }}</template>
</DataTable>
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
<OutsourcedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
</OutsourcedBusinessListLayout>
</template>
<style scoped>
.outsourced-business-statistics { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); background: white; border: 1px solid var(--admin-table-border); }
.outsourced-business-statistics > div { padding: var(--admin-space-4) var(--admin-space-5); border-right: 1px solid var(--admin-table-border); }
.outsourced-business-statistics > div:last-child { border-right: 0; }
.outsourced-business-statistics strong { display: block; margin-top: var(--admin-space-2); font-size: var(--admin-font-title); }
.outsourced-business-destination + .outsourced-business-destination { margin-top: var(--admin-space-3); border-top: 1px solid var(--admin-table-border); padding-top: var(--admin-space-3); }
.outsourced-business-destination small { display: block; }
</style>
@@ -0,0 +1,96 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import dayjs from 'dayjs'
import OutsourcedBusinessView from './OutsourcedBusinessView.vue'
import OutsourcedBusinessDetailView from './OutsourcedBusinessDetailView.vue'
import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures'
import { overseasMasterDataPermissions as permission } from '../permissions'
const calls = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), history: vi.fn(), content: vi.fn(), warehouses: vi.fn(), push: vi.fn(), allowed: new Set<string>() }))
vi.mock('../api/outsourcedBusiness', () => ({ listOutsourcedBusiness: calls.list, getOutsourcedBusiness: calls.detail, listOutsourcedBusinessHistory: calls.history, getOutsourcedBusinessPhotoContent: calls.content }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: calls.warehouses }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => calls.allowed.has(code) }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: calls.push }), useRoute: () => ({ params: { dispatchNo: 'DS-OUT-1' } }) }))
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
const Button = defineComponent({ props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' })
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
const Pane = defineComponent({ props: ['name','label'], template: '<article><slot /></article>' })
const Query = defineComponent({ props: ['modelValue','fields'], emits: ['update:modelValue','search'], template: '<div />' })
const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([,slot]) => slot?.({row}))))]) } })
const OperationsEditor = defineComponent({ name: 'OutsourcedBusinessOperationsEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
const PhotosEditor = defineComponent({ name: 'OutsourcedBusinessPhotosEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, DataTable: Table, QueryToolbar: Query, ElTabs: Tabs, ElTabPane: Pane, ElButton: Button, OutsourcedBusinessOperationsEditor: OperationsEditor, OutsourcedBusinessPhotosEditor: PhotosEditor, LoadErrorState: { props:['message'], template:'<div>{{message}}<slot name="details" /></div>' }, ApiErrorMeta: true }, directives: { loading: () => {} } }
beforeEach(() => {
vi.clearAllMocks(); calls.allowed = new Set(Object.values(permission)); calls.allowed.add('platform:warehouse:list')
const detail = outsourcedBusinessFixture()
calls.list.mockResolvedValue({ records:[detail.row],page:1,size:20,total:41,summary:{dispatchCount:41,afterLoadingCount:30,deliveringCount:10,deliveredCount:1} })
calls.detail.mockResolvedValue(detail); calls.history.mockResolvedValue({records:[],page:1,size:20,total:0}); calls.warehouses.mockResolvedValue([])
})
describe('outsourced business views', () => {
it('uses one list request for all statistics and never hydrates each row', async () => {
const wrapper = mount(OutsourcedBusinessView,{global}); await flushPromises()
expect(calls.list).toHaveBeenCalledTimes(1); expect(calls.detail).not.toHaveBeenCalled(); expect(calls.content).not.toHaveBeenCalled()
expect(wrapper.find('[aria-label="完整筛选范围外配业务统计"]').text()).toContain('排车数41')
expect(wrapper.text()).not.toContain('今日排车')
expect(wrapper.findComponent(Table).props('columns')[0].key).toBe('actions')
expect(wrapper.findComponent(Table).props('total')).toBe(41)
expect(wrapper.text()).toContain('测试外配承运商'); expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('自有成本')
wrapper.unmount()
})
it('applies planned date half-open boundaries only on query and keeps all stage counts from the response', async () => {
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises();const query=wrapper.findComponent(Query)
query.vm.$emit('update:modelValue',{plannedRange:['2026-10-20','2026-10-21'],keyword:' FBA-1 ',stage:'AFTER_LOADING',warehouseId:'1'});await wrapper.vm.$nextTick()
expect(calls.list).toHaveBeenCalledTimes(1)
query.vm.$emit('search');await flushPromises()
expect(calls.list).toHaveBeenLastCalledWith({page:1,size:20,keyword:'FBA-1',stage:'AFTER_LOADING',warehouseId:'1',plannedDepartureFrom:dayjs('2026-10-20').startOf('day').toISOString(),plannedDepartureTo:dayjs('2026-10-21').add(1,'day').startOf('day').toISOString()})
expect(wrapper.find('[aria-label="完整筛选范围外配业务统计"]').text()).toContain('装车完成30')
wrapper.unmount()
})
it('opens the operations modal on demand with auxiliary zero version instead of the dispatch version', async () => {
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises()
expect(calls.detail).not.toHaveBeenCalled();expect(wrapper.findComponent(OperationsEditor).props('modelValue')).toBe(false)
await wrapper.findAll('button').find(button=>button.text()==='登记运营信息')!.trigger('click');await flushPromises()
expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OUT-1')
const editor=wrapper.findComponent(OperationsEditor)
expect(editor.props('modelValue')).toBe(true);expect(wrapper.findComponent(PhotosEditor).props('modelValue')).toBe(false)
expect(editor.props('detail').row.recordVersion).toBe('0');expect(editor.props('detail').dispatch.recordVersion).toBe('8')
wrapper.unmount()
})
it('keeps a list-only user read-only while retaining detail and authorized photo reads', async () => {
calls.allowed=new Set([permission.outsourcedBusinessList])
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises()
expect(wrapper.text()).not.toContain('登记运营信息');expect(wrapper.text()).toContain('查看照片');expect(calls.warehouses).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows a temporary external vehicle and a missing carrier without requiring a master ID', async () => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle = { vehicleId:null,plate:'TEMP-1',ownership:'DISPATCHED',carrierName:null }
calls.detail.mockResolvedValue(detail)
const wrapper = mount(OutsourcedBusinessDetailView,{global}); await flushPromises()
expect(wrapper.text()).toContain('TEMP-1'); expect(wrapper.text()).toContain('承运商'); expect(wrapper.text()).toContain('—')
expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OUT-1'); expect(calls.content).not.toHaveBeenCalled(); wrapper.unmount()
})
it('loads aggregated history only on its tab, preserving null operations on dispatch events', async () => {
calls.history.mockResolvedValue({records:[{eventSource:'DISPATCH',recordVersion:null,dispatchRecordVersion:'8',action:'LOADING_COMPLETE',operatorName:null,occurredAt:'2026-10-20T09:00:00Z',reason:null,operations:null,photos:[]},{eventSource:'OPERATIONS',recordVersion:'1',dispatchRecordVersion:null,action:'SAVE_OPERATIONS',operatorName:'登记员',occurredAt:'2026-10-20T10:00:00Z',reason:null,operations:outsourcedBusinessFixture().row.operations,photos:[]}],page:1,size:20,total:2})
const wrapper=mount(OutsourcedBusinessDetailView,{global});await flushPromises()
expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.history).not.toHaveBeenCalled()
wrapper.findComponent(Tabs).vm.$emit('update:modelValue','history');await flushPromises()
expect(calls.history).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',1,20)
expect(wrapper.text()).toContain('原排车');expect(wrapper.text()).toContain('运营登记')
const table=wrapper.findComponent(Table), records=table.props('data')
expect(records[0].eventKey).not.toBe(records[1].eventKey);expect(records[0].operations).toBeNull()
table.vm.$emit('update:page',2);await flushPromises();expect(calls.history).toHaveBeenLastCalledWith('DS-OUT-1',2,20)
expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.content).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows authoritative large money text and unit price without rounding or recomputing from two cargo rows', async () => {
const detail=outsourcedBusinessFixture();detail.row.operations.money={currency:'USD',amount:'9999999999999999.99'};detail.row.unitPrice='9999999999999999.99'
calls.detail.mockResolvedValue(detail)
const wrapper=mount(OutsourcedBusinessDetailView,{global});await flushPromises()
expect(wrapper.text()).toContain('USD 9999999999999999.99');expect(wrapper.text()).not.toContain('10000000000000000')
expect(wrapper.text()).not.toContain('4999999999999999')
wrapper.unmount()
})
})
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import OwnedBusinessDetailLayout from '../components/OwnedBusinessDetailLayout.vue'
import OwnedBusinessOperationsEditor from '../components/OwnedBusinessOperationsEditor.vue'
import OwnedBusinessPhotosEditor from '../components/OwnedBusinessPhotosEditor.vue'
import { getOwnedBusiness, getOwnedBusinessPhotoContent, listOwnedBusinessHistory } from '../api/ownedBusiness'
import { ownedBusinessActionLabels, ownedBusinessStages, ownedBusinessText as text, ownedBusinessPhotoRoles, type OwnedBusinessDetail, type OwnedBusinessHistory, type OwnedBusinessPhoto } from '../ownedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const dispatchNo = computed(() => typeof route.params.dispatchNo === 'string' ? route.params.dispatchNo : '')
const detail = ref<OwnedBusinessDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), activeTab = ref<'task'|'route'|'history'>('task')
const editorOpen = ref(false), photosOpen = ref(false), busy = ref(false)
const history = ref<OwnedBusinessHistory[]>([]), historyPage = ref(1), historySize = ref(20), historyTotal = ref(0), historyLoading = ref(false), historyError = ref<ApiErrorDetails | null>(null), previewError = ref<ApiErrorDetails | null>(null)
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
const previewUrls = new Set<string>()
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
const taskFields = computed(() => {
const row = detail.value?.row
return [['计划发车', time(row?.plannedDepartureAt)], ['装车完成', time(row?.loadingCompletedAt)], ['实际发车', time(row?.departedAt)], ['派送完成', time(row?.deliveryCompletedAt)], ['车牌', text(row?.vehicle.plate)], ['司机', text(row?.driver.name)], ['司机电话', text(row?.driver.phone)], ['提货仓库', text(row?.origin.warehouseName)], ['实际物理托数', text(row?.actualPalletCount)], ['整车金额', row?.operations.money ? `${row.operations.money.currency} ${row.operations.money.amount}` : '未登记'], ['单价', row?.unitPrice == null ? '—' : `${row.operations.money?.currency || ''} ${row.unitPrice}`], ['实际里程(mile)', row?.operations.actualDistanceMiles ?? '未登记'], ['油耗(L/100km)', row?.operations.fuelConsumptionLitresPer100Km ?? '未登记']]
})
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
function destinationLabel(key: string) { const value = detail.value?.row.destinations.find(destination => destination.destinationKey === key); return value?.warehouseCode || value?.warehouseName || value?.address || '—' }
function photoLabel(photo: OwnedBusinessPhoto) { return ownedBusinessPhotoRoles.find(role => role.slot === photo.slot)?.label || '照片' }
async function load() {
if (!active) return
const request = ++generation, no = dispatchNo.value; loading.value = true; error.value = null
try { if (!no || !access.can(permission.ownedBusinessList)) throw new Error('缺少排车业务号或查看权限'); const value = await getOwnedBusiness(no); if (active && request === generation) { if (value.row.dispatchNo !== no) throw new Error('当前运营资料与排车业务号不一致'); detail.value = value; if (activeTab.value === 'history') void loadHistory() } }
catch (cause) { if (active && request === generation) { detail.value = null; error.value = getApiErrorDetails(cause, '自有业务详情读取失败') } }
finally { if (request === generation) loading.value = false }
}
async function loadHistory() {
if (!active || activeTab.value !== 'history' || !detail.value) return
const request = ++historyGeneration, no = dispatchNo.value; historyLoading.value = true; historyError.value = null
try { const value = await listOwnedBusinessHistory(no, historyPage.value, historySize.value); if (active && request === historyGeneration && no === dispatchNo.value) { history.value = value.records; historyTotal.value = value.total } }
catch (cause) { if (request === historyGeneration) historyError.value = getApiErrorDetails(cause, '操作记录读取失败') }
finally { if (request === historyGeneration) historyLoading.value = false }
}
async function preview(photo: OwnedBusinessPhoto, revision: string | null) {
const no = dispatchNo.value, request = generation; previewError.value = null
if (revision == null) { previewError.value = { message: '历史照片缺少运营资料版本,请重新读取' }; return }
const windowRef = window.open('', '_blank')
if (!windowRef) { previewError.value = { message: '请允许本站打开图片预览窗口' }; return }
try { const blob = await getOwnedBusinessPhotoContent(no, photo, revision); if (!active || request !== generation || no !== dispatchNo.value) { windowRef.close(); return }; const url = URL.createObjectURL(blob); previewUrls.add(url); windowRef.location.href = url }
catch (cause) { windowRef.close(); previewError.value = getApiErrorDetails(cause, '历史照片读取失败') }
}
function saved(value: OwnedBusinessDetail) { if (value.row.dispatchNo !== dispatchNo.value) return; detail.value = value; if (activeTab.value === 'history') void loadHistory() }
watch([activeTab,historyPage,historySize], () => { if (active) void loadHistory() })
watch(dispatchNo, () => { generation++; historyGeneration++; detail.value = null; history.value = []; historyTotal.value = 0; activeTab.value = 'task'; editorOpen.value = false; photosOpen.value = false; if (active) void load() })
onMounted(load)
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
onDeactivated(() => { active = false; generation++; historyGeneration++; loading.value = false; historyLoading.value = false; editorOpen.value = false; photosOpen.value = false })
onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previewUrls.forEach(url => URL.revokeObjectURL(url)) })
</script>
<template>
<OwnedBusinessDetailLayout v-model:active-tab="activeTab" :dispatch-no="detail?.row.dispatchNo || dispatchNo" :status-text="detail ? ownedBusinessStages[detail.row.stage] : ''" :loading="loading" @back="router.push('/overseas-master-data/owned-business')">
<template #actions><el-button v-if="detail && access.can(permission.ownedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.ownedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
<template #state><LoadErrorState v-if="error" title="自有业务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
<template #task><dl v-if="detail" class="density-fields owned-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="owned-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable><section class="owned-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ ownedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><p>里程 {{ row.operations.actualDistanceMiles ?? '未登记' }} mile · 油耗 {{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }} L/100km</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></template>
</OwnedBusinessDetailLayout>
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
<OwnedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
</template>
<style scoped>
.owned-business-fields { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); padding: var(--admin-space-4); }
.owned-business-fields > div { min-width: 0; overflow-wrap: anywhere; }
.owned-business-route { padding: var(--admin-space-4); border-bottom: 1px solid var(--admin-table-border); }
.owned-business-route h3 { font-size: inherit; margin: 0 0 var(--admin-space-3); }
</style>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import QueryToolbar, { type QueryField, type QueryModel, type QueryOption } from '@/shared/components/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OwnedBusinessListLayout from '../components/OwnedBusinessListLayout.vue'
import OwnedBusinessOperationsEditor from '../components/OwnedBusinessOperationsEditor.vue'
import OwnedBusinessPhotosEditor from '../components/OwnedBusinessPhotosEditor.vue'
import { getOwnedBusiness, listOwnedBusiness } from '../api/ownedBusiness'
import { ownedBusinessStages, ownedBusinessText as text, type OwnedBusinessDetail, type OwnedBusinessList, type OwnedBusinessQuery, type OwnedBusinessRow, type OwnedBusinessStage } from '../ownedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
const router = useRouter(), access = useAccessPreviewStore()
const result = ref<OwnedBusinessList | null>(null), page = ref(1), size = ref(20), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ plannedRange: [], keyword: '', stage: '', warehouseId: '' })
const warehouseOptions = ref<QueryOption[]>([]), warehouseError = ref<ApiErrorDetails | null>(null)
const detail = ref<OwnedBusinessDetail | null>(null), editorOpen = ref(false), photosOpen = ref(false), opening = ref(false), mutating = ref(false)
let applied: Omit<OwnedBusinessQuery,'page'|'size'> = {}, generation = 0, openGeneration = 0, active = true, initialActivation = true
const fields = computed<QueryField[]>(() => [
{ key: 'plannedRange', type: 'daterange', label: '计划发车日期', width: 270, props: { valueFormat: 'YYYY-MM-DD', 'aria-label': '计划发车日期范围' } },
{ key: 'keyword', type: 'input', label: '关键词', width: 230, placeholder: '排车单号 / 车牌 / 目的仓', props: { maxlength: 128 } },
{ key: 'stage', type: 'select', label: '阶段', width: 140, options: Object.entries(ownedBusinessStages).map(([value,label]) => ({ value,label })) },
{ key: 'warehouseId', type: 'select', label: '提货仓库', width: 180, options: warehouseOptions.value, props: { disabled: !access.can('platform:warehouse:list') } },
])
const summary = computed(() => [
['排车数', result.value?.summary.dispatchCount], ['装车完成', result.value?.summary.afterLoadingCount], ['派送中', result.value?.summary.deliveringCount], ['派送完成', result.value?.summary.deliveredCount],
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
{ key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 285, slotName: 'destinations' },
{ key: 'fuel', label: '油耗(L/100km)', minWidth: 145, slotName: 'fuel' }, { prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
{ key: 'money', label: '整车总金额 / 单价', minWidth: 170, slotName: 'money' },
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
{ key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' },
]
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
async function load() {
if (!active) return
const request = ++generation
loading.value = true; error.value = null
try {
if (!access.can(permission.ownedBusinessList)) throw new Error('没有自有业务查看权限')
const value = await listOwnedBusiness({ ...applied, page: page.value, size: size.value })
if (active && request === generation) result.value = value
} catch (cause) { if (active && request === generation) { result.value = null; error.value = getApiErrorDetails(cause, '自有业务读取失败') } }
finally { if (request === generation) loading.value = false }
}
function search() {
try {
const range = filters.value.plannedRange
const dates = Array.isArray(range) ? range : []
if (dates.length && (dates.length !== 2 || !dayjs(String(dates[0])).isValid() || !dayjs(String(dates[1])).isValid() || dayjs(String(dates[1])).isBefore(dayjs(String(dates[0]))))) throw new Error('请选择有效的计划发车日期范围')
applied = { keyword: String(filters.value.keyword || '').trim() || undefined, stage: (filters.value.stage || undefined) as OwnedBusinessStage | undefined, warehouseId: String(filters.value.warehouseId || '') || undefined,
plannedDepartureFrom: dates.length ? dayjs(String(dates[0])).startOf('day').toISOString() : undefined, plannedDepartureTo: dates.length ? dayjs(String(dates[1])).add(1,'day').startOf('day').toISOString() : undefined }
if (page.value !== 1) page.value = 1; else void load()
} catch (cause) { error.value = getApiErrorDetails(cause, '筛选条件无效') }
}
async function loadWarehouses() {
if (!access.can('platform:warehouse:list')) return
try { const values = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }); warehouseOptions.value = values.map(value => ({ value: String(value.id), label: value.warehouseName })) }
catch (cause) { warehouseError.value = getApiErrorDetails(cause, '仓库目录读取失败,可继续使用其他筛选') }
}
async function openAction(row: OwnedBusinessRow, kind: 'operations' | 'photos') {
if (opening.value || mutating.value) return
const code = kind === 'operations' ? permission.ownedBusinessEdit : permission.ownedBusinessList
if (!access.can(code) || kind === 'operations' && !row.actions.edit.allowed) return
const request = ++openGeneration; opening.value = true; error.value = null
try { const value = await getOwnedBusiness(row.dispatchNo); if (!active || request !== openGeneration) return; detail.value = value; if (kind === 'operations') editorOpen.value = true; else photosOpen.value = true }
catch (cause) { if (request === openGeneration) error.value = getApiErrorDetails(cause, '当前运营资料读取失败') }
finally { if (request === openGeneration) opening.value = false }
}
function saved(value: OwnedBusinessDetail) { detail.value = value; void load() }
watch([page,size], () => { if (active) void load() })
onMounted(() => { void load(); void loadWarehouses() })
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
onDeactivated(() => { active = false; generation++; openGeneration++; loading.value = false; opening.value = false; editorOpen.value = false; photosOpen.value = false })
onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
</script>
<template>
<OwnedBusinessListLayout>
<template #statistics><section class="owned-business-statistics" aria-label="完整筛选范围自有业务统计" :aria-busy="loading"><div v-for="item in summary" :key="String(item[0])"><span>{{ item[0] }}</span><strong>{{ item[1] ?? '—' }}</strong></div></section></template>
<DataTable appearance="integrated" :stripe="false" v-model:page="page" v-model:page-size="size" :data="result?.records || []" :columns="columns" :total="result?.total || 0" :loading="loading" row-key="dispatchNo" preference-key="owned-business-table" empty-text="当前筛选范围暂无已装车的自有车辆排车单">
<template #toolbar-start><h1>自有业务</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自有业务筛选" preference-key="owned-business-query" :loading="loading" @search="search" /><small>日期和时间按浏览器本地时区显示;统计覆盖同一完整筛选范围。</small></template>
<template #notices><LoadErrorState v-if="error" title="自有业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" link @click="router.push(`/overseas-master-data/owned-business/${encodeURIComponent(row.dispatchNo)}`)">查看明细</el-button><el-button v-if="access.can(permission.ownedBusinessEdit)" class="density-action" link :disabled="opening || mutating || !row.actions.edit.allowed" @click="openAction(row as OwnedBusinessRow, 'operations')">登记运营信息</el-button><el-button class="density-action" link :disabled="opening || mutating" @click="openAction(row as OwnedBusinessRow, 'photos')">{{ access.can(permission.ownedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></div></template>
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="owned-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约 {{ time(appointment.scheduledAt) }}</div><div>实到 {{ time(destination.actualArrivedAt) }}</div></div></template>
<template #fuel="{ row }">{{ row.operations.fuelConsumptionLitresPer100Km ?? '未登记' }}</template>
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}<br>单价 {{ row.unitPrice ?? '—' }}</template><span v-else>未登记</span></template>
<template #plannedDeparture="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #loaded="{ row }">{{ time(row.loadingCompletedAt) }}</template><template #stage="{ row }">{{ ownedBusinessStages[row.stage as OwnedBusinessStage] }}</template>
</DataTable>
<OwnedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
<OwnedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
</OwnedBusinessListLayout>
</template>
<style scoped>
.owned-business-statistics { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); background: white; border: 1px solid var(--admin-table-border); }
.owned-business-statistics > div { padding: var(--admin-space-4) var(--admin-space-5); border-right: 1px solid var(--admin-table-border); }
.owned-business-statistics > div:last-child { border-right: 0; }
.owned-business-statistics strong { display: block; margin-top: var(--admin-space-2); font-size: var(--admin-font-title); }
.owned-business-destination + .owned-business-destination { margin-top: var(--admin-space-3); border-top: 1px solid var(--admin-table-border); padding-top: var(--admin-space-3); }
.owned-business-destination small { display: block; }
</style>

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