Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test
This commit is contained in:
@@ -63,6 +63,20 @@ describe('admin router access metadata', () => {
|
||||
expect(detailIndex).toBeGreaterThan(globalIndex)
|
||||
})
|
||||
|
||||
it('registers phase-three service center routes with their stable permissions', () => {
|
||||
const expected = [
|
||||
['port-schedules', '/port-schedules', 'platform:firstleg:port-schedule:list'],
|
||||
['customs-cases', '/customs', 'platform:firstleg:customs:list'],
|
||||
['outbound-dispatches', '/outbound-dispatches', 'platform:firstleg:outbound-dispatch:list'],
|
||||
]
|
||||
|
||||
for (const [name, path, permission] of expected) {
|
||||
const route = router.getRoutes().find((item) => item.name === name)
|
||||
expect(route?.path).toBe(path)
|
||||
expect(route?.meta.permission).toBe(permission)
|
||||
}
|
||||
})
|
||||
|
||||
it('protects counterparty records with a stable master-data permission', () => {
|
||||
const route = router.getRoutes().find((item) => item.name === 'counterparties')
|
||||
expect(route?.path).toBe('/master-data/counterparties')
|
||||
|
||||
@@ -21,6 +21,9 @@ import { warehouseRoutes } from '@/modules/warehouses/routes'
|
||||
import { prototypeModuleRoutes } from '@/prototype/routes'
|
||||
import { oceanBookingRoutes } from '@/modules/ocean-bookings/routes'
|
||||
import { truckingRoutes } from '@/modules/trucking/routes'
|
||||
import { portScheduleRoutes } from '@/modules/port-schedules/routes'
|
||||
import { customsRoutes } from '@/modules/customs/routes'
|
||||
import { outboundDispatchRoutes } from '@/modules/outbound-dispatch/routes'
|
||||
import { feeItemRoutes } from '@/modules/fee-items/routes'
|
||||
import { containerLoadingRoutes } from '@/modules/container-loading/routes'
|
||||
import { loadingListRoutes } from '@/modules/loading-lists/routes'
|
||||
@@ -49,7 +52,10 @@ const router = createRouter({
|
||||
...dashboardRoutes,
|
||||
...prototypeModuleRoutes,
|
||||
...oceanBookingRoutes,
|
||||
...portScheduleRoutes,
|
||||
...truckingRoutes,
|
||||
...customsRoutes,
|
||||
...outboundDispatchRoutes,
|
||||
...containerLoadingRoutes,
|
||||
...loadingListRoutes,
|
||||
...financeRoutes,
|
||||
|
||||
@@ -35,6 +35,13 @@ describe('adminNavigation', () => {
|
||||
'库位管理',
|
||||
'托盘管理',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'service-center')?.children?.map((item) => item.title)).toEqual([
|
||||
'订舱列表',
|
||||
'船期信息',
|
||||
'拖车列表',
|
||||
'报关协同',
|
||||
'国内出仓',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'supplier-center')?.children?.map((item) => item.title)).toEqual([
|
||||
'供应商列表',
|
||||
'快递公司',
|
||||
@@ -89,11 +96,14 @@ describe('adminNavigation', () => {
|
||||
expect(paths).toContain('/container-loading/smart-loading')
|
||||
expect(paths).toContain('/loading-plans')
|
||||
expect(paths).toContain('/loading-lists')
|
||||
expect(paths).toContain('/port-schedules')
|
||||
expect(paths).toContain('/customs')
|
||||
expect(paths).toContain('/outbound-dispatches')
|
||||
expect(paths).not.toContain('/prototype/container-management/container-list')
|
||||
expect(paths).not.toContain('/prototype/container-management/preload-list')
|
||||
expect(paths).not.toContain('/prototype/container-management/manifest')
|
||||
expect(paths).not.toContain('/prototype/general/fee-items')
|
||||
expect(paths).toContain('/prototype/finance/profit-report')
|
||||
expect(paths).toContain('/finance/bills')
|
||||
expect(paths).toContain('/prototype/system-management/brand-settings')
|
||||
expect(paths).toContain('/system/permissions')
|
||||
expect(new Set(paths).size).toBe(paths.length)
|
||||
@@ -166,6 +176,25 @@ describe('adminNavigation', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('shows each phase-three service center entry only with its matching permission', () => {
|
||||
const filtered = filterAdminNavigation(adminNavigation, new Set([
|
||||
'platform:firstleg:ocean-booking:list',
|
||||
'platform:firstleg:port-schedule:list',
|
||||
'platform:firstleg:truck-appointment:list',
|
||||
'platform:firstleg:customs:list',
|
||||
'platform:firstleg:outbound-dispatch:list',
|
||||
]))
|
||||
|
||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
|
||||
'/dashboard',
|
||||
'/ocean-bookings',
|
||||
'/port-schedules',
|
||||
'/trucking',
|
||||
'/customs',
|
||||
'/outbound-dispatches',
|
||||
])
|
||||
})
|
||||
|
||||
it('supports manually hiding a module without removing its route definition', () => {
|
||||
const filtered = filterAdminNavigation([{ key: 'hidden', title: '隐藏模块', icon: adminNavigation[0].icon, path: '/hidden', hidden: true }], new Set())
|
||||
|
||||
|
||||
@@ -78,7 +78,10 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
icon: ClipboardList,
|
||||
children: [
|
||||
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
|
||||
{ key: 'port-schedules', title: '船期信息', icon: ClipboardList, path: '/port-schedules', permission: 'platform:firstleg:port-schedule:list' },
|
||||
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
|
||||
{ key: 'customs-cases', title: '报关协同', icon: FileCheck2, path: '/customs', permission: 'platform:firstleg:customs:list' },
|
||||
{ key: 'outbound-dispatches', title: '国内出仓', icon: Truck, path: '/outbound-dispatches', permission: 'platform:firstleg:outbound-dispatch:list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -137,7 +140,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'finance-bills', title: '客户账单', icon: FileCheck2, path: '/finance/bills', permission: 'platform:finance:bill:view' },
|
||||
{ key: 'finance-receipts', title: '收款登记', icon: CircleDollarSign, path: '/finance/receipts', permission: 'platform:finance:receipt:view' },
|
||||
{ key: 'finance-costs', title: '成本录入', icon: Database, path: '/finance/costs', permission: 'platform:finance:cost:view' },
|
||||
{ key: 'finance-reports', title: '财务统计', icon: BarChart3, path: '/finance/reports', permission: 'platform:finance:report:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,8 +8,18 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { listContainers } from '../api/platformContainerLoading'
|
||||
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/', component: ContainersView },
|
||||
{ path: '/container-loading/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '<div />' } },
|
||||
{ path: '/loading-plans', name: 'loading-plans', component: { template: '<div />' } },
|
||||
{ path: '/loading-lists', name: 'loading-lists', component: { template: '<div />' } },
|
||||
] })
|
||||
}
|
||||
|
||||
describe('ContainersView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -18,7 +28,7 @@ describe('ContainersView', () => {
|
||||
})
|
||||
|
||||
it('loads the real paged contract and renders core container fields', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] })
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
@@ -30,6 +40,18 @@ describe('ContainersView', () => {
|
||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the cross-warehouse list usable when warehouse form options fail', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new Error('warehouse unavailable'))
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('CB-1')
|
||||
expect(wrapper.text()).not.toContain('装柜仓库加载失败')
|
||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('uses stable view labels when cabinetNo is missing', async () => {
|
||||
const base = {
|
||||
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
|
||||
@@ -47,7 +69,7 @@ describe('ContainersView', () => {
|
||||
],
|
||||
page: 1, size: 20, total: 3, pages: 1,
|
||||
} as never)
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] })
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
@@ -58,4 +80,39 @@ describe('ContainersView', () => {
|
||||
expect(labels).toEqual(expect.arrayContaining(['查看 MSCU-2', '查看 BK-3', '查看货柜详情']))
|
||||
expect(labels.every((label) => !/undefined|null/.test(label))).toBe(true)
|
||||
})
|
||||
|
||||
it('routes each plan status to its owning workflow without reopening pushed drafts', async () => {
|
||||
const base = {
|
||||
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', version: 0,
|
||||
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
|
||||
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
|
||||
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000,
|
||||
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
|
||||
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
|
||||
}
|
||||
vi.mocked(listContainers).mockResolvedValueOnce({
|
||||
records: [
|
||||
{ ...base, planStatus: 'EDITING' as const },
|
||||
{ ...base, id: '9002', planId: '9102', cabinetNo: 'CB-2', planStatus: 'LOADING' as const },
|
||||
{ ...base, id: '9003', planId: '9103', cabinetNo: 'CB-3', planStatus: 'COMPLETED' as const },
|
||||
],
|
||||
page: 1, size: 20, total: 3, pages: 1,
|
||||
})
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined as never)
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('排柜')
|
||||
expect(wrapper.text()).toContain('查看预装清单')
|
||||
expect(wrapper.text()).toContain('查看装柜清单')
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().trim() === '排柜')!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('查看预装清单'))!.trigger('click')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('查看装柜清单'))!.trigger('click')
|
||||
expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } })
|
||||
expect(push).toHaveBeenNthCalledWith(2, { name: 'loading-plans', query: { planId: '9102', warehouseId: '8' } })
|
||||
expect(push).toHaveBeenNthCalledWith(3, { name: 'loading-lists', query: { planId: '9103', warehouseId: '8' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Boxes, Eye, Pencil, Plus, Sparkles } from '@lucide/vue'
|
||||
import { Boxes, Eye, ListChecks, Pencil, Plus, Sparkles } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
@@ -16,7 +16,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createContainer, getContainer, listContainers, updateContainer, updateContainerTransport } from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource, ContainerPlanStatus, ContainerSaveRequest } from '../types'
|
||||
|
||||
type Filters = QueryModel & { keyword: string; warehouseId: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
||||
type Filters = QueryModel & { keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
||||
type ContainerForm = ContainerSaveRequest & { id: string; version: number }
|
||||
|
||||
const router = useRouter()
|
||||
@@ -30,7 +30,7 @@ const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const filters = ref<Filters>({ keyword: '', warehouseId: '', containerType: '', status: '', sailingRange: [] })
|
||||
const filters = ref<Filters>({ keyword: '', containerType: '', status: '', sailingRange: [] })
|
||||
const applied = ref<Filters>({ ...filters.value })
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
@@ -65,18 +65,19 @@ const rules: FormRules<ContainerForm> = {
|
||||
const canCreate = computed(() => access.can('platform:firstleg:container:create'))
|
||||
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
|
||||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const editorTitle = computed(() => form.id ? (detail.value?.planStatus === 'LOADING' ? '修改运输信息' : '编辑货柜') : '新增货柜')
|
||||
const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING')
|
||||
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、柜号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
|
||||
{ key: 'warehouseId', type: 'select', label: '装柜仓库', width: 220, options: warehouses.value.map((warehouse) => ({ label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`, value: String(warehouse.id) })), props: { 'aria-label': '装柜仓库', filterable: true, loading: warehouseLoading.value } },
|
||||
{ key: 'containerType', type: 'select', label: '柜型', width: 140, options: ['20GP', '40GP', '40HQ', '45HQ'].map((value) => ({ label: value, value })), props: { 'aria-label': '柜型' } },
|
||||
{ key: 'status', type: 'select', label: '排柜状态', width: 150, options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
|
||||
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'cabinetNo', label: '柜次', minWidth: 180, slotName: 'cabinet' },
|
||||
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
|
||||
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
|
||||
@@ -131,7 +132,7 @@ async function loadRows() {
|
||||
try {
|
||||
const result = await listContainers({
|
||||
page: page.value, size: pageSize.value, keyword: applied.value.keyword.trim() || undefined,
|
||||
warehouseId: applied.value.warehouseId || undefined, containerType: applied.value.containerType || undefined,
|
||||
containerType: applied.value.containerType || undefined,
|
||||
status: applied.value.status, estimatedSailingFrom: from ? `${from}T00:00:00` : undefined,
|
||||
estimatedSailingTo: to ? `${to}T23:59:59` : undefined,
|
||||
})
|
||||
@@ -170,11 +171,36 @@ async function save() {
|
||||
ElMessage.success(form.id ? '货柜已更新' : '货柜已新增'); editorOpen.value = false; await loadRows()
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '保存失败')
|
||||
editorError.value = { ...details, message: details.code === 'A0409' ? '数据已被其他人修改,请刷新最新数据后重试' : details.message }
|
||||
ElMessage.error(details.code === 'A0409' ? '数据已被其他人修改,请刷新列表后重试' : details.message)
|
||||
if (details.code === 'A0409' && form.id) {
|
||||
const submitted = { ...form }
|
||||
try {
|
||||
const latest = await getContainer(form.id)
|
||||
detail.value = latest
|
||||
replaceForm(latest)
|
||||
Object.assign(form, submitted, { id: latest.id, version: latest.version })
|
||||
editorError.value = { ...details, message: '数据已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
|
||||
ElMessage.warning(editorError.value.message)
|
||||
} catch (refreshError) {
|
||||
editorError.value = getApiErrorDetails(refreshError, '数据已被其他人修改,且最新数据刷新失败,请稍后重试')
|
||||
ElMessage.error(editorError.value.message)
|
||||
}
|
||||
} else {
|
||||
editorError.value = details
|
||||
ElMessage.error(details.message)
|
||||
}
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
function goWorkbench(source: object) { const row = source as ContainerBookingResource; void router.push({ name: 'smart-loading-workbench', params: { containerId: row.id } }) }
|
||||
function goContainerProcess(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
if (row.planStatus === 'EDITING') {
|
||||
void router.push({ name: 'smart-loading-workbench', params: { containerId: row.id } })
|
||||
return
|
||||
}
|
||||
void router.push({
|
||||
name: row.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
|
||||
query: { planId: row.planId || undefined, warehouseId: row.warehouseId },
|
||||
})
|
||||
}
|
||||
|
||||
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
|
||||
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
|
||||
@@ -189,14 +215,13 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
|
||||
<template #toolbar>
|
||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
|
||||
</template>
|
||||
<template v-if="warehouseError || loadError" #state>
|
||||
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-else-if="loadError" title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-if="loadError" #state>
|
||||
<LoadErrorState title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
</template>
|
||||
<section v-if="!warehouseError && !loadError" class="surface-panel table-panel">
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="container-loading-table" empty-text="暂无货柜">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增货柜</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canEdit && row.planStatus !== 'COMPLETED'" link type="primary" @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="canSmartLoading && row.planStatus !== 'COMPLETED'" link type="primary" @click="goWorkbench(row)"><Sparkles :size="15" />排柜</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canEdit && row.planStatus !== 'COMPLETED'" link type="primary" @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" link type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" link type="primary" @click="goContainerProcess(row)"><ListChecks :size="15" />查看预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" link type="primary" @click="goContainerProcess(row)"><ListChecks :size="15" />查看装柜清单</el-button></div></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>{{ row.bookingNo }}</small></template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
|
||||
@@ -213,10 +238,11 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
|
||||
<el-descriptions v-else-if="detail" :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDrawer v-model="editorOpen" :title="editorTitle" size="min(860px, 96vw)" :confirm-loading="saving" confirm-text="保存" @confirm="save">
|
||||
<AppDrawer v-model="editorOpen" :title="editorTitle" size="min(860px, 96vw)" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="保存" @confirm="save">
|
||||
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
||||
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
|
||||
<LoadErrorState v-if="warehouseError && !transportOnly" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<el-form v-if="!warehouseError || transportOnly" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
|
||||
<el-form-item label="柜次号" prop="cabinetNo"><el-input v-model="form.cabinetNo" maxlength="64" :disabled="transportOnly" /></el-form-item>
|
||||
<el-form-item label="集装箱号"><el-input v-model="form.containerNo" maxlength="64" placeholder="开始装柜前必须填写" /></el-form-item>
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable :disabled="transportOnly"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
removeSmartLoadingItem,
|
||||
} from '../api/platformContainerLoading'
|
||||
|
||||
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: routerPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/core/access/accessPreview', () => ({
|
||||
useAccessPreviewStore: () => ({ can: () => true }),
|
||||
}))
|
||||
@@ -75,6 +81,7 @@ describe('GlobalSmartLoadingView', () => {
|
||||
{ label: '待装柜', value: 'WAIT_LOADING' },
|
||||
{ label: '预装柜中', value: 'EDITING' },
|
||||
{ label: '拣货中', value: 'LOADING' },
|
||||
{ label: '装柜完成', value: 'COMPLETED' },
|
||||
])
|
||||
|
||||
expect(wrapper.text()).not.toContain('税率')
|
||||
@@ -92,22 +99,36 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.text()).toContain('CB-1 装柜方案')
|
||||
})
|
||||
|
||||
it('keeps loading containers editable and hides completed containers', async () => {
|
||||
const loadingContainer = { ...container, id: '9007199254740995', cabinetNo: 'CB-LOADING', planStatus: 'LOADING' as const }
|
||||
const completedContainer = { ...container, id: '9007199254740996', cabinetNo: 'CB-COMPLETED', planStatus: 'COMPLETED' as const }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [completedContainer, loadingContainer], page: 1, size: 100, total: 2, pages: 1 })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, bookingId: loadingContainer.id, status: 'LOADING' })
|
||||
|
||||
it('keeps picking containers editable while hiding the push action', async () => {
|
||||
const pushed = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-2', planStatus: 'LOADING' as const }
|
||||
const pickingPlan = { ...plan, bookingId: pushed.id, status: 'LOADING' as const }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [pushed], page: 1, size: 100, total: 1, pages: 1 })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue(pickingPlan)
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(loadingContainer.id)
|
||||
expect(wrapper.text()).toContain('CB-LOADING')
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(pushed.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(pushed.warehouseId)
|
||||
expect(wrapper.text()).toContain('CB-2')
|
||||
expect(wrapper.text()).toContain('拣货中')
|
||||
expect(wrapper.text()).not.toContain('CB-COMPLETED')
|
||||
expect(wrapper.text()).toContain('智能推荐')
|
||||
expect(wrapper.text()).toContain('加入')
|
||||
expect(wrapper.text()).not.toContain('推送预装')
|
||||
expect(wrapper.findAll('button').some((item) => item.text().trim() === '推送预装')).toBe(false)
|
||||
})
|
||||
|
||||
it('routes completed containers to the read-only loading list', async () => {
|
||||
const completed = { ...container, id: '9007199254740997', planId: '9007199254740998', cabinetNo: 'CB-3', planStatus: 'COMPLETED' as const }
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [completed], page: 1, size: 100, total: 1, pages: 1 })
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('该柜次已完成装柜')
|
||||
await wrapper.findAll('button').find((item) => item.text().includes('查看装柜清单'))!.trigger('click')
|
||||
expect(routerPush).toHaveBeenCalledWith({
|
||||
name: 'loading-lists',
|
||||
query: { planId: completed.planId, warehouseId: completed.warehouseId },
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes the latest plan after A0409 without losing the selected container', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -30,6 +31,7 @@ import type {
|
||||
} from '../types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const router = useRouter()
|
||||
const containers = ref<ContainerBookingResource[]>([])
|
||||
const selectedContainerId = ref('')
|
||||
const plan = ref<SmartLoadingPlan | null>(null)
|
||||
@@ -45,7 +47,7 @@ const assignOpen = ref(false)
|
||||
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
|
||||
const assignForm = reactive({ packageCount: 1 })
|
||||
const quantityDrafts = reactive<Record<string, number>>({})
|
||||
const query = ref<QueryModel>({ orderKeyword: '', containerKeyword: '', warehouseId: '', status: '' })
|
||||
const query = ref<QueryModel>({ orderKeyword: '', containerKeyword: '', status: '' })
|
||||
const appliedQuery = ref<QueryModel>({ ...query.value })
|
||||
let selectionRequest = 0
|
||||
|
||||
@@ -55,6 +57,7 @@ const canDelete = computed(() => access.can('platform:firstleg:smart-loading:del
|
||||
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
|
||||
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
|
||||
const editable = computed(() => plan.value?.status === 'EDITING' || plan.value?.status === 'LOADING')
|
||||
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
|
||||
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
|
||||
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
|
||||
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
||||
@@ -64,29 +67,21 @@ const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg |
|
||||
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
|
||||
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
|
||||
|
||||
const warehouseOptions = computed(() => {
|
||||
const values = new Map<string, string>()
|
||||
for (const item of containers.value) values.set(item.warehouseId, item.warehouseName || `仓库 ${item.warehouseId}`)
|
||||
return [...values].map(([value, label]) => ({ value, label }))
|
||||
})
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderKeyword', type: 'input', label: '订单或货物', placeholder: '订单号、入库单、客户、货物', width: 250 },
|
||||
{ key: 'containerKeyword', type: 'input', label: '柜次', placeholder: '柜次号、集装箱号、目的仓', width: 230 },
|
||||
{ key: 'warehouseId', type: 'select', label: '装柜仓库', options: warehouseOptions.value, width: 190 },
|
||||
{ key: 'status', type: 'select', label: '柜次状态', width: 160, options: [
|
||||
{ label: '待装柜', value: 'WAIT_LOADING' },
|
||||
{ label: '预装柜中', value: 'EDITING' },
|
||||
{ label: '拣货中', value: 'LOADING' },
|
||||
{ label: '装柜完成', value: 'COMPLETED' },
|
||||
] },
|
||||
])
|
||||
|
||||
const filteredContainers = computed(() => {
|
||||
const keyword = String(appliedQuery.value.containerKeyword || '').trim().toLowerCase()
|
||||
const warehouseId = String(appliedQuery.value.warehouseId || '')
|
||||
const status = String(appliedQuery.value.status || '')
|
||||
return containers.value.filter((item) => {
|
||||
if (item.planStatus === 'COMPLETED') return false
|
||||
if (warehouseId && item.warehouseId !== warehouseId) return false
|
||||
if (status && item.planStatus !== status) return false
|
||||
if (!keyword) return true
|
||||
return [item.cabinetNo, item.containerNo, item.bookingNo, item.destinationWarehouseCode, item.destinationWarehouseName]
|
||||
@@ -192,7 +187,15 @@ async function loadSelectedData(preserved: Record<string, number> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
function openList(status: 'LOADING' | 'COMPLETED') {
|
||||
const routeName = status === 'LOADING' ? 'loading-plans' : 'loading-lists'
|
||||
const query = selectedContainer.value
|
||||
? { planId: selectedContainer.value.planId || undefined, warehouseId: selectedContainer.value.warehouseId }
|
||||
: undefined
|
||||
void router.push({ name: routeName, query })
|
||||
}
|
||||
|
||||
async function loadAll(preserved: Record<string, number> = {}) {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
@@ -200,17 +203,16 @@ async function load() {
|
||||
const remainingPages = firstPage.pages > 1
|
||||
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
|
||||
: []
|
||||
containers.value = [firstPage, ...remainingPages]
|
||||
.flatMap((page) => page.records)
|
||||
.filter((item) => item.planStatus !== 'COMPLETED')
|
||||
containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
|
||||
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) selectedContainerId.value = preferredContainerId()
|
||||
await loadSelectedData()
|
||||
await loadSelectedData(preserved)
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorDetails(error, '智能装柜工作台加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
async function load() { await loadAll() }
|
||||
async function selectContainer(containerId: string) {
|
||||
if (selectedContainerId.value === containerId) return
|
||||
selectedContainerId.value = containerId
|
||||
@@ -302,7 +304,7 @@ async function mutate(
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, fallback)
|
||||
if (details.code === 'A0409') {
|
||||
await loadSelectedData(recovery.preserveDrafts)
|
||||
await loadAll(recovery.preserveDrafts)
|
||||
const pickingMessage = recovery.pickingConflict && details.message.includes('拣货')
|
||||
? '该订单在拣货中不许删除'
|
||||
: null
|
||||
@@ -380,7 +382,7 @@ onMounted(load)
|
||||
</section>
|
||||
|
||||
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
|
||||
<header class="pane-header"><div><strong>货柜</strong><small>待装柜、预装柜中与拣货中</small></div></header>
|
||||
<header class="pane-header"><div><strong>货柜</strong><small>待装柜、预装柜中、拣货中与装柜完成</small></div></header>
|
||||
<div v-loading="loading" class="container-list">
|
||||
<el-empty v-if="!filteredContainers.length" description="暂无符合条件的货柜" :image-size="72" />
|
||||
<button
|
||||
@@ -415,6 +417,12 @@ onMounted(load)
|
||||
<template #details><ApiErrorMeta :details="selectionError" /></template>
|
||||
</LoadErrorState>
|
||||
<el-empty v-else-if="!selectedContainer" description="请选择一个货柜" />
|
||||
<section v-else-if="selectedPlanStatus === 'COMPLETED'" class="readonly-status-card" aria-label="装柜完成柜次">
|
||||
<el-tag type="success">装柜完成</el-tag>
|
||||
<strong>该柜次已完成装柜</strong>
|
||||
<p>装柜计划、明细和运输信息已永久只读,请在装柜清单查看完整记录。</p>
|
||||
<el-button type="primary" @click="openList('COMPLETED')">查看装柜清单</el-button>
|
||||
</section>
|
||||
<el-empty v-else-if="noDraft" description="当前货柜尚未创建装柜方案">
|
||||
<el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
|
||||
</el-empty>
|
||||
@@ -472,6 +480,9 @@ onMounted(load)
|
||||
.plan-actions,.row-actions { flex-wrap: wrap; }
|
||||
.plan-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--cc-border); }
|
||||
.readonly-status-card { display: grid; align-content: start; justify-items: start; gap: 10px; padding: 28px 24px; }
|
||||
.readonly-status-card strong { font-size: 16px; }
|
||||
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
|
||||
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
|
||||
.plan-pane :deep(.el-input-number) { width: 120px; }
|
||||
.assign-dialog p { margin: 0 0 10px; }
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { AxiosError } from 'axios'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
|
||||
import { getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates } from '../api/platformContainerLoading'
|
||||
import { getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading } from '../api/platformContainerLoading'
|
||||
|
||||
vi.mock('../api/platformContainerLoading', () => ({
|
||||
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
|
||||
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), recommendSmartLoading: vi.fn(),
|
||||
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), pushSmartLoadingPreload: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
|
||||
const container = {
|
||||
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
|
||||
@@ -25,6 +27,7 @@ const container = {
|
||||
describe('SmartLoadingWorkbenchView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(getContainer).mockResolvedValue(container)
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
|
||||
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
|
||||
@@ -42,4 +45,41 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(wrapper.text()).toContain('ORD-1')
|
||||
expect(wrapper.text()).toContain('按 110% 上限')
|
||||
})
|
||||
|
||||
it('keeps picking containers editable while hiding the push action', async () => {
|
||||
vi.mocked(getContainer).mockResolvedValue({ ...container, planStatus: 'LOADING' })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'LOADING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||||
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
|
||||
expect(listSmartLoadingCandidates).toHaveBeenCalledWith(container.warehouseId)
|
||||
expect(wrapper.text()).toContain('拣货中')
|
||||
expect(wrapper.text()).toContain('智能推荐')
|
||||
expect(wrapper.text()).toContain('ORD-1')
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('推送预装'))).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes the latest plan after A0409', async () => {
|
||||
vi.mocked(getSmartLoadingPlanByBooking)
|
||||
.mockResolvedValueOnce({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
|
||||
.mockResolvedValueOnce({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 3, items: [] })
|
||||
vi.mocked(recommendSmartLoading).mockRejectedValue(new AxiosError(
|
||||
'conflict', 'ERR_BAD_REQUEST', undefined, undefined,
|
||||
{ data: { code: 'A0409', message: '数据已被修改', requestId: 'request-1', traceId: 'trace-1' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
|
||||
))
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||||
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('智能推荐'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('版本 3')
|
||||
expect(wrapper.text()).toContain('已刷新最新版本')
|
||||
expect(wrapper.text()).toContain('request-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
|
||||
import { ArrowLeft, ListChecks, Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -38,7 +38,8 @@ const canCreate = computed(() => access.can('platform:firstleg:smart-loading:cre
|
||||
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
|
||||
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
|
||||
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
|
||||
const editable = computed(() => plan.value?.status === 'EDITING')
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const editable = computed(() => plan.value?.status === 'EDITING' || plan.value?.status === 'LOADING')
|
||||
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
|
||||
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
||||
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
|
||||
@@ -93,6 +94,11 @@ async function load() {
|
||||
loading.value = true; loadError.value = null; noDraft.value = false
|
||||
try {
|
||||
container.value = await getContainer(containerId.value)
|
||||
if (container.value.planStatus === 'COMPLETED') {
|
||||
plan.value = null
|
||||
candidates.value = []
|
||||
return
|
||||
}
|
||||
try { acceptPlan(await getSmartLoadingPlanByBooking(containerId.value)) }
|
||||
catch (error) {
|
||||
const detail = getApiErrorDetails(error)
|
||||
@@ -103,11 +109,18 @@ async function load() {
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '智能排柜工作台加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function openOperationalList() {
|
||||
if (!container.value || container.value.planStatus === 'EDITING') return
|
||||
void router.push({
|
||||
name: container.value.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
|
||||
query: { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId },
|
||||
})
|
||||
}
|
||||
async function createPlan() {
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
try { acceptPlan(await createSmartLoadingPlan(containerId.value, null)); ElMessage.success('排柜计划已创建') }
|
||||
catch (error) { handleMutationError(error, '创建排柜计划失败') }
|
||||
catch (error) { await handleMutationError(error, '创建排柜计划失败') }
|
||||
finally { mutating.value = false }
|
||||
}
|
||||
function openAssign(source: object) { const candidate = candidateRow(source); selectedCandidate.value = candidate; assignForm.packageCount = Math.min(candidate.availablePackageCount, 1); assignOpen.value = true }
|
||||
@@ -118,7 +131,7 @@ async function assign() {
|
||||
try {
|
||||
acceptPlan(await assignSmartLoadingItem(plan.value.id, { inboundId: selectedCandidate.value.inboundId, inboundDetailId: selectedCandidate.value.inboundDetailId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version }))
|
||||
assignOpen.value = false; ElMessage.success('货物已分配')
|
||||
} catch (error) { handleMutationError(error, '分配失败') }
|
||||
} catch (error) { await handleMutationError(error, '分配失败') }
|
||||
finally { mutating.value = false }
|
||||
}
|
||||
async function recommend() {
|
||||
@@ -126,7 +139,7 @@ async function recommend() {
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
try { acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version)); ElMessage.success('智能建议已生成,请人工核对后再推送') }
|
||||
catch (error) { handleMutationError(error, '智能推荐失败') }
|
||||
catch (error) { await handleMutationError(error, '智能推荐失败') }
|
||||
finally { mutating.value = false }
|
||||
}
|
||||
async function adjust(source: object) {
|
||||
@@ -137,7 +150,7 @@ async function adjust(source: object) {
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
try { acceptPlan(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
|
||||
catch (error) { handleMutationError(error, '调整失败') }
|
||||
catch (error) { await handleMutationError(error, '调整失败') }
|
||||
finally { mutating.value = false }
|
||||
}
|
||||
async function remove(source: object) {
|
||||
@@ -148,7 +161,7 @@ async function remove(source: object) {
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
try { acceptPlan(await removeSmartLoadingItem(plan.value.id, item.id, plan.value.version, item.version)); ElMessage.success('装柜明细已移除') }
|
||||
catch (error) { handleMutationError(error, '移除失败', true) }
|
||||
catch (error) { await handleMutationError(error, '移除失败', true) }
|
||||
finally { mutating.value = false }
|
||||
})
|
||||
}
|
||||
@@ -159,15 +172,22 @@ async function pushPreload() {
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('已推送预装清单') }
|
||||
catch (error) { handleMutationError(error, '推送失败') }
|
||||
catch (error) { await handleMutationError(error, '推送失败') }
|
||||
finally { mutating.value = false }
|
||||
}
|
||||
function handleMutationError(error: unknown, fallback: string, pickingConflict = false) {
|
||||
async function handleMutationError(error: unknown, fallback: string, pickingConflict = false) {
|
||||
const details = getApiErrorDetails(error, fallback)
|
||||
const message = pickingConflict && details.code === 'A0409' && details.message.includes('拣货')
|
||||
if (details.code === 'A0409') {
|
||||
const preserved = { ...quantityDrafts }
|
||||
await load()
|
||||
for (const item of plan.value?.items || []) {
|
||||
if (preserved[item.id] != null) quantityDrafts[item.id] = preserved[item.id]
|
||||
}
|
||||
}
|
||||
const pickingMessage = pickingConflict && details.code === 'A0409' && details.message.includes('拣货')
|
||||
? '该订单在拣货中不许删除'
|
||||
: details.code === 'A0409' ? '计划已被其他人修改,请刷新最新计划后重试' : details.message
|
||||
mutationError.value = { ...details, message }
|
||||
: null
|
||||
mutationError.value = { ...details, message: pickingMessage || (details.code === 'A0409' ? '计划已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' : details.message) }
|
||||
ElMessage.error(mutationError.value.message)
|
||||
}
|
||||
|
||||
@@ -187,12 +207,19 @@ onMounted(load)
|
||||
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
|
||||
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
||||
<el-button v-if="canPush && editable" type="primary" :disabled="!plan?.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
|
||||
<el-button v-if="canPush && plan?.status === 'EDITING'" type="primary" :disabled="!plan.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
|
||||
</div>
|
||||
</section>
|
||||
<LoadErrorState v-if="loadError" title="智能排柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<template v-else>
|
||||
<LoadErrorState v-if="mutationError" title="排柜操作失败" :message="mutationError.message" compact retry-text="刷新最新计划" @retry="load"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
|
||||
<section v-if="container?.planStatus === 'COMPLETED'" class="readonly-status-card" aria-label="装柜完成状态">
|
||||
<el-tag type="success">装柜完成</el-tag>
|
||||
<strong>该柜次已完成装柜</strong>
|
||||
<p>智能装柜方案已锁定,不能继续调整。请进入装柜清单查看完整记录。</p>
|
||||
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看装柜清单</el-button>
|
||||
</section>
|
||||
<template v-else>
|
||||
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div><div><span>目的仓</span><strong>{{ container.destinationWarehouseName || container.destinationWarehouseCode || '不限' }}</strong></div><div><span>计划状态</span><strong>{{ plan?.status === 'LOADING' ? '拣货中' : plan ? '预装柜中' : '待装柜' }}</strong></div></section>
|
||||
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(volumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(weightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
|
||||
<el-empty v-if="noDraft" description="该货柜尚未创建排柜计划"><el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建排柜计划</el-button></el-empty>
|
||||
@@ -214,6 +241,7 @@ onMounted(load)
|
||||
<template #route="{ row }"><el-tag :type="routeMatches(row) ? 'success' : 'danger'">{{ routeMatches(row) ? '匹配' : '冲突' }}</el-tag><small>{{ explainCandidate(row) }}</small></template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<AppDialog v-model="assignOpen" title="分配候选货物" :confirm-loading="mutating" confirm-text="确认分配" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p><p>{{ selectedCandidate.orderNo || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /></div></AppDialog>
|
||||
</section>
|
||||
@@ -235,6 +263,9 @@ onMounted(load)
|
||||
.summary-band>div { display: grid; gap: 5px; padding: 14px; background: #fff; }
|
||||
.summary-band span { color: var(--cc-muted); font-size: 12px; }
|
||||
.capacity-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.readonly-status-card { display: grid; justify-items: start; gap: 10px; padding: 24px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
||||
.readonly-status-card strong { font-size: 16px; }
|
||||
.readonly-status-card p { margin: 0; color: var(--cc-muted); }
|
||||
.meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; }
|
||||
.row-actions { min-width: 150px; }
|
||||
.workbench :deep(.el-input-number) { width: 130px; }
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { createCustomsCase, listCustomsCases, submitCustomsCase } from './platformCustoms'
|
||||
vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn() } } })
|
||||
describe('platformCustoms', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes case ids', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ id: 1, customsNo: 'C1', orderId: 2, inboundId: 3, checklistVersionId: 4, checklistCode: 'US', checklistVersionNo: 'V1', status: 'DRAFT', version: '1' }], page: 1, size: 20, total: 1, pages: 1 } }); const page = await listCustomsCases({ page: 1, size: 20 }); expect(page.records[0]).toMatchObject({ id: '1', orderId: '2', inboundId: '3', checklistVersionId: '4', version: 1 }) }); it('writes with idempotency and version', async () => { const detail = { customsCase: { id: 1, customsNo: 'C1', orderId: 2, inboundId: 3, checklistVersionId: 4, checklistCode: 'US', checklistVersionNo: 'V1', status: 'DRAFT', version: 0 }, documents: [], supplements: [], submissionBatches: [], receipts: [], history: [] }; vi.mocked(http.post).mockResolvedValueOnce({ code: '200', data: detail }).mockResolvedValueOnce({ code: '200', data: {} }); await createCustomsCase({ customerId: '8', orderId: '2', inboundId: '3', checklistVersionId: '4', expectedVersion: 0 }); await submitCustomsCase('1', { customsAgentName: '代理', expectedVersion: 2 }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }); expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 2 }) }) })
|
||||
@@ -0,0 +1,11 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CustomsCaseCreateRequest, CustomsCaseDetail, CustomsCasePage, CustomsCaseQuery, CustomsCaseResource } from '../types'
|
||||
const basePath = '/platform/v1/customs'
|
||||
const keys = ['id', 'tenantId', 'orderId', 'inboundId', 'containerBookingId', 'checklistVersionId'] as const
|
||||
function item(raw: CustomsCaseResource) { const value = { ...raw } as CustomsCaseResource & Record<string, unknown>; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value }
|
||||
function detail(raw: CustomsCaseDetail): CustomsCaseDetail { return { ...raw, customsCase: item(raw.customsCase), documents: (raw.documents || []).map((x) => ({ ...x, id: String(x.id) })), submissionBatches: (raw.submissionBatches || []).map((x) => ({ ...x, id: String(x.id) })), receipts: (raw.receipts || []).map((x) => ({ ...x, id: String(x.id) })), history: (raw.history || []).map((x) => ({ ...x, id: String(x.id) })) } }
|
||||
function idem() { return `customs-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
export async function listCustomsCases(query: CustomsCaseQuery) { const response = await http.get<ApiEnvelope<CustomsCasePage>, ApiEnvelope<CustomsCasePage>>(`${basePath}/cases`, { params: query }); const data = unwrapApiResponse(response); return { ...data, records: (data.records || []).map(item), page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) } }
|
||||
export async function getCustomsCase(id: string) { const response = await http.get<ApiEnvelope<CustomsCaseDetail>, ApiEnvelope<CustomsCaseDetail>>(`${basePath}/cases/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
|
||||
export async function createCustomsCase(request: CustomsCaseCreateRequest) { const response = await http.post<ApiEnvelope<CustomsCaseDetail>, ApiEnvelope<CustomsCaseDetail>>(`${basePath}/cases`, request, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) }
|
||||
export async function submitCustomsCase(id: string, request: { customsAgentName: string; externalReference?: string; note?: string; expectedVersion: number }) { const response = await http.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`${basePath}/cases/${encodeURIComponent(id)}/submission-batches`, request, { headers: { 'Idempotency-Key': idem() } }); return unwrapApiResponse(response) }
|
||||
@@ -0,0 +1,2 @@
|
||||
export { customsRoutes } from './routes'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/customs/pages/CustomsCasesView.vue'), 'utf8')
|
||||
describe('CustomsCasesView', () => { it('uses shared list and dialog components', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain('AppDialog'); expect(source).toContain("fixed: 'left'") }); it('supports create, detail, history and agent submission', () => { expect(source).toContain('createCustomsCase'); expect(source).toContain('getCustomsCase'); expect(source).toContain('submitCustomsCase'); expect(source).toContain('detail.history') }) })
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, Plus, Send } from '@lucide/vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createCustomsCase, getCustomsCase, listCustomsCases, submitCustomsCase } from '../api/platformCustoms'
|
||||
import type { CustomsCaseCreateRequest, CustomsCaseDetail, CustomsCaseResource } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore(); const rows = ref<CustomsCaseResource[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const filters = reactive<QueryModel>({ orderId: '', inboundId: '', status: '' }); const fields: QueryField[] = [{ key: 'orderId', type: 'input', label: '订单 ID', width: 170 }, { key: 'inboundId', type: 'input', label: '入库单 ID', width: 170 }, { key: 'status', type: 'select', label: '状态', options: ['DRAFT', 'COLLECTING', 'READY', 'SUBMITTED', 'RELEASED', 'REJECTED'].map((value) => ({ label: value, value })) }]
|
||||
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { prop: 'orderId', label: '订单 ID', minWidth: 155 }, { prop: 'inboundId', label: '入库单 ID', minWidth: 155 }, { prop: 'checklistCode', label: '资料清单', minWidth: 125 }, { prop: 'checklistVersionNo', label: '版本', width: 90 }, { prop: 'status', label: '状态', width: 110, slotName: 'status' }, { prop: 'updatedAt', label: '更新时间', minWidth: 160 }]
|
||||
async function load() { loading.value = true; error.value = null; try { const result = await listCustomsCases({ page: page.value, size: pageSize.value, orderId: String(filters.orderId || '') || undefined, inboundId: String(filters.inboundId || '') || undefined, status: String(filters.status || '') || undefined }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
|
||||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||||
const createOpen = ref(false); const createSaving = ref(false); const createError = ref<ApiErrorDetails | null>(null); const formRef = ref<FormInstance>(); const createForm = reactive<CustomsCaseCreateRequest>({ customerId: '', orderId: '', inboundId: '', containerBookingId: null, checklistVersionId: '', remark: '', expectedVersion: 0 }); const rules: FormRules = { customerId: [{ required: true, message: '请输入客户 ID', trigger: 'blur' }], orderId: [{ required: true, message: '请输入订单 ID', trigger: 'blur' }], inboundId: [{ required: true, message: '请输入入库单 ID', trigger: 'blur' }], checklistVersionId: [{ required: true, message: '请输入资料清单版本 ID', trigger: 'blur' }] }
|
||||
function openCreate() { Object.assign(createForm, { customerId: '', orderId: '', inboundId: '', containerBookingId: null, checklistVersionId: '', remark: '', expectedVersion: 0 }); createError.value = null; createOpen.value = true }
|
||||
async function create() { if (!await formRef.value?.validate().catch(() => false)) return; createSaving.value = true; createError.value = null; try { await createCustomsCase({ ...createForm }); ElMessage.success('报关协同已创建'); createOpen.value = false; await load() } catch (cause) { createError.value = getApiErrorDetails(cause) } finally { createSaving.value = false } }
|
||||
const detailOpen = ref(false); const detail = ref<CustomsCaseDetail | null>(null); const detailLoading = ref(false); const actionError = ref<ApiErrorDetails | null>(null); const submitForm = reactive({ customsAgentName: '', externalReference: '', note: '' }); const canSubmit = computed(() => detail.value && access.can('platform:firstleg:customs:submit') && !['SUBMITTED', 'RELEASED'].includes(detail.value.customsCase.status))
|
||||
async function openDetail(id: string) { detailOpen.value = true; detailLoading.value = true; actionError.value = null; try { detail.value = await getCustomsCase(id) } catch (cause) { actionError.value = getApiErrorDetails(cause) } finally { detailLoading.value = false } }
|
||||
async function submit() { if (!detail.value || !submitForm.customsAgentName.trim()) return; actionError.value = null; try { await submitCustomsCase(detail.value.customsCase.id, { ...submitForm, expectedVersion: detail.value.customsCase.version }); ElMessage.success('已登记线下提交批次'); detail.value = await getCustomsCase(detail.value.customsCase.id) } catch (cause) { actionError.value = getApiErrorDetails(cause); if (actionError.value.code === 'A0409') detail.value = await getCustomsCase(detail.value.customsCase.id) } }
|
||||
watch([page, pageSize], () => void load()); onMounted(load)
|
||||
</script>
|
||||
<template><AdminPage as="div"><ListPageLayout aria-label="报关协同列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="报关协同筛选" :fields="fields" @search="search" /></template><template v-if="error" #state><LoadErrorState title="报关协同加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" preference-key="customs-cases" empty-text="暂无报关协同记录"><template #toolbar-actions><el-button v-if="access.can('platform:firstleg:customs:manage')" type="primary" @click="openCreate"><Plus :size="16" />新增报关</el-button></template><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:customs:view')" link type="primary" @click="openDetail(row.id)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="row.status" /></template></DataTable></section></ListPageLayout>
|
||||
<AppDialog v-model="createOpen" title="新增报关协同" width="720px" :confirm-loading="createSaving" @confirm="create"><el-alert v-if="createError" type="error" :closable="false"><ApiErrorMeta :details="createError" /></el-alert><el-form ref="formRef" :model="createForm" :rules="rules" label-position="top"><el-row :gutter="16"><el-col :xs="24" :sm="12"><el-form-item label="客户 ID" prop="customerId"><el-input v-model="createForm.customerId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="订单 ID" prop="orderId"><el-input v-model="createForm.orderId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="入库单 ID" prop="inboundId"><el-input v-model="createForm.inboundId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="柜次 ID"><el-input v-model="createForm.containerBookingId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="资料清单版本 ID" prop="checklistVersionId"><el-input v-model="createForm.checklistVersionId" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" /></el-form-item></el-col></el-row></el-form></AppDialog>
|
||||
<AppDialog v-model="detailOpen" title="报关协同详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError && !detail" title="详情加载失败" :message="actionError.message" :loading="detailLoading" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-if="detail"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-descriptions :column="2" border><el-descriptions-item label="报关编号">{{ detail.customsCase.customsNo }}</el-descriptions-item><el-descriptions-item label="状态"><StatusBadge :status="detail.customsCase.status" /></el-descriptions-item><el-descriptions-item label="资料清单">{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}</el-descriptions-item><el-descriptions-item label="资料/回执">{{ detail.documents.length }} / {{ detail.receipts.length }}</el-descriptions-item></el-descriptions><el-form v-if="canSubmit" label-position="top" class="submit-form"><el-row :gutter="12"><el-col :xs="24" :sm="12"><el-form-item label="报关代理"><el-input v-model="submitForm.customsAgentName" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" /></el-form-item></el-col></el-row><el-button type="primary" @click="submit"><Send :size="16" />登记线下提交</el-button></el-form><h3>状态历史</h3><el-timeline><el-timeline-item v-for="entry in detail.history" :key="entry.id" :timestamp="entry.createdAt">{{ entry.action }}:{{ entry.fromStatus || '—' }} → {{ entry.toStatus || '—' }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
|
||||
<style scoped>.submit-form{margin-top:18px}.submit-form+.el-button{margin-bottom:12px}h3{margin:20px 0 12px}small{display:block;color:var(--cc-muted)}</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const customsRoutes = [{ path: 'customs', name: 'customs-cases', component: () => import('./pages/CustomsCasesView.vue'), meta: { title: '报关协同', description: '维护报关资料、代理提交与放行回执', permission: 'platform:firstleg:customs:list' } }] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface CustomsCaseResource { id: string; customsNo: string; tenantId?: string | null; orderId: string; inboundId: string; containerBookingId?: string | null; checklistVersionId: string; checklistCode: string; checklistVersionNo: string; status: string; remark?: string | null; version: number; updatedAt?: string | null }
|
||||
export interface CustomsDocumentResource { id: string; itemCode: string; fileName?: string | null; status?: string | null; version?: number }
|
||||
export interface CustomsHistoryResource { id: string; action: string; fromStatus?: string | null; toStatus?: string | null; note?: string | null; operatorName?: string | null; createdAt: string }
|
||||
export interface CustomsCaseDetail { customsCase: CustomsCaseResource; documents: CustomsDocumentResource[]; supplements: unknown[]; submissionBatches: Array<{ id: string; customsAgentName?: string; status?: string; createdAt?: string }>; receipts: Array<{ id: string; receiptType?: string; status?: string; createdAt?: string }>; history: CustomsHistoryResource[] }
|
||||
export interface CustomsCasePage { records: CustomsCaseResource[]; page: number; size: number; total: number; pages: number }
|
||||
export interface CustomsCaseQuery { page: number; size: number; customerId?: string; orderId?: string; inboundId?: string; containerBookingId?: string; status?: string }
|
||||
export interface CustomsCaseCreateRequest { customerId: string; orderId: string; inboundId: string; containerBookingId?: string | null; checklistVersionId: string; remark?: string | null; expectedVersion: number }
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts, updateFinanceBill, voidFinanceBill } from './finance'
|
||||
|
||||
describe('finance api', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses the OpenAPI bill path and pageSize parameter', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({
|
||||
code: '200', message: 'ok', data: [{ id: 1, bill_no: 'B-1', customer_name_snapshot: '客户A', bill_date: '2026-08-11', due_date: '2026-09-10', total_amount: 100, received_amount: 0, outstanding_amount: 100, status: 'DRAFT', version: 2 }],
|
||||
})
|
||||
|
||||
const result = await listFinanceBills({ customerId: '10001', status: 'DRAFT', page: 1, pageSize: 20 })
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/bills', { params: { customerId: '10001', status: 'DRAFT', page: 1, pageSize: 20 } })
|
||||
expect(result.records[0]).toMatchObject({ id: '1', billNo: 'B-1', currency: 'CNY', status: 'DRAFT', version: 2 })
|
||||
})
|
||||
|
||||
it('uses the receipt and cost entry paths and fixes currency to CNY', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] })
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} })
|
||||
|
||||
await listFinanceReceipts('10001')
|
||||
await listFinanceCosts('10001')
|
||||
await createFinanceReceipt({ customerId: '10001', amount: 100 }, ['90001'])
|
||||
await createFinanceCost({ customerId: '10001', amount: 100 }, ['90001'])
|
||||
|
||||
expect(get).toHaveBeenNthCalledWith(1, '/platform/finance/v1/receipts', { params: { customerId: '10001' } })
|
||||
expect(get).toHaveBeenNthCalledWith(2, '/platform/finance/v1/cost-entries', { params: { customerId: '10001' } })
|
||||
expect(post.mock.calls[0]?.[1]).toMatchObject({ currency: 'CNY', voucherFileIds: ['90001'] })
|
||||
expect(post.mock.calls[1]?.[1]).toMatchObject({ currency: 'CNY', voucherFileIds: ['90001'] })
|
||||
expect(post.mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
expect(post.mock.calls[1]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
})
|
||||
|
||||
it('maps the real cost fee_item_name and cost_amount fields', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ id: 71, tenant_id: 10001, cost_type: 'CONTAINER_SHARED', container_id: 81, fee_item_name: '拖车费', cost_amount: '680.500000', voucher_file_ids: '["90001"]', created_at: '2026-08-11T10:00:00' }] })
|
||||
|
||||
const result = await listFinanceCosts('10001')
|
||||
|
||||
expect(result.records[0]).toMatchObject({ id: '71', customerId: '10001', containerId: '81', expenseName: '拖车费', amount: 680.5, voucherFileIds: ['90001'] })
|
||||
})
|
||||
|
||||
it('normalizes bill details and uses update, adjust and void contracts', async () => {
|
||||
const raw = { id: 81, tenant_id: 10001, bill_no: 'B-81', customer_name_snapshot: '客户A', bill_date: '2026-08-11', due_date: '2026-09-10', total_amount: 100, received_amount: 0, outstanding_amount: 100, status: 'DRAFT', version: 3, lines: [{ source_id: 91, source_version: 2, fee_item_name: '运费', amount: 100 }] }
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: raw })
|
||||
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'ok', data: raw })
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: raw })
|
||||
|
||||
const detail = await getFinanceBill('81')
|
||||
await updateFinanceBill('81', { customerId: '10001', expectedVersion: 3, lines: [] })
|
||||
await adjustFinanceBill('81', { expectedVersion: 3, reason: '金额修正', lines: [] })
|
||||
await voidFinanceBill('81', 3, '重复账单')
|
||||
|
||||
expect(detail).toMatchObject({ id: '81', customerId: '10001', version: 3, lines: [{ sourceFeeId: '91', sourceFeeVersion: 2, feeItemName: '运费', amount: 100 }] })
|
||||
expect(put).toHaveBeenCalledWith('/platform/finance/v1/bills/81', expect.objectContaining({ expectedVersion: 3 }), { headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
expect(post).toHaveBeenNthCalledWith(1, '/platform/finance/v1/bills/81/adjust', expect.objectContaining({ expectedVersion: 3, reason: '金额修正' }), { headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/platform/finance/v1/bills/81/void', { expectedVersion: 3, reason: '重复账单' }, { headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
})
|
||||
|
||||
it('issues a bill with optimistic version and an idempotency key', async () => {
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} })
|
||||
|
||||
await issueFinanceBill('81001', 3)
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/platform/finance/v1/bills/81001/issue', { expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
})
|
||||
})
|
||||
@@ -1,46 +1,115 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { FinanceBillResource, FinanceCostResource, FinanceReceiptResource, FinanceReportResource } from '../types'
|
||||
import type { FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceCostResource, FinancePaymentMethod, FinanceReceiptResource } from '../types'
|
||||
|
||||
const basePath = '/platform/v1/finance'
|
||||
interface PageResult<T> { records: T[]; total: number; page: number; size: number }
|
||||
const basePath = '/platform/finance/v1'
|
||||
function idempotencyKey(operation: string) { return `finance-${operation}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
type RawResource = Record<string, unknown>
|
||||
interface PageResult<T> { records: T[]; total: number; serverPaged: boolean }
|
||||
type RawPage = RawResource[] | { records?: RawResource[]; total?: number }
|
||||
|
||||
export async function listFinanceBills(params: Record<string, string | number | undefined>) {
|
||||
const response = await http.get<ApiEnvelope<PageResult<FinanceBillResource>>, ApiEnvelope<PageResult<FinanceBillResource>>>(`${basePath}/bills`, { params: { ...params, currency: 'CNY' } })
|
||||
return unwrapApiResponse(response)
|
||||
function stringValue(value: unknown) { return value == null ? '' : String(value) }
|
||||
function numberValue(value: unknown) { return Number(value ?? 0) }
|
||||
function fileIds(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map(String)
|
||||
if (typeof value !== 'string' || !value.trim()) return []
|
||||
try { const parsed = JSON.parse(value) as unknown; return Array.isArray(parsed) ? parsed.map(String) : [] } catch { return [] }
|
||||
}
|
||||
function normalizePage<T>(payload: RawPage, map: (item: RawResource) => T): PageResult<T> {
|
||||
const records = Array.isArray(payload) ? payload : payload.records || []
|
||||
return { records: records.map(map), total: Array.isArray(payload) ? records.length : Number(payload.total ?? records.length), serverPaged: !Array.isArray(payload) }
|
||||
}
|
||||
function normalizeBill(item: RawResource, customerId = stringValue(item.tenant_id ?? item.customerId)): FinanceBillResource {
|
||||
return {
|
||||
id: stringValue(item.id), billNo: stringValue(item.bill_no ?? item.billNo), customerId,
|
||||
customerName: stringValue(item.customer_name_snapshot ?? item.customerName) || `客户 ${customerId}`,
|
||||
billDate: stringValue(item.bill_date ?? item.billDate), dueDate: stringValue(item.due_date ?? item.dueDate), currency: 'CNY',
|
||||
totalAmount: numberValue(item.total_amount ?? item.totalAmount), paidAmount: numberValue(item.received_amount ?? item.paidAmount),
|
||||
outstandingAmount: numberValue(item.outstanding_amount ?? item.outstandingAmount), status: stringValue(item.status) as FinanceBillResource['status'],
|
||||
version: numberValue(item.version),
|
||||
}
|
||||
}
|
||||
function normalizeBillLine(item: RawResource): FinanceBillLine {
|
||||
return {
|
||||
sourceFeeId: stringValue(item.source_id ?? item.sourceFeeId), sourceFeeVersion: numberValue(item.source_version ?? item.sourceFeeVersion) || 1,
|
||||
orderId: item.order_id == null && item.orderId == null ? null : stringValue(item.order_id ?? item.orderId),
|
||||
inboundId: item.inbound_id == null && item.inboundId == null ? null : stringValue(item.inbound_id ?? item.inboundId),
|
||||
feeItemName: stringValue(item.fee_item_name ?? item.feeItemName), amount: numberValue(item.amount),
|
||||
}
|
||||
}
|
||||
function normalizeBillDetail(item: RawResource): FinanceBillDetail {
|
||||
return { ...normalizeBill(item), remark: stringValue(item.remark), lines: Array.isArray(item.lines) ? item.lines.map((line) => normalizeBillLine(line as RawResource)) : [] }
|
||||
}
|
||||
function mutationHeaders(operation: string) { return { headers: { 'Idempotency-Key': idempotencyKey(operation) } } }
|
||||
|
||||
export async function listFinanceBills(params: { customerId: string; status?: string; page: number; pageSize: number }) {
|
||||
const response = await http.get<ApiEnvelope<RawPage>, ApiEnvelope<RawPage>>(`${basePath}/bills`, { params })
|
||||
return normalizePage<FinanceBillResource>(unwrapApiResponse(response), (item) => normalizeBill(item, params.customerId))
|
||||
}
|
||||
|
||||
export async function createFinanceBill(payload: Record<string, unknown>) {
|
||||
const response = await http.post<ApiEnvelope<FinanceBillResource>, ApiEnvelope<FinanceBillResource>>(`${basePath}/bills`, { ...payload, currency: 'CNY' })
|
||||
const response = await http.post<ApiEnvelope<FinanceBillResource>, ApiEnvelope<FinanceBillResource>>(`${basePath}/bills`, { ...payload }, mutationHeaders('bill'))
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listFinanceReceipts(params: Record<string, string | number | undefined>) {
|
||||
const response = await http.get<ApiEnvelope<PageResult<FinanceReceiptResource>>, ApiEnvelope<PageResult<FinanceReceiptResource>>>(`${basePath}/receipts`, { params: { ...params, currency: 'CNY' } })
|
||||
export async function getFinanceBill(billId: string) {
|
||||
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/bills/${billId}`)
|
||||
return normalizeBillDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function updateFinanceBill(billId: string, payload: Record<string, unknown>) {
|
||||
const response = await http.put<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/bills/${billId}`, payload, mutationHeaders('bill-update'))
|
||||
return normalizeBillDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function adjustFinanceBill(billId: string, payload: Record<string, unknown>) {
|
||||
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/bills/${billId}/adjust`, payload, mutationHeaders('bill-adjust'))
|
||||
return normalizeBillDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function voidFinanceBill(billId: string, expectedVersion: number, reason: string) {
|
||||
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/bills/${billId}/void`, { expectedVersion, reason }, mutationHeaders('bill-void'))
|
||||
return normalizeBillDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function listFinanceReceipts(customerId?: string) {
|
||||
const response = await http.get<ApiEnvelope<RawPage>, ApiEnvelope<RawPage>>(`${basePath}/receipts`, { params: { customerId: customerId || undefined } })
|
||||
const payload = unwrapApiResponse(response)
|
||||
const normalized = normalizePage<FinanceReceiptResource>(payload, (item) => ({
|
||||
id: stringValue(item.id), customerId: stringValue(item.tenant_id), customerName: `客户 ${stringValue(item.tenant_id)}`,
|
||||
receivedDate: stringValue(item.received_at).slice(0, 10), amount: numberValue(item.received_amount), currency: 'CNY',
|
||||
paymentMethod: stringValue(item.payment_method) as FinancePaymentMethod, voucherFileIds: fileIds(item.voucher_file_ids),
|
||||
allocationCount: item.bill_id == null ? 0 : 1,
|
||||
}))
|
||||
const grouped = new Map<string, FinanceReceiptResource>()
|
||||
for (const item of normalized.records) {
|
||||
const existing = grouped.get(item.id)
|
||||
if (existing) existing.allocationCount += item.allocationCount
|
||||
else grouped.set(item.id, { ...item })
|
||||
}
|
||||
return { ...normalized, records: [...grouped.values()], total: normalized.serverPaged ? normalized.total : grouped.size }
|
||||
}
|
||||
|
||||
export async function createFinanceReceipt(payload: Record<string, unknown>, voucherFileIds: string[]) {
|
||||
const response = await http.post<ApiEnvelope<FinanceReceiptResource>, ApiEnvelope<FinanceReceiptResource>>(`${basePath}/receipts`, { ...payload, currency: 'CNY', voucherFileIds }, { headers: { 'Idempotency-Key': idempotencyKey('receipt') } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function createFinanceReceipt(payload: Record<string, unknown>, voucher?: File) {
|
||||
const body = new FormData()
|
||||
body.append('payload', JSON.stringify({ ...payload, currency: 'CNY' }))
|
||||
if (voucher) body.append('file', voucher)
|
||||
const response = await http.post<ApiEnvelope<FinanceReceiptResource>, ApiEnvelope<FinanceReceiptResource>>(`${basePath}/receipts`, body)
|
||||
export async function listFinanceCosts(customerId?: string) {
|
||||
const response = await http.get<ApiEnvelope<RawPage>, ApiEnvelope<RawPage>>(`${basePath}/cost-entries`, { params: { customerId: customerId || undefined } })
|
||||
return normalizePage<FinanceCostResource>(unwrapApiResponse(response), (item) => ({
|
||||
id: stringValue(item.id), customerId: stringValue(item.tenant_id), costType: stringValue(item.cost_type) as FinanceCostResource['costType'],
|
||||
orderId: item.order_id == null ? null : stringValue(item.order_id), containerId: item.container_id == null ? null : stringValue(item.container_id),
|
||||
expenseName: stringValue(item.fee_item_name ?? item.expense_name), amount: numberValue(item.cost_amount ?? item.amount), currency: 'CNY',
|
||||
voucherFileIds: fileIds(item.voucher_file_ids), createdAt: stringValue(item.created_at),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createFinanceCost(payload: Record<string, unknown>, voucherFileIds: string[]) {
|
||||
const response = await http.post<ApiEnvelope<FinanceCostResource>, ApiEnvelope<FinanceCostResource>>(`${basePath}/cost-entries`, { ...payload, currency: 'CNY', voucherFileIds }, { headers: { 'Idempotency-Key': idempotencyKey('cost') } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listFinanceCosts(params: Record<string, string | number | undefined>) {
|
||||
const response = await http.get<ApiEnvelope<PageResult<FinanceCostResource>>, ApiEnvelope<PageResult<FinanceCostResource>>>(`${basePath}/costs`, { params: { ...params, currency: 'CNY' } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function createFinanceCost(payload: Record<string, unknown>, voucher?: File) {
|
||||
const body = new FormData()
|
||||
body.append('payload', JSON.stringify({ ...payload, currency: 'CNY' }))
|
||||
if (voucher) body.append('file', voucher)
|
||||
const response = await http.post<ApiEnvelope<FinanceCostResource>, ApiEnvelope<FinanceCostResource>>(`${basePath}/costs`, body)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listFinanceReports(params: Record<string, string | number | undefined>) {
|
||||
const response = await http.get<ApiEnvelope<FinanceReportResource[]>, ApiEnvelope<FinanceReportResource[]>>(`${basePath}/reports`, { params: { ...params, currency: 'CNY' } })
|
||||
export async function issueFinanceBill(billId: string, expectedVersion: number) {
|
||||
const response = await http.post<ApiEnvelope<FinanceBillResource>, ApiEnvelope<FinanceBillResource>>(`${basePath}/bills/${billId}/issue`, { expectedVersion }, mutationHeaders('bill-issue'))
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceManualPage.vue'), 'utf8')
|
||||
|
||||
describe('FinanceManualPage delivery contract', () => {
|
||||
it('keeps every business table operation column first and fixed left', () => {
|
||||
const declarations = [...source.matchAll(/return \[([^\n]+)\]/g)].map((match) => match[1] || '').filter((value) => value.includes("key: 'actions'"))
|
||||
expect(declarations).toHaveLength(3)
|
||||
for (const declaration of declarations) {
|
||||
expect(declaration.trim().startsWith("{ key: 'actions'")).toBe(true)
|
||||
expect(declaration).toContain("fixed: 'left'")
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes supported bill operations with permission checks and traceable errors', () => {
|
||||
expect(source).toContain("platform:finance:bill:update")
|
||||
expect(source).toContain("platform:finance:bill:adjust")
|
||||
expect(source).toContain("platform:finance:bill:void")
|
||||
expect(source).toContain("openBill(row, 'detail')")
|
||||
expect(source).toContain("openBill(row, 'edit')")
|
||||
expect(source).toContain("openBill(row, 'adjust')")
|
||||
expect(source).toContain('<ApiErrorMeta :details="actionError"')
|
||||
expect(source).toContain('<ApiErrorMeta :details="drawerError"')
|
||||
})
|
||||
|
||||
it('blocks uncontracted voucher uploads and declares narrow-screen layouts', () => {
|
||||
expect(source).toContain('凭证上传接口尚未接通')
|
||||
expect(source).toContain('type="file" accept="image/*" disabled')
|
||||
expect(source).toContain('@media (max-width: 720px)')
|
||||
expect(source).toContain('@media (max-width: 480px)')
|
||||
expect(source).toContain('grid-template-columns: 1fr')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { FileUp, Plus, Save } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, FileCheck2, FileUp, MoreHorizontal, Pencil, Plus, Save, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createFinanceBill, createFinanceCost, createFinanceReceipt, listFinanceBills, listFinanceCosts, listFinanceReceipts, listFinanceReports } from '../api/finance'
|
||||
import type { FinanceBillResource, FinanceCostResource, FinanceReceiptResource, FinanceReportResource } from '../types'
|
||||
import { adjustFinanceBill, createFinanceBill, getFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts, updateFinanceBill, voidFinanceBill } from '../api/finance'
|
||||
import type { FinanceBillDetail, FinanceBillResource, FinanceCostResource, FinanceReceiptResource } from '../types'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
@@ -15,72 +15,175 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
|
||||
type Mode = 'bills' | 'receipts' | 'costs' | 'reports'
|
||||
type Mode = 'bills' | 'receipts' | 'costs'
|
||||
type DrawerMode = 'create' | 'detail' | 'edit' | 'adjust'
|
||||
type TableRow = Record<string, string | number | null>
|
||||
const props = defineProps<{ mode: Mode }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<TableRow[]>([])
|
||||
const allRows = ref<TableRow[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const serverPaged = ref(false)
|
||||
const filters = reactive<QueryModel>({ keyword: '', customerId: '', status: '' })
|
||||
const drawerOpen = ref(false)
|
||||
const drawerMode = ref<DrawerMode>('create')
|
||||
const drawerLoading = ref(false)
|
||||
const drawerError = ref<ApiErrorDetails | null>(null)
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedBill = ref<FinanceBillDetail | null>(null)
|
||||
const saving = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const file = ref<File | null>(null)
|
||||
const form = reactive({ customerId: '', billDate: '', amount: '', paymentMethod: '', referenceNo: '', costType: 'WAYBILL', feeName: '', supplierName: '', remark: '' })
|
||||
const form = reactive({ customerId: '', billDate: '', amount: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', costType: 'SINGLE_ORDER', feeName: '', remark: '' })
|
||||
const allocations = reactive([{ billId: '', amount: '' }])
|
||||
const billLines = reactive([{ sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' }])
|
||||
|
||||
const title = computed(() => ({ bills: '客户账单', receipts: '收款登记', costs: '成本录入', reports: '财务统计' }[props.mode]))
|
||||
const viewPermission = computed(() => ({ bills: 'platform:finance:bill:view', receipts: 'platform:finance:receipt:view', costs: 'platform:finance:cost:view', reports: 'platform:finance:report:view' }[props.mode]))
|
||||
const createPermission = computed(() => ({ bills: 'platform:finance:bill:create', receipts: 'platform:finance:receipt:create', costs: 'platform:finance:cost:create', reports: '' }[props.mode]))
|
||||
const title = computed(() => ({ bills: '客户账单', receipts: '收款登记', costs: '成本录入' }[props.mode]))
|
||||
const viewPermission = computed(() => ({ bills: 'platform:finance:bill:view', receipts: 'platform:finance:receipt:view', costs: 'platform:finance:cost:view' }[props.mode]))
|
||||
const createPermission = computed(() => ({ bills: 'platform:finance:bill:create', receipts: 'platform:finance:receipt:create', costs: 'platform:finance:cost:create' }[props.mode]))
|
||||
const canView = computed(() => access.can(viewPermission.value))
|
||||
const canCreate = computed(() => Boolean(createPermission.value) && access.can(createPermission.value))
|
||||
const canIssue = computed(() => access.can('platform:finance:bill:issue'))
|
||||
const canUpdate = computed(() => access.can('platform:finance:bill:update'))
|
||||
const canAdjust = computed(() => access.can('platform:finance:bill:adjust'))
|
||||
const canVoid = computed(() => access.can('platform:finance:bill:void'))
|
||||
const drawerTitle = computed(() => {
|
||||
if (props.mode !== 'bills') return props.mode === 'receipts' ? '登记人工收款' : '录入人工成本'
|
||||
return { create: '新建客户账单', detail: '账单详情', edit: '编辑草稿账单', adjust: '调整已出账账单' }[drawerMode.value]
|
||||
})
|
||||
const rows = computed(() => serverPaged.value ? allRows.value : allRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '客户/单号/费用项目', props: { 'aria-label': '关键词' } },
|
||||
{ key: 'customerId', type: 'input', label: '客户', width: 180, placeholder: '请输入客户名称或ID', props: { 'aria-label': '客户' } },
|
||||
...(props.mode === 'bills' ? [{ key: 'status', type: 'select' as const, label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '草稿', value: 'DRAFT' }, { label: '已出账', value: 'ISSUED' }, { label: '部分收款', value: 'PARTIALLY_PAID' }, { label: '已收清', value: 'PAID' }, { label: '作废', value: 'VOID' }] }] : []),
|
||||
{ key: 'customerId', type: 'input', label: '客户ID', width: 180, placeholder: props.mode === 'bills' ? '查询账单时必填' : '留空查询全部', props: { 'aria-label': '客户ID' } },
|
||||
...(props.mode === 'bills' ? [{ key: 'status', type: 'select' as const, label: '状态', width: 160, placeholder: '全部状态', options: [{ label: '草稿', value: 'DRAFT' }, { label: '已出账', value: 'ISSUED' }, { label: '部分收款', value: 'PARTIALLY_PAID' }, { label: '已收清', value: 'PAID' }, { label: '作废', value: 'VOIDED' }] }] : []),
|
||||
])
|
||||
const columns = computed<DataTableColumn[]>(() => {
|
||||
if (props.mode === 'bills') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '账单编号', minWidth: 170 }, { key: 'customerName', label: '客户', minWidth: 150 }, { key: 'billDate', label: '账单日期', width: 120 }, { key: 'dueDate', label: '到期日', width: 120 }, { key: 'amount', label: '账单金额(CNY)', width: 140 }, { key: 'paidAmount', label: '已收(CNY)', width: 120 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 120 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }]
|
||||
if (props.mode === 'bills') return [{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '账单编号', minWidth: 170 }, { key: 'customerName', label: '客户', minWidth: 150 }, { key: 'billDate', label: '账单日期', width: 120 }, { key: 'dueDate', label: '到期日', width: 120 }, { key: 'amount', label: '账单金额(CNY)', width: 140 }, { key: 'paidAmount', label: '已收(CNY)', width: 120 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 120 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }]
|
||||
if (props.mode === 'receipts') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'customerName', label: '客户', minWidth: 150 }, { key: 'receivedAt', label: '收款日期', width: 130 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'paymentMethod', label: '收款方式', width: 130 }, { key: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'allocationCount', label: '关联账单数', width: 120 }]
|
||||
if (props.mode === 'costs') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'costType', label: '成本类型', width: 130, slotName: 'costType' }, { key: 'referenceNo', label: '关联单号', minWidth: 160 }, { key: 'feeName', label: '费用项目', minWidth: 160 }, { key: 'supplierName', label: '成本对象', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'createdAt', label: '登记时间', width: 150 }]
|
||||
return [{ key: 'customerName', label: '客户', minWidth: 180 }, { key: 'billedAmount', label: '账单收入(CNY)', width: 150 }, { key: 'receivedAmount', label: '已收(CNY)', width: 130 }, { key: 'costAmount', label: '成本(CNY)', width: 130 }, { key: 'profitAmount', label: '利润(CNY)', width: 130 }]
|
||||
if (props.mode === 'costs') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'costType', label: '成本类型', width: 130, slotName: 'costType' }, { key: 'referenceNo', label: '关联单号', minWidth: 160 }, { key: 'feeName', label: '费用项目', minWidth: 160 }, { key: 'supplierName', label: '客户', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'createdAt', label: '登记时间', width: 150 }]
|
||||
return []
|
||||
})
|
||||
const rules: FormRules = { customerId: [{ required: true, message: '请输入客户', trigger: 'blur' }], amount: [{ required: true, message: '请输入金额', trigger: 'blur' }] }
|
||||
const rules: FormRules = {
|
||||
customerId: [{ required: true, message: '请输入客户ID', trigger: 'blur' }],
|
||||
billDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
|
||||
amount: [{ required: true, message: '请输入金额', trigger: 'blur' }],
|
||||
referenceNo: [{ required: true, message: '请输入关联ID', trigger: 'blur' }],
|
||||
feeName: [{ required: true, message: '请输入费用项目', trigger: 'blur' }],
|
||||
paymentMethod: [{ required: true, message: '请选择收款方式', trigger: 'change' }],
|
||||
}
|
||||
|
||||
function mapBill(item: FinanceBillResource): TableRow { return { id: item.id, billNo: item.billNo, customerName: item.customerName, billDate: item.billDate, dueDate: item.dueDate, amount: item.totalAmount, paidAmount: item.paidAmount, outstandingAmount: item.outstandingAmount, status: item.status } }
|
||||
function mapReceipt(item: FinanceReceiptResource): TableRow { return { id: item.id, customerName: item.customerName, receivedAt: item.receivedAt, amount: item.amount, paymentMethod: item.paymentMethod, voucherFileName: item.voucherFileName || '未上传', allocationCount: item.allocationCount } }
|
||||
function mapCost(item: FinanceCostResource): TableRow { return { id: item.id, costType: item.costType, referenceNo: item.referenceNo, feeName: item.feeName, supplierName: item.supplierName || '—', amount: item.amount, voucherFileName: item.voucherFileName || '未上传', createdAt: item.createdAt } }
|
||||
function mapReport(item: FinanceReportResource): TableRow { return { customerName: item.customerName, billedAmount: item.billedAmount, receivedAmount: item.receivedAmount, costAmount: item.costAmount, profitAmount: item.profitAmount } }
|
||||
function mapBill(item: FinanceBillResource): TableRow { return { id: item.id, billNo: item.billNo, customerName: item.customerName, billDate: item.billDate, dueDate: item.dueDate, amount: item.totalAmount, paidAmount: item.paidAmount, outstandingAmount: item.outstandingAmount, status: item.status, version: item.version } }
|
||||
function mapReceipt(item: FinanceReceiptResource): TableRow { return { id: item.id, customerName: item.customerName, receivedAt: item.receivedDate, amount: item.amount, paymentMethod: item.paymentMethod, voucherFileName: item.voucherFileIds.length ? `${item.voucherFileIds.length} 个文件` : '未上传', allocationCount: item.allocationCount } }
|
||||
function mapCost(item: FinanceCostResource): TableRow { return { id: item.id, costType: item.costType, referenceNo: item.orderId || item.containerId || '—', feeName: item.expenseName, supplierName: `客户 ${item.customerId}`, amount: item.amount, voucherFileName: item.voucherFileIds.length ? `${item.voucherFileIds.length} 个文件` : '未上传', createdAt: item.createdAt } }
|
||||
|
||||
async function load() {
|
||||
if (!canView.value) return
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const params = { keyword: String(filters.keyword || ''), customerId: String(filters.customerId || ''), status: String(filters.status || ''), page: page.value, size: pageSize.value }
|
||||
if (props.mode === 'bills') { const result = await listFinanceBills(params); rows.value = result.records.map(mapBill); total.value = result.total }
|
||||
else if (props.mode === 'receipts') { const result = await listFinanceReceipts(params); rows.value = result.records.map(mapReceipt); total.value = result.total }
|
||||
else if (props.mode === 'costs') { const result = await listFinanceCosts(params); rows.value = result.records.map(mapCost); total.value = result.total }
|
||||
else { rows.value = (await listFinanceReports(params)).map(mapReport); total.value = rows.value.length }
|
||||
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, `${title.value}加载失败,请检查 Finance 接口后重试`) }
|
||||
const customerId = String(filters.customerId || '').trim()
|
||||
if (props.mode === 'bills' && !customerId) { allRows.value = []; total.value = 0; return }
|
||||
let source: TableRow[]
|
||||
let responseTotal = 0
|
||||
if (props.mode === 'bills') {
|
||||
const result = await listFinanceBills({ customerId, status: String(filters.status || '') || undefined, page: page.value, pageSize: pageSize.value })
|
||||
source = result.records.map(mapBill)
|
||||
responseTotal = result.total; serverPaged.value = result.serverPaged
|
||||
} else if (props.mode === 'receipts') {
|
||||
const result = await listFinanceReceipts(customerId); source = result.records.map(mapReceipt); responseTotal = result.total; serverPaged.value = result.serverPaged
|
||||
} else {
|
||||
const result = await listFinanceCosts(customerId); source = result.records.map(mapCost); responseTotal = result.total; serverPaged.value = result.serverPaged
|
||||
}
|
||||
const keyword = String(filters.keyword || '').trim().toLowerCase()
|
||||
const status = String(filters.status || '')
|
||||
allRows.value = source.filter((row) => (!keyword || Object.values(row).some((value) => String(value ?? '').toLowerCase().includes(keyword))) && (!status || row.status === status))
|
||||
total.value = keyword || status ? allRows.value.length : responseTotal
|
||||
} catch (error) { allRows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, `${title.value}加载失败,请检查 Finance 接口后重试`) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function applyFilters(model: QueryModel) { Object.assign(filters, model); page.value = 1; void load() }
|
||||
function openCreate() { Object.assign(form, { customerId: '', billDate: new Date().toISOString().slice(0, 10), amount: '', paymentMethod: '', referenceNo: '', costType: 'WAYBILL', feeName: '', supplierName: '', remark: '' }); file.value = null; drawerOpen.value = true }
|
||||
function changePageSize() { page.value = 1; void load() }
|
||||
function openCreate() {
|
||||
drawerMode.value = 'create'; selectedBill.value = null; drawerError.value = null
|
||||
Object.assign(form, { customerId: '', billDate: new Date().toISOString().slice(0, 10), amount: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', costType: 'SINGLE_ORDER', feeName: '', remark: '' })
|
||||
allocations.splice(0, allocations.length, { billId: '', amount: '' })
|
||||
billLines.splice(0, billLines.length, { sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' })
|
||||
file.value = null; drawerOpen.value = true
|
||||
}
|
||||
function selectFile(event: Event) { const selected = (event.target as HTMLInputElement).files?.[0] || null; file.value = selected }
|
||||
function positiveNumber(value: string) { const amount = Number(value); return Number.isFinite(amount) && amount > 0 }
|
||||
function addAllocation() { allocations.push({ billId: '', amount: '' }) }
|
||||
function removeAllocation(index: number) { if (allocations.length > 1) allocations.splice(index, 1) }
|
||||
function addBillLine() { billLines.push({ sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' }) }
|
||||
function removeBillLine(index: number) { if (billLines.length > 1) billLines.splice(index, 1) }
|
||||
function billPayloadLines() { return billLines.map((line) => ({ sourceFeeId: line.sourceFeeId, sourceFeeVersion: line.sourceFeeVersion, orderId: line.orderId || undefined, inboundId: line.inboundId || undefined, feeItemName: line.feeItemName.trim(), amount: Number(line.amount) })) }
|
||||
function validateBusinessForm() {
|
||||
if (!/^\d+$/.test(form.customerId)) return '客户ID必须为数字'
|
||||
if (!form.billDate) return '请选择日期'
|
||||
if (props.mode !== 'bills' && !positiveNumber(form.amount)) return '金额必须大于 0'
|
||||
if (props.mode === 'bills' && billLines.some((line) => !/^\d+$/.test(line.sourceFeeId) || !line.feeItemName.trim() || !positiveNumber(line.amount))) return '每条账单费用都必须填写数字格式的费用来源ID、费用项目和大于 0 的金额'
|
||||
if (drawerMode.value === 'adjust' && !form.remark.trim()) return '调整原因不能为空'
|
||||
if (props.mode === 'costs' && (!/^\d+$/.test(form.referenceNo) || !form.feeName.trim())) return `请填写数字格式的${form.costType === 'SINGLE_ORDER' ? '订单ID' : '柜次ID'}和费用项目`
|
||||
if (props.mode === 'receipts') {
|
||||
if (allocations.some((item) => !/^\d+$/.test(item.billId) || !positiveNumber(item.amount))) return '每条分配都必须填写数字格式的账单ID和大于 0 的金额'
|
||||
const allocated = allocations.reduce((sum, item) => sum + Number(item.amount), 0)
|
||||
if (Math.abs(allocated - Number(form.amount)) > 0.000001) return `分配金额合计 ${allocated.toFixed(2)} 元,必须等于收款金额`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
async function submit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false); if (!valid) return
|
||||
const validationMessage = validateBusinessForm(); if (validationMessage) { ElMessage.warning(validationMessage); return }
|
||||
if (props.mode !== 'bills') { ElMessage.error('凭证上传接口尚未接通,当前不能保存收款或成本,避免形成无凭证台账'); return }
|
||||
saving.value = true
|
||||
drawerError.value = null
|
||||
try {
|
||||
if (props.mode === 'bills') await createFinanceBill({ customerId: form.customerId, billDate: form.billDate, amount: Number(form.amount), remark: form.remark })
|
||||
else if (props.mode === 'receipts') await createFinanceReceipt({ customerId: form.customerId, receivedAt: form.billDate, amount: Number(form.amount), paymentMethod: form.paymentMethod, allocations: form.referenceNo.split(',').map((billId) => ({ billId: billId.trim(), amount: Number(form.amount) })).filter((item) => item.billId) }, file.value || undefined)
|
||||
else await createFinanceCost({ costType: form.costType, referenceNo: form.referenceNo, feeName: form.feeName, supplierName: form.supplierName, amount: Number(form.amount), remark: form.remark }, file.value || undefined)
|
||||
const lines = billPayloadLines()
|
||||
if (drawerMode.value === 'edit' && selectedBill.value) await updateFinanceBill(selectedBill.value.id, { customerId: form.customerId, billDate: form.billDate, lines, expectedVersion: selectedBill.value.version })
|
||||
else if (drawerMode.value === 'adjust' && selectedBill.value) await adjustFinanceBill(selectedBill.value.id, { expectedVersion: selectedBill.value.version, reason: form.remark.trim(), lines })
|
||||
else await createFinanceBill({ customerId: form.customerId, billDate: form.billDate, lines })
|
||||
drawerOpen.value = false; ElMessage.success(`${title.value}已保存`); await load()
|
||||
} catch (error) { const details = getApiErrorDetails(error, `${title.value}保存失败,请检查 Finance 接口后重试`); ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message) }
|
||||
} catch (error) { const details = getApiErrorDetails(error, `${title.value}保存失败,请检查 Finance 接口后重试`); drawerError.value = details; ElMessage.error(details.message) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function issueBill(row: TableRow) {
|
||||
actionError.value = null
|
||||
try {
|
||||
await ElMessageBox.confirm('出账后账单将冻结,不能再按草稿方式修改。确认出账吗?', '确认出账', { type: 'warning', confirmButtonText: '确认出账', cancelButtonText: '取消' })
|
||||
await issueFinanceBill(String(row.id), Number(row.version))
|
||||
ElMessage.success('账单已出账并冻结'); await load()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
const details = getApiErrorDetails(error, '账单出账失败,请重试'); actionError.value = details; ElMessage.error(details.message)
|
||||
}
|
||||
}
|
||||
async function openBill(row: TableRow, mode: DrawerMode) {
|
||||
drawerMode.value = mode; drawerOpen.value = true; drawerLoading.value = true; drawerError.value = null; selectedBill.value = null
|
||||
try {
|
||||
const detail = await getFinanceBill(String(row.id)); selectedBill.value = detail
|
||||
Object.assign(form, { customerId: detail.customerId, billDate: detail.billDate, remark: '' })
|
||||
billLines.splice(0, billLines.length, ...detail.lines.map((line) => ({ sourceFeeId: line.sourceFeeId, sourceFeeVersion: line.sourceFeeVersion, feeItemName: line.feeItemName, amount: String(line.amount), orderId: line.orderId || '', inboundId: line.inboundId || '' })))
|
||||
if (!billLines.length) addBillLine()
|
||||
} catch (error) { drawerError.value = getApiErrorDetails(error, '账单详情加载失败,请重试') }
|
||||
finally { drawerLoading.value = false }
|
||||
}
|
||||
async function voidBill(row: TableRow) {
|
||||
actionError.value = null
|
||||
try {
|
||||
const result = await ElMessageBox.prompt('请输入作废原因。已有收款的账单不能作废。', '作废账单', { type: 'warning', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '作废原因不能为空', confirmButtonText: '确认作废', cancelButtonText: '取消' })
|
||||
await voidFinanceBill(String(row.id), Number(row.version), result.value.trim())
|
||||
ElMessage.success('账单已作废'); await load()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
const details = getApiErrorDetails(error, '账单作废失败,请重试'); actionError.value = details; ElMessage.error(details.message)
|
||||
}
|
||||
}
|
||||
function handleBillCommand(command: string, row: TableRow) {
|
||||
if (command === 'issue') void issueBill(row)
|
||||
else if (command === 'void') void voidBill(row)
|
||||
}
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
@@ -90,47 +193,101 @@ onMounted(() => void load())
|
||||
<ListPageLayout v-else aria-label="财务人工模块">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="queryFields" :preference-key="`finance-${props.mode}-query`" @search="applyFilters" /></template>
|
||||
<template v-if="loadError" #state><LoadErrorState :title="`${title}加载失败`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<div v-if="actionError" class="surface-panel operation-error" role="alert"><el-alert type="error" :closable="true" :title="actionError.message" @close="actionError = null" /><ApiErrorMeta :details="actionError" /></div>
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="props.mode === 'reports' ? undefined : total" :preference-key="`finance-${props.mode}-table`" :aria-label="`${title}列表`" row-key="id" empty-text="暂无数据">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`finance-${props.mode}-table`" :aria-label="`${title}列表`" row-key="id" empty-text="暂无数据" @update:page="load" @update:page-size="changePageSize">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.mode === 'bills' ? '新建账单' : props.mode === 'receipts' ? '登记收款' : '录入成本' }}</el-button></template>
|
||||
<template #actions><span class="muted-value">人工台账</span></template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOID' ? 'info' : 'warning'">{{ row.status === 'DRAFT' ? '草稿' : row.status === 'ISSUED' ? '已出账' : row.status === 'PARTIALLY_PAID' ? '部分收款' : row.status === 'PAID' ? '已收清' : '作废' }}</el-tag></template>
|
||||
<template #costType="{ row }">{{ row.costType === 'CONTAINER' ? '整柜公共成本' : '单票成本' }}</template>
|
||||
<template #actions="{ row }">
|
||||
<div v-if="props.mode === 'bills'" class="row-actions">
|
||||
<el-button link type="primary" @click="openBill(row, 'detail')"><Eye :size="15" />查看</el-button>
|
||||
<el-button v-if="row.status === 'DRAFT' && canUpdate" link type="primary" @click="openBill(row, 'edit')"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="['ISSUED', 'PARTIALLY_PAID', 'PAID'].includes(String(row.status)) && canAdjust" link type="primary" @click="openBill(row, 'adjust')"><Pencil :size="15" />调整</el-button>
|
||||
<el-button v-if="row.status === 'ISSUED' && canVoid" link type="danger" @click="voidBill(row)"><Trash2 :size="15" />作废</el-button>
|
||||
<el-dropdown v-if="row.status === 'DRAFT' && (canIssue || canVoid)" trigger="click" @command="handleBillCommand($event, row)">
|
||||
<el-button link aria-label="更多账单操作"><MoreHorizontal :size="17" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canIssue" command="issue"><FileCheck2 :size="15" />出账</el-dropdown-item><el-dropdown-item v-if="canVoid" command="void" divided><Trash2 :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<span v-else class="muted-value">人工台账</span>
|
||||
</template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ row.status === 'DRAFT' ? '草稿' : row.status === 'ISSUED' ? '已出账' : row.status === 'PARTIALLY_PAID' ? '部分收款' : row.status === 'PAID' ? '已收清' : '作废' }}</el-tag></template>
|
||||
<template #costType="{ row }">{{ row.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</template>
|
||||
<template #empty><el-empty description="暂无数据;后端接口接通后将显示真实财务记录" /></template>
|
||||
<template #pagination-left>币种:CNY · 数据由平台财务人工维护</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<AppDrawer v-model="drawerOpen" :title="props.mode === 'bills' ? '新建客户账单' : props.mode === 'receipts' ? '登记人工收款' : '录入人工成本'" size="min(620px, 94vw)" :confirm-loading="saving">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
|
||||
<AppDrawer v-model="drawerOpen" :title="drawerTitle" size="min(760px, 94vw)" :loading="drawerLoading" :confirm-loading="saving">
|
||||
<div v-if="drawerError" class="drawer-error" role="alert"><el-alert type="error" :closable="false" :title="drawerError.message" /><ApiErrorMeta :details="drawerError" /></div>
|
||||
<template v-if="props.mode === 'bills' && drawerMode === 'detail' && selectedBill">
|
||||
<el-descriptions :column="2" border class="bill-summary">
|
||||
<el-descriptions-item label="账单编号">{{ selectedBill.billNo }}</el-descriptions-item><el-descriptions-item label="当前版本">v{{ selectedBill.version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户">{{ selectedBill.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ selectedBill.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账单日期">{{ selectedBill.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedBill.dueDate }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账单金额">{{ selectedBill.totalAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedBill.outstandingAmount }} CNY</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<FormSection title="费用明细" description="当前账单版本中的真实费用明细。">
|
||||
<div v-for="(line, index) in selectedBill.lines" :key="`${line.sourceFeeId}-${index}`" class="detail-line"><strong>{{ line.feeItemName }}</strong><span>来源ID:{{ line.sourceFeeId }}</span><b>{{ line.amount }} CNY</b></div>
|
||||
<el-empty v-if="!selectedBill.lines.length" description="暂无费用明细" />
|
||||
</FormSection>
|
||||
<el-alert type="info" :closable="false" show-icon title="后端已保存调整快照;修订记录查询接口尚未提供,当前只能查看最新版本号。" />
|
||||
</template>
|
||||
<el-form v-else-if="drawerMode !== 'detail'" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || drawerLoading">
|
||||
<FormSection title="基本信息" description="首期只支持 CNY,由平台财务人工填写并留存凭证。">
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="客户" prop="customerId"><el-input v-model="form.customerId" placeholder="请输入客户名称或ID" /></el-form-item>
|
||||
<el-form-item label="日期"><el-date-picker v-model="form.billDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" /></el-form-item>
|
||||
<el-form-item label="客户ID" prop="customerId"><el-input v-model="form.customerId" inputmode="numeric" placeholder="请输入客户ID" :disabled="props.mode === 'bills' && drawerMode !== 'create'" /></el-form-item>
|
||||
<el-form-item label="日期" prop="billDate"><el-date-picker v-model="form.billDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" :disabled="props.mode === 'bills' && drawerMode === 'adjust'" /></el-form-item>
|
||||
</div>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item v-if="props.mode === 'costs'" label="成本类型"><el-select v-model="form.costType" style="width: 100%"><el-option label="单票成本" value="WAYBILL" /><el-option label="整柜公共成本" value="CONTAINER" /></el-select></el-form-item>
|
||||
<el-form-item label="金额(CNY)" prop="amount"><el-input v-model="form.amount" inputmode="decimal" placeholder="请输入金额" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="props.mode !== 'bills'" label="关联单号/账单ID"><el-input v-model="form.referenceNo" placeholder="多个账单用逗号分隔" /></el-form-item>
|
||||
<el-form-item v-if="props.mode === 'receipts'" label="收款方式"><el-input v-model="form.paymentMethod" placeholder="例如:银行转账" /></el-form-item>
|
||||
<el-form-item v-if="props.mode === 'costs'" label="费用项目"><el-input v-model="form.feeName" placeholder="例如:清关费、拖车费" /></el-form-item>
|
||||
<el-form-item v-if="props.mode === 'costs'" label="供应商/成本对象"><el-input v-model="form.supplierName" placeholder="请输入成本对象" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" /></el-form-item>
|
||||
</FormSection>
|
||||
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="当前按人工上传凭证设计;后端未提供接口时会明确返回失败,不使用假数据。">
|
||||
<label class="file-picker"><FileUp :size="16" />{{ file?.name || '选择凭证图片' }}<input type="file" accept="image/*,.pdf" @change="selectFile" /></label>
|
||||
<template v-if="props.mode === 'bills'">
|
||||
<div class="allocation-title">费用明细</div>
|
||||
<div v-for="(line, index) in billLines" :key="index" class="bill-line-row">
|
||||
<el-input v-model="line.sourceFeeId" inputmode="numeric" placeholder="费用来源ID" />
|
||||
<el-input v-model="line.feeItemName" placeholder="费用项目" />
|
||||
<el-input v-model="line.amount" inputmode="decimal" placeholder="金额(CNY)" />
|
||||
<el-button link type="danger" aria-label="删除费用明细" @click="removeBillLine(index)"><Trash2 :size="16" /></el-button>
|
||||
</div>
|
||||
<el-button link type="primary" @click="addBillLine"><Plus :size="15" />添加费用明细</el-button>
|
||||
<el-form-item v-if="drawerMode === 'adjust'" label="调整原因" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" /></el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item v-if="props.mode === 'costs'" label="成本类型"><el-select v-model="form.costType" style="width: 100%"><el-option label="单票成本" value="SINGLE_ORDER" /><el-option label="整柜公共成本" value="CONTAINER_SHARED" /></el-select></el-form-item>
|
||||
<el-form-item label="金额(CNY)" prop="amount"><el-input v-model="form.amount" inputmode="decimal" placeholder="请输入金额" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="props.mode === 'costs'" label="关联ID" prop="referenceNo"><el-input v-model="form.referenceNo" :placeholder="form.costType === 'SINGLE_ORDER' ? '请输入订单ID' : '请输入柜次ID'" /></el-form-item>
|
||||
<template v-if="props.mode === 'receipts'">
|
||||
<el-form-item label="收款方式" prop="paymentMethod"><el-select v-model="form.paymentMethod" style="width: 100%"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item>
|
||||
<div class="allocation-title">账单分配</div>
|
||||
<div v-for="(allocation, index) in allocations" :key="index" class="allocation-row"><el-input v-model="allocation.billId" placeholder="账单ID" /><el-input v-model="allocation.amount" inputmode="decimal" placeholder="分配金额(CNY)" /><el-button link type="danger" aria-label="删除分配" @click="removeAllocation(index)"><Trash2 :size="16" /></el-button></div>
|
||||
<el-button link type="primary" @click="addAllocation"><Plus :size="15" />添加账单分配</el-button>
|
||||
</template>
|
||||
<el-form-item v-if="props.mode === 'costs'" label="费用项目" prop="feeName"><el-input v-model="form.feeName" placeholder="例如:清关费、拖车费" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" /></el-form-item>
|
||||
</template>
|
||||
</FormSection>
|
||||
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="后端上传契约提供后才允许形成财务台账。"><el-alert type="warning" :closable="false" show-icon title="凭证上传接口尚未接通;收款和成本保存已暂时阻止。" /><label class="file-picker is-disabled"><FileUp :size="16" />凭证选择将在接口接通后启用<input type="file" accept="image/*" disabled @change="selectFile" /></label></FormSection>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="submit"><Save :size="16" />保存</el-button></div></template>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'detail' ? '关闭' : '取消' }}</el-button><el-button v-if="drawerMode !== 'detail'" type="primary" :loading="saving" :disabled="props.mode !== 'bills'" @click="submit"><Save :size="16" />{{ props.mode === 'bills' ? drawerMode === 'adjust' ? '保存调整' : '保存' : '等待凭证接口' }}</el-button></div></template>
|
||||
</AppDrawer>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.finance-page { min-width: 0; }
|
||||
.file-picker { display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; color: var(--cc-text); }
|
||||
.operation-error, .drawer-error { display: grid; gap: 8px; padding: 12px; }
|
||||
.row-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
|
||||
.row-actions .el-button { margin-left: 0; }
|
||||
.file-picker { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; padding: 10px 14px; border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; color: var(--cc-text); overflow-wrap: anywhere; }
|
||||
.file-picker.is-disabled { cursor: not-allowed; color: var(--cc-muted); background: var(--el-fill-color-light); }
|
||||
.file-picker input { display: none; }
|
||||
.drawer-footer { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
@media (max-width: 720px) { :deep(.form-grid.two-cols) { grid-template-columns: 1fr; } }
|
||||
.allocation-title { margin: 4px 0 8px; font-weight: 600; }
|
||||
.allocation-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px; gap: 8px; margin-bottom: 8px; align-items: center; }
|
||||
.bill-line-row { display: grid; grid-template-columns: minmax(110px, .8fr) minmax(140px, 1.2fr) minmax(110px, .7fr) 32px; gap: 8px; margin-bottom: 8px; align-items: center; }
|
||||
.bill-summary { margin-bottom: 16px; }
|
||||
.detail-line { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, .8fr) auto; gap: 12px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.detail-line span { color: var(--cc-muted); overflow-wrap: anywhere; }
|
||||
.detail-line b { white-space: nowrap; }
|
||||
@media (max-width: 720px) { :deep(.form-grid.two-cols) { grid-template-columns: 1fr; } .bill-line-row { grid-template-columns: 1fr 1fr; } .drawer-footer .el-button { min-height: 44px; } :deep(.bill-summary .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
|
||||
@media (max-width: 480px) { .drawer-footer { flex-wrap: wrap; } .drawer-footer .el-button { flex: 1 1 120px; } .allocation-row, .bill-line-row, .detail-line { grid-template-columns: 1fr; } .allocation-row .el-button, .bill-line-row .el-button { justify-self: end; } :deep(.bill-summary .el-descriptions__cell) { display: block; width: 100%; } }
|
||||
</style>
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<script setup lang="ts">import FinanceManualPage from './FinanceManualPage.vue'</script>
|
||||
<template><FinanceManualPage mode="reports" /></template>
|
||||
@@ -4,5 +4,4 @@ export const financeRoutes = [
|
||||
{ path: 'finance/bills', name: 'finance-bills', component: () => import('./pages/FinanceBillsView.vue'), meta: { title: '客户账单', description: '人工维护 CNY 客户账单并在出账后冻结', permission: 'platform:finance:bill:view' } },
|
||||
{ path: 'finance/receipts', name: 'finance-receipts', component: () => import('./pages/FinanceReceiptsView.vue'), meta: { title: '收款登记', description: '人工登记收款并上传凭证图片', permission: 'platform:finance:receipt:view' } },
|
||||
{ path: 'finance/costs', name: 'finance-costs', component: () => import('./pages/FinanceCostsView.vue'), meta: { title: '成本录入', description: '人工登记单票成本和整柜公共成本', permission: 'platform:finance:cost:view' } },
|
||||
{ path: 'finance/reports', name: 'finance-reports', component: () => import('./pages/FinanceReportsView.vue'), meta: { title: '财务统计', description: '按账单统计 CNY 收入、成本和利润', permission: 'platform:finance:report:view' } },
|
||||
] satisfies RouteRecordRaw[]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOID'
|
||||
export type FinanceCostType = 'WAYBILL' | 'CONTAINER'
|
||||
export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
|
||||
export type FinanceCostType = 'SINGLE_ORDER' | 'CONTAINER_SHARED'
|
||||
export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER'
|
||||
|
||||
export interface FinanceBillResource {
|
||||
id: string
|
||||
@@ -13,36 +14,44 @@ export interface FinanceBillResource {
|
||||
paidAmount: number
|
||||
outstandingAmount: number
|
||||
status: FinanceBillStatus
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface FinanceBillLine {
|
||||
sourceFeeId: string
|
||||
sourceFeeVersion: number
|
||||
orderId: string | null
|
||||
inboundId: string | null
|
||||
feeItemName: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface FinanceBillDetail extends FinanceBillResource {
|
||||
remark: string
|
||||
lines: FinanceBillLine[]
|
||||
}
|
||||
|
||||
export interface FinanceReceiptResource {
|
||||
id: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
receivedAt: string
|
||||
receivedDate: string
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
paymentMethod: string
|
||||
voucherFileName: string | null
|
||||
paymentMethod: FinancePaymentMethod
|
||||
voucherFileIds: string[]
|
||||
allocationCount: number
|
||||
}
|
||||
|
||||
export interface FinanceCostResource {
|
||||
id: string
|
||||
costType: FinanceCostType
|
||||
referenceNo: string
|
||||
feeName: string
|
||||
supplierName: string | null
|
||||
customerId: string
|
||||
orderId: string | null
|
||||
containerId: string | null
|
||||
expenseName: string
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
voucherFileName: string | null
|
||||
voucherFileIds: string[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface FinanceReportResource {
|
||||
customerName: string
|
||||
billedAmount: number
|
||||
receivedAmount: number
|
||||
costAmount: number
|
||||
profitAmount: number
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<el-alert v-else-if="mode === 'confirmed'" type="info" :closable="false" show-icon title="装柜完成后清单、明细和运输信息永久只读" />
|
||||
|
||||
<section class="detail-section surface-panel">
|
||||
<header class="detail-section__header"><h2>装柜货物明细</h2><span>{{ detail.items.length }} 条</span></header>
|
||||
<DataTable
|
||||
:data="detail.items"
|
||||
:columns="itemColumns"
|
||||
@@ -116,7 +117,6 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
empty-text="暂无装柜货物明细"
|
||||
aria-label="装柜货物明细"
|
||||
>
|
||||
<template #toolbar-start><h2>装柜货物明细</h2></template>
|
||||
<template #product="{ row }"><strong>{{ row.chineseName || row.englishName || '—' }}</strong><small v-if="row.englishName && row.chineseName">{{ row.englishName }}</small></template>
|
||||
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
|
||||
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
|
||||
@@ -153,7 +153,9 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
.detail-summary__wide { grid-column: span 2; }
|
||||
.detail-actions { display: flex; justify-content: flex-end; }
|
||||
.detail-section, .history-section { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-section :deep(.data-table__toolbar) { min-height: 56px; }
|
||||
.detail-section__header { display: flex; align-items: baseline; gap: 10px; padding: 18px 18px 0; }
|
||||
.detail-section__header h2 { margin: 0; font-size: 16px; }
|
||||
.detail-section__header span { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-section :deep(.el-table) { width: 100%; }
|
||||
.detail-section small { display: block; margin-top: 3px; color: var(--cc-muted); }
|
||||
.history-section { padding: 18px; }
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('LoadingListsView', () => {
|
||||
expect(source).toContain("selectedWarehouseId.value = String(row.warehouseId || '')")
|
||||
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
|
||||
expect(source).not.toContain("'请先选择仓库后查询'")
|
||||
expect(source).toContain('MISSING_WAREHOUSE_CONTEXT')
|
||||
})
|
||||
|
||||
it('uses the shared query, table, drawer, dialog and error components', () => {
|
||||
@@ -53,4 +54,24 @@ describe('LoadingListsView', () => {
|
||||
expect(source).toContain('openEditFromRow(row)')
|
||||
expect(source).toContain('await loadDetail()')
|
||||
})
|
||||
|
||||
it('refreshes the detail version on A0409 while preserving transport input', () => {
|
||||
expect(source).toContain("details.code === 'A0409'")
|
||||
expect(source).toContain('await loadDetail()')
|
||||
expect(source).toContain('当前输入已保留,请核对后重新保存')
|
||||
})
|
||||
|
||||
it('opens a validated plan and warehouse deep link without mixing list modes', () => {
|
||||
expect(source).toContain('useRoute()')
|
||||
expect(source).toContain('route.query.planId')
|
||||
expect(source).toContain('route.query.warehouseId')
|
||||
expect(source).toContain("const expectedStatus = isPreload.value ? 'LOADING' : 'COMPLETED'")
|
||||
expect(source).toContain('MODE_STATUS_MISMATCH')
|
||||
})
|
||||
|
||||
it('passes translated status text directly to the badge', () => {
|
||||
expect(source).toContain('<StatusBadge :status="statusLabel(row.status)" />')
|
||||
expect(source).not.toContain('<StatusBadge :status="row.status" />')
|
||||
expect(detailSource).toContain('<StatusBadge :status="statusLabel(summary.status)" />')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, Pencil } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from '../api/platformLoadingLists'
|
||||
|
||||
const props = defineProps<{ mode: LoadingListMode }>()
|
||||
const route = useRoute()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<LoadingListSummary[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -47,6 +49,7 @@ const editSaving = ref(false)
|
||||
const editError = ref<ApiErrorDetails | null>(null)
|
||||
const editFormRef = ref<FormInstance>()
|
||||
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
|
||||
const handledDeepLink = ref('')
|
||||
|
||||
const isPreload = computed(() => props.mode === 'preload')
|
||||
const title = computed(() => isPreload.value ? '预装清单' : '装柜清单')
|
||||
@@ -111,6 +114,10 @@ function statusLabel(status?: string | null) {
|
||||
return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' } as Record<string, string>)[status || ''] || status || '—'
|
||||
}
|
||||
|
||||
function queryId(value: unknown) {
|
||||
return typeof value === 'string' && /^[1-9]\d*$/.test(value) ? value : ''
|
||||
}
|
||||
|
||||
function toQuery(): LoadingListQuery {
|
||||
const range = Array.isArray(query.createdRange) ? query.createdRange.map(String) : []
|
||||
return {
|
||||
@@ -131,6 +138,7 @@ async function load() {
|
||||
const result = isPreload.value ? await listPreloadPlans(request) : await listConfirmedLoadingLists(request)
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
await openDeepLinkedDetail()
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, `${title.value}加载失败,请稍后重试`)
|
||||
} finally {
|
||||
@@ -138,6 +146,30 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openDeepLinkedDetail() {
|
||||
const planId = queryId(route.query.planId)
|
||||
const warehouseId = queryId(route.query.warehouseId)
|
||||
if (!planId || !warehouseId || !canView.value) return
|
||||
const key = `${props.mode}:${planId}:${warehouseId}`
|
||||
if (handledDeepLink.value === key) return
|
||||
handledDeepLink.value = key
|
||||
selectedPlanId.value = planId
|
||||
selectedWarehouseId.value = warehouseId
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
detailOpen.value = true
|
||||
await loadDetail()
|
||||
const expectedStatus = isPreload.value ? 'LOADING' : 'COMPLETED'
|
||||
const loadedDetail = detail.value as LoadingDetail | null
|
||||
if (loadedDetail && loadedDetail.summary.status !== expectedStatus) {
|
||||
detail.value = null
|
||||
detailError.value = {
|
||||
code: 'MODE_STATUS_MISMATCH',
|
||||
message: `该计划不属于当前${title.value}状态,请从对应清单入口查看。`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
if (page.value !== 1) page.value = 1
|
||||
else void load()
|
||||
@@ -145,7 +177,15 @@ function search() {
|
||||
|
||||
|
||||
async function loadDetail() {
|
||||
if (!selectedPlanId.value || !selectedWarehouseId.value || !canView.value) return
|
||||
if (!canView.value) return
|
||||
if (!selectedPlanId.value || !selectedWarehouseId.value) {
|
||||
detail.value = null
|
||||
detailError.value = {
|
||||
message: '该清单缺少所属仓库信息,无法安全查询详情,请联系管理员核对数据。',
|
||||
code: 'MISSING_WAREHOUSE_CONTEXT',
|
||||
}
|
||||
return
|
||||
}
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
try {
|
||||
@@ -208,13 +248,23 @@ async function saveTransport() {
|
||||
ElMessage.success('运输信息已更新')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
editError.value = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
|
||||
const details = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
|
||||
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
|
||||
await loadDetail()
|
||||
editError.value = {
|
||||
...details,
|
||||
message: '运输信息已被其他人修改,系统已刷新最新版本;当前输入已保留,请核对后重新保存。',
|
||||
}
|
||||
} else {
|
||||
editError.value = details
|
||||
}
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => void load())
|
||||
watch(() => [route.query.planId, route.query.warehouseId], () => void openDeepLinkedDetail())
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowLeft, Plus, Save, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -199,9 +200,9 @@ onMounted(load)
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load" />
|
||||
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else>
|
||||
<el-alert v-if="actionError" type="error" :closable="false" show-icon>{{ actionError.message }}</el-alert>
|
||||
<el-alert v-if="actionError" type="error" :closable="false" show-icon><ApiErrorMeta :details="actionError" /></el-alert>
|
||||
<section class="surface-panel">
|
||||
<div class="section-title">
|
||||
<div><h2>订舱信息</h2><p v-if="detail && !editable">当前状态仅可查看,草稿状态才允许编辑。</p></div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -47,7 +48,7 @@ watch([page, pageSize], () => void load()); onMounted(load)
|
||||
<AdminPage as="div" class="ocean-bookings-page">
|
||||
<ListPageLayout aria-label="订舱列表">
|
||||
<template #toolbar><QueryToolbar v-model="query" class="surface-panel" ariaLabel="订舱筛选" :fields="fields" @search="search" /></template>
|
||||
<template v-if="error" #state><LoadErrorState title="订舱列表加载失败" :message="error.message" :loading="loading" @retry="load" /></template>
|
||||
<template v-if="error" #state><LoadErrorState title="订舱列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<section v-if="!error" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
|
||||
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:ocean-booking:create')" type="primary" @click="open()"><Plus :size="16" />新增订舱</el-button></template>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { changeOutboundDispatch, listOutboundDispatches } from './platformOutboundDispatch'
|
||||
vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn() } } })
|
||||
describe('platformOutboundDispatch', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes aggregate ids', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', customerId: 3, version: '4' }] }); const result = await listOutboundDispatches({}); expect(result[0]).toMatchObject({ orderId: '1', inboundId: '2', customerId: '3', version: 4 }) }); it('posts action with version and idempotency', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate: { orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', version: 5 }, timeline: [] } }); await changeOutboundDispatch('confirm-dispatch', { inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[1]).toEqual({ inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) }) })
|
||||
@@ -0,0 +1,9 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource, OutboundGateResult } from '../types'
|
||||
const basePath = '/platform/v1/first-leg/outbound-dispatches'; const keys = ['orderId', 'inboundId', 'containerBookingId', 'customerId'] as const
|
||||
function item(raw: OutboundDispatchResource) { const value = { ...raw } as OutboundDispatchResource & Record<string, unknown>; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value }
|
||||
function idem() { return `outbound-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
export async function listOutboundDispatches(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundDispatchResource[]>, ApiEnvelope<OutboundDispatchResource[]>>(basePath, { params: query }); return (unwrapApiResponse(response) || []).map(item) }
|
||||
export async function getOutboundDispatchDetail(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/detail`, { params: query }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
|
||||
export async function checkOutboundGate(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundGateResult>, ApiEnvelope<OutboundGateResult>>(`${basePath}/check-gate`, { params: query }); return unwrapApiResponse(response) }
|
||||
export async function changeOutboundDispatch(action: 'confirm-dispatch' | 'reverse-dispatch', request: { inboundId: string; containerBookingId?: string | null; expectedVersion: number; remark?: string }) { const response = await http.post<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/${action}`, request, { headers: { 'Idempotency-Key': idem() } }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
|
||||
@@ -0,0 +1,2 @@
|
||||
export { outboundDispatchRoutes } from './routes'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
|
||||
describe('OutboundDispatchesView', () => { it('uses standard list components and left actions', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain("fixed: 'left'") }); it('waits for a business id before querying', () => { expect(source).not.toContain('onMounted(load)'); expect(source).toContain("String(filters.orderId || '').trim() || undefined"); expect(source).toContain('if (!params.orderId && !params.inboundId && !params.containerBookingId)'); expect(source).toContain('请输入订单 ID、入库单 ID 或柜次 ID 后查询'); expect(source).toContain('@reset="resetSearch"') }); it('shows gate, timeline and guarded dispatch actions', () => { expect(source).toContain('aggregate.gate?.items'); expect(source).toContain('detail.timeline'); expect(source).toContain("'confirm-dispatch'"); expect(source).toContain("'reverse-dispatch'"); expect(source).toContain("error.value?.code === 'A0409'") }) })
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Eye, RotateCcw, Send } from '@lucide/vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { changeOutboundDispatch, getOutboundDispatchDetail, listOutboundDispatches } from '../api/platformOutboundDispatch'
|
||||
import type { OutboundDispatchDetail, OutboundDispatchResource } from '../types'
|
||||
const access = useAccessPreviewStore(); const rows = ref<OutboundDispatchResource[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const filters = reactive<QueryModel>({ orderId: '', inboundId: '', containerBookingId: '' })
|
||||
const fields: QueryField[] = [{ key: 'orderId', type: 'input', label: '订单 ID' }, { key: 'inboundId', type: 'input', label: '入库单 ID' }, { key: 'containerBookingId', type: 'input', label: '柜次 ID' }]
|
||||
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 240, slotName: 'actions', hideable: false, movable: false }, { prop: 'orderNo', label: '订单号', minWidth: 145 }, { prop: 'inboundCode', label: '入库单号', minWidth: 145 }, { prop: 'customerName', label: '客户', minWidth: 140 }, { prop: 'containerNo', label: '柜号', minWidth: 130 }, { prop: 'oceanBookingStatus', label: '订舱', width: 105, slotName: 'booking' }, { prop: 'truckAppointmentStatus', label: '拖车', width: 105, slotName: 'truck' }, { prop: 'pickTaskStatus', label: '拣货', width: 105, slotName: 'pick' }, { prop: 'dispatchStatus', label: '出仓状态', width: 115, slotName: 'status' }, { key: 'gate', label: '门禁', width: 90, slotName: 'gate' }]
|
||||
function query() { return { orderId: String(filters.orderId || '').trim() || undefined, inboundId: String(filters.inboundId || '').trim() || undefined, containerBookingId: String(filters.containerBookingId || '').trim() || undefined } }
|
||||
const queried = ref(false)
|
||||
async function load() { const params = query(); if (!params.orderId && !params.inboundId && !params.containerBookingId) { rows.value = []; error.value = null; queried.value = false; return } queried.value = true; loading.value = true; error.value = null; try { rows.value = await listOutboundDispatches(params) } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
|
||||
function resetSearch() { rows.value = []; error.value = null; queried.value = false }
|
||||
const detailOpen = ref(false); const detail = ref<OutboundDispatchDetail | null>(null); const actionError = ref<ApiErrorDetails | null>(null)
|
||||
async function open(value: unknown) { const row = value as OutboundDispatchResource; detailOpen.value = true; actionError.value = null; try { detail.value = await getOutboundDispatchDetail({ inboundId: row.inboundId, containerBookingId: row.containerBookingId || undefined }) } catch (cause) { actionError.value = getApiErrorDetails(cause) } }
|
||||
async function action(value: unknown, name: 'confirm-dispatch' | 'reverse-dispatch') { const row = value as OutboundDispatchResource; try { await ElMessageBox.confirm(name === 'confirm-dispatch' ? '确认国内出仓?系统将校验全部装柜门禁。' : '确认撤销国内出仓?', '出仓状态确认', { type: name === 'reverse-dispatch' ? 'warning' : 'info' }); await changeOutboundDispatch(name, { inboundId: row.inboundId, containerBookingId: row.containerBookingId, expectedVersion: row.version, remark: name === 'confirm-dispatch' ? '后台确认出仓' : '后台撤销出仓' }); ElMessage.success('出仓状态已更新'); await load() } catch (cause) { if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) error.value = getApiErrorDetails(cause); if (error.value?.code === 'A0409') await load() } }
|
||||
</script>
|
||||
<template><AdminPage as="div"><ListPageLayout aria-label="国内出仓列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="国内出仓筛选" :fields="fields" @search="load" @reset="resetSearch" /></template><template v-if="error" #state><LoadErrorState title="国内出仓加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><el-empty v-if="!queried" description="请输入订单 ID、入库单 ID 或柜次 ID 后查询" /><DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="inboundId" preference-key="outbound-dispatches" empty-text="暂无符合条件的出仓记录"><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" link type="primary" @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus !== 'DISPATCHED'" link type="primary" @click="action(row, 'confirm-dispatch')"><Send :size="15" />确认</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:reverse') && row.dispatchStatus === 'DISPATCHED'" link type="danger" @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></template><template #booking="{ row }"><StatusBadge :status="row.oceanBookingStatus || '未关联'" /></template><template #truck="{ row }"><StatusBadge :status="row.truckAppointmentStatus || '未关联'" /></template><template #pick="{ row }"><StatusBadge :status="row.pickTaskStatus || '未关联'" /></template><template #status="{ row }"><StatusBadge :status="row.dispatchStatus || '待处理'" /></template><template #gate="{ row }"><el-tag :type="row.gate?.passed ? 'success' : 'danger'">{{ row.gate?.passed ? '通过' : '未通过' }}</el-tag></template></DataTable></section></ListPageLayout><AppDialog v-model="detailOpen" title="国内出仓详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError" title="出仓详情加载失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="订单号">{{ detail.aggregate.orderNo }}</el-descriptions-item><el-descriptions-item label="入库单号">{{ detail.aggregate.inboundCode }}</el-descriptions-item><el-descriptions-item label="柜号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="detail.aggregate.dispatchStatus || '待处理'" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table :data="detail.aggregate.gate?.items || []"><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ entry.module }} · {{ entry.action }}:{{ entry.fromStatus || '—' }} → {{ entry.toStatus || '—' }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
|
||||
<style scoped>h3{margin:20px 0 12px}small{display:block;color:var(--cc-muted)}</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const outboundDispatchRoutes = [{ path: 'outbound-dispatches', name: 'outbound-dispatches', component: () => import('./pages/OutboundDispatchesView.vue'), meta: { title: '国内出仓', description: '查看装柜门禁、整体时间线并确认国内出仓', permission: 'platform:firstleg:outbound-dispatch:list' } }] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface OutboundGateItem { code: string; name: string; passed: boolean; actual?: string | null; requirement?: string | null }
|
||||
export interface OutboundGateResult { passed: boolean; items: OutboundGateItem[]; checkedAt: string }
|
||||
export interface OutboundDispatchResource { orderId: string; orderNo: string; inboundId: string; inboundCode: string; customerId?: string | null; customerName?: string | null; inboundStatus?: string | null; containerBookingId?: string | null; containerNo?: string | null; loadingPlanStatus?: string | null; pickTaskStatus?: string | null; oceanBookingStatus?: string | null; truckAppointmentStatus?: string | null; dispatchStatus?: string | null; version: number; gate?: OutboundGateResult | null }
|
||||
export interface OutboundDispatchDetail { aggregate: OutboundDispatchResource; timeline: Array<{ module: string; action: string; fromStatus?: string | null; toStatus?: string | null; operatorName?: string | null; remark?: string | null; occurredAt: string }> }
|
||||
export interface OutboundDispatchQuery { orderId?: string; inboundId?: string; containerBookingId?: string }
|
||||
@@ -0,0 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { createPortSchedule, listPortSchedules, updatePortSchedule } from './platformPortSchedules'
|
||||
vi.mock('@/shared/api/http', async () => { const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http'); return { ...actual, http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } } })
|
||||
const data = { customerId: '1', oceanBookingId: '2', sourceType: 'MANUAL', sourceName: '美森', sourceRef: 'web', vesselName: 'MATSON', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', observedAt: '2026-08-11T10:00:00', expiresAt: '2026-08-14T10:00:00' }
|
||||
describe('platformPortSchedules', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes long ids in pages', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ schedule: { ...data, id: 9, scheduleNo: 'PS1', customerId: 1, oceanBookingId: 2, version: '3' }, expired: false }], page: 1, size: 20, total: 1, pages: 1 } }); const result = await listPortSchedules({ page: 1, size: 20 }); expect(result.records[0]?.schedule).toMatchObject({ id: '9', customerId: '1', oceanBookingId: '2', version: 3 }) }); it('uses idempotency and expectedVersion for writes', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { portSchedule: { schedule: { ...data, id: 9, scheduleNo: 'PS1', version: 0 }, expired: false }, histories: [] } }); vi.mocked(http.put).mockResolvedValue({ code: '200', data: { portSchedule: { schedule: { ...data, id: 9, scheduleNo: 'PS1', version: 2 }, expired: false }, histories: [] } }); await createPortSchedule(data); await updatePortSchedule('9', { expectedVersion: 1, data }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }); expect(vi.mocked(http.put).mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 1 }) }) })
|
||||
@@ -0,0 +1,24 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { PortScheduleData, PortScheduleDetail, PortSchedulePage, PortScheduleQuery, PortScheduleResource, PortScheduleSaveRequest, PortScheduleView } from '../types'
|
||||
|
||||
const basePath = '/platform/v1/port-schedules'
|
||||
const ids = ['id', 'customerId', 'oceanBookingId', 'containerBookingId'] as const
|
||||
function resource(raw: PortScheduleResource): PortScheduleResource {
|
||||
const value = { ...raw } as PortScheduleResource & Record<string, unknown>
|
||||
for (const key of ids) if (value[key] != null) value[key] = String(value[key])
|
||||
value.version = Number(value.version ?? 0)
|
||||
return value
|
||||
}
|
||||
function view(raw: PortScheduleView): PortScheduleView { return { ...raw, schedule: resource(raw.schedule) } }
|
||||
function detail(raw: PortScheduleDetail): PortScheduleDetail {
|
||||
return { ...raw, portSchedule: view(raw.portSchedule), histories: (raw.histories || []).map((item) => ({ ...item, id: String(item.id) })) }
|
||||
}
|
||||
function idem() { return `port-schedule-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
export async function listPortSchedules(query: PortScheduleQuery) {
|
||||
const response = await http.get<ApiEnvelope<PortSchedulePage>, ApiEnvelope<PortSchedulePage>>(basePath, { params: query })
|
||||
const data = unwrapApiResponse(response)
|
||||
return { ...data, records: (data.records || []).map(view), page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) }
|
||||
}
|
||||
export async function getPortSchedule(id: string) { const response = await http.get<ApiEnvelope<PortScheduleDetail>, ApiEnvelope<PortScheduleDetail>>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
|
||||
export async function createPortSchedule(data: PortScheduleData) { const response = await http.post<ApiEnvelope<PortScheduleDetail>, ApiEnvelope<PortScheduleDetail>>(basePath, { expectedVersion: 0, data }, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) }
|
||||
export async function updatePortSchedule(id: string, request: PortScheduleSaveRequest) { const response = await http.put<ApiEnvelope<PortScheduleDetail>, ApiEnvelope<PortScheduleDetail>>(`${basePath}/${encodeURIComponent(id)}`, request, { headers: { 'Idempotency-Key': idem() } }); return detail(unwrapApiResponse(response)) }
|
||||
@@ -0,0 +1,2 @@
|
||||
export { portScheduleRoutes } from './routes'
|
||||
export * from './types'
|
||||
@@ -0,0 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/port-schedules/pages/PortSchedulesView.vue'), 'utf8')
|
||||
describe('PortSchedulesView', () => { it('uses standard list components and a left action column', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain("fixed: 'left'"); expect(source).toContain('AppDialog') }); it('exposes expiry and conflict handling', () => { expect(source).toContain("query.expired === 'true'"); expect(source).toContain("actionError.value.code === 'A0409'"); expect(source).toContain('ApiErrorMeta') }) })
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPortSchedule, getPortSchedule, listPortSchedules, updatePortSchedule } from '../api/platformPortSchedules'
|
||||
import type { PortScheduleData, PortScheduleView } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<PortScheduleView[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const query = reactive<QueryModel>({ keyword: '', expired: '' })
|
||||
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '关键词', placeholder: '船期号/船名/航次/港口', width: 230 }, { key: 'expired', type: 'select', label: '有效性', options: [{ label: '有效', value: 'false' }, { label: '已过期', value: 'true' }] }]
|
||||
const columns = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 165, slotName: 'actions', hideable: false, movable: false },
|
||||
{ prop: 'schedule.scheduleNo', label: '船期编号', minWidth: 145 }, { prop: 'schedule.sourceName', label: '信息来源', minWidth: 130 },
|
||||
{ prop: 'schedule.vesselName', label: '船名/航次', minWidth: 155, slotName: 'vessel' }, { prop: 'schedule.departurePortName', label: '起运港', minWidth: 120 },
|
||||
{ prop: 'schedule.destinationPortName', label: '目的港', minWidth: 120 }, { prop: 'schedule.etdAt', label: '预计开船', minWidth: 160 },
|
||||
{ prop: 'schedule.observedAt', label: '查询时间', minWidth: 160 }, { prop: 'expired', label: '有效性', width: 95, slotName: 'expired' },
|
||||
]
|
||||
function blank(): PortScheduleData { const now = new Date().toISOString().slice(0, 19); return { customerId: '', oceanBookingId: '', containerBookingId: null, sourceType: 'MANUAL', sourceName: '', sourceRef: '', vesselName: '', voyageNo: '', departurePortCode: '', departurePortName: '', destinationPortCode: '', destinationPortName: '', etdAt: null, etaAt: null, bookingCutoffAt: null, customsCutoffAt: null, portCutoffAt: null, observedAt: now, expiresAt: now, remark: '' } }
|
||||
const dialogOpen = ref(false); const saving = ref(false); const editingId = ref(''); const expectedVersion = ref(0); const formRef = ref<FormInstance>(); const form = reactive<PortScheduleData>(blank()); const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const rules: FormRules = { customerId: [{ required: true, message: '请输入客户 ID', trigger: 'blur' }], oceanBookingId: [{ required: true, message: '请输入订舱 ID', trigger: 'blur' }], sourceName: [{ required: true, message: '请输入信息来源', trigger: 'blur' }], sourceRef: [{ required: true, message: '请输入来源链接或参考号', trigger: 'blur' }], vesselName: [{ required: true, message: '请输入船名', trigger: 'blur' }], voyageNo: [{ required: true, message: '请输入航次', trigger: 'blur' }], departurePortCode: [{ required: true, message: '请输入起运港代码', trigger: 'blur' }], destinationPortCode: [{ required: true, message: '请输入目的港代码', trigger: 'blur' }], observedAt: [{ required: true, message: '请选择查询时间', trigger: 'change' }], expiresAt: [{ required: true, message: '请选择过期时间', trigger: 'change' }] }
|
||||
const canCreate = computed(() => access.can('platform:firstleg:port-schedule:create'))
|
||||
async function load() { loading.value = true; error.value = null; try { const result = await listPortSchedules({ page: page.value, size: pageSize.value, keyword: String(query.keyword || '') || undefined, expired: query.expired === '' ? undefined : query.expired === 'true' }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
|
||||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||||
function openCreate() { editingId.value = ''; expectedVersion.value = 0; Object.assign(form, blank()); actionError.value = null; dialogOpen.value = true }
|
||||
async function openEdit(id: string) { actionError.value = null; try { const result = await getPortSchedule(id); editingId.value = id; expectedVersion.value = result.portSchedule.schedule.version; Object.assign(form, result.portSchedule.schedule); dialogOpen.value = true } catch (cause) { error.value = getApiErrorDetails(cause) } }
|
||||
async function save() { if (!await formRef.value?.validate().catch(() => false)) return; saving.value = true; actionError.value = null; try { if (editingId.value) await updatePortSchedule(editingId.value, { expectedVersion: expectedVersion.value, data: { ...form }, reason: '后台维护船期' }); else await createPortSchedule({ ...form }); ElMessage.success(editingId.value ? '船期已更新' : '船期已新增'); dialogOpen.value = false; await load() } catch (cause) { actionError.value = getApiErrorDetails(cause); if (actionError.value.code === 'A0409' && editingId.value) await openEdit(editingId.value) } finally { saving.value = false } }
|
||||
watch([page, pageSize], () => void load()); onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage as="div"><ListPageLayout aria-label="船期列表">
|
||||
<template #toolbar><QueryToolbar v-model="query" class="surface-panel" ariaLabel="船期筛选" :fields="fields" @search="search" @reset="search" /></template>
|
||||
<template v-if="error" #state><LoadErrorState title="船期列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="schedule.id" preference-key="port-schedules" empty-text="暂无符合条件的船期">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增船期</el-button></template>
|
||||
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:port-schedule:view')" link type="primary" @click="openEdit(row.schedule.id)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:port-schedule:edit')" link @click="openEdit(row.schedule.id)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #vessel="{ row }">{{ row.schedule.vesselName }} / {{ row.schedule.voyageNo }}</template><template #expired="{ row }"><el-tag :type="row.expired ? 'danger' : 'success'">{{ row.expired ? '已过期' : '有效' }}</el-tag></template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" :title="editingId ? '编辑船期' : '新增船期'" width="820px" :confirm-loading="saving" @confirm="save"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="schedule-form"><el-row :gutter="16">
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="客户 ID" prop="customerId"><el-input v-model="form.customerId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="订舱 ID" prop="oceanBookingId"><el-input v-model="form.oceanBookingId" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="信息来源" prop="sourceName"><el-input v-model="form.sourceName" placeholder="如美森官网" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="来源参考" prop="sourceRef"><el-input v-model="form.sourceRef" placeholder="查询链接或参考号" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="船名" prop="vesselName"><el-input v-model="form.vesselName" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="航次" prop="voyageNo"><el-input v-model="form.voyageNo" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="起运港代码" prop="departurePortCode"><el-input v-model="form.departurePortCode" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="目的港代码" prop="destinationPortCode"><el-input v-model="form.destinationPortCode" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="预计开船"><el-date-picker v-model="form.etdAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="预计到港"><el-date-picker v-model="form.etaAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" /></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12"><el-form-item label="查询时间" prop="observedAt"><el-date-picker v-model="form.observedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="过期时间" prop="expiresAt"><el-date-picker v-model="form.expiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" /></el-form-item></el-col>
|
||||
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" /></el-form-item></el-col>
|
||||
</el-row></el-form></AppDialog></AdminPage>
|
||||
</template>
|
||||
<style scoped>.schedule-form{max-height:65vh;overflow:auto;padding-inline:2px}</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
export const portScheduleRoutes = [{ path: 'port-schedules', name: 'port-schedules', component: () => import('./pages/PortSchedulesView.vue'), meta: { title: '船期信息', description: '维护船公司船期、查询时间与过期标识', permission: 'platform:firstleg:port-schedule:list' } }] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface PortScheduleResource {
|
||||
id: string
|
||||
scheduleNo: string
|
||||
customerId: string
|
||||
oceanBookingId: string
|
||||
containerBookingId?: string | null
|
||||
sourceType: string
|
||||
sourceName: string
|
||||
sourceRef: string
|
||||
vesselName: string
|
||||
voyageNo: string
|
||||
departurePortCode: string
|
||||
departurePortName?: string | null
|
||||
destinationPortCode: string
|
||||
destinationPortName?: string | null
|
||||
etdAt?: string | null
|
||||
etaAt?: string | null
|
||||
bookingCutoffAt?: string | null
|
||||
customsCutoffAt?: string | null
|
||||
portCutoffAt?: string | null
|
||||
observedAt: string
|
||||
expiresAt: string
|
||||
remark?: string | null
|
||||
version: number
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface PortScheduleView { schedule: PortScheduleResource; expired: boolean }
|
||||
export interface PortScheduleHistory { id: string; action: string; fromVersion?: number | null; toVersion?: number | null; reason?: string | null; operatorName?: string | null; createdAt: string }
|
||||
export interface PortScheduleDetail { portSchedule: PortScheduleView; histories: PortScheduleHistory[] }
|
||||
export interface PortSchedulePage { records: PortScheduleView[]; page: number; size: number; total: number; pages: number }
|
||||
export interface PortScheduleQuery { page: number; size: number; keyword?: string; expired?: boolean }
|
||||
export type PortScheduleData = Omit<PortScheduleResource, 'id' | 'scheduleNo' | 'version' | 'updatedAt'>
|
||||
export interface PortScheduleSaveRequest { expectedVersion: number; data: PortScheduleData; reason?: string | null }
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
completeLoading,
|
||||
confirmingPreload,
|
||||
deleteSmartLoadingItem,
|
||||
downloadEvidenceFile,
|
||||
getContainerBooking,
|
||||
getOutboundDispatch,
|
||||
getPicking,
|
||||
@@ -86,6 +87,13 @@ describe('unified order workflow API contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('downloads loading evidence through the tenant and inbound scoped file endpoint', async () => {
|
||||
const blob = new Blob(['photo'], { type: 'image/jpeg' })
|
||||
vi.mocked(http.get).mockResolvedValue(blob)
|
||||
await expect(downloadEvidenceFile('2', '8', '88')).resolves.toBe(blob)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/files/loading-evidence/2/8/88', { responseType: 'blob' })
|
||||
})
|
||||
|
||||
it('normalizes smart-loading long identifiers as strings', async () => {
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ data: { id: '2086085512165634050', warehouseId: '2086085512165634051', capacityCbm: '68', maxWeightKg: '25000', version: 2 } })
|
||||
|
||||
@@ -538,6 +538,17 @@ export async function uploadEvidenceFile(
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export function downloadEvidenceFile(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
fileId: string | number,
|
||||
) {
|
||||
return http.get<Blob, Blob>(
|
||||
`/platform/v1/files/loading-evidence/${encodeURIComponent(String(tenantId))}/${encodeURIComponent(String(inboundId))}/${encodeURIComponent(String(fileId))}`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
}
|
||||
|
||||
export async function voidEvidence(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import UnifiedOrderWorkflowPanel from './UnifiedOrderWorkflowPanel.vue'
|
||||
import {
|
||||
getEvidence,
|
||||
getOutboundDispatch,
|
||||
getPreload,
|
||||
smartSplit,
|
||||
@@ -14,6 +15,7 @@ vi.mock('../api', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../api')>()
|
||||
return {
|
||||
...original,
|
||||
getEvidence: vi.fn(),
|
||||
getOutboundDispatch: vi.fn(),
|
||||
getPreload: vi.fn(),
|
||||
smartSplit: vi.fn(),
|
||||
@@ -29,14 +31,14 @@ function preload(cargoReference: string, version = 3) {
|
||||
}
|
||||
}
|
||||
|
||||
function mountPanel(permissions: string[]) {
|
||||
function mountPanel(permissions: string[], mainStatus: 'WAIT_SPLIT' | 'LOADING' | 'LOADED' = 'WAIT_SPLIT') {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().permissions = permissions
|
||||
return mount(UnifiedOrderWorkflowPanel, {
|
||||
props: {
|
||||
tenantId: '2086085512165634052', inboundId: '2086085512165634051', orderId: '2086085512165634053',
|
||||
mainStatus: 'WAIT_SPLIT', inboundVersion: 4,
|
||||
mainStatus, inboundVersion: 4,
|
||||
},
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
@@ -45,6 +47,7 @@ function mountPanel(permissions: string[]) {
|
||||
describe('UnifiedOrderWorkflowPanel preload editor', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getOutboundDispatch).mockReset().mockResolvedValue(null as never)
|
||||
vi.mocked(getEvidence).mockReset().mockResolvedValue({ inboundId: '2086085512165634051', evidences: [] })
|
||||
vi.mocked(getPreload).mockReset().mockResolvedValue(preload('REF-OLD'))
|
||||
vi.mocked(smartSplit).mockReset()
|
||||
})
|
||||
@@ -94,4 +97,23 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
|
||||
expect(wrapper.text()).toContain('REF-OLD')
|
||||
expect(wrapper.text()).not.toContain('REF-SERVER')
|
||||
})
|
||||
|
||||
it('keeps completed loading evidence visible and renders every active photo', async () => {
|
||||
vi.mocked(getEvidence).mockResolvedValue({
|
||||
inboundId: '2086085512165634051',
|
||||
evidences: [
|
||||
{ id: '1', inboundId: '2086085512165634051', tenantId: '2086085512165634052', evidenceType: 'PHOTO_STEP_1', fileId: '11', fileName: 'empty-1.jpg', sealNo: null, remark: null, version: 0, status: 'ACTIVE', createdAt: null, updatedAt: null },
|
||||
{ id: '2', inboundId: '2086085512165634051', tenantId: '2086085512165634052', evidenceType: 'PHOTO_STEP_1', fileId: '12', fileName: 'empty-2.jpg', sealNo: null, remark: null, version: 0, status: 'ACTIVE', createdAt: null, updatedAt: null },
|
||||
],
|
||||
})
|
||||
const wrapper = mountPanel(['platform:firstleg:inbound:loading:view'], 'LOADED')
|
||||
await flushPromises()
|
||||
|
||||
expect(getEvidence).toHaveBeenCalledWith('2086085512165634052', '2086085512165634051')
|
||||
expect(wrapper.text()).toContain('empty-1.jpg')
|
||||
expect(wrapper.text()).toContain('empty-2.jpg')
|
||||
expect(wrapper.text()).toContain('2 张')
|
||||
expect(wrapper.text()).not.toContain('完成装柜')
|
||||
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RefreshCw, Save, Check, Play, Camera, Plus, Pencil, Trash2 } from '@lucide/vue'
|
||||
import { RefreshCw, Save, Check, Play, Camera, Eye, Plus, Pencil, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
completePicking,
|
||||
confirmingPreload,
|
||||
confirmPreload,
|
||||
downloadEvidenceFile,
|
||||
getEvidence,
|
||||
getOutboundDispatch,
|
||||
getPicking,
|
||||
@@ -54,6 +55,7 @@ const picking = ref<PickingTask | null>(null)
|
||||
const evidences = ref<Evidence[]>([])
|
||||
const dispatch = ref<OutboundDispatchAggregate | null>(null)
|
||||
const loadingRemark = ref('')
|
||||
const sealNoDraft = ref('')
|
||||
const splitRemark = ref('')
|
||||
const busy = ref('')
|
||||
const loading = ref(false)
|
||||
@@ -87,9 +89,12 @@ const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'
|
||||
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
|
||||
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
|
||||
// 操作员、理货员由 IAM 授予 loading:edit;拣货员仅能在 PDA 现场采集,不授予该权限。
|
||||
const canEvidence = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:loading:edit'))
|
||||
const canEvidence = computed(() => !props.readOnly && isLoading.value && access.can('platform:firstleg:inbound:loading:edit'))
|
||||
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
|
||||
const canLoadingComplete = computed(() => !props.readOnly && !isLoaded.value && access.can('platform:firstleg:inbound:loading:complete'))
|
||||
const evidenceReady = computed(() => evidenceTypes.every(({ type }) => activeEvidences(type).some((item) => (
|
||||
Boolean(item.fileId) && (type !== 'SEAL' || Boolean(item.sealNo?.trim()))
|
||||
))))
|
||||
|
||||
const preloadColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 132, fixed: 'left', slotName: 'actions' },
|
||||
@@ -147,8 +152,8 @@ function formatVolume(value: number | null) {
|
||||
return value === null || !Number.isFinite(Number(value)) ? '—' : Number(value).toFixed(3)
|
||||
}
|
||||
|
||||
function evidence(type: EvidenceType) {
|
||||
return evidences.value.find((item) => item.evidenceType === type && item.status === 'ACTIVE')
|
||||
function activeEvidences(type: EvidenceType) {
|
||||
return evidences.value.filter((item) => item.evidenceType === type && item.status === 'ACTIVE')
|
||||
}
|
||||
|
||||
function idempotentMessage(action: string) {
|
||||
@@ -216,7 +221,9 @@ async function loadLoading() {
|
||||
picking.value = await getPicking(props.tenantId, props.inboundId)
|
||||
} catch (cause) {
|
||||
picking.value = null
|
||||
messages.push(cause instanceof Error ? cause.message : '拣货任务加载失败')
|
||||
const details = getApiErrorDetails(cause, '拣货任务加载失败')
|
||||
messages.push(details.message)
|
||||
errorDetails.value ||= details
|
||||
}
|
||||
}
|
||||
if (canEvidenceView.value) {
|
||||
@@ -224,7 +231,9 @@ async function loadLoading() {
|
||||
evidences.value = (await getEvidence(props.tenantId, props.inboundId)).evidences || []
|
||||
} catch (cause) {
|
||||
evidences.value = []
|
||||
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
|
||||
const details = getApiErrorDetails(cause, '装柜证据加载失败')
|
||||
messages.push(details.message)
|
||||
errorDetails.value ||= details
|
||||
}
|
||||
}
|
||||
error.value = messages.join(';')
|
||||
@@ -242,7 +251,7 @@ async function loadDispatch() {
|
||||
async function refresh() {
|
||||
await loadDispatch()
|
||||
if (isSplit.value) await loadPreload()
|
||||
if (isLoading.value) await loadLoading()
|
||||
if (isLoading.value || isLoaded.value) await loadLoading()
|
||||
}
|
||||
|
||||
async function runSmartSplit() {
|
||||
@@ -309,35 +318,29 @@ async function completePickingTask() {
|
||||
if (result) picking.value = result
|
||||
}
|
||||
|
||||
async function saveEvidence(type: EvidenceType) {
|
||||
const current = evidence(type)
|
||||
const result = await run(`evidence-${type}`, () => upsertEvidence(props.tenantId, props.inboundId, {
|
||||
expectedVersion: current?.version ?? 0,
|
||||
evidenceType: type,
|
||||
fileId: current?.fileId || undefined,
|
||||
fileName: current?.fileName || undefined,
|
||||
sealNo: current?.sealNo || undefined,
|
||||
remark: current?.remark || undefined,
|
||||
}), `${type === 'SEAL' ? '封签' : '证据'}已保存`)
|
||||
if (result?.evidences) evidences.value = result.evidences.evidences || []
|
||||
}
|
||||
|
||||
async function uploadAndSaveEvidence(type: EvidenceType, event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || !canEvidence.value) return
|
||||
const sealNo = sealNoDraft.value.trim()
|
||||
if (type === 'SEAL' && !sealNo) {
|
||||
error.value = '上传带锁码照前必须填写锁码'
|
||||
errorDetails.value = null
|
||||
return
|
||||
}
|
||||
const uploaded = await run(`evidence-upload-${type}`, () => uploadEvidenceFile(props.tenantId, props.inboundId, type, file), '文件已上传,正在登记证据')
|
||||
if (!uploaded) return
|
||||
const current = evidence(type)
|
||||
const result = await run(`evidence-${type}`, () => upsertEvidence(props.tenantId, props.inboundId, {
|
||||
expectedVersion: current?.version ?? 0,
|
||||
expectedVersion: 0,
|
||||
evidenceType: type,
|
||||
fileId: uploaded.fileId,
|
||||
sealNo: current?.sealNo || undefined,
|
||||
remark: current?.remark || undefined,
|
||||
sealNo: type === 'SEAL' ? sealNo : undefined,
|
||||
}), `${type === 'SEAL' ? '带锁码照' : '证据'}已登记`)
|
||||
if (result?.evidences) evidences.value = result.evidences.evidences || []
|
||||
if (result?.evidences) {
|
||||
evidences.value = result.evidences.evidences || []
|
||||
if (type === 'SEAL') sealNoDraft.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function markEvidenceVoided(item: Evidence) {
|
||||
@@ -351,6 +354,25 @@ async function markEvidenceVoided(item: Evidence) {
|
||||
if (result?.evidences) evidences.value = result.evidences.evidences || []
|
||||
}
|
||||
|
||||
async function previewEvidence(item: Evidence) {
|
||||
if (!canEvidenceView.value || !item.fileId) return
|
||||
busy.value = `evidence-preview-${item.id}`
|
||||
error.value = ''
|
||||
errorDetails.value = null
|
||||
try {
|
||||
const blob = await downloadEvidenceFile(props.tenantId, props.inboundId, item.fileId)
|
||||
const url = URL.createObjectURL(blob)
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '证据照片加载失败,请重试')
|
||||
error.value = details.message
|
||||
errorDetails.value = details
|
||||
} finally {
|
||||
busy.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function finishLoading() {
|
||||
const expectedVersion = inboundExpectedVersion.value
|
||||
if (expectedVersion === null) {
|
||||
@@ -364,28 +386,6 @@ async function finishLoading() {
|
||||
if (result) await refresh()
|
||||
}
|
||||
|
||||
function updateEvidenceField(type: EvidenceType, field: 'fileName' | 'sealNo' | 'remark', value: string) {
|
||||
const current = evidence(type)
|
||||
if (current) {
|
||||
current[field] = value || null
|
||||
return
|
||||
}
|
||||
evidences.value.push({
|
||||
id: `draft-${type}`,
|
||||
inboundId: props.inboundId,
|
||||
tenantId: props.tenantId,
|
||||
evidenceType: type,
|
||||
fileId: null,
|
||||
fileName: field === 'fileName' ? value : null,
|
||||
sealNo: field === 'sealNo' ? value : null,
|
||||
remark: field === 'remark' ? value : null,
|
||||
version: 0,
|
||||
status: 'ACTIVE',
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.mainStatus, () => { void refresh() })
|
||||
onMounted(() => { void refresh() })
|
||||
</script>
|
||||
@@ -404,7 +404,7 @@ onMounted(() => { void refresh() })
|
||||
</el-alert>
|
||||
<el-alert v-if="notice" class="workflow-alert" type="success" :closable="false" show-icon>{{ notice }}</el-alert>
|
||||
<el-alert v-if="busy" class="workflow-alert" type="info" :closable="false">{{ idempotentMessage(busy) }}</el-alert>
|
||||
<el-alert v-if="props.readOnly" class="workflow-alert" type="info" :closable="false" show-icon>当前仅开放进度查询;五图取证和装柜完成门禁接通后再开放操作。</el-alert>
|
||||
<el-alert v-if="props.readOnly" class="workflow-alert" type="info" :closable="false" show-icon>当前为只读查看,不能修改作业数据。</el-alert>
|
||||
|
||||
<el-card v-if="dispatch" shadow="never" class="workflow-card">
|
||||
<template #header><div class="card-title"><span>子流程与门禁摘要</span><el-tag :type="dispatch.gate?.passed ? 'success' : 'warning'">{{ dispatch.gate?.passed ? '已通过' : '待补齐' }}</el-tag></div></template>
|
||||
@@ -472,29 +472,37 @@ onMounted(() => { void refresh() })
|
||||
<el-input v-model="loadingRemark" :disabled="props.readOnly" class="workflow-remark" type="textarea" :rows="2" aria-label="装柜备注" placeholder="装柜备注(可选)" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="workflow-card">
|
||||
<template #header><div class="card-title"><span>装柜五类证据与锁码</span><el-tag type="info">五类必传</el-tag></div></template>
|
||||
</template>
|
||||
|
||||
<el-card v-if="isLoading || isLoaded" v-loading="loading" shadow="never" class="workflow-card">
|
||||
<template #header><div class="card-title"><span>装柜五类证据与锁码</span><el-tag :type="evidenceReady ? 'success' : 'info'">{{ evidenceReady ? '证据齐全' : '五类必传' }}</el-tag></div></template>
|
||||
<el-alert v-if="!canEvidenceView" type="warning" :closable="false" show-icon>当前账号无装柜证据查询权限。</el-alert>
|
||||
<div class="evidence-grid">
|
||||
<div v-for="entry in evidenceTypes" :key="entry.type" class="evidence-item">
|
||||
<strong><Camera :size="15" />{{ entry.label }}</strong>
|
||||
<strong><Camera :size="15" />{{ entry.label }}<el-tag size="small" :type="activeEvidences(entry.type).length ? 'success' : 'danger'">{{ activeEvidences(entry.type).length }} 张</el-tag></strong>
|
||||
<div class="evidence-control">
|
||||
<el-input v-if="entry.type === 'SEAL'" :disabled="props.readOnly" :model-value="evidence(entry.type)?.sealNo || ''" aria-label="锁码" placeholder="锁码(带锁码照必填)" @update:model-value="updateEvidenceField(entry.type, 'sealNo', $event)" />
|
||||
<span v-if="evidence(entry.type)?.fileId" class="workflow-hint">{{ evidence(entry.type)?.fileName || '已上传文件' }}</span>
|
||||
<label v-if="canEvidence" class="file-picker"><Camera :size="14" />上传照片<input type="file" accept="image/*" @change="uploadAndSaveEvidence(entry.type, $event)" /></label>
|
||||
<el-input v-if="entry.type === 'SEAL' && canEvidence" v-model="sealNoDraft" maxlength="64" show-word-limit aria-label="下一张照片的锁码" placeholder="先填写锁码,再上传带锁码照" />
|
||||
<label v-if="canEvidence" class="file-picker"><Camera :size="14" />追加照片<input type="file" accept="image/*" @change="uploadAndSaveEvidence(entry.type, $event)" /></label>
|
||||
</div>
|
||||
<div class="evidence-actions">
|
||||
<el-button v-if="entry.type === 'SEAL'" plain :disabled="!canEvidence || Boolean(busy)" :loading="busy === `evidence-${entry.type}`" @click="saveEvidence(entry.type)">保存锁码</el-button>
|
||||
<el-button v-for="item in evidences.filter((candidate) => candidate.evidenceType === entry.type && candidate.status === 'ACTIVE')" :key="String(item.id)" link type="danger" :disabled="!canEvidence || Boolean(busy)" @click="markEvidenceVoided(item)">作废</el-button>
|
||||
<div v-if="canEvidenceView" class="evidence-files">
|
||||
<div v-for="item in activeEvidences(entry.type)" :key="String(item.id)" class="evidence-file">
|
||||
<span>{{ item.fileName || `证据 #${item.id}` }}<template v-if="item.sealNo"> · 锁码 {{ item.sealNo }}</template></span>
|
||||
<div class="evidence-file__actions">
|
||||
<el-button link type="primary" :disabled="Boolean(busy)" @click="previewEvidence(item)"><Eye :size="14" />查看</el-button>
|
||||
<el-button v-if="canEvidence" link type="danger" :disabled="Boolean(busy)" @click="markEvidenceVoided(item)">作废</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="activeEvidences(entry.type).length === 0" class="workflow-hint">尚未上传有效照片</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="workflow-hint">五类证据各至少一张有效照片;锁码文本与带锁码照同时必填。错误照片只能作废并保留审计记录。</p>
|
||||
<div class="workflow-actions">
|
||||
<el-button type="primary" :disabled="!canLoadingComplete || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'loading-complete'" @click="finishLoading"><Check :size="15" />完成装柜</el-button>
|
||||
<div v-if="isLoading" class="workflow-actions">
|
||||
<el-button type="primary" :disabled="!canLoadingComplete || !evidenceReady || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'loading-complete'" @click="finishLoading"><Check :size="15" />完成装柜</el-button>
|
||||
<span v-if="!evidenceReady" class="workflow-hint">五类有效照片及锁码齐全后可提交;其他门禁仍由服务端校验</span>
|
||||
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-alert v-if="isLoaded" class="workflow-alert" type="success" :closable="false" show-icon>
|
||||
已装柜完成。国内端不提供在线冲正、回退或推进派送完成,后续状态仅接受海外/尾程可信回传。
|
||||
@@ -538,10 +546,13 @@ onMounted(() => { void refresh() })
|
||||
.workflow-actions { margin-top: 14px; }
|
||||
.workflow-remark { margin-top: 10px; }
|
||||
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.evidence-item { display: grid; grid-template-columns: minmax(100px, auto) minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||
.evidence-item { display: grid; min-width: 0; gap: 8px; align-content: start; padding: 12px; border: 1px solid var(--cc-border); border-radius: 6px; }
|
||||
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.evidence-control { display: flex; min-width: 0; align-items: center; gap: 8px; }
|
||||
.evidence-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.evidence-files { display: grid; gap: 4px; }
|
||||
.evidence-file { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||||
.evidence-file span { min-width: 0; overflow-wrap: anywhere; }
|
||||
.evidence-file__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 4px; }
|
||||
.gate-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
||||
.gate-grid > div { display: flex; min-height: 34px; align-items: center; justify-content: space-between; gap: 8px; padding: 7px 9px; border: 1px solid var(--cc-border); border-radius: 5px; }
|
||||
.gate-grid span { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||||
|
||||
Reference in New Issue
Block a user