优化优化

This commit is contained in:
2026-08-11 18:06:35 +08:00
parent 8a8c5a3faa
commit 03e1cd0c80
40 changed files with 2092 additions and 354 deletions
+7 -3
View File
@@ -3,7 +3,7 @@
## 项目定位
- 本项目是虫虫出海内部运营与管理后台,仅服务平台员工。
- 当前范围包括工作台、客户管理、头程订单、内部用户、角色管理和菜单权限。
- 当前范围包括工作台、客户管理、头程订单、仓储与国内出仓协同、财务管理、内部用户、角色管理和菜单权限。
- 不在本项目承载企业客户自助流程、品牌官网、微信小程序、独立 APP 或仓库 PDA 作业。
## 技术基线
@@ -37,15 +37,19 @@
| 业务 | 模块入口 | 联查模块或服务端 |
|---|---|---|
| 工作台与业务总览 | `modules/dashboard/` | 按具体指标联查其所属业务模块和服务端,不在工作台复制领域契约 |
| 客户、企业审核 | `modules/customers/` | `iam/`、`file/` |
| 平台用户与 RBAC | `modules/iam/`、`core/access/` | `auth/`、`iam/` |
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` |
| 订单列表与详情编排 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/` |
| 入库、测量和计费 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`file/`、`integration-service/` |
| 财务账户、账单、收款与成本 | `modules/finance/` | `finance/`;费用来源与柜次成本快照联查 `firstleg/`,凭证联查 `file/` |
| 货柜与智能排柜 | `modules/container-loading/` | `firstleg/loading/`、`firstleg/split/` |
| 预装与装柜清单 | `modules/loading-lists/` | `firstleg/loading/`、`file/`、`warehouse/` |
| 海运订舱与船期 | `modules/ocean-bookings/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`、`integration-service/` |
| 海运订舱与船期 | `modules/ocean-bookings/`、`modules/port-schedules/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`、`integration-service/` |
| 拖车预约 | `modules/trucking/` | `firstleg/outbound/dispatch/`、`firstleg/` |
| 报关协同 | `modules/customs/` | `firstleg/customs/`、`file/` |
| 国内出仓确认 | `modules/outbound-dispatch/` | `firstleg/outbound/dispatch/` |
| 主数据与下载 | `modules/master-data/`、`modules/downloads/` | `warehouse/`、`file/` |
## 一期/二期/三期 Admin 快速索引
@@ -56,7 +60,7 @@
|---|---|---|
| 一期:头程订单闭环 | `modules/auth/` 登录;`customers/` 客户与企业审核;`iam/` 内部用户、角色、权限;`warehouses/` 平台收货仓;`master-data/` 快递公司、往来单位;`first-leg-orders/` 头程订单;`dashboard/` 工作台 | 企业审核归客户列表,平台 RBAC 仅维护平台员工与平台权限资源 |
| 二期:到仓与头程计费 | `modules/inbounds/` 入库预报、收货、测量/OCR、审核、差异与费用快照;`warehouse-resources/pallets/` 托盘;`warehouse-resources/locations/` 库位;`fba-pricing/`、`dimension-config/` 计费规则与尺寸配置;`unified-orders/` 订单工作流编排 | 页面范围停在 `WAIT_SPLIT + APPROVED + CONFIRMED`;海外仓阶梯费用页面是否开放需单独确认;客户侧可见字段按授权结果映射,内部备注、文件和原始证据不得进入 Client 契约 |
| 三期:国内出仓 | `unified-orders/` 订单编排;`container-loading/` 货柜与排柜;`loading-lists/` 预装/装柜清单;`ocean-bookings/` 订舱船期;`trucking/` 拖车 | 页面、权限和 API 以稳定服务端契约发布;完整 WMS、海外仓及原型未纳入能力不属于本范围 |
| 三期:国内出仓 | `unified-orders/` 订单编排;`container-loading/` 货柜与排柜;`loading-lists/` 预装/装柜清单;`ocean-bookings/` 订舱;`port-schedules/` 船期;`trucking/` 拖车;`customs/` 报关;`outbound-dispatch/` 国内出仓确认 | 页面、权限和 API 以稳定服务端契约发布;完整 WMS、海外仓及原型未纳入能力不属于本范围 |
阶段文档入口(路径相对本文件所在目录):一期读 `../../docs/一期/README.md` 与闭环设计;二期读 `../../docs/二期/README.md` 和主流程;三期读 `../../docs/三期/README.md`、需求确认表、开工门禁、开发规划和页面清单。进度、排期或验收状态问题再读取相应进度表和验收报告。
@@ -105,4 +105,17 @@ describe('AdminSidebarMenu', () => {
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
expect(wrapper.get('[data-kind="item"]').attributes('data-index')).toBe('/tracking')
})
it('recreates the menu when asynchronously loaded groups change', async () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: [adminNavigation[0]], activePath: '/dashboard', collapsed: false },
global: { stubs },
})
const initialMenu = wrapper.get('nav').element
await wrapper.setProps({ items: visibleNavigation })
expect(wrapper.get('nav').element).not.toBe(initialMenu)
expect(wrapper.text()).toContain('系统管理')
})
})
+2
View File
@@ -16,10 +16,12 @@ function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
}
const defaultOpeneds = computed(() => getGroupKeys(props.items))
const menuInstanceKey = computed(() => `${props.collapsed ? 'collapsed' : 'expanded'}:${defaultOpeneds.value.join('|')}`)
</script>
<template>
<el-menu
:key="menuInstanceKey"
class="side-nav-menu"
:default-active="activePath"
:collapse="collapsed"
+27 -3
View File
@@ -80,7 +80,7 @@ describe('adminNavigation', () => {
])
})
it('assigns every page a unique path and keeps real permissions unique', () => {
it('assigns every page a unique path and only shares permissions backed by the API contract', () => {
const leaves = getNavigationLeaves(adminNavigation)
const paths = leaves.map((item) => item.path)
const permissions = leaves.flatMap((item) => item.permission ? [item.permission] : [])
@@ -103,14 +103,38 @@ describe('adminNavigation', () => {
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('/finance/bills')
expect(paths).toEqual(expect.arrayContaining([
'/finance/customer-accounts',
'/finance/customer-arap',
'/finance/customer-statements',
'/finance/settlement-receivables',
'/finance/settlement-payables',
]))
expect(paths).not.toEqual(expect.arrayContaining(['/finance/bills', '/finance/receipts', '/finance/costs']))
expect(paths).toContain('/prototype/system-management/brand-settings')
expect(paths).toContain('/system/permissions')
expect(new Set(paths).size).toBe(paths.length)
expect(new Set(permissions).size).toBe(permissions.length)
const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index)
expect(new Set(duplicatedPermissions)).toEqual(new Set(['platform:finance:bill:view']))
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
})
it('shows the five formal finance pages with their real backend permissions', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:finance:bill:view',
'platform:finance:receipt:view',
'platform:finance:cost:view',
]))
expect(getNavigationLeaves(filtered).map((item) => item.path).filter((path) => path.startsWith('/finance/'))).toEqual([
'/finance/customer-accounts',
'/finance/customer-arap',
'/finance/customer-statements',
'/finance/settlement-receivables',
'/finance/settlement-payables',
])
})
it('filters protected pages without mutating the source tree', () => {
const sourceSnapshot = JSON.stringify(adminNavigation, (key, value) => key === 'icon' ? '[icon]' : value)
const filtered = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
+5 -3
View File
@@ -137,9 +137,11 @@ export const adminNavigation: AdminNavigationNode[] = [
title: '财务管理',
icon: CircleDollarSign,
children: [
{ 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-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:bill:view' },
{ key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:bill:view' },
{ key: 'finance-customer-statements', title: '客户对账单', icon: FileCheck2, path: '/finance/customer-statements', permission: 'platform:finance:bill:view' },
{ key: 'finance-settlement-receivables', title: '结算单应收', icon: CircleDollarSign, path: '/finance/settlement-receivables', permission: 'platform:finance:receipt:view' },
{ key: 'finance-settlement-payables', title: '结算单应付', icon: Database, path: '/finance/settlement-payables', permission: 'platform:finance:cost:view' },
],
},
{
@@ -3,12 +3,19 @@ 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 { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ContainersView from './ContainersView.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listPlatformPartners } from '@/modules/master-data'
import { listOceanBookings } from '@/modules/ocean-bookings'
import { listContainers } from '../api/platformContainerLoading'
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookings: vi.fn() }))
const accessCan = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
function createTestRouter() {
@@ -23,7 +30,10 @@ function createTestRouter() {
describe('ContainersView', () => {
beforeEach(() => {
setActivePinia(createPinia())
accessCan.mockReset(); accessCan.mockReturnValue(true)
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING', 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 }], page: 1, size: 20, total: 1, pages: 1 })
})
@@ -115,4 +125,37 @@ describe('ContainersView', () => {
expect(push).toHaveBeenNthCalledWith(2, { name: 'loading-plans', query: { planId: '9102', warehouseId: '8' } })
expect(push).toHaveBeenNthCalledWith(3, { name: 'loading-lists', query: { planId: '9103', warehouseId: '8' } })
})
it('keeps view and edit operations behind their own backend permissions', async () => {
accessCan.mockImplementation((permission: string) => permission !== 'platform:firstleg:container:view')
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
})
it('uses business selectors and keeps code/name pairs internal', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('placeholder="请选择船公司"')
expect(source).toContain('placeholder="请选择起运港"')
expect(source).toContain('placeholder="请选择目的港"')
expect(source).toContain('placeholder="请选择目的仓"')
expect(source).toContain("selectPort('departure'")
expect(source).toContain('selectDestinationWarehouse')
expect(source).toContain('重新加载业务选项')
expect(source).not.toContain('label="起运港代码"')
expect(source).not.toContain('label="起运港名称"')
expect(source).not.toContain('label="目的仓代码"')
expect(source).not.toContain('label="目的仓名称"')
})
it('keeps query and pagination on a single list request path', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("if (page.value === 1) void loadRows(); else page.value = 1")
expect(source).toContain("if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()")
expect(source).not.toContain('@reset=')
})
})
@@ -5,6 +5,8 @@ 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'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
@@ -23,10 +25,15 @@ const router = useRouter()
const access = useAccessPreviewStore()
const rows = ref<ContainerBookingResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const destinationWarehouses = ref<PlatformWarehouseSummaryResource[]>([])
const partners = ref<PlatformPartnerResource[]>([])
const oceanBookings = ref<OceanBookingResource[]>([])
const loading = ref(false)
const warehouseLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const warehouseError = ref<ApiErrorDetails | null>(null)
const businessOptionsLoading = ref(false)
const businessOptionsError = ref('')
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
@@ -63,12 +70,40 @@ const rules: FormRules<ContainerForm> = {
}
const canCreate = computed(() => access.can('platform:firstleg:container:create'))
const canView = computed(() => access.can('platform:firstleg:container:view'))
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 shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
const truckingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.some((type) => ['CARRIER', 'SUPPLIER'].includes(type))))
interface PortOption { value: string; code: string | null; name: string | null }
function portValue(code: string | null | undefined, name: string | null | undefined) { return code || name ? JSON.stringify([code || '', name || '']) : '' }
function collectPorts(direction: 'departure' | 'destination'): PortOption[] {
const entries = oceanBookings.value.map((booking) => direction === 'departure'
? { code: booking.departurePortCode, name: booking.departurePortName }
: { code: booking.destinationPortCode, name: booking.destinationPortName })
const current = direction === 'departure'
? { code: form.departurePortCode, name: form.departurePortName }
: { code: form.destinationPortCode, name: form.destinationPortName }
entries.push(current)
const options = new Map<string, PortOption>()
for (const entry of entries) {
if (!entry.code && !entry.name) continue
const value = portValue(entry.code, entry.name)
options.set(value, { value, code: entry.code || null, name: entry.name || null })
}
return [...options.values()].sort((left, right) => (left.name || left.code || '').localeCompare(right.name || right.code || '', 'zh-CN'))
}
const departurePorts = computed(() => collectPorts('departure'))
const destinationPorts = computed(() => collectPorts('destination'))
const departurePortValue = computed(() => portValue(form.departurePortCode, form.departurePortName))
const destinationPortValue = computed(() => portValue(form.destinationPortCode, form.destinationPortName))
const shippingCompanyValue = computed(() => form.shippingCompanyId || (form.shippingCompanyName ? `name:${form.shippingCompanyName}` : ''))
const destinationWarehouseValue = computed(() => form.destinationWarehouseCode || (form.destinationWarehouseName ? `name:${form.destinationWarehouseName}` : ''))
const queryFields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、柜号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
@@ -119,12 +154,80 @@ function payload(): ContainerSaveRequest {
}
}
function partnerLabel(partner: PlatformPartnerResource) {
return `${partner.partnerName}${partner.partnerCode ? `(${partner.partnerCode})` : ''}`
}
function portLabel(port: PortOption) {
return port.name && port.code ? `${port.name}(${port.code})` : port.name || port.code || '未命名港口'
}
function selectShippingCompany(partnerId: string) {
if (partnerId.startsWith('name:')) {
form.shippingCompanyId = null
form.shippingCompanyName = partnerId.slice(5) || null
return
}
const partner = partners.value.find((item) => String(item.id) === partnerId)
form.shippingCompanyId = partnerId || null
form.shippingCompanyName = partner?.partnerName || null
}
function selectPort(direction: 'departure' | 'destination', value: string) {
const option = (direction === 'departure' ? departurePorts.value : destinationPorts.value).find((item) => item.value === value)
if (direction === 'departure') {
form.departurePortCode = option?.code || null
form.departurePortName = option?.name || null
} else {
form.destinationPortCode = option?.code || null
form.destinationPortName = option?.name || null
}
}
function selectDestinationWarehouse(warehouseCode: string) {
if (warehouseCode.startsWith('name:')) {
form.destinationWarehouseCode = null
form.destinationWarehouseName = warehouseCode.slice(5) || null
return
}
const warehouse = destinationWarehouses.value.find((item) => item.warehouseCode === warehouseCode)
form.destinationWarehouseCode = warehouseCode || null
form.destinationWarehouseName = warehouse?.warehouseName || null
}
async function loadWarehouses() {
warehouseLoading.value = true; warehouseError.value = null
try { warehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' }) }
try {
const records = await listAllPlatformWarehouses({ status: 'ACTIVE' })
warehouses.value = records.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
destinationWarehouses.value = records.filter((warehouse) => warehouse.warehouseType !== 'DOMESTIC')
}
catch (error) { warehouseError.value = getApiErrorDetails(error, '装柜仓库加载失败') }
finally { warehouseLoading.value = false }
}
async function loadBusinessOptions() {
businessOptionsLoading.value = true
businessOptionsError.value = ''
const errors: string[] = []
const [partnerResult, bookingResult] = await Promise.allSettled([
loadAllPartners(),
loadAllOceanBookings(),
])
if (partnerResult.status === 'fulfilled') partners.value = partnerResult.value
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
if (bookingResult.status === 'fulfilled') oceanBookings.value = bookingResult.value
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
businessOptionsError.value = errors.join(';')
businessOptionsLoading.value = false
}
async function loadAllPartners() {
const firstPage = await listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' })
if (firstPage.pages <= 1) return firstPage.records
const remaining = await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listPlatformPartners({ page: index + 2, size: 200, status: 'ACTIVE' })))
return [firstPage, ...remaining].flatMap((pageResult) => pageResult.records)
}
async function loadAllOceanBookings() {
const firstPage = await listOceanBookings({ page: 1, size: 200 })
if (firstPage.pages <= 1) return firstPage.records
const remaining = await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200 })))
return [firstPage, ...remaining].flatMap((pageResult) => pageResult.records)
}
async function loadRows() {
const sequence = ++listSequence
loading.value = true; loadError.value = null
@@ -205,7 +308,7 @@ function goContainerProcess(source: object) {
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
})
onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
onMounted(() => { void Promise.all([loadWarehouses(), loadBusinessOptions(), loadRows()]) })
</script>
<template>
@@ -221,7 +324,7 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
<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="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 #actions="{ row }"><div class="row-actions"><el-button v-if="canView" link type="primary" :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && 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>
@@ -242,27 +345,25 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
<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" />
<LoadErrorState v-if="warehouseError && !transportOnly" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-alert v-if="businessOptionsError && !transportOnly" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
<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>
<el-form-item label="柜型" prop="containerType"><el-select v-model="form.containerType" :disabled="transportOnly"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item>
<el-form-item label="容量上限(m³)"><el-input-number v-model="form.capacityCbm" :min="0.001" :precision="3" controls-position="right" :disabled="transportOnly" /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" controls-position="right" :disabled="transportOnly" /></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="船公司"><el-input v-model="form.shippingCompanyName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" /></el-form-item>
<el-form-item label="起运港代码"><el-input v-model="form.departurePortCode" maxlength="32" :disabled="transportOnly" /></el-form-item>
<el-form-item label="起运港名称"><el-input v-model="form.departurePortName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="目的港代码"><el-input v-model="form.destinationPortCode" maxlength="32" :disabled="transportOnly" /></el-form-item>
<el-form-item label="目的港名称"><el-input v-model="form.destinationPortName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="目的仓代码"><el-input v-model="form.destinationWarehouseCode" maxlength="64" :disabled="transportOnly" /></el-form-item>
<el-form-item label="目的仓名称"><el-input v-model="form.destinationWarehouseName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="拖车公司"><el-input v-model="form.terminalTruckingCompany" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :disabled="transportOnly" /></el-form-item>
<el-form-item label="柜次号" prop="cabinetNo"><el-input v-model="form.cabinetNo" maxlength="64" placeholder="请输入柜次号" :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 placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库" :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>
<el-form-item label="柜型" prop="containerType"><el-select v-model="form.containerType" placeholder="请选择柜型" :disabled="transportOnly"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item>
<el-form-item label="容量上限(m³)"><el-input-number v-model="form.capacityCbm" :min="0.001" :precision="3" controls-position="right" placeholder="请输入容量上限" :disabled="transportOnly" /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" controls-position="right" placeholder="请输入重量上限" :disabled="transportOnly" /></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" :disabled="transportOnly" /></el-form-item>
<el-form-item label="船公司"><el-select :model-value="shippingCompanyValue" filterable clearable placeholder="请选择船公司" :loading="businessOptionsLoading" loading-text="船公司加载中" no-data-text="暂无可用船公司" :disabled="transportOnly" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="shippingCompanyValue && !shippingCompanies.some((partner) => String(partner.id) === shippingCompanyValue)" :label="form.shippingCompanyName || '原船公司'" :value="shippingCompanyValue" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" :disabled="transportOnly" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="起运港"><el-select :model-value="departurePortValue" filterable clearable placeholder="请选择起运港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" :disabled="transportOnly" @update:model-value="selectPort('departure', String($event || ''))"><el-option v-for="port in departurePorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的港"><el-select :model-value="destinationPortValue" filterable clearable placeholder="请选择目的港" :loading="businessOptionsLoading" loading-text="港口加载中" no-data-text="暂无订舱港口数据" :disabled="transportOnly" @update:model-value="selectPort('destination', String($event || ''))"><el-option v-for="port in destinationPorts" :key="port.value" :label="portLabel(port)" :value="port.value" /></el-select></el-form-item>
<el-form-item label="目的仓"><el-select :model-value="destinationWarehouseValue" filterable clearable placeholder="请选择目的仓" :loading="warehouseLoading" loading-text="目的仓加载中" no-data-text="暂无可用海外/FBA仓库" :disabled="transportOnly" @update:model-value="selectDestinationWarehouse(String($event || ''))"><el-option v-if="destinationWarehouseValue && !destinationWarehouses.some((warehouse) => warehouse.warehouseCode === destinationWarehouseValue)" :label="form.destinationWarehouseName || form.destinationWarehouseCode || '原目的仓'" :value="destinationWarehouseValue" /><el-option v-for="warehouse in destinationWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" /></el-select></el-form-item>
<el-form-item label="拖车公司"><el-select v-model="form.terminalTruckingCompany" filterable clearable allow-create default-first-option placeholder="请选择或输入拖车公司" :loading="businessOptionsLoading" loading-text="拖车公司加载中" no-data-text="暂无合作单位,可直接输入" :disabled="transportOnly"><el-option v-for="partner in truckingCompanies" :key="String(partner.id)" :label="partnerLabel(partner)" :value="partner.partnerName" /></el-select></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划装柜时间" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" :disabled="transportOnly" /></el-form-item>
</el-form>
</AppDrawer>
</section>
@@ -2,4 +2,22 @@ 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') }) })
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') })
it('selects business resources instead of asking operators for customer and order ids', () => {
expect(source).toContain('listPlatformTenants')
expect(source).toContain('listPlatformUnifiedOrders')
expect(source).toContain('listContainers')
expect(source).toContain('selectOrder')
expect(source).toContain('重新加载选项')
expect(source).not.toContain('label="客户 ID"')
expect(source).not.toContain('label="订单 ID"')
expect(source).not.toContain('label="入库单 ID"')
})
it('only exposes active customers in create selectors', () => {
expect(source).toContain("customers.value.filter((customer) => customer.status === 'ACTIVE')")
expect(source).toContain('v-for="customer in activeCustomers"')
expect(source).not.toContain(':disabled="customer.status !== \'ACTIVE\'"')
})
})
+27 -4
View File
@@ -12,6 +12,9 @@ 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 { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformUnifiedOrders, type UnifiedOrderResource } from '@/modules/unified-orders'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
import { createCustomsCase, getCustomsCase, listCustomsCases, submitCustomsCase } from '../api/platformCustoms'
import type { CustomsCaseCreateRequest, CustomsCaseDetail, CustomsCaseResource } from '../types'
@@ -20,8 +23,28 @@ const filters = reactive<QueryModel>({ orderId: '', inboundId: '', status: '' })
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 }
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 customers = ref<PlatformTenantResource[]>([]); const orders = ref<UnifiedOrderResource[]>([]); const containers = ref<ContainerBookingResource[]>([]); const optionsLoading = ref(false); const ordersLoading = ref(false); const optionsError = ref('')
const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE'))
const rules: FormRules = { customerId: [{ required: true, message: '请选择客户', trigger: 'change' }], orderId: [{ required: true, message: '请选择订单', trigger: 'change' }], inboundId: [{ required: true, message: '所选订单尚未生成入库单', trigger: 'change' }], checklistVersionId: [{ required: true, message: '请输入资料清单版本 ID', trigger: 'blur' }] }
async function loadCreateOptions() {
optionsLoading.value = true; optionsError.value = ''
const [customerResult, containerResult] = await Promise.allSettled([listPlatformTenants({ page: 1, size: 200 }), listContainers({ page: 1, size: 200 })])
if (customerResult.status === 'fulfilled') customers.value = customerResult.value.records; else optionsError.value = '客户列表加载失败'
if (containerResult.status === 'fulfilled') containers.value = containerResult.value.records; else optionsError.value += `${optionsError.value ? ';' : ''}柜次列表加载失败`
optionsLoading.value = false
}
async function reloadCreateOptions() { await loadCreateOptions(); if (createForm.customerId) await selectCustomer(createForm.customerId) }
async function selectCustomer(customerId: string) {
createForm.customerId = customerId; createForm.orderId = ''; createForm.inboundId = ''; orders.value = []
if (!customerId) return
ordersLoading.value = true
try { const result = await listPlatformUnifiedOrders({ customerId, page: 1, size: 200 }); orders.value = result.records.filter((item) => item.inboundId != null) }
catch { optionsError.value = '该客户的订单列表加载失败,请重新加载选项' }
finally { ordersLoading.value = false }
}
function selectOrder(orderId: string) { createForm.orderId = orderId; createForm.inboundId = String(orders.value.find((item) => String(item.orderId) === orderId)?.inboundId || '') }
function openCreate() { Object.assign(createForm, { customerId: '', orderId: '', inboundId: '', containerBookingId: null, checklistVersionId: '', remark: '', expectedVersion: 0 }); createError.value = null; orders.value = []; createOpen.value = true; void loadCreateOptions() }
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 } }
@@ -29,6 +52,6 @@ async function submit() { if (!detail.value || !submitForm.customsAgentName.trim
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>
<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-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading || ordersLoading" @click="reloadCreateOptions">重新加载选项</el-button></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="客户" prop="customerId"><el-select :model-value="createForm.customerId" filterable placeholder="请选择客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-for="customer in activeCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="订单 / 入库单" prop="orderId"><el-select :model-value="createForm.orderId" filterable placeholder="请先选择客户,再选择已入库订单" :loading="ordersLoading" :disabled="!createForm.customerId" style="width:100%" @update:model-value="selectOrder(String($event || ''))"><el-option v-for="order in orders" :key="String(order.orderId)" :label="`${order.orderNo || order.orderId} / ${order.inboundCode || order.inboundId}`" :value="String(order.orderId)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="关联柜次(可选)"><el-select v-model="createForm.containerBookingId" filterable clearable placeholder="请选择柜次" :loading="optionsLoading" style="width:100%"><el-option v-for="container in containers" :key="container.id" :label="`${container.cabinetNo || container.bookingNo}${container.containerNo ? ` / ${container.containerNo}` : ''}`" :value="container.id" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="资料清单版本 ID" prop="checklistVersionId"><el-input v-model="createForm.checklistVersionId" placeholder="请输入已发布的资料清单版本 ID" /><small>当前服务端尚未提供资料清单版本查询接口。</small></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" placeholder="请输入报关协同备注(可选)" /></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" placeholder="请输入线下报关代理名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" placeholder="请输入代理回传的参考号(可选)" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" placeholder="请输入本次提交说明(可选)" /></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>
+63 -25
View File
@@ -1,48 +1,62 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts, updateFinanceBill, uploadFinanceVoucher, voidFinanceBill } from './finance'
import { adjustFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerTerms, issueFinanceBill, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, updateFinanceBill, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('uses the OpenAPI bill path and pageSize parameter', async () => {
it('uses the customer statement read-model path', 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 }],
code: '200', message: 'ok', data: [{ id: 1, customerId: 10001, billNo: 'B-1', customerName: '客户A', billDate: '2026-08-11', dueDate: '2026-09-10', totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, status: 'DRAFT', version: 2 }],
})
const result = await listFinanceBills({ customerId: '10001', status: 'DRAFT', page: 1, pageSize: 20 })
const result = await listFinanceStatements('10001')
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 })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/statements', { params: { customerId: '10001' } })
expect(result[0]).toMatchObject({ id: '1', customerId: '10001', 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: [] })
it('uses fixed read-model paths for all five finance pages', async () => {
const get = vi.spyOn(http, 'get')
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { customerId: 10001, totalReceivable: 300, totalReceived: 100, outstandingAmount: 200, entries: [] } })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ poolItemId: 9, direction: 'PAYABLE', sourceType: 'CONTAINER_COST', sourceId: 81, sourceVersion: 2, containerId: 81, feeItemName: '拖车费', amount: 680.5, status: 'OPEN' }] })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [] })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ id: 81, customerId: 10001, settlementNo: 'AR-81', receivableAmount: 300, receivedAmount: 100, outstandingAmount: 200, status: 'PARTIALLY_PAID', version: 2 }] })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ id: 71, customerId: 10001, settlementNo: 'AP-71', costType: 'CONTAINER_SHARED', containerId: 81, feeItemName: '拖车费', payableAmount: 680.5, paidAmount: 0, outstandingAmount: 680.5, status: 'UNPAID', version: 1 }] })
const account = await getFinanceCustomerAccount('10001')
const pool = await listFinanceFeePool('10001', 'PAYABLE')
await listFinanceStatements('10001')
const receivables = await listFinanceReceivableSettlements('10001')
const payables = await listFinancePayableSettlements('10001')
expect(get.mock.calls.map(([path]) => path)).toEqual([
'/platform/finance/v1/customer-accounts/10001',
'/platform/finance/v1/fee-pool',
'/platform/finance/v1/statements',
'/platform/finance/v1/receivable-settlements',
'/platform/finance/v1/payable-settlements',
])
expect(account).toMatchObject({ customerId: '10001', currency: 'CNY', accountMode: 'MANUAL_RECEIVABLE_LEDGER', outstandingAmount: 200 })
expect(pool[0]).toMatchObject({ poolItemId: '9', sourceId: '81', sourceVersion: 2, containerId: '81', feeItemName: '拖车费', amount: 680.5 })
expect(receivables[0]).toMatchObject({ id: '81', settlementNo: 'AR-81', currency: 'CNY' })
expect(payables[0]).toMatchObject({ id: '71', settlementNo: 'AP-71', feeItemName: '拖车费', payableAmount: 680.5, currency: 'CNY' })
})
it('keeps receipt and cost commands on CNY with real voucher ids', async () => {
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('uploads a real voucher through the Finance Controller proxy', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { fileId: 90001, tenantId: 10001, fileName: 'receipt.png', contentType: 'image/png', sizeBytes: 128 } })
const file = new File(['image'], 'receipt.png', { type: 'image/png' })
@@ -56,17 +70,41 @@ describe('finance api', () => {
expect(post.mock.calls[0]?.[2]).toEqual({ params: { customerId: '10001' }, headers: { 'Content-Type': 'multipart/form-data' } })
})
it('submits a non-empty container volume snapshot with the uploaded voucher id', async () => {
it('lets Finance backend obtain the container snapshot from Firstleg', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: {} })
const payload = { customerId: '10001', costType: 'CONTAINER_SHARED', containerId: '81', expenseName: '拖车费', amount: '600.000000', containerOrders: [{ orderId: '91', inboundId: '101', containerId: '81', volumeCbm: '2.500000' }] }
const payload = { customerId: '10001', costType: 'CONTAINER_SHARED', containerId: '81', expenseName: '拖车费', amount: '600.000000' }
await createFinanceCost(payload, ['90001'])
expect(post.mock.calls[0]?.[1]).toMatchObject({ ...payload, currency: 'CNY', voucherFileIds: ['90001'], containerOrders: [{ orderId: '91', volumeCbm: '2.500000' }] })
expect(post.mock.calls[0]?.[1]).toEqual({ ...payload, currency: 'CNY', voucherFileIds: ['90001'] })
expect(post.mock.calls[0]?.[1]).not.toHaveProperty('containerOrders')
})
it('gets and updates the customer credit terms', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: { tenantId: 10001, customerName: '客户A', creditDays: 45, status: 'ACTIVE' } })
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'ok', data: { tenantId: 10001, customerName: '客户A', creditDays: 60, status: 'ACTIVE' } })
const terms = await getFinanceCustomerTerms('10001')
const updated = await updateFinanceCustomerTerms('10001', { customerName: '客户A', creditDays: 60 })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/customers/10001/terms')
expect(put).toHaveBeenCalledWith('/platform/finance/v1/customers/10001/terms', { customerName: '客户A', creditDays: 60 }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(terms.creditDays).toBe(45)
expect(updated.creditDays).toBe(60)
})
it('queries and normalizes real bill revision snapshots', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ id: 301, bill_id: 81, version: 2, reason: '金额修正', bill_snapshot: JSON.stringify({ status: 'ISSUED', total_amount: '120.00', received_amount: '20.00', outstanding_amount: '100.00' }), line_snapshot: JSON.stringify([{ source_id: 91, source_version: 2, fee_item_name: '运费', amount: '120.00' }]), created_by: 7, created_at: '2026-08-11T12:00:00' }] })
const revisions = await listFinanceBillRevisions('81', '10001')
expect(get).toHaveBeenCalledWith('/platform/finance/v1/bills/81/revisions', { params: { customerId: '10001' } })
expect(revisions[0]).toMatchObject({ id: '301', billId: '81', version: 2, reason: '金额修正', createdBy: '7', status: 'ISSUED', totalAmount: 120, receivedAmount: 20, outstandingAmount: 100, lines: [{ sourceFeeId: '91', sourceFeeVersion: 2, feeItemName: '运费', amount: 120 }] })
})
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 }] }
const statement = { id: 81, customerId: 10001, billNo: 'B-81', customerName: '客户A', billDate: '2026-08-11', dueDate: '2026-09-10', totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, status: 'DRAFT', version: 3 }
const raw = { statement, lines: [{ sourceId: 91, sourceVersion: 2, orderId: 92, inboundId: 93, feeItemName: '运费', 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 })
@@ -76,7 +114,7 @@ describe('finance api', () => {
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(detail).toMatchObject({ id: '81', customerId: '10001', version: 3, lines: [{ sourceFeeId: '91', sourceFeeVersion: 2, orderId: '92', inboundId: '93', 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) } })
+75 -44
View File
@@ -1,49 +1,93 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceCostResource, FinancePaymentMethod, FinanceReceiptResource, FinanceVoucherResource } from '../types'
import type { FinanceAccountEntry, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceStatementReadModel, FinanceVoucherResource } from '../types'
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 }
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 jsonRecord(value: unknown): RawResource {
if (value && typeof value === 'object' && !Array.isArray(value)) return value as RawResource
if (typeof value !== 'string' || !value.trim()) return {}
try { const parsed = JSON.parse(value) as unknown; return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as RawResource : {} } 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 jsonRecords(value: unknown): RawResource[] {
if (Array.isArray(value)) return value.filter((item): item is RawResource => Boolean(item) && typeof item === 'object' && !Array.isArray(item))
if (typeof value !== 'string' || !value.trim()) return []
try { const parsed = JSON.parse(value) as unknown; return Array.isArray(parsed) ? parsed.filter((item): item is RawResource => Boolean(item) && typeof item === 'object' && !Array.isArray(item)) : [] } catch { return [] }
}
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),
totalAmount: numberValue(item.total_amount ?? item.totalAmount), paidAmount: numberValue(item.received_amount ?? item.receivedAmount ?? 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,
sourceFeeId: stringValue(item.source_id ?? item.sourceId ?? item.sourceFeeId), sourceFeeVersion: numberValue(item.source_version ?? item.sourceVersion ?? 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)) : [] }
const statement = item.statement && typeof item.statement === 'object' ? item.statement as RawResource : item
return { ...normalizeBill(statement), remark: stringValue(statement.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 getFinanceCustomerAccount(customerId: string): Promise<FinanceCustomerAccountReadModel> {
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/customer-accounts/${customerId}`)
const item = unwrapApiResponse(response)
return {
customerId: stringValue(item.customerId), currency: 'CNY', accountMode: 'MANUAL_RECEIVABLE_LEDGER',
totalReceivable: numberValue(item.totalReceivable), totalReceived: numberValue(item.totalReceived), outstandingAmount: numberValue(item.outstandingAmount),
entries: (Array.isArray(item.entries) ? item.entries : []).map((entry) => { const row = entry as RawResource; return { entryType: stringValue(row.entryType) as FinanceAccountEntry['entryType'], resourceId: stringValue(row.resourceId), resourceNo: stringValue(row.resourceNo), occurredAt: stringValue(row.occurredAt), debitAmount: numberValue(row.debitAmount), creditAmount: numberValue(row.creditAmount), currency: 'CNY', status: stringValue(row.status) } }),
}
}
export async function listFinanceFeePool(customerId: string, direction?: 'RECEIVABLE' | 'PAYABLE'): Promise<FinanceFeePoolItem[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/fee-pool`, { params: { customerId, direction } })
return unwrapApiResponse(response).map((item) => ({ poolItemId: stringValue(item.poolItemId), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) }))
}
export async function listFinanceStatements(customerId: string): Promise<FinanceStatementReadModel[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/statements`, { params: { customerId } })
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), totalAmount: numberValue(item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceStatementReadModel['status'], version: numberValue(item.version) }))
}
export async function listFinanceReceivableSettlements(customerId: string): Promise<FinanceReceivableSettlement[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/receivable-settlements`, { params: { customerId } })
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), settlementNo: stringValue(item.settlementNo), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), receivableAmount: numberValue(item.receivableAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceReceivableSettlement['status'], version: numberValue(item.version) }))
}
export async function listFinancePayableSettlements(customerId: string): Promise<FinancePayableSettlement[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/payable-settlements`, { params: { customerId } })
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), settlementNo: stringValue(item.settlementNo), costType: stringValue(item.costType) as FinancePayableSettlement['costType'], orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), supplierName: stringValue(item.supplierName), feeItemName: stringValue(item.feeItemName), payableAmount: numberValue(item.payableAmount), paidAmount: numberValue(item.paidAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt), version: numberValue(item.version) }))
}
function normalizeCustomerTerms(item: RawResource, customerId: string): FinanceCustomerTerms {
return {
customerId: stringValue(item.tenant_id ?? item.tenantId ?? customerId),
customerName: stringValue(item.customer_name_snapshot ?? item.customerName),
creditDays: numberValue(item.credit_days ?? item.creditDays ?? 30),
status: stringValue(item.status) || 'ACTIVE',
}
}
export async function getFinanceCustomerTerms(customerId: string) {
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/customers/${customerId}/terms`)
return normalizeCustomerTerms(unwrapApiResponse(response), customerId)
}
export async function updateFinanceCustomerTerms(customerId: string, payload: { customerName: string; creditDays: number }) {
const response = await http.put<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/customers/${customerId}/terms`, payload, mutationHeaders('customer-terms'))
return normalizeCustomerTerms(unwrapApiResponse(response), customerId)
}
export async function createFinanceBill(payload: Record<string, unknown>) {
@@ -56,6 +100,21 @@ export async function getFinanceBill(billId: string) {
return normalizeBillDetail(unwrapApiResponse(response))
}
export async function listFinanceBillRevisions(billId: string, customerId: string): Promise<FinanceBillRevision[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/bills/${billId}/revisions`, { params: { customerId } })
return unwrapApiResponse(response).map((item) => {
const bill = jsonRecord(item.bill_snapshot ?? item.billSnapshot)
const lines = jsonRecords(item.line_snapshot ?? item.lineSnapshot)
return {
id: stringValue(item.id), billId: stringValue(item.bill_id ?? item.billId ?? billId), version: numberValue(item.version),
reason: stringValue(item.reason), createdBy: stringValue(item.created_by ?? item.createdBy), createdAt: stringValue(item.created_at ?? item.createdAt),
status: stringValue(bill.status) as FinanceBillRevision['status'], totalAmount: numberValue(bill.total_amount ?? bill.totalAmount),
receivedAmount: numberValue(bill.received_amount ?? bill.receivedAmount), outstandingAmount: numberValue(bill.outstanding_amount ?? bill.outstandingAmount),
lines: lines.map(normalizeBillLine),
}
})
}
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))
@@ -71,24 +130,6 @@ export async function voidFinanceBill(billId: string, expectedVersion: number, r
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)
@@ -108,16 +149,6 @@ export async function uploadFinanceVoucher(customerId: string, file: File): Prom
}
}
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)
@@ -0,0 +1,52 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
function source(path: string) {
return readFileSync(resolve(process.cwd(), path), 'utf8')
}
describe('finance five-page delivery contract', () => {
const routes = source('src/modules/finance/routes.ts')
const navigation = source('src/core/navigation/adminNavigation.ts')
const api = source('src/modules/finance/api/finance.ts')
const pages = [
['finance/customer-accounts', '客户账户'],
['finance/customer-arap', '客户应收应付'],
['finance/customer-statements', '客户对账单'],
['finance/settlement-receivables', '结算单应收'],
['finance/settlement-payables', '结算单应付'],
] as const
it('registers exactly the five formal routes and menu entries', () => {
for (const [path, title] of pages) {
expect(routes).toContain(`path: '${path}'`)
expect(routes).toContain(`title: '${title}'`)
expect(navigation).toContain(`path: '/${path}'`)
expect(navigation).toContain(`title: '${title}'`)
}
expect(routes.match(/path: 'finance\//g)).toHaveLength(5)
expect(navigation.match(/path: '\/finance\//g)).toHaveLength(5)
})
it('does not expose the old three routes or out-of-scope reports', () => {
for (const forbidden of ['/finance/bills', '/finance/receipts', '/finance/costs', '费用分析', '利润表']) {
expect(routes).not.toContain(forbidden)
expect(navigation).not.toContain(forbidden)
}
})
it('keeps production reads on fixed DTO endpoints only', () => {
for (const endpoint of ['customer-accounts', 'fee-pool', 'statements', 'receivable-settlements', 'payable-settlements']) {
expect(api).toContain(`${basePathMarker(endpoint)}`)
}
expect(api).not.toContain('function listFinanceBills')
expect(api).not.toContain('function listFinanceReceipts')
expect(api).not.toContain('function listFinanceCosts')
})
})
function basePathMarker(endpoint: string) {
return '${basePath}/' + endpoint
}
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, Save } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import FormSection from '@/shared/components/FormSection.vue'
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'
import { getFinanceCustomerAccount, getFinanceCustomerTerms, updateFinanceCustomerTerms } from '../api/finance'
import type { FinanceAccountEntry, FinanceCustomerTerms } from '../types'
interface AccountRow {
id: string
customerName: string
customerCode: string
creditDays: number
totalReceivable: number
totalReceived: number
outstandingAmount: number
entryCount: number
entries: FinanceAccountEntry[]
status: string
}
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null)
const filters = reactive<QueryModel>({ customerId: '' })
const rows = ref<AccountRow[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const drawerOpen = ref(false)
const editing = ref(false)
const saving = ref(false)
const selected = ref<AccountRow | null>(null)
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
const creditDays = ref(30)
const canView = computed(() => access.can('platform:finance:bill:view') && access.can('platform:finance:customer-terms:view'))
const canUpdate = computed(() => access.can('platform:finance:customer-terms:update'))
const stateError = computed(() => customersError.value || loadError.value)
const queryFields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: `${item.tenantName} (${item.tenantCode})`, value: String(item.id), disabled: item.status !== 'ACTIVE' })) }])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'customerName', label: '客户', minWidth: 180 }, { key: 'customerCode', label: '客户编号', width: 150 },
{ key: 'creditDays', label: '账期(天)', width: 100 }, { key: 'totalReceivable', label: '累计应收(CNY)', width: 140 },
{ key: 'totalReceived', label: '累计已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 120 },
{ key: 'entryCount', label: '账户流水数', width: 120 },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
]
async function loadCustomers() {
customersLoading.value = true; customersError.value = null
try {
const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1
do { const result = await listPlatformTenants({ page: currentPage, size: 200 }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages)
customers.value = records
} catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
finally { customersLoading.value = false }
}
async function load(model: QueryModel = filters) {
Object.assign(filters, model)
const customerId = String(filters.customerId || '')
rows.value = []; loadError.value = null
if (!customerId) return
const customer = customers.value.find((item) => String(item.id) === customerId)
if (!customer) return
loading.value = true
try {
const [terms, account] = await Promise.all([getFinanceCustomerTerms(customerId), getFinanceCustomerAccount(customerId)])
rows.value = [{ id: customerId, customerName: customer.tenantName, customerCode: customer.tenantCode, creditDays: terms.creditDays, totalReceivable: account.totalReceivable, totalReceived: account.totalReceived, outstandingAmount: account.outstandingAmount, entryCount: account.entries.length, entries: account.entries, status: customer.status }]
} catch (error) { loadError.value = getApiErrorDetails(error, '客户账户加载失败,请重试') }
finally { loading.value = false }
}
async function openAccount(row: Record<string, unknown>, edit: boolean) {
const account = row as unknown as AccountRow
selected.value = account; editing.value = edit; drawerOpen.value = true; loadError.value = null
try { selectedTerms.value = await getFinanceCustomerTerms(account.id); creditDays.value = selectedTerms.value.creditDays }
catch (error) { loadError.value = getApiErrorDetails(error, '客户账期加载失败,请重试') }
}
async function saveTerms() {
if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return }
saving.value = true
try { selectedTerms.value = await updateFinanceCustomerTerms(selected.value.id, { customerName: selected.value.customerName, creditDays: creditDays.value }); editing.value = false; ElMessage.success('客户账期已保存'); await load() }
catch (error) { loadError.value = getApiErrorDetails(error, '客户账期保存失败,请重试'); ElMessage.error(loadError.value.message) }
finally { saving.value = false }
}
function money(value: unknown) { return Number(value || 0).toFixed(2) }
onMounted(() => void loadCustomers())
</script>
<template>
<AdminPage as="div" class="finance-account-page" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户账户" sub-title="请联系管理员开通账单和客户账期查看权限" />
<ListPageLayout v-else aria-label="客户账户">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="load" /></template>
<template v-if="stateError" #state><LoadErrorState title="客户账户加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
<DataTable :data="rows" :columns="columns" :loading="loading" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="id" empty-text="请选择客户查询账户">
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdate" link type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
<template #pagination-left>币种:CNY · 账户由对账单与收款记录生成</template>
</DataTable>
</section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" :title="editing ? '维护客户账期' : '客户账户详情'" size="min(680px, 94vw)" :confirm-loading="saving">
<template v-if="selected">
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="累计应收">{{ money(selected.totalReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="累计已收">{{ money(selected.totalReceived) }} CNY</el-descriptions-item><el-descriptions-item label="未收">{{ money(selected.outstandingAmount) }} CNY</el-descriptions-item><el-descriptions-item label="账户模式">人工应收台账</el-descriptions-item></el-descriptions>
<FormSection title="账户流水" description="由对账单和收款记录生成。"><div v-for="entry in selected.entries" :key="`${entry.entryType}-${entry.resourceId}`" class="account-entry"><span>{{ entry.entryType === 'RECEIPT' ? '收款' : '应收' }} · {{ entry.resourceNo }}</span><span>{{ entry.occurredAt }}</span><b>{{ entry.entryType === 'RECEIPT' ? entry.creditAmount : entry.debitAmount }} CNY</b></div><el-empty v-if="!selected.entries.length" description="暂无账户流水" /></FormSection>
<FormSection title="账期设置" description="每个客户独立设置,默认 30 天;新账单按该账期计算到期日。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" :disabled="!editing" /><span class="term-unit">天</span></FormSection>
</template>
<template #footer><div class="drawer-footer"><el-button @click="drawerOpen = false">关闭</el-button><el-button v-if="editing && canUpdate" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
</AppDrawer>
</AdminPage>
</template>
<style scoped>
.finance-account-page { min-width: 0; }
.row-actions, .drawer-footer { display: flex; align-items: center; gap: 8px; }
.drawer-footer { justify-content: flex-end; }
.row-actions .el-button { margin-left: 0; }
.account-detail { margin-bottom: 16px; }
.term-unit { margin-left: 8px; color: var(--cc-muted); }
.account-entry { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 12px; padding: 9px 0; border-bottom: 1px solid var(--el-border-color-lighter); }
.account-entry span:nth-child(2) { color: var(--cc-muted); }
.account-entry b { white-space: nowrap; }
@media (max-width: 720px) { .drawer-footer .el-button { min-height: 44px; } :deep(.account-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
@media (max-width: 480px) { .drawer-footer .el-button { flex: 1 1 120px; } .account-entry { grid-template-columns: 1fr; } :deep(.account-detail .el-descriptions__cell) { display: block; width: 100%; } }
</style>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
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'
import { listFinanceFeePool } from '../api/finance'
interface ArapRow {
id: string
customerName: string
direction: 'RECEIVABLE' | 'PAYABLE'
documentNo: string
sourceType: string
sourceId: string
orderId: string
inboundId: string
containerId: string
feeName: string
amount: number
status: string
occurredAt: string
}
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null)
const filters = reactive<QueryModel>({ customerId: '', direction: '', status: '', keyword: '' })
const allRows = ref<ArapRow[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const drawerOpen = ref(false)
const selected = ref<ArapRow | null>(null)
const page = ref(1)
const pageSize = ref(20)
const canView = computed(() => access.can('platform:finance:bill:view'))
const stateError = computed(() => customersError.value || loadError.value)
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 260, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id), disabled: item.status !== 'ACTIVE' })) },
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '全部', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '草稿', value: 'DRAFT' }, { label: '已出账', value: 'ISSUED' }, { label: '部分收款', value: 'PARTIALLY_PAID' }, { label: '已收清', value: 'PAID' }, { label: '作废', value: 'VOIDED' }, { label: '已登记', value: 'RECORDED' }] },
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '单号/费用/来源ID', props: { 'aria-label': '关键词' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180 }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120 },
{ key: 'sourceId', label: '来源追溯', minWidth: 200 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 },
{ key: 'status', label: '状态', width: 120, slotName: 'status' }, { key: 'occurredAt', label: '发生日期', width: 130 },
]
const filteredRows = computed(() => {
const keyword = String(filters.keyword || '').trim().toLowerCase(); const direction = String(filters.direction || ''); const status = String(filters.status || '')
return allRows.value.filter((row) => (!direction || row.direction === direction) && (!status || row.status === status) && (!keyword || [row.documentNo, row.sourceId, row.feeName].some((value) => value.toLowerCase().includes(keyword))))
})
const rows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
async function loadCustomers() {
customersLoading.value = true; customersError.value = null
try { const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1; do { const result = await listPlatformTenants({ page: currentPage, size: 200 }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages); customers.value = records }
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
finally { customersLoading.value = false }
}
async function load(model: QueryModel = filters) {
Object.assign(filters, model); page.value = 1; allRows.value = []; loadError.value = null
const customerId = String(filters.customerId || ''); if (!customerId) return
const customerName = customers.value.find((item) => String(item.id) === customerId)?.tenantName || `客户 ${customerId}`
loading.value = true
try {
const direction = String(filters.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
const items = await listFinanceFeePool(customerId, direction || undefined)
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, customerName, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt }))
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
finally { loading.value = false }
}
function openDetail(row: Record<string, unknown>) { selected.value = row as unknown as ArapRow; drawerOpen.value = true }
function statusLabel(status: string) { return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废', RECORDED: '已登记' } as Record<string, string>)[status] || status }
onMounted(() => void loadCustomers())
</script>
<template>
<AdminPage as="div" class="finance-arap-page" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
<ListPageLayout v-else aria-label="客户应收应付">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="load" /></template>
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="请选择客户查询应收应付">
<template #actions="{ row }"><el-button link type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ statusLabel(row.status) }}</el-tag></template>
<template #pagination-left>费用来源来自真实账单明细、入库费用快照和成本台账 · CNY</template>
</DataTable></section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" title="应收应付详情" size="min(680px, 94vw)"><el-descriptions v-if="selected" :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ selected.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(selected.status) }}</el-descriptions-item><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="费用单据">{{ selected.documentNo }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ selected.sourceType }}</el-descriptions-item><el-descriptions-item label="来源追溯">{{ selected.sourceId }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ selected.feeName }}</el-descriptions-item><el-descriptions-item label="金额">{{ selected.amount }} CNY</el-descriptions-item><el-descriptions-item label="发生日期">{{ selected.occurredAt }}</el-descriptions-item></el-descriptions><template #footer><el-button @click="drawerOpen = false">关闭</el-button></template></AppDrawer>
</AdminPage>
</template>
<style scoped>
.finance-arap-page { min-width: 0; }
.arap-detail { margin-bottom: 16px; }
@media (max-width: 720px) { :deep(.el-drawer__footer .el-button) { min-height: 44px; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
@media (max-width: 480px) { :deep(.arap-detail .el-descriptions__cell) { display: block; width: 100%; } }
</style>
@@ -25,20 +25,60 @@ describe('FinanceManualPage delivery contract', () => {
expect(source).toContain('<ApiErrorMeta :details="drawerError"')
})
it('uses real voucher uploads, requires container volume snapshots and declares narrow-screen layouts', () => {
it('uses real voucher uploads and declares narrow-screen layouts', () => {
expect(source).toContain('uploadFinanceVoucher(form.customerId, file.value)')
expect(source).toContain('voucherFileIds = uploadedVoucher.value ? [uploadedVoucher.value.fileId] : []')
expect(source).toContain("form.costType === 'CONTAINER_SHARED' && containerOrders.some")
expect(source).toContain('containerOrders.map((item) =>')
expect(source).toContain('体积(CBM)')
expect(source).toContain("form.costType === 'CONTAINER_SHARED' ? form.referenceNo : undefined")
expect(source).toContain('由后端从 Firstleg 获取真实柜内订单体积快照并完成分摊')
expect(source).not.toContain('containerOrders')
expect(source).not.toContain('体积(CBM)')
expect(source).toContain('type="file" accept="image/*" :disabled="saving"')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('@media (max-width: 480px)')
expect(source).toContain('grid-template-columns: 1fr')
})
it('shows an explicit revision query blocker instead of fabricated history', () => {
expect(source).toContain('修订记录查询接口尚未提供')
it('loads and displays real revision history without fabricated data', () => {
expect(source).toContain('listFinanceBillRevisions(detail.id, detail.customerId)')
expect(source).toContain('v-for="revision in billRevisions"')
expect(source).toContain('<ApiErrorMeta :details="revisionError"')
expect(source).toContain('暂无历史修订记录')
expect(source).not.toContain('mockRevision')
})
it('uses business selectors instead of asking operators for internal ids', () => {
expect(source).toContain("type: 'select', label: '客户'")
expect(source).toContain('placeholder="请选择客户"')
expect(source).toContain('placeholder="选择订单 / 入库单"')
expect(source).toContain('placeholder="选择费用来源"')
expect(source).toContain("listPlatformUnifiedOrderFeeDetails(form.customerId, inboundId)")
expect(source).toContain('placeholder="请选择未结清账单"')
expect(source).toContain('placeholder="请选择客户订单"')
expect(source).toContain('placeholder="请选择客户订单关联的柜次"')
expect(source).toContain('const records = await listFinanceStatements(customerId)')
expect(source).not.toContain('listFinanceBills')
expect(source).toContain('listContainers({ page: 1, size: 100 })')
expect(source).not.toContain('placeholder="请输入客户ID"')
expect(source).not.toContain('placeholder="费用来源ID"')
expect(source).not.toContain('placeholder="账单ID"')
expect(source).not.toContain("'请输入订单ID'")
expect(source).not.toContain("'请输入柜次ID'")
expect(source).not.toContain('label="客户ID"')
expect(source).toContain('customerNameById(selectedLedgerRow.customerId)')
expect(source).toContain('options: activeCustomers.value.map')
expect(source).toContain('v-for="customer in selectableFormCustomers"')
expect(source).not.toContain("disabled: customer.status !== 'ACTIVE'")
expect(source).not.toContain(':disabled="customer.status !== \'ACTIVE\'"')
})
it('exposes loading, empty and retry states for dependent business options', () => {
expect(source).toContain('该客户暂无未结清账单')
expect(source).toContain('该客户暂无可选订单')
expect(source).toContain('该客户订单暂无关联柜次')
expect(source).toContain('重新加载未结清账单')
expect(source).toContain('重新加载业务对象')
expect(source).toContain('重新加载客户订单')
expect(source).toContain('由账单分配金额自动汇总')
expect(source).toContain('账单分配金额不能超过该账单的未收金额')
})
})
+352 -72
View File
@@ -4,8 +4,8 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem
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 { adjustFinanceBill, createFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, issueFinanceBill, listFinanceBills, listFinanceCosts, listFinanceReceipts, updateFinanceBill, uploadFinanceVoucher, voidFinanceBill } from '../api/finance'
import type { FinanceBillDetail, FinanceBillResource, FinanceCostResource, FinanceReceiptResource, FinanceVoucherResource } from '../types'
import { adjustFinanceBill, createFinanceBill, createFinanceCost, createFinanceReceipt, getFinanceBill, issueFinanceBill, listFinanceBillRevisions, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, updateFinanceBill, uploadFinanceVoucher, voidFinanceBill } from '../api/finance'
import type { FinanceBillDetail, FinanceBillRevision, FinancePayableSettlement, FinanceReceivableSettlement, FinanceStatementReadModel, FinanceVoucherResource } from '../types'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
@@ -14,10 +14,26 @@ import FormSection from '@/shared/components/FormSection.vue'
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'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import {
listPlatformUnifiedOrderFeeDetails,
listPlatformUnifiedOrders,
type UnifiedOrderFeeDetailResource,
type UnifiedOrderResource,
} from '@/modules/unified-orders'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
type Mode = 'bills' | 'receipts' | 'costs'
type DrawerMode = 'create' | 'detail' | 'edit' | 'adjust'
type TableRow = Record<string, string | number | null>
interface BillLineDraft {
sourceFeeId: string
sourceFeeVersion: number
feeItemName: string
amount: string
orderId: string
inboundId: string
}
const props = defineProps<{ mode: Mode }>()
const access = useAccessPreviewStore()
const allRows = ref<TableRow[]>([])
@@ -34,17 +50,42 @@ const drawerLoading = ref(false)
const drawerError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const selectedBill = ref<FinanceBillDetail | null>(null)
const selectedLedgerRow = ref<TableRow | null>(null)
const billRevisions = ref<FinanceBillRevision[]>([])
const revisionLoading = ref(false)
const revisionError = ref<ApiErrorDetails | null>(null)
const saving = ref(false)
const formRef = ref<FormInstance>()
const file = ref<File | null>(null)
const uploadedVoucher = ref<FinanceVoucherResource | null>(null)
const voucherError = ref('')
const customers = ref<PlatformTenantResource[]>([])
const customerOptionsLoading = ref(false)
const customerOptionsError = ref<ApiErrorDetails | null>(null)
const billingOrders = ref<UnifiedOrderResource[]>([])
const billingOrdersLoading = ref(false)
const billingOrdersError = ref('')
const receiptBills = ref<FinanceStatementReadModel[]>([])
const receiptBillsLoading = ref(false)
const receiptBillsError = ref('')
const costOrders = ref<UnifiedOrderResource[]>([])
const costContainers = ref<ContainerBookingResource[]>([])
const costOptionsLoading = ref(false)
const costOptionsError = ref('')
const feeSourcesByInbound = reactive<Record<string, UnifiedOrderFeeDetailResource[]>>({})
const feeSourcesLoading = reactive<Record<string, boolean>>({})
const form = reactive({ customerId: '', billDate: '', amount: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', costType: 'SINGLE_ORDER', feeName: '', remark: '' })
const activeCustomers = computed(() => customers.value.filter((customer) => customer.status === 'ACTIVE'))
const selectableFormCustomers = computed(() => customers.value.filter((customer) => (
customer.status === 'ACTIVE' || String(customer.id) === form.customerId
)))
const allocations = reactive([{ billId: '', amount: '' }])
const billLines = reactive([{ sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' }])
const containerOrders = reactive([{ orderId: '', inboundId: '', volumeCbm: '' }])
const billLines = reactive<BillLineDraft[]>([emptyBillLine()])
let billingOrdersRequest = 0
let receiptBillsRequest = 0
let costOptionsRequest = 0
const title = computed(() => ({ bills: '客户账单', receipts: '收款登记', costs: '成本录入' }[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))
@@ -53,51 +94,247 @@ 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 stateError = computed(() => customerOptionsError.value || loadError.value)
const drawerTitle = computed(() => {
if (props.mode !== 'bills') return props.mode === 'receipts' ? '登记人工收款' : '录入人工成本'
return { create: '新建客户账单', detail: '账单详情', edit: '编辑草稿账单', adjust: '调整已出账账单' }[drawerMode.value]
if (props.mode !== 'bills') return drawerMode.value === 'detail' ? props.mode === 'receipts' ? '应收结算详情' : '应付结算详情' : 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: '客户ID', width: 180, placeholder: props.mode === 'bills' ? '查询账单时必填' : '留空查询全部', props: { 'aria-label': '客户ID' } },
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customerOptionsLoading.value }, options: activeCustomers.value.map((customer) => ({ label: customer.tenantName, value: String(customer.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: 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 }]
if (props.mode === 'receipts') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '结算单号', minWidth: 170 }, { key: 'billDate', label: '账单日期', width: 120 }, { key: 'dueDate', label: '到期日', width: 120 }, { key: 'amount', label: '应收(CNY)', width: 130 }, { key: 'paidAmount', label: '已收(CNY)', width: 130 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 130 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
if (props.mode === 'costs') return [{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'settlementNo', label: '结算单号', minWidth: 170 }, { key: 'costType', label: '成本类型', width: 130, slotName: 'costType' }, { key: 'referenceNo', label: '来源追溯', minWidth: 180 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'supplierName', label: '供应商', minWidth: 150 }, { key: 'amount', label: '应付(CNY)', width: 130 }, { key: 'paidAmount', label: '已付(CNY)', width: 120 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 120 }, { key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'createdAt', label: '登记时间', width: 150 }]
return []
})
const rules: FormRules = {
customerId: [{ required: true, message: '请输入客户ID', trigger: 'blur' }],
customerId: [{ required: true, message: '请选择客户', trigger: 'change' }],
billDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
amount: [{ required: true, message: '请输入金额', trigger: 'blur' }],
referenceNo: [{ required: true, message: '请输入关联ID', trigger: 'blur' }],
referenceNo: [{ required: true, message: '请选择关联业务对象', trigger: 'change' }],
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, 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 } }
function mapStatement(item: FinanceStatementReadModel): TableRow { return { id: item.id, customerId: item.customerId, billNo: item.billNo, customerName: item.customerName, billDate: item.billDate, dueDate: item.dueDate, amount: item.totalAmount, paidAmount: item.receivedAmount, outstandingAmount: item.outstandingAmount, status: item.status, version: item.version } }
function mapReceivable(item: FinanceReceivableSettlement): TableRow { return { id: item.id, customerId: item.customerId, settlementNo: item.settlementNo, billDate: item.billDate, dueDate: item.dueDate, amount: item.receivableAmount, paidAmount: item.receivedAmount, outstandingAmount: item.outstandingAmount, status: item.status, version: item.version } }
function mapPayable(item: FinancePayableSettlement): TableRow { return { id: item.id, customerId: item.customerId, settlementNo: item.settlementNo, costType: item.costType, referenceNo: item.containerId ? `柜次 ${item.containerId}` : item.inboundId ? `入库单 ${item.inboundId}` : item.orderId ? `订单 ${item.orderId}` : '—', feeName: item.feeItemName, supplierName: item.supplierName || '未填写', amount: item.payableAmount, paidAmount: item.paidAmount, outstandingAmount: item.outstandingAmount, status: item.status, createdAt: item.createdAt, version: item.version } }
function customerNameById(customerId: string | number | null) {
return customers.value.find((customer) => String(customer.id) === String(customerId))?.tenantName || '客户信息暂不可用'
}
function emptyBillLine(): BillLineDraft {
return { sourceFeeId: '', sourceFeeVersion: 1, feeItemName: '', amount: '', orderId: '', inboundId: '' }
}
async function loadCustomerOptions() {
customerOptionsLoading.value = true
customerOptionsError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) {
customerOptionsError.value = getApiErrorDetails(error, '客户列表加载失败')
} finally {
customerOptionsLoading.value = false
}
}
async function loadBillingOrders(customerId: string) {
const request = ++billingOrdersRequest
billingOrders.value = []
billingOrdersError.value = ''
if (!customerId) return
billingOrdersLoading.value = true
try {
const result = await listPlatformUnifiedOrders({ tenantId: customerId, customerId, page: 1, size: 200 })
if (request === billingOrdersRequest) billingOrders.value = result.records.filter((order) => order.inboundId != null)
} catch (error) {
if (request === billingOrdersRequest) billingOrdersError.value = getApiErrorDetails(error, '客户订单加载失败').message
} finally {
if (request === billingOrdersRequest) billingOrdersLoading.value = false
}
}
async function listAllCustomerOrders(customerId: string) {
const records: UnifiedOrderResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformUnifiedOrders({ tenantId: customerId, customerId, page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
return records
}
async function loadReceiptBills(customerId: string) {
const request = ++receiptBillsRequest
receiptBills.value = []
receiptBillsError.value = ''
if (!customerId) return
receiptBillsLoading.value = true
try {
const records = await listFinanceStatements(customerId)
if (request === receiptBillsRequest) receiptBills.value = records.filter((bill) => ['ISSUED', 'PARTIALLY_PAID'].includes(bill.status) && bill.outstandingAmount > 0)
} catch (error) {
if (request === receiptBillsRequest) receiptBillsError.value = getApiErrorDetails(error, '未结清账单加载失败').message
} finally {
if (request === receiptBillsRequest) receiptBillsLoading.value = false
}
}
async function loadCostOptions(customerId: string) {
const request = ++costOptionsRequest
costOrders.value = []
costContainers.value = []
costOptionsError.value = ''
if (!customerId) return
costOptionsLoading.value = true
try {
const orders = await listAllCustomerOrders(customerId)
if (request !== costOptionsRequest) return
costOrders.value = orders
const relatedContainerKeys = new Set(orders.flatMap((order) => [order.transport?.containerNo, order.transport?.cabinetNo]).filter((value): value is string => Boolean(value)))
if (!relatedContainerKeys.size) return
const firstPage = await listContainers({ page: 1, size: 100 })
const remainingPages = firstPage.pages > 1
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
if (request !== costOptionsRequest) return
costContainers.value = [firstPage, ...remainingPages]
.flatMap((result) => result.records)
.filter((container) => relatedContainerKeys.has(container.containerNo || '') || relatedContainerKeys.has(container.cabinetNo))
} catch (error) {
if (request === costOptionsRequest) costOptionsError.value = getApiErrorDetails(error, '关联订单和柜次加载失败').message
} finally {
if (request === costOptionsRequest) costOptionsLoading.value = false
}
}
function orderLabel(order: UnifiedOrderResource) {
return `${order.orderNo || `订单 ${order.orderId}`} · ${order.inboundCode || `入库单 ${order.inboundId}`}`
}
async function loadFeeSources(inboundId: string) {
if (!inboundId || feeSourcesByInbound[inboundId] || feeSourcesLoading[inboundId]) return
feeSourcesLoading[inboundId] = true
try {
feeSourcesByInbound[inboundId] = await listPlatformUnifiedOrderFeeDetails(form.customerId, inboundId)
} catch (error) {
feeSourcesByInbound[inboundId] = []
ElMessage.error(getApiErrorDetails(error, '费用来源加载失败').message)
} finally {
feeSourcesLoading[inboundId] = false
}
}
function handleCustomerChange(customerId: string) {
uploadedVoucher.value = null
file.value = null
billingOrders.value = []
receiptBills.value = []
costOrders.value = []
costContainers.value = []
form.referenceNo = ''
if (props.mode !== 'bills') form.amount = ''
allocations.splice(0, allocations.length, { billId: '', amount: '' })
for (const key of Object.keys(feeSourcesByInbound)) delete feeSourcesByInbound[key]
billLines.splice(0, billLines.length, emptyBillLine())
if (props.mode === 'bills') void loadBillingOrders(customerId)
else if (props.mode === 'receipts') void loadReceiptBills(customerId)
else void loadCostOptions(customerId)
}
function handleCostTypeChange() {
form.referenceNo = ''
}
function orderOptionLabel(order: UnifiedOrderResource) {
return [order.orderNo || `订单 ${order.orderId}`, order.inboundCode ? `入库单 ${order.inboundCode}` : ''].filter(Boolean).join(' · ')
}
function containerOptionLabel(container: ContainerBookingResource) {
return [container.cabinetNo || `柜次 ${container.id}`, container.containerNo ? `柜号 ${container.containerNo}` : '', container.warehouseName].filter(Boolean).join(' · ')
}
function receiptBillLabel(bill: FinanceStatementReadModel) {
return `${bill.billNo || `账单 ${bill.id}`} · 未收 ${bill.outstandingAmount.toFixed(2)} CNY`
}
function allocationBillDisabled(billId: string, index: number) {
return allocations.some((allocation, allocationIndex) => allocationIndex !== index && allocation.billId === billId)
}
function syncReceiptAmount() {
form.amount = allocations.reduce((sum, allocation) => sum + (positiveNumber(allocation.amount) ? Number(allocation.amount) : 0), 0).toFixed(2)
}
function handleAllocationBillChange(index: number, billId: string) {
const allocation = allocations[index]
if (!allocation) return
allocation.billId = billId
const bill = receiptBills.value.find((item) => item.id === billId)
allocation.amount = bill ? bill.outstandingAmount.toFixed(2) : ''
syncReceiptAmount()
}
function handleLineOrderChange(line: BillLineDraft, orderId: string) {
const order = billingOrders.value.find((item) => String(item.orderId) === orderId)
line.orderId = orderId
line.inboundId = order?.inboundId == null ? '' : String(order.inboundId)
line.sourceFeeId = ''
line.sourceFeeVersion = 1
line.feeItemName = ''
line.amount = ''
if (line.inboundId) void loadFeeSources(line.inboundId)
}
function handleFeeSourceChange(line: BillLineDraft, sourceFeeId: string) {
const source = (feeSourcesByInbound[line.inboundId] || []).find((item) => String(item.sourceFeeId ?? '') === sourceFeeId)
line.sourceFeeId = sourceFeeId
line.sourceFeeVersion = Number(source?.sourceFeeVersion || 1)
line.feeItemName = source?.feeTypeNameSnapshot || ''
line.amount = source?.amount == null ? '' : String(source.amount)
}
function feeSourceOptions(line: BillLineDraft) {
return (feeSourcesByInbound[line.inboundId] || []).filter((item) => item.sourceFeeId != null && Number(item.sourceFeeVersion) > 0)
}
function feeSourceLabel(source: UnifiedOrderFeeDetailResource) {
return `${source.feeTypeNameSnapshot || '未命名费用'} · ${source.amount ?? 0} ${source.currency || 'CNY'}`
}
async function load() {
if (!canView.value) return
loading.value = true; loadError.value = null
try {
const customerId = String(filters.customerId || '').trim()
if (props.mode === 'bills' && !customerId) { allRows.value = []; total.value = 0; return }
if (!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
const result = await listFinanceStatements(customerId)
source = result.map(mapStatement)
responseTotal = result.length; serverPaged.value = false
} else if (props.mode === 'receipts') {
const result = await listFinanceReceipts(customerId); source = result.records.map(mapReceipt); responseTotal = result.total; serverPaged.value = result.serverPaged
const result = await listFinanceReceivableSettlements(customerId); source = result.map(mapReceivable); responseTotal = result.length; serverPaged.value = false
} else {
const result = await listFinanceCosts(customerId); source = result.records.map(mapCost); responseTotal = result.total; serverPaged.value = result.serverPaged
const result = await listFinancePayableSettlements(customerId); source = result.map(mapPayable); responseTotal = result.length; serverPaged.value = false
}
const keyword = String(filters.keyword || '').trim().toLowerCase()
const status = String(filters.status || '')
@@ -109,11 +346,12 @@ async function load() {
function applyFilters(model: QueryModel) { Object.assign(filters, model); page.value = 1; void load() }
function changePageSize() { page.value = 1; void load() }
function openCreate() {
drawerMode.value = 'create'; selectedBill.value = null; drawerError.value = null
billingOrdersRequest += 1; receiptBillsRequest += 1; costOptionsRequest += 1
drawerMode.value = 'create'; selectedBill.value = null; selectedLedgerRow.value = null; billRevisions.value = []; revisionError.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: '' })
containerOrders.splice(0, containerOrders.length, { orderId: '', inboundId: '', volumeCbm: '' })
billLines.splice(0, billLines.length, emptyBillLine())
billingOrders.value = []; billingOrdersError.value = ''; receiptBills.value = []; receiptBillsError.value = ''; costOrders.value = []; costContainers.value = []; costOptionsError.value = ''
file.value = null; uploadedVoucher.value = null; voucherError.value = ''; drawerOpen.value = true
}
function selectFile(event: Event) {
@@ -124,22 +362,22 @@ function selectFile(event: Event) {
}
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 removeAllocation(index: number) { if (allocations.length > 1) { allocations.splice(index, 1); syncReceiptAmount() } }
function addBillLine() { billLines.push(emptyBillLine()) }
function removeBillLine(index: number) { if (billLines.length > 1) billLines.splice(index, 1) }
function addContainerOrder() { containerOrders.push({ orderId: '', inboundId: '', volumeCbm: '' }) }
function removeContainerOrder(index: number) { if (containerOrders.length > 1) containerOrders.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 (!/^\d+$/.test(form.customerId)) return '请选择有效客户'
if (props.mode !== 'costs' && !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 (props.mode === 'bills' && billLines.some((line) => !/^\d+$/.test(line.sourceFeeId) || !line.orderId || !line.inboundId || !line.feeItemName.trim() || !positiveNumber(line.amount))) return '请为每条账单明细选择订单和已生成的费用来源'
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 === 'costs' && form.costType === 'CONTAINER_SHARED' && containerOrders.some((item) => !/^\d+$/.test(item.orderId) || (item.inboundId && !/^\d+$/.test(item.inboundId)) || !positiveNumber(item.volumeCbm))) return '整柜成本必须填写每票数字格式的订单ID、可选入库单ID和大于 0 的体积快照'
if (props.mode === 'costs' && (!form.referenceNo || !form.feeName.trim())) return `请选择${form.costType === 'SINGLE_ORDER' ? '订单' : '柜次'}并填写费用项目`
if (props.mode === 'receipts') {
if (allocations.some((item) => !/^\d+$/.test(item.billId) || !positiveNumber(item.amount))) return '每条分配都必须填写数字格式的账单ID和大于 0 的金额'
if (allocations.some((item) => !receiptBills.value.some((bill) => bill.id === item.billId) || !positiveNumber(item.amount))) return '每条分配都必须选择未结清账单并填写大于 0 的金额'
if (new Set(allocations.map((item) => item.billId)).size !== allocations.length) return '同一账单不能重复分配'
const exceededBill = allocations.find((item) => Number(item.amount) > (receiptBills.value.find((bill) => bill.id === item.billId)?.outstandingAmount || 0) + 0.000001)
if (exceededBill) return '账单分配金额不能超过该账单的未收金额'
const allocated = allocations.reduce((sum, item) => sum + Number(item.amount), 0)
if (Math.abs(allocated - Number(form.amount)) > 0.000001) return `分配金额合计 ${allocated.toFixed(2)} 元,必须等于收款金额`
}
@@ -165,7 +403,7 @@ async function submit() {
if (props.mode === 'receipts') {
await createFinanceReceipt({ customerId: form.customerId, receivedDate: form.billDate, amount: String(form.amount), paymentMethod: form.paymentMethod, remark: form.remark, allocations: allocations.map((item) => ({ billId: item.billId, amount: String(item.amount) })) }, voucherFileIds)
} else {
await createFinanceCost({ customerId: form.customerId, costType: form.costType, orderId: form.costType === 'SINGLE_ORDER' ? form.referenceNo : undefined, containerId: form.costType === 'CONTAINER_SHARED' ? form.referenceNo : undefined, expenseName: form.feeName.trim(), amount: String(form.amount), remark: form.remark, containerOrders: form.costType === 'CONTAINER_SHARED' ? containerOrders.map((item) => ({ orderId: item.orderId, inboundId: item.inboundId || undefined, containerId: form.referenceNo, volumeCbm: String(item.volumeCbm) })) : [] }, voucherFileIds)
await createFinanceCost({ customerId: form.customerId, costType: form.costType, orderId: form.costType === 'SINGLE_ORDER' ? form.referenceNo : undefined, containerId: form.costType === 'CONTAINER_SHARED' ? form.referenceNo : undefined, expenseName: form.feeName.trim(), amount: String(form.amount), remark: form.remark }, voucherFileIds)
}
}
drawerOpen.value = false; ElMessage.success(`${title.value}已保存`); await load()
@@ -184,15 +422,29 @@ async function issueBill(row: TableRow) {
}
}
async function openBill(row: TableRow, mode: DrawerMode) {
drawerMode.value = mode; drawerOpen.value = true; drawerLoading.value = true; drawerError.value = null; selectedBill.value = null
drawerMode.value = mode; drawerOpen.value = true; drawerLoading.value = true; drawerError.value = null; revisionError.value = null; billRevisions.value = []; 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()
await loadBillingOrders(detail.customerId)
await Promise.all([...new Set(billLines.map((line) => line.inboundId).filter(Boolean))].map(loadFeeSources))
if (mode === 'detail') {
revisionLoading.value = true
try { billRevisions.value = await listFinanceBillRevisions(detail.id, detail.customerId) }
catch (error) { revisionError.value = getApiErrorDetails(error, '账单修订记录加载失败,请重试') }
finally { revisionLoading.value = false }
}
} catch (error) { drawerError.value = getApiErrorDetails(error, '账单详情加载失败,请重试') }
finally { drawerLoading.value = false }
}
function openLedgerDetail(row: TableRow) {
drawerMode.value = 'detail'
selectedLedgerRow.value = row
drawerError.value = null
drawerOpen.value = true
}
async function voidBill(row: TableRow) {
actionError.value = null
try {
@@ -208,7 +460,7 @@ function handleBillCommand(command: string, row: TableRow) {
if (command === 'issue') void issueBill(row)
else if (command === 'void') void voidBill(row)
}
onMounted(() => void load())
onMounted(() => { void load(); void loadCustomerOptions() })
</script>
<template>
@@ -216,11 +468,11 @@ onMounted(() => void load())
<el-result v-if="!canView" icon="warning" title="无权访问财务模块" sub-title="请联系管理员开通平台财务权限" />
<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>
<template v-if="stateError" #state><LoadErrorState :title="customerOptionsError ? '客户选项加载失败' : `${title}加载失败`" :message="stateError.message" :loading="customerOptionsLoading || loading" @retry="customerOptionsError ? loadCustomerOptions() : load()"><template #details><ApiErrorMeta :details="stateError" /></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">
<section v-if="!loadError && !customerOptionsError" 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="`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 #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="{ row }">
<div v-if="props.mode === 'bills'" class="row-actions">
<el-button link type="primary" @click="openBill(row, 'detail')"><Eye :size="15" />查看</el-button>
@@ -232,11 +484,11 @@ onMounted(() => void load())
<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>
<el-button v-else link type="primary" @click="openLedgerDetail(row)"><Eye :size="15" />查看</el-button>
</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 #status="{ row }"><el-tag :type="row.status === 'PAID' || row.status === 'CONFIRMED' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ row.status === 'DRAFT' ? '草稿' : row.status === 'ISSUED' ? '已出账' : row.status === 'PARTIALLY_PAID' ? '部分收款' : row.status === 'PAID' ? '已收清' : row.status === 'CONFIRMED' ? '已确认' : row.status === 'VOIDED' ? '作废' : row.status }}</el-tag></template>
<template #costType="{ row }">{{ row.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</template>
<template #empty><el-empty description="暂无数据;后端接口接通后将显示真实财务记录" /></template>
<template #empty><el-empty description="暂无符合条件的财务记录" /></template>
<template #pagination-left>币种:CNY · 数据由平台财务人工维护</template>
</DataTable>
</section>
@@ -251,53 +503,70 @@ onMounted(() => void load())
<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>
<div v-for="(line, index) in selectedBill.lines" :key="`${line.sourceFeeId}-${index}`" class="detail-line"><strong>{{ line.feeItemName }}</strong><span>{{ line.inboundId ? `入库单 ${line.inboundId} · ` : '' }}{{ line.orderId ? `订单 ${line.orderId} · ` : '' }}来源 {{ line.sourceFeeId }}/v{{ line.sourceFeeVersion }}</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="后端已保存调整快照;修订记录查询接口尚未提供,当前只能查看最新版本号。" />
<FormSection title="修订记录" description="按版本倒序展示后端保存的调整前账单快照。">
<el-skeleton v-if="revisionLoading" :rows="2" animated />
<div v-else-if="revisionError" class="revision-error" role="alert"><el-alert type="error" :closable="false" :title="revisionError.message" /><ApiErrorMeta :details="revisionError" /></div>
<el-empty v-else-if="!billRevisions.length" description="暂无历史修订记录" />
<div v-else class="revision-list">
<section v-for="revision in billRevisions" :key="revision.id" class="revision-item">
<div class="revision-heading"><strong>调整前版本 v{{ revision.version }}</strong><span>{{ revision.createdAt || '时间未知' }}</span></div>
<div class="revision-meta"><span>原因:{{ revision.reason || '未填写' }}</span><span>操作人ID:{{ revision.createdBy || '—' }}</span><span>状态:{{ revision.status || '—' }}</span><span>金额:{{ revision.totalAmount }} CNY</span></div>
<div v-for="(line, index) in revision.lines" :key="`${revision.id}-${line.sourceFeeId}-${index}`" class="revision-line"><span>{{ line.feeItemName || `费用来源 ${line.sourceFeeId}` }}</span><b>{{ line.amount }} CNY</b></div>
<p v-if="!revision.lines.length" class="revision-empty">该版本没有费用明细快照</p>
</section>
</div>
</FormSection>
</template>
<template v-else-if="props.mode !== 'bills' && drawerMode === 'detail' && selectedLedgerRow">
<el-descriptions :column="2" border class="bill-summary">
<el-descriptions-item label="结算单号">{{ selectedLedgerRow.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerNameById(selectedLedgerRow.customerId) }}</el-descriptions-item>
<template v-if="props.mode === 'receipts'"><el-descriptions-item label="账单日期">{{ selectedLedgerRow.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedLedgerRow.dueDate }}</el-descriptions-item><el-descriptions-item label="应收金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已收金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ selectedLedgerRow.status }}</el-descriptions-item></template>
<template v-else><el-descriptions-item label="费用项目">{{ selectedLedgerRow.feeName }}</el-descriptions-item><el-descriptions-item label="成本类型">{{ selectedLedgerRow.costType === 'CONTAINER_SHARED' ? '整柜公共成本' : '单票成本' }}</el-descriptions-item><el-descriptions-item label="关联来源">{{ selectedLedgerRow.referenceNo }}</el-descriptions-item><el-descriptions-item label="应付金额">{{ selectedLedgerRow.amount }} CNY</el-descriptions-item><el-descriptions-item label="已付金额">{{ selectedLedgerRow.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未付金额">{{ selectedLedgerRow.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="登记时间">{{ selectedLedgerRow.createdAt }}</el-descriptions-item><el-descriptions-item label="状态">{{ selectedLedgerRow.status }}</el-descriptions-item></template>
</el-descriptions>
</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="客户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>
<el-form-item label="客户" prop="customerId"><el-select v-model="form.customerId" filterable placeholder="请选择客户" :loading="customerOptionsLoading" :loading-text="'客户加载中'" :no-data-text="customerOptionsError?.message || '暂无可选客户'" :disabled="props.mode === 'bills' && drawerMode !== 'create'" style="width: 100%" @change="handleCustomerChange"><el-option v-for="customer in selectableFormCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item v-if="props.mode !== 'costs'" :label="props.mode === 'receipts' ? '收款日期' : '账单日期'" prop="billDate"><el-date-picker v-model="form.billDate" type="date" value-format="YYYY-MM-DD" :placeholder="props.mode === 'receipts' ? '请选择收款日期' : '请选择账单日期'" style="width: 100%" :disabled="props.mode === 'bills' && drawerMode === 'adjust'" /></el-form-item>
</div>
<div v-if="customerOptionsError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="customerOptionsError.message" show-icon /><ApiErrorMeta :details="customerOptionsError" /><el-button link type="primary" :loading="customerOptionsLoading" @click="loadCustomerOptions">重新加载客户</el-button></div>
<template v-if="props.mode === 'bills'">
<div class="allocation-title">费用明细</div>
<div v-if="billingOrdersError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="billingOrdersError" show-icon /><el-button link type="primary" :loading="billingOrdersLoading" @click="loadBillingOrders(form.customerId)">重新加载客户订单</el-button></div>
<p v-else class="form-hint">先选择客户订单,再选择该入库单已生成的费用来源;系统会自动带出费用项目、版本和金额。</p>
<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-select :model-value="line.orderId" filterable placeholder="选择订单 / 入库单" :loading="billingOrdersLoading" loading-text="订单加载中" :no-data-text="form.customerId ? '该客户暂无已入库订单' : '请先选择客户'" :disabled="!form.customerId" @update:model-value="handleLineOrderChange(line, String($event || ''))"><el-option v-for="order in billingOrders" :key="String(order.orderId)" :label="orderLabel(order)" :value="String(order.orderId)" /></el-select>
<el-select :model-value="line.sourceFeeId" filterable placeholder="选择费用来源" :loading="Boolean(feeSourcesLoading[line.inboundId])" loading-text="费用来源加载中" :no-data-text="line.inboundId ? '该入库单暂无可选费用' : '请先选择订单'" :disabled="!line.inboundId" @update:model-value="handleFeeSourceChange(line, String($event || ''))"><el-option v-for="source in feeSourceOptions(line)" :key="`${source.sourceFeeId}-${source.sourceFeeVersion}`" :label="feeSourceLabel(source)" :value="String(source.sourceFeeId)" /></el-select>
<el-input v-model="line.feeItemName" placeholder="费用项目(自动带出)" disabled />
<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>
<el-form-item v-if="drawerMode === 'adjust'" label="调整原因" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请说明本次调整原因" /></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>
<el-form-item v-if="props.mode === 'costs'" label="成本类型"><el-select v-model="form.costType" placeholder="请选择成本类型" style="width: 100%" @change="handleCostTypeChange"><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" :readonly="props.mode === 'receipts'" :placeholder="props.mode === 'receipts' ? '由账单分配金额自动汇总' : '请输入成本金额'" /></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>
<div v-if="props.mode === 'costs' && costOptionsError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="costOptionsError" show-icon /><el-button link type="primary" :loading="costOptionsLoading" @click="loadCostOptions(form.customerId)">重新加载业务对象</el-button></div>
<el-form-item v-if="props.mode === 'costs' && form.costType === 'SINGLE_ORDER'" label="关联订单" prop="referenceNo"><el-select v-model="form.referenceNo" filterable placeholder="请选择客户订单" :loading="costOptionsLoading" loading-text="订单加载中" :no-data-text="form.customerId ? '该客户暂无可选订单' : '请先选择客户'" :disabled="!form.customerId" style="width: 100%"><el-option v-for="order in costOrders" :key="String(order.orderId)" :label="orderOptionLabel(order)" :value="String(order.orderId)" /></el-select></el-form-item>
<el-form-item v-if="props.mode === 'costs' && form.costType === 'CONTAINER_SHARED'" label="关联柜次" prop="referenceNo"><el-select v-model="form.referenceNo" filterable placeholder="请选择客户订单关联的柜次" :loading="costOptionsLoading" loading-text="柜次加载中" :no-data-text="form.customerId ? '该客户订单暂无关联柜次' : '请先选择客户'" :disabled="!form.customerId" style="width: 100%"><el-option v-for="container in costContainers" :key="container.id" :label="containerOptionLabel(container)" :value="container.id" /></el-select></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>
<el-form-item label="收款方式" prop="paymentMethod"><el-select v-model="form.paymentMethod" placeholder="请选择收款方式" 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>
<div v-if="receiptBillsError" class="option-error" role="alert"><el-alert type="error" :closable="false" :title="receiptBillsError" show-icon /><el-button link type="primary" :loading="receiptBillsLoading" @click="loadReceiptBills(form.customerId)">重新加载未结清账单</el-button></div>
<p v-else class="form-hint">选择未结清对账单后自动带出未收金额,可按本次实际到账金额调整分配。</p>
<div v-for="(allocation, index) in allocations" :key="index" class="allocation-row"><el-select :model-value="allocation.billId" filterable placeholder="请选择未结清账单" :loading="receiptBillsLoading" loading-text="账单加载中" :no-data-text="form.customerId ? '该客户暂无未结清账单' : '请先选择客户'" :disabled="!form.customerId" @update:model-value="handleAllocationBillChange(index, String($event || ''))"><el-option v-for="bill in receiptBills" :key="bill.id" :label="receiptBillLabel(bill)" :value="bill.id" :disabled="allocationBillDisabled(bill.id, index)" /></el-select><el-input v-model="allocation.amount" inputmode="decimal" placeholder="本次分配金额(CNY)" @input="syncReceiptAmount" /><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>
<template v-if="props.mode === 'costs' && form.costType === 'CONTAINER_SHARED'">
<div class="allocation-title">柜内订单体积快照</div>
<el-alert type="info" :closable="false" show-icon title="按当前柜内同客户有效订单的实际体积填写;该快照将随成本保存并用于分摊。" />
<div v-for="(item, index) in containerOrders" :key="index" class="container-volume-row">
<el-input v-model="item.orderId" inputmode="numeric" placeholder="订单ID" />
<el-input v-model="item.inboundId" inputmode="numeric" placeholder="入库单ID(可选)" />
<el-input v-model="item.volumeCbm" inputmode="decimal" placeholder="体积(CBM)" />
<el-button link type="danger" aria-label="删除体积快照" @click="removeContainerOrder(index)"><Trash2 :size="16" /></el-button>
</div>
<el-button link type="primary" @click="addContainerOrder"><Plus :size="15" />添加柜内订单</el-button>
</template>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" /></el-form-item>
<el-alert v-if="props.mode === 'costs' && form.costType === 'CONTAINER_SHARED'" type="info" :closable="false" show-icon title="整柜成本保存时由后端从 Firstleg 获取真实柜内订单体积快照并完成分摊。" />
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入备注(可选)" /></el-form-item>
</template>
</FormSection>
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="先上传真实凭证取得 fileId,再创建人工财务台账。">
@@ -323,13 +592,24 @@ onMounted(() => void load())
.voucher-success { margin: 8px 0 0; color: var(--el-color-success); overflow-wrap: anywhere; }
.drawer-footer { display: flex; justify-content: flex-end; gap: 10px; }
.allocation-title { margin: 4px 0 8px; font-weight: 600; }
.option-error { display: grid; justify-items: start; gap: 4px; margin-bottom: 10px; }
.option-error .el-alert { width: 100%; }
.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; }
.container-volume-row { display: grid; grid-template-columns: minmax(110px, 1fr) minmax(130px, 1fr) minmax(110px, .8fr) 32px; gap: 8px; margin: 8px 0; align-items: center; }
.bill-line-row { display: grid; grid-template-columns: minmax(180px, 1.2fr) minmax(180px, 1.2fr) minmax(140px, 1fr) minmax(110px, .7fr) 32px; gap: 8px; margin-bottom: 8px; align-items: center; }
.form-hint { margin: 0 0 10px; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
.bill-summary { margin-bottom: 16px; }
.revision-error { display: grid; gap: 8px; }
.revision-list { display: grid; gap: 12px; }
.revision-item { padding-bottom: 12px; border-bottom: 1px solid var(--el-border-color-lighter); }
.revision-heading, .revision-meta, .revision-line { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.revision-heading span, .revision-meta, .revision-empty { color: var(--cc-muted); }
.revision-meta { flex-wrap: wrap; justify-content: flex-start; margin: 6px 0; font-size: 13px; }
.revision-line { padding: 5px 0; }
.revision-line b { white-space: nowrap; }
.revision-empty { margin: 6px 0 0; }
.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, .container-volume-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, .container-volume-row, .detail-line { grid-template-columns: 1fr; } .allocation-row .el-button, .bill-line-row .el-button, .container-volume-row .el-button { justify-self: end; } :deep(.bill-summary .el-descriptions__cell) { display: block; width: 100%; } }
@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; } .revision-heading, .revision-line { align-items: flex-start; } :deep(.bill-summary .el-descriptions__cell) { display: block; width: 100%; } }
</style>
+5 -3
View File
@@ -1,7 +1,9 @@
import type { RouteRecordRaw } from 'vue-router'
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/customer-accounts', name: 'finance-customer-accounts', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户', description: '查看客户 CNY 应收台账并维护独立账期', permission: 'platform:finance:bill:view' } },
{ path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:bill:view' } },
{ path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:bill:view' } },
{ path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:receipt:view' } },
{ path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:cost:view' } },
] satisfies RouteRecordRaw[]
+106
View File
@@ -10,6 +10,98 @@ export interface FinanceVoucherResource {
sizeBytes: number
}
export interface FinanceCustomerTerms {
customerId: string
customerName: string
creditDays: number
status: string
}
export interface FinanceAccountEntry {
entryType: 'RECEIVABLE' | 'RECEIPT'
resourceId: string
resourceNo: string
occurredAt: string
debitAmount: number
creditAmount: number
currency: 'CNY'
status: string
}
export interface FinanceCustomerAccountReadModel {
customerId: string
currency: 'CNY'
accountMode: 'MANUAL_RECEIVABLE_LEDGER'
totalReceivable: number
totalReceived: number
outstandingAmount: number
entries: FinanceAccountEntry[]
}
export interface FinanceFeePoolItem {
poolItemId: string
direction: 'RECEIVABLE' | 'PAYABLE'
sourceType: string
sourceId: string
sourceVersion: number
orderId: string | null
inboundId: string | null
containerId: string | null
feeItemName: string
amount: number
currency: 'CNY'
status: string
createdAt: string
}
export interface FinanceStatementReadModel {
id: string
customerId: string
billNo: string
customerName: string
billDate: string
dueDate: string
totalAmount: number
receivedAmount: number
outstandingAmount: number
currency: 'CNY'
status: FinanceBillStatus
version: number
}
export interface FinanceReceivableSettlement {
id: string
customerId: string
settlementNo: string
billDate: string
dueDate: string
receivableAmount: number
receivedAmount: number
outstandingAmount: number
currency: 'CNY'
status: FinanceBillStatus
version: number
}
export interface FinancePayableSettlement {
id: string
customerId: string
settlementNo: string
costType: FinanceCostType
orderId: string | null
inboundId: string | null
containerId: string | null
supplierName: string
feeItemName: string
payableAmount: number
paidAmount: number
outstandingAmount: number
currency: 'CNY'
status: string
createdAt: string
version: number
}
export interface FinanceBillResource {
id: string
billNo: string
@@ -39,6 +131,20 @@ export interface FinanceBillDetail extends FinanceBillResource {
lines: FinanceBillLine[]
}
export interface FinanceBillRevision {
id: string
billId: string
version: number
reason: string
createdBy: string
createdAt: string
status: FinanceBillStatus
totalAmount: number
receivedAmount: number
outstandingAmount: number
lines: FinanceBillLine[]
}
export interface FinanceReceiptResource {
id: string
customerId: string
@@ -101,9 +101,14 @@ describe('InboundOrdersView', () => {
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeSearch + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 }))
const requestsBeforeCustomerChange = vi.mocked(listPlatformInbounds).mock.calls.length
await selectCustomer(wrapper)
expect(listPlatformInbounds).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCustomerChange)
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
toolbar.vm.$emit('search')
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCustomerChange + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(document.body.textContent).toContain('RK-001')
expect(document.body.textContent).toContain('查看')
@@ -150,9 +155,14 @@ describe('InboundOrdersView', () => {
destinationCode: 'ONT8',
})
await flushPromises()
const requestsBeforeCollapse = vi.mocked(listPlatformInbounds).mock.calls.length
await advancedButton()?.trigger('click')
await flushPromises()
expect(fieldKeys()).not.toContain('feeReviewStatus')
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCollapse)
toolbar.vm.$emit('search')
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCollapse + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({
feeReviewStatus: undefined,
destinationCode: undefined,
@@ -161,9 +171,13 @@ describe('InboundOrdersView', () => {
it('passes the selected backend tab and opens an aggregate read-only detail', async () => {
const wrapper = mountPage()
await selectCustomer(wrapper)
const toolbar = await selectCustomer(wrapper)
toolbar.vm.$emit('search')
await flushPromises()
const requestsBeforeTab = vi.mocked(listPlatformInbounds).mock.calls.length
;[...document.querySelectorAll<HTMLButtonElement>('.inbound-tab')].find((button) => button.textContent?.includes('待审核'))?.click()
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeTab + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ tab: 'WAIT_REVIEW', page: 1 }))
;[...document.querySelectorAll<HTMLButtonElement>('button')].filter((button) => button.textContent?.trim() === '查看').at(-1)?.click()
await flushPromises()
@@ -233,11 +247,18 @@ describe('InboundOrdersView', () => {
expect(document.body.textContent).toContain('RK-001')
})
it('clears the selected list and detail when the selected customer changes', async () => {
it('updates customer-dependent options without querying until search', async () => {
const wrapper = mountPage()
const toolbar = await selectCustomer(wrapper)
toolbar.vm.$emit('search')
await flushPromises()
const requestsBeforeCustomerChange = vi.mocked(listPlatformInbounds).mock.calls.length
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId: '10002' })
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCustomerChange)
toolbar.vm.$emit('search')
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeCustomerChange + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10002', expect.objectContaining({ page: 1 }))
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(1, 200)
})
@@ -260,8 +281,10 @@ describe('InboundOrdersView', () => {
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 60, pages: 3 })
const wrapper = mountPage()
await flushPromises()
const requestsBeforePage = vi.mocked(listPlatformInbounds).mock.calls.length
wrapper.findComponent(DataTable).vm.$emit('update:page', 2)
await flushPromises()
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforePage + 1)
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 2, size: 20 }))
})
@@ -51,7 +51,9 @@ const detailTarget = ref<InboundOrderSummary | null>(null)
const printingInboundId = ref('')
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
const appliedFilters = ref<FilterModel>({ ...filters.value, expectedArrivalRange: [] })
const selectedCustomerId = ref('')
const appliedCustomerId = ref('')
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customerLoadError = ref<ApiErrorDetails | null>(null)
@@ -159,7 +161,7 @@ const columns: DataTableColumn[] = [
]
const selectedCustomerName = computed(() => {
const customer = customers.value.find((item) => String(item.id) === selectedCustomerId.value)
const customer = customers.value.find((item) => String(item.id) === appliedCustomerId.value)
return customer?.legalName?.trim() || customer?.tenantName?.trim() || '全部客户'
})
const listEmptyText = '暂无符合条件的入库单'
@@ -246,24 +248,25 @@ async function loadInbounds() {
const requestId = ++loadRequestId
loading.value = true
loadError.value = null
const range = filters.value.expectedArrivalRange
const applied = appliedFilters.value
const range = applied.expectedArrivalRange
try {
const response = await listPlatformInbounds(selectedCustomerId.value || undefined, {
const response = await listPlatformInbounds(appliedCustomerId.value || undefined, {
page: page.value,
size: pageSize.value,
tab: tab.value || undefined,
inboundCode: queryValue(filters.value.inboundCode),
orderNo: queryValue(filters.value.orderNo),
warehouseId: queryValue(filters.value.warehouseId),
deliveryWarehouseId: queryValue(filters.value.deliveryWarehouseId),
processStatus: queryValue(filters.value.processStatus),
orderStatus: queryValue(filters.value.orderStatus),
measureReviewStatus: queryValue(filters.value.measureReviewStatus),
customerName: queryValue(filters.value.customerName),
destinationCode: queryValue(filters.value.destinationCode),
feeStatus: queryValue(filters.value.feeStatus),
feeReviewStatus: queryValue(filters.value.feeReviewStatus),
holdFlag: filters.value.holdFlag === '' ? undefined : filters.value.holdFlag === 'true',
inboundCode: queryValue(applied.inboundCode),
orderNo: queryValue(applied.orderNo),
warehouseId: queryValue(applied.warehouseId),
deliveryWarehouseId: queryValue(applied.deliveryWarehouseId),
processStatus: queryValue(applied.processStatus),
orderStatus: queryValue(applied.orderStatus),
measureReviewStatus: queryValue(applied.measureReviewStatus),
customerName: queryValue(applied.customerName),
destinationCode: queryValue(applied.destinationCode),
feeStatus: queryValue(applied.feeStatus),
feeReviewStatus: queryValue(applied.feeReviewStatus),
holdFlag: applied.holdFlag === '' ? undefined : applied.holdFlag === 'true',
expectedArrivalFrom: dateParam(range?.[0]),
expectedArrivalTo: dateParam(range?.[1], true),
})
@@ -278,6 +281,8 @@ async function loadInbounds() {
}
function applyFilters() {
appliedFilters.value = { ...filters.value, expectedArrivalRange: [...filters.value.expectedArrivalRange] }
appliedCustomerId.value = selectedCustomerId.value
if (page.value === 1) void loadInbounds()
else page.value = 1
}
@@ -383,19 +388,15 @@ function toggleAdvancedFilters() {
destinationCode: '',
holdFlag: '',
}
applyFilters()
}
watch(() => filters.value.customerId, (customerId) => {
if (customerId === selectedCustomerId.value) return
selectedCustomerId.value = customerId
page.value = 1
tab.value = ''
filters.value = { ...filters.value, warehouseId: '', deliveryWarehouseId: '' }
resetDetail()
detailOpen.value = false
void loadWarehouseOptions()
void loadInbounds()
})
watch(tab, () => { if (page.value === 1) void loadInbounds(); else page.value = 1 })
watch([page, pageSize], () => { void loadInbounds() })
+1
View File
@@ -1,2 +1,3 @@
export { oceanBookingRoutes } from './routes'
export { listOceanBookings } from './api/platformOceanBookings'
export * from './types'
@@ -4,30 +4,29 @@ import { describe, expect, it } from 'vitest'
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingDetailView.vue'), 'utf8')
describe('OceanBookingDetailView contract', () => {
it('uses the backend draft fields and all required parties', () => {
for (const field of ['SHIPPER', 'CONSIGNEE', 'NOTIFY_PARTY', 'partyName', 'departurePortName', 'destinationPortName', 'vesselName', 'voyageNo', 'containerRequirements']) {
expect(source).toContain(field)
}
expect(source).not.toContain('goodsDescription')
expect(source).not.toContain('form.data.soNo')
describe('OceanBookingDetailView interaction contract', () => {
it('selects business objects and automatically keeps their ids and names together', () => {
expect(source).toContain('placeholder="请选择客户"')
expect(source).toContain('placeholder="请选择船公司"')
expect(source).toContain('placeholder="请选择订舱代理"')
expect(source).toContain("selectPartner('shippingCompany'")
expect(source).toContain("selectPartner('bookingAgent'")
expect(source).not.toContain('label="客户 ID"')
expect(source).not.toContain('label="船公司 ID"')
expect(source).not.toContain('label="订舱代理 ID"')
})
it('reads the real version and protects create/edit permissions', () => {
expect(source).toContain('form.expectedVersion = value.version')
expect(source).toContain('expectedVersion: 0')
expect(source).toContain("platform:firstleg:ocean-booking:create")
expect(source).toContain("platform:firstleg:ocean-booking:edit")
expect(source).toContain(':disabled="!isNew"')
expect(source).toContain(':disabled="!editable"')
it('hides unavailable customers instead of filling the selector with disabled options', () => {
expect(source).toContain("customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)")
expect(source).toContain('v-for="customer in selectableCustomers"')
expect(source).not.toContain(':disabled="customer.status !== \'ACTIVE\'"')
})
it('maps each status action to its server permission and uses a change-specific request', () => {
for (const permission of ['submit', 'confirm', 'release', 'cancel', 'change']) {
expect(source).toContain(`platform:firstleg:ocean-booking:${permission}`)
}
expect(source).toContain('requestOceanBookingChange')
expect(source).toContain("changeForm.vesselName = detail.value?.vesselName")
expect(source).toContain('expectedVersion: detail.value.version')
it('provides guidance for every free-text booking field', () => {
for (const placeholder of [
'请输入提单号(可选)', '请输入船名', '请输入航次', '例如:CNNGB', '例如:宁波港',
'例如:USLAX', '例如:洛杉矶港', '请输入最终交货地点', '例如:CY-CY',
'例如:PREPAID', '请输入订舱备注(可选)',
]) expect(source).toContain(`placeholder="${placeholder}"`)
})
})
@@ -12,6 +12,8 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data/counterparties'
import {
createOceanBooking,
getOceanBooking,
@@ -67,6 +69,15 @@ const actionSaving = ref(false)
const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const formRef = ref<FormInstance>()
const customers = ref<PlatformTenantResource[]>([])
const partners = ref<PlatformPartnerResource[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
const selectableCustomers = computed(() => customers.value.filter((customer) => (
customer.status === 'ACTIVE' || String(customer.id) === String(form.data.customerId)
)))
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.types.includes('CARRIER')))
const bookingAgents = computed(() => partners.value.filter((partner) => partner.types.some((type) => ['SUPPLIER', 'CUSTOMS_BROKER'].includes(type))))
function blankParties(): OceanBookingParty[] {
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', address: '' }))
@@ -79,8 +90,7 @@ function blankData(): OceanBookingDraftData {
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
const rules: FormRules = {
customerId: [{ required: true, message: '请输入客户 ID', trigger: 'blur' }],
customerName: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
customerId: [{ required: true, message: '请选择客户', trigger: 'change' }],
}
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
const canSave = computed(() => isNew.value
@@ -95,6 +105,36 @@ const changeForm = reactive({ vesselName: '', voyageNo: '', estimatedSailingAt:
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
return status ? (statusLabels[status as OceanBookingStatus] || status) : '未设置'
}
async function loadOptions() {
optionsLoading.value = true
optionsError.value = ''
try {
const [customerResult, partnerResult] = await Promise.all([
listPlatformTenants({ page: 1, size: 200 }),
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
])
customers.value = customerResult.records
partners.value = partnerResult.records
} catch (cause) {
optionsError.value = getApiErrorDetails(cause, '客户及合作单位选项加载失败').message
} finally {
optionsLoading.value = false
}
}
function selectCustomer(customerId: string) {
form.data.customerId = customerId
form.data.customerName = customers.value.find((customer) => String(customer.id) === customerId)?.tenantName || ''
}
function selectPartner(kind: 'shippingCompany' | 'bookingAgent', partnerId: string) {
const partner = partners.value.find((item) => String(item.id) === partnerId)
if (kind === 'shippingCompany') {
form.data.shippingCompanyId = partnerId || null
form.data.shippingCompanyName = partner?.partnerName || null
} else {
form.data.bookingAgentId = partnerId || null
form.data.bookingAgentName = partner?.partnerName || null
}
}
function applyDetail(value: OceanBookingDetail) {
detail.value = value
form.expectedVersion = value.version
@@ -185,7 +225,7 @@ async function runAction(action: OceanBookingAction) {
function addContainer() { form.data.containerRequirements.push({ containerType: '40HC', containerKind: 'FCL', quantity: 1 }) }
function removeContainer(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
onMounted(load)
onMounted(() => { void load(); void loadOptions() })
</script>
<template>
@@ -211,26 +251,24 @@ onMounted(load)
<el-button v-if="canSave" type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button>
</div>
</div>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!editable" class="form-grid">
<el-form-item label="客户 ID" prop="customerId"><el-input v-model="form.data.customerId" :disabled="!isNew" /></el-form-item>
<el-form-item label="客户名称" prop="customerName"><el-input v-model="form.data.customerName" /></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.data.billOfLadingNo" /></el-form-item>
<el-form-item label="船公司 ID"><el-input v-model="form.data.shippingCompanyId" /></el-form-item>
<el-form-item label="船公司名称"><el-input v-model="form.data.shippingCompanyName" /></el-form-item>
<el-form-item label="订舱代理 ID"><el-input v-model="form.data.bookingAgentId" /></el-form-item>
<el-form-item label="订舱代理名称"><el-input v-model="form.data.bookingAgentName" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.data.vesselName" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.data.voyageNo" /></el-form-item>
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" /></el-form-item>
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" /></el-form-item>
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" /></el-form-item>
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" /></el-form-item>
<el-form-item label="交货地点"><el-input v-model="form.data.placeOfDelivery" /></el-form-item>
<el-form-item label="服务条款"><el-input v-model="form.data.serviceTerm" /></el-form-item>
<el-form-item label="运费条款"><el-input v-model="form.data.freightTerm" /></el-form-item>
<el-form-item label="预计开船"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预计到港"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item class="wide" label="备注"><el-input v-model="form.data.remark" type="textarea" /></el-form-item>
<el-form-item label="客户" prop="customerId"><el-select :model-value="form.data.customerId" filterable placeholder="请选择客户" :loading="optionsLoading" :disabled="!isNew" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.data.billOfLadingNo" placeholder="请输入提单号(可选)" /></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('shippingCompany', String($event || ''))"><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable placeholder="请选择订舱代理" :loading="optionsLoading" style="width:100%" @update:model-value="selectPartner('bookingAgent', String($event || ''))"><el-option v-for="partner in bookingAgents" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="船名"><el-input v-model="form.data.vesselName" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.data.voyageNo" placeholder="请输入航次" /></el-form-item>
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" placeholder="例如:CNNGB" /></el-form-item>
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" placeholder="例如:宁波港" /></el-form-item>
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" placeholder="例如:USLAX" /></el-form-item>
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" placeholder="例如:洛杉矶港" /></el-form-item>
<el-form-item label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
<el-form-item label="服务条款"><el-input v-model="form.data.serviceTerm" placeholder="例如:CY-CY" /></el-form-item>
<el-form-item label="运费条款"><el-input v-model="form.data.freightTerm" placeholder="例如:PREPAID" /></el-form-item>
<el-form-item label="预计开船"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<el-form-item label="预计到港"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item class="wide" label="备注"><el-input v-model="form.data.remark" type="textarea" placeholder="请输入订舱备注(可选)" /></el-form-item>
</el-form>
</section>
@@ -266,11 +304,11 @@ onMounted(load)
<AppDialog v-model="changeDialogOpen" title="申请改船" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="!changeForm.vesselName.trim() || !changeForm.voyageNo.trim() || !changeForm.reason.trim()" confirm-text="提交申请" @confirm="submitChange">
<el-form label-position="top">
<el-form-item label="新船名" required><el-input v-model="changeForm.vesselName" /></el-form-item>
<el-form-item label="新航次" required><el-input v-model="changeForm.voyageNo" /></el-form-item>
<el-form-item label="预计开船"><el-date-picker v-model="changeForm.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预计到港"><el-date-picker v-model="changeForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="变更原因" required><el-input v-model="changeForm.reason" type="textarea" /></el-form-item>
<el-form-item label="新船名" required><el-input v-model="changeForm.vesselName" placeholder="请输入新船名" /></el-form-item>
<el-form-item label="新航次" required><el-input v-model="changeForm.voyageNo" placeholder="请输入新航次" /></el-form-item>
<el-form-item label="预计开船"><el-date-picker v-model="changeForm.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<el-form-item label="预计到港"><el-date-picker v-model="changeForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item label="变更原因" required><el-input v-model="changeForm.reason" type="textarea" placeholder="请说明改船原因" /></el-form-item>
</el-form>
</AppDialog>
</AdminPage>
@@ -1,7 +1,7 @@
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 item(raw: OutboundDispatchResource): 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) } }
@@ -1,5 +1,138 @@
import { describe, expect, it } from 'vitest'
import ElementPlus from 'element-plus'
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'") }) })
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformUnifiedOrders, type UnifiedOrderResource } from '@/modules/unified-orders'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
import { getOutboundDispatchDetail, listOutboundDispatches } from '../api/platformOutboundDispatch'
import type { OutboundDispatchResource } from '../types'
import OutboundDispatchesView from './OutboundDispatchesView.vue'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('@/modules/unified-orders', () => ({ listPlatformUnifiedOrders: vi.fn() }))
vi.mock('@/modules/container-loading', () => ({ listContainers: vi.fn() }))
vi.mock('../api/platformOutboundDispatch', () => ({ listOutboundDispatches: vi.fn(), getOutboundDispatchDetail: vi.fn(), changeOutboundDispatch: vi.fn() }))
const customer = { id: '10001', tenantCode: 'CUS-1', tenantName: '示例客户', legalName: '示例客户有限公司', status: 'ACTIVE' } as PlatformTenantResource
const order = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户' } as UnifiedOrderResource
const secondOrder = { ...order, orderId: '20002', orderNo: 'ORD-002', inboundId: '30002', inboundCode: 'IN-002' } as UnifiedOrderResource
const container = { id: '40001', bookingNo: 'BK-001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001' } as ContainerBookingResource
const row = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户', containerBookingId: '40001', containerNo: 'MSCU-001', dispatchStatus: 'WAITING', version: 1, gate: { passed: true, items: [], checkedAt: '2026-08-11T10:00:00' } } as OutboundDispatchResource
enableAutoUnmount(afterEach)
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise })
return { promise, resolve, reject }
}
function mountPage() {
return mount(OutboundDispatchesView, { attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
}
async function setFilters(wrapper: ReturnType<typeof mountPage>, values: Record<string, string>) {
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values })
await flushPromises()
return toolbar
}
describe('OutboundDispatchesView', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [order, secondOrder], page: 1, size: 200, total: 2, pages: 1 })
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listOutboundDispatches).mockResolvedValue([row])
})
it('starts idle and queries real ids selected through business labels', async () => {
const wrapper = mountPage()
await flushPromises()
expect(listOutboundDispatches).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请选择订单 / 入库单或柜次后查询')
const toolbar = await setFilters(wrapper, { orderId: '20001' })
toolbar.vm.$emit('search')
await flushPromises()
expect(listOutboundDispatches).toHaveBeenCalledOnce()
expect(listOutboundDispatches).toHaveBeenCalledWith({ orderId: '20001', inboundId: '30001', containerBookingId: undefined })
expect(wrapper.text()).toContain('ORD-001')
})
it('does not issue a search when reset returns to the initial state', async () => {
const wrapper = mountPage()
await flushPromises()
const toolbar = await setFilters(wrapper, { containerBookingId: '40001' })
toolbar.vm.$emit('search')
await flushPromises()
expect(listOutboundDispatches).toHaveBeenCalledOnce()
const resetButton = wrapper.findAll('button').find((button) => button.text().includes('重置'))
await resetButton?.trigger('click')
await flushPromises()
expect(listOutboundDispatches).toHaveBeenCalledOnce()
expect(wrapper.text()).toContain('请选择订单 / 入库单或柜次后查询')
})
it('shows loading, empty and recoverable error states', async () => {
const pending = deferred<OutboundDispatchResource[]>()
vi.mocked(listOutboundDispatches).mockReturnValueOnce(pending.promise)
const wrapper = mountPage()
await flushPromises()
const toolbar = await setFilters(wrapper, { containerBookingId: '40001' })
toolbar.vm.$emit('search')
await flushPromises()
expect(wrapper.findComponent(DataTable).props('loading')).toBe(true)
pending.resolve([])
await flushPromises()
expect(wrapper.text()).toContain('暂无符合条件的出仓记录')
vi.mocked(listOutboundDispatches).mockRejectedValueOnce(new Error('network down')).mockResolvedValueOnce([row])
toolbar.vm.$emit('search')
await flushPromises()
expect(wrapper.text()).toContain('国内出仓加载失败')
const retry = wrapper.findAll('button').find((button) => button.text().includes('重新加载'))
await retry?.trigger('click')
await flushPromises()
expect(listOutboundDispatches).toHaveBeenCalledTimes(3)
expect(wrapper.text()).toContain('ORD-001')
})
it('ignores stale list responses when a newer query wins', async () => {
const first = deferred<OutboundDispatchResource[]>()
const second = deferred<OutboundDispatchResource[]>()
vi.mocked(listOutboundDispatches).mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const wrapper = mountPage()
await flushPromises()
let toolbar = await setFilters(wrapper, { orderId: '20001' })
toolbar.vm.$emit('search')
toolbar = await setFilters(wrapper, { orderId: '20002' })
toolbar.vm.$emit('search')
second.resolve([{ ...row, orderId: '20002', orderNo: 'ORD-002', inboundId: '30002', inboundCode: 'IN-002' }])
await flushPromises()
first.resolve([row])
await flushPromises()
expect(wrapper.text()).toContain('ORD-002')
expect(wrapper.text()).not.toContain('ORD-001')
})
it('keeps the operation column first and guarded workflow actions', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
const columnSource = source.slice(source.indexOf('const columns'))
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("prop: 'orderNo'"))
expect(columnSource).toContain("fixed: 'left'")
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'")
expect(getOutboundDispatchDetail).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } 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'
@@ -12,18 +12,197 @@ 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 { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformUnifiedOrders, type UnifiedOrderResource } from '@/modules/unified-orders'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
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 } }
import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource } from '../types'
type DispatchFilters = QueryModel & { customerId: string; orderId: string; containerBookingId: string }
const access = useAccessPreviewStore()
const rows = ref<OutboundDispatchResource[]>([])
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
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() } }
const filters = ref<DispatchFilters>({ customerId: '', orderId: '', containerBookingId: '' })
const customers = ref<PlatformTenantResource[]>([])
const orders = ref<UnifiedOrderResource[]>([])
const containers = ref<ContainerBookingResource[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
const activeQuery = ref<OutboundDispatchQuery | null>(null)
let listRequestId = 0
let optionRequestId = 0
let detailRequestId = 0
let disposed = false
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.legalName?.trim() || item.tenantName, value: String(item.id), disabled: item.status !== 'ACTIVE' })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'orderId', type: 'select', label: '订单 / 入库单', width: 280, placeholder: filters.value.customerId ? '请选择该客户的订单' : '请选择订单或入库单', options: orders.value.map((item) => ({ label: `${item.orderNo || item.orderId} / ${item.inboundCode || item.inboundId || '未入库'}`, value: String(item.orderId), disabled: item.inboundId == null })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'containerBookingId', type: 'select', label: '柜次', width: 240, placeholder: '请选择柜号或集装箱号', options: containers.value.map((item) => ({ label: `${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } },
])
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 currentQuery(): OutboundDispatchQuery | null {
const order = orders.value.find((item) => String(item.orderId) === filters.value.orderId)
const params = {
orderId: order ? String(order.orderId) : undefined,
inboundId: order?.inboundId == null ? undefined : String(order.inboundId),
containerBookingId: String(filters.value.containerBookingId || '').trim() || undefined,
}
return params.orderId || params.inboundId || params.containerBookingId ? params : null
}
async function load(params = activeQuery.value) {
if (!params) return
const requestId = ++listRequestId
loading.value = true
error.value = null
queried.value = true
try {
const result = await listOutboundDispatches(params)
if (disposed || requestId !== listRequestId) return
rows.value = result
} catch (cause) {
if (disposed || requestId !== listRequestId) return
rows.value = []
error.value = getApiErrorDetails(cause)
} finally {
if (!disposed && requestId === listRequestId) loading.value = false
}
}
function search() {
const params = currentQuery()
if (!params) {
listRequestId += 1
activeQuery.value = null
rows.value = []
error.value = null
loading.value = false
queried.value = false
return
}
activeQuery.value = params
void load(params)
}
function resetSearch() {
listRequestId += 1
activeQuery.value = null
rows.value = []
error.value = null
loading.value = false
queried.value = false
}
async function loadOptions(customerId = filters.value.customerId) {
const requestId = ++optionRequestId
optionsLoading.value = true
optionsError.value = ''
const results = await Promise.allSettled([
listPlatformTenants({ page: 1, size: 200 }),
listPlatformUnifiedOrders({ customerId: customerId || undefined, page: 1, size: 200 }),
listContainers({ page: 1, size: 200 }),
])
if (disposed || requestId !== optionRequestId) return
if (results[0].status === 'fulfilled') customers.value = results[0].value.records
if (results[1].status === 'fulfilled') orders.value = results[1].value.records
if (results[2].status === 'fulfilled') containers.value = results[2].value.records
const failed = ['客户', '订单', '柜次'].filter((_, index) => results[index]?.status === 'rejected')
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败,可重新加载后再查询`
optionsLoading.value = false
}
watch(() => filters.value.customerId, (customerId, previous) => {
if (customerId === previous) return
filters.value = { ...filters.value, orderId: '' }
orders.value = []
void loadOptions(customerId)
})
const detailOpen = ref(false)
const detail = ref<OutboundDispatchDetail | null>(null)
const detailTarget = ref<OutboundDispatchResource | null>(null)
const detailLoading = ref(false)
const actionError = ref<ApiErrorDetails | null>(null)
async function open(value: unknown) {
const row = value as OutboundDispatchResource
const requestId = ++detailRequestId
detailOpen.value = true
detailTarget.value = row
detail.value = null
detailLoading.value = true
actionError.value = null
try {
const result = await getOutboundDispatchDetail({ inboundId: row.inboundId, containerBookingId: row.containerBookingId || undefined })
if (disposed || requestId !== detailRequestId) return
detail.value = result
} catch (cause) {
if (disposed || requestId !== detailRequestId) return
actionError.value = getApiErrorDetails(cause)
} finally {
if (!disposed && requestId === detailRequestId) detailLoading.value = false
}
}
function retryDetail() { if (detailTarget.value) void open(detailTarget.value) }
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()
}
}
onMounted(() => void loadOptions())
onBeforeUnmount(() => { disposed = true; listRequestId += 1; optionRequestId += 1; detailRequestId += 1 })
</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>
<template>
<AdminPage as="div">
<ListPageLayout aria-label="国内出仓列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="国内出仓筛选" :fields="fields" :search-on-reset="false" @search="search" @reset="resetSearch" /></template>
<template v-if="optionsError || error" #state>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions()">重新加载选项</el-button></el-alert>
<LoadErrorState v-if="error" 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="请选择订单 / 入库单或柜次后查询" />
<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" :loading="detailLoading" @retry="retryDetail"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
<div v-else-if="detailLoading" class="detail-loading" aria-busy="true"><el-skeleton :rows="5" animated /></div>
<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); }
.detail-loading { padding: 12px 0; }
</style>
@@ -2,4 +2,59 @@ 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') }) })
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('lets QueryToolbar issue only one search when reset', () => {
expect(source).toContain('@search="search"')
expect(source).not.toContain('@reset="search"')
})
it('keeps view mode read-only without a save entry', () => {
expect(source).toContain("openSchedule(row.schedule.id, 'view')")
expect(source).toContain("openSchedule(row.schedule.id, 'edit')")
expect(source).toContain(':show-confirm="!dialogReadonly"')
expect(source).toContain(':disabled="dialogReadonly"')
expect(source).toContain('if (dialogReadonly.value ||')
})
it('selects real customers and customer-scoped ocean bookings', () => {
expect(source).toContain("from '@/modules/iam'")
expect(source).toContain("from '@/modules/ocean-bookings'")
expect(source).toContain('listPlatformTenants({ page: 1, size: 200 })')
expect(source).toContain('listOceanBookings({ page: 1, size: 200, customerId })')
expect(source).toContain("form.oceanBookingId = ''")
expect(source).not.toContain('label="客户 ID"')
expect(source).not.toContain('label="订舱 ID"')
})
it('protects candidate requests and exposes loading, empty, error and retry states', () => {
expect(source).toContain('customerRequestSerial')
expect(source).toContain('bookingRequestSerial')
expect(source).toContain(':loading="customersLoading"')
expect(source).toContain(':loading="bookingsLoading"')
expect(source).toContain('no-data-text="暂无可用客户"')
expect(source).toContain('no-data-text="该客户暂无订舱记录"')
expect(source).toContain('@click="loadCustomers"')
expect(source).toContain('@click="loadBookings()"')
})
it('gives business fields actionable placeholders', () => {
expect(source).toContain('placeholder="请输入船名"')
expect(source).toContain('placeholder="请输入航次"')
expect(source).toContain('placeholder="例如 CNSHA"')
expect(source).toContain('placeholder="请选择预计开船时间"')
expect(source).toContain('placeholder="请输入船期补充说明(可选)"')
})
it('exposes expiry and conflict handling', () => {
expect(source).toContain("query.expired === 'true'")
expect(source).toContain("actionError.value.code === 'A0409'")
expect(source).toContain('ApiErrorMeta')
})
})
@@ -11,6 +11,8 @@ 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 { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
import { createPortSchedule, getPortSchedule, listPortSchedules, updatePortSchedule } from '../api/platformPortSchedules'
import type { PortScheduleData, PortScheduleView } from '../types'
@@ -27,34 +29,45 @@ const columns = [
{ 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' }] }
type DialogMode = 'create' | 'view' | 'edit'
const dialogOpen = ref(false); const dialogMode = ref<DialogMode>('create'); 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: '请选择客户', trigger: 'change' }], oceanBookingId: [{ required: true, message: '请选择订舱记录', trigger: 'change' }], 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 customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref<ApiErrorDetails | null>(null); let customerRequestSerial = 0
const bookings = ref<OceanBookingResource[]>([]); const bookingsLoading = ref(false); const bookingsError = ref<ApiErrorDetails | null>(null); let bookingRequestSerial = 0
const canCreate = computed(() => access.can('platform:firstleg:port-schedule:create'))
const dialogTitle = computed(() => dialogMode.value === 'create' ? '新增船期' : dialogMode.value === 'edit' ? '编辑船期' : '查看船期')
const dialogReadonly = computed(() => dialogMode.value === 'view')
function customerLabel(customer: PlatformTenantResource) { return `${customer.tenantName}(${customer.tenantCode})` }
function bookingLabel(booking: OceanBookingResource) { return [booking.bookingNo || '待生成订舱号', [booking.vesselName, booking.voyageNo].filter(Boolean).join(' / '), [booking.departurePortCode, booking.destinationPortCode].filter(Boolean).join(' → ')].filter(Boolean).join(' · ') }
async function loadCustomers() { const serial = ++customerRequestSerial; customersLoading.value = true; customersError.value = null; try { const result = await listPlatformTenants({ page: 1, size: 200 }); if (serial === customerRequestSerial) customers.value = result.records } catch (cause) { if (serial === customerRequestSerial) customersError.value = getApiErrorDetails(cause, '客户候选加载失败') } finally { if (serial === customerRequestSerial) customersLoading.value = false } }
async function loadBookings(customerId = String(form.customerId || '')) { const serial = ++bookingRequestSerial; bookings.value = []; bookingsError.value = null; if (!customerId) { bookingsLoading.value = false; return } bookingsLoading.value = true; try { const result = await listOceanBookings({ page: 1, size: 200, customerId }); if (serial === bookingRequestSerial && customerId === String(form.customerId || '')) bookings.value = result.records } catch (cause) { if (serial === bookingRequestSerial && customerId === String(form.customerId || '')) bookingsError.value = getApiErrorDetails(cause, '订舱候选加载失败') } finally { if (serial === bookingRequestSerial) bookingsLoading.value = false } }
function handleCustomerChange(value: string) { form.customerId = String(value || ''); form.oceanBookingId = ''; void formRef.value?.clearValidate('oceanBookingId'); void loadBookings(form.customerId) }
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 } }
function openCreate() { dialogMode.value = 'create'; editingId.value = ''; expectedVersion.value = 0; Object.assign(form, blank()); bookings.value = []; bookingsError.value = null; actionError.value = null; dialogOpen.value = true; if (!customers.value.length) void loadCustomers() }
async function openSchedule(id: string, mode: Extract<DialogMode, 'view' | 'edit'>) { actionError.value = null; try { const result = await getPortSchedule(id); dialogMode.value = mode; editingId.value = id; expectedVersion.value = result.portSchedule.schedule.version; Object.assign(form, result.portSchedule.schedule); dialogOpen.value = true; if (!customers.value.length) void loadCustomers(); void loadBookings(String(form.customerId || '')) } catch (cause) { error.value = getApiErrorDetails(cause) } }
async function save() { if (dialogReadonly.value || !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 openSchedule(editingId.value, 'edit') } 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 #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 #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 #actions="{ row }"><el-button v-if="access.can('platform:firstleg:port-schedule:view')" link type="primary" @click="openSchedule(row.schedule.id, 'view')"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:port-schedule:edit')" link @click="openSchedule(row.schedule.id, 'edit')"><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>
<AppDialog v-model="dialogOpen" :title="dialogTitle" width="820px" :confirm-loading="saving" :show-confirm="!dialogReadonly" :cancel-text="dialogReadonly ? '关闭' : '取消'" @confirm="save"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><div v-if="customersError" class="selector-error"><span>{{ customersError.message }}</span><el-button link type="primary" @click="loadCustomers">重新加载客户</el-button></div><div v-if="bookingsError" class="selector-error"><span>{{ bookingsError.message }}</span><el-button link type="primary" @click="loadBookings()">重新加载订舱</el-button></div><el-form ref="formRef" :model="form" :rules="rules" :disabled="dialogReadonly" label-position="top" class="schedule-form"><el-row :gutter="16">
<el-col :xs="24" :sm="12"><el-form-item label="客户" prop="customerId"><el-select :model-value="form.customerId" filterable placeholder="请选择客户" :loading="customersLoading" no-data-text="暂无可用客户" style="width:100%" @update:model-value="handleCustomerChange(String($event || ''))"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customerLabel(customer)" :value="String(customer.id)" :disabled="customer.status !== 'ACTIVE' && String(customer.id) !== String(form.customerId)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="订舱记录" prop="oceanBookingId"><el-select v-model="form.oceanBookingId" filterable placeholder="请先选择客户,再选择订舱记录" :loading="bookingsLoading" :disabled="!form.customerId" no-data-text="该客户暂无订舱记录" style="width:100%"><el-option v-for="booking in bookings" :key="booking.id" :label="bookingLabel(booking)" :value="booking.id" /></el-select></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-col :xs="24" :sm="12"><el-form-item label="船名" prop="vesselName"><el-input v-model="form.vesselName" placeholder="请输入船名" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="航次" prop="voyageNo"><el-input v-model="form.voyageNo" placeholder="请输入航次" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="起运港代码" prop="departurePortCode"><el-input v-model="form.departurePortCode" placeholder="例如 CNSHA" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="目的港代码" prop="destinationPortCode"><el-input v-model="form.destinationPortCode" placeholder="例如 USLAX" /></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" placeholder="请选择预计开船时间" 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" placeholder="请选择预计到港时间" 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" placeholder="请选择本次查询时间" 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" placeholder="请选择结果过期时间" 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" maxlength="500" show-word-limit placeholder="请输入船期补充说明(可选)" /></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>
<style scoped>.schedule-form{max-height:65vh;overflow:auto;padding-inline:2px}.selector-error{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;padding:6px 10px;border-radius:var(--cc-radius-sm);background:var(--cc-danger-soft);color:var(--el-color-danger);font-size:12px;line-height:20px}.selector-error span{overflow-wrap:anywhere}</style>
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/components/TruckAppointmentForm.vue'), 'utf8')
describe('TruckAppointmentForm interaction contract', () => {
it('uses searchable business selectors for shared resources', () => {
expect(source).toContain('listPlatformTenants')
expect(source).toContain('listPlatformPartners')
expect(source).toContain('listContainers')
expect(source).toContain('listOceanBookings')
expect(source).toContain('listAllPlatformWarehouses')
expect(source).not.toContain('label="承运商 ID"')
expect(source).not.toContain('label="客户关联 ID"')
})
it('synchronizes ids and names and recovers failed option loading', () => {
expect(source).toContain('selectCarrier')
expect(source).toContain('draft.carrierName')
expect(source).toContain('selectCustomer')
expect(source).toContain('draft.customerName')
expect(source).toContain('selectContainer')
expect(source).toContain('draft.loadingPlanId = item.planId')
expect(source).toContain('重新加载选项')
})
it('gives editable fields clear placeholders', () => {
expect(source).toContain('placeholder="请输入司机姓名"')
expect(source).toContain('placeholder="请选择预计装箱时间"')
expect(source).toContain('placeholder="请输入目的门店地址"')
expect(source).toContain('placeholder="请输入拖车预约备注(可选)"')
})
})
@@ -1,8 +1,13 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Eye, Save } from '@lucide/vue'
import type { SaveTruckAppointmentRequest, TruckAppointmentResource } from '../types'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
import { listOceanBookings, type OceanBookingResource } from '@/modules/ocean-bookings'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
@@ -22,7 +27,7 @@ const phoneRule = (label: string) => ({ validator: (_rule: unknown, value: unkno
else callback(new Error(`${label}格式不正确`))
}, trigger: 'blur' })
const rules: FormRules = {
carrierId: [{ required: true, message: '请输入运输公司 ID', trigger: 'blur' }, { pattern: /^[1-9]\d*$/, message: '运输公司 ID 必须为正整数', trigger: 'blur' }],
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }],
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
fleetPhone: [phoneRule('车队电话')], driverPhone: [phoneRule('司机电话')], storePhone: [phoneRule('门店电话')],
packageCount: [nonNegative('件数', true)], palletCount: [nonNegative('托盘数量', true)], transitMinutes: [nonNegative('在途分钟', true)],
@@ -30,52 +35,81 @@ const rules: FormRules = {
currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }],
}
const relationText = computed(() => [props.resource?.customerName, props.resource?.outboundOrderId, props.resource?.containerBookingId].filter(Boolean).join(' / ') || '由服务端关联资源返回')
const customers = ref<PlatformTenantResource[]>([])
const carriers = ref<PlatformPartnerResource[]>([])
const containers = ref<ContainerBookingResource[]>([])
const bookings = ref<OceanBookingResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
function selectCustomer(customerId: string) { draft.customerId = customerId || null; draft.customerName = customers.value.find((item) => String(item.id) === customerId)?.tenantName || null }
function selectCarrier(carrierId: string) { draft.carrierId = carrierId || null; draft.carrierName = carriers.value.find((item) => String(item.id) === carrierId)?.partnerName || null }
function selectContainer(containerId: string) { const item = containers.value.find((entry) => entry.id === containerId); draft.containerBookingId = containerId || null; if (item) { draft.loadingPlanId = item.planId; draft.warehouseId = item.warehouseId } }
function selectBooking(bookingId: string) { const item = bookings.value.find((entry) => entry.id === bookingId); draft.oceanBookingId = bookingId || null; if (item?.customerId) selectCustomer(item.customerId) }
async function loadOptions() {
optionsLoading.value = true; optionsError.value = ''
const results = await Promise.allSettled([
listPlatformTenants({ page: 1, size: 200 }),
listPlatformPartners({ page: 1, size: 200, type: 'CARRIER', status: 'ACTIVE' }),
listContainers({ page: 1, size: 200 }),
listOceanBookings({ page: 1, size: 200 }),
listAllPlatformWarehouses(),
])
if (results[0].status === 'fulfilled') customers.value = results[0].value.records
if (results[1].status === 'fulfilled') carriers.value = results[1].value.records
if (results[2].status === 'fulfilled') containers.value = results[2].value.records
if (results[3].status === 'fulfilled') bookings.value = results[3].value.records
if (results[4].status === 'fulfilled') warehouses.value = results[4].value
const failed = ['客户', '承运商', '柜次', '订舱', '仓库'].filter((_, index) => results[index]?.status === 'rejected')
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
optionsLoading.value = false
}
async function validate() { return formRef.value?.validate() }
async function submitForm() { if (await formRef.value?.validate()) emit('submit') }
defineExpose({ validate })
onMounted(() => { if (!props.readonly) void loadOptions() })
watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.length) void loadOptions() })
</script>
<template>
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
<el-alert v-if="resource" type="info" :closable="false" class="relation-alert">关联资源:{{ relationText }};柜型由关联柜体返回(只读)</el-alert>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<el-row :gutter="16">
<el-col :xs="24"><el-divider content-position="left">关联资源</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="柜体关联 ID"><el-input v-model="draft.containerBookingId" placeholder="请输入柜体预约 ID" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="出仓单关联 ID"><el-input v-model="draft.outboundOrderId" placeholder="请输入出仓单 ID" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="海运订舱关联 ID"><el-input v-model="draft.oceanBookingId" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="装柜计划关联 ID"><el-input v-model="draft.loadingPlanId" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="仓库关联 ID"><el-input v-model="draft.warehouseId" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="客户关联 ID"><el-input v-model="draft.customerId" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="请选择柜次" :loading="optionsLoading" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-for="item in containers" :key="item.id" :label="`${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱"><el-select :model-value="draft.oceanBookingId" filterable clearable placeholder="请选择订舱单" :loading="optionsLoading" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-for="item in bookings" :key="item.id" :label="item.bookingNo || `订舱 ${item.id}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="客户"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" :disabled="item.status !== 'ACTIVE'" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-for="item in warehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" :disabled="item.status !== 'ACTIVE'" /></el-select></el-form-item></el-col>
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="派车单号"><el-input :model-value="resource.appointmentNo || ''" disabled /></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">运输与时间计划</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="承运商 ID" prop="carrierId"><el-input v-model="draft.carrierId" placeholder="请输入后端资源 ID" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="承运商名称"><el-input v-model="draft.carrierName" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队电话" prop="fleetPhone"><el-input v-model="draft.fleetPhone" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机"><el-input v-model="draft.driverName" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机电话" prop="driverPhone"><el-input v-model="draft.driverPhone" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机身份证号"><el-input v-model="draft.driverIdCard" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车头号"><el-input v-model="draft.vehicleNo" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" 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="carrierId"><el-select :model-value="draft.carrierId" filterable placeholder="请选择承运商" :loading="optionsLoading" style="width:100%" @update:model-value="selectCarrier(String($event || ''))"><el-option v-for="item in carriers" :key="String(item.id)" :label="item.partnerName" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" placeholder="请输入车队联系人" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队电话" prop="fleetPhone"><el-input v-model="draft.fleetPhone" placeholder="请输入车队联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机"><el-input v-model="draft.driverName" placeholder="请输入司机姓名" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机电话" prop="driverPhone"><el-input v-model="draft.driverPhone" placeholder="请输入司机联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机身份证号"><el-input v-model="draft.driverIdCard" placeholder="请输入司机身份证号" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车头号"><el-input v-model="draft.vehicleNo" placeholder="请输入车牌或车头号" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装箱时间" style="width:100%" /></el-form-item></el-col>
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="实际装箱时间"><el-input :model-value="resource.actualLoadingAt || ''" disabled /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="件数" prop="packageCount"><el-input v-model="draft.packageCount" inputmode="numeric" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="毛重(kg)" prop="grossWeightKg"><el-input v-model="draft.grossWeightKg" inputmode="decimal" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input v-model="draft.volumeCbm" inputmode="decimal" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量" prop="palletCount"><el-input v-model="draft.palletCount" inputmode="numeric" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="在途分钟" prop="transitMinutes"><el-input v-model="draft.transitMinutes" inputmode="numeric" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="件数" prop="packageCount"><el-input v-model="draft.packageCount" inputmode="numeric" placeholder="请输入件数" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="毛重(kg)" prop="grossWeightKg"><el-input v-model="draft.grossWeightKg" inputmode="decimal" placeholder="请输入毛重" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input v-model="draft.volumeCbm" inputmode="decimal" placeholder="请输入体积" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量" prop="palletCount"><el-input v-model="draft.palletCount" inputmode="numeric" placeholder="请输入托盘数量" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="在途分钟" prop="transitMinutes"><el-input v-model="draft.transitMinutes" inputmode="numeric" placeholder="请输入预计在途分钟" /></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">费用信息</el-divider></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应收拖车费" prop="receivableAmount"><el-input v-model="draft.receivableAmount"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="币种" prop="currency"><el-input v-model="draft.currency" maxlength="8" @blur="draft.currency = draft.currency?.toUpperCase()" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店联系人"><el-input v-model="draft.storeContact" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应付拖车费" prop="payableAmount"><el-input v-model="draft.payableAmount" placeholder="请输入应付金额"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应收拖车费" prop="receivableAmount"><el-input v-model="draft.receivableAmount" placeholder="请输入应收金额"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="币种" prop="currency"><el-input v-model="draft.currency" placeholder="例如:RMB" maxlength="8" @blur="draft.currency = draft.currency?.toUpperCase()" /></el-form-item></el-col>
<el-col :xs="24"><el-divider content-position="left">门店信息与备注</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店电话" prop="storePhone"><el-input v-model="draft.storePhone" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" placeholder="请输入目的门店名称" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" placeholder="请输入目的门店地址" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店联系人"><el-input v-model="draft.storeContact" placeholder="请输入门店联系人" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店电话" prop="storePhone"><el-input v-model="draft.storePhone" placeholder="请输入门店联系电话" maxlength="32" show-word-limit /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" placeholder="请输入城市" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" placeholder="请输入邮政编码" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" placeholder="请输入采购订单号(可选)" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" placeholder="请输入拖车预约备注(可选)" /></el-form-item></el-col>
</el-row>
<div class="form-actions"><el-button :disabled="submitting" @click="emit('attachments')"><Eye :size="16" />查看附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="submitForm"><Save :size="16" />保存</el-button></div>
</el-form>
@@ -211,6 +211,8 @@ export interface UnifiedOrderDetailResource {
export interface UnifiedOrderFeeDetailResource {
id: number | string
sourceFeeId?: number | string | null
sourceFeeVersion?: number | null
inboundId: number | string
inboundCode: string | null
reviewStatus: string | null
+6
View File
@@ -0,0 +1,6 @@
export {
listPlatformUnifiedOrderFeeDetails,
listPlatformUnifiedOrders,
type UnifiedOrderFeeDetailResource,
type UnifiedOrderResource,
} from './api/unifiedOrders'
@@ -0,0 +1,118 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listPlatformUnifiedOrders } from '@/modules/unified-orders/api/unifiedOrders'
import UnifiedOrdersView from './UnifiedOrdersView.vue'
vi.mock('vue-router', () => ({
useRoute: () => ({ params: {}, query: {} }),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn() }))
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
addPlatformUnifiedOrderNote: vi.fn(),
approvePlatformUnifiedOrder: vi.fn(),
getPlatformUnifiedOrder: vi.fn(),
listPlatformUnifiedOrderFeeDetails: vi.fn(),
listPlatformUnifiedOrders: vi.fn(),
unifiedOrderStatusOptions: [
{ value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' },
{ value: 'INBOUNDED', label: '已入库' },
{ value: 'WAIT_SPLIT', label: '待分柜' },
{ value: 'LOADING', label: '装柜中' },
{ value: 'LOADED', label: '装柜完成' },
{ value: 'DELIVERED', label: '派送完成' },
],
}))
function emptyPage() {
return { records: [], page: 1, size: 20, total: 0, pages: 0 }
}
function mountView() {
return shallowMount(UnifiedOrdersView, {
global: {
directives: { loading: () => undefined },
stubs: {
AdminPage: { template: '<main><slot /></main>' },
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search', 'reset'], template: '<div data-testid="query-toolbar" />' },
DataTable: { name: 'DataTable', props: ['page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '<div data-testid="data-table" />' },
'el-button': { template: '<button><slot /></button>' },
},
},
})
}
describe('UnifiedOrdersView list request coordination', () => {
beforeEach(() => {
vi.mocked(listPlatformUnifiedOrders).mockReset().mockResolvedValue(emptyPage())
})
it('loads once initially and once for query, reset, status tab and pagination', async () => {
const wrapper = mountView()
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1)
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
toolbar.vm.$emit('search')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(2)
toolbar.vm.$emit('reset')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(3)
await wrapper.findAll('.status-tab')[1]!.trigger('click')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(4)
wrapper.getComponent({ name: 'DataTable' }).vm.$emit('update:page', 2)
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(5)
})
it('does not apply draft filters to pagination until query is submitted', async () => {
const wrapper = mountView()
await flushPromises()
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
const draft = { ...toolbar.props('modelValue'), inboundCode: ' DRAFT-CODE ' }
toolbar.vm.$emit('update:modelValue', draft)
await wrapper.vm.$nextTick()
wrapper.getComponent({ name: 'DataTable' }).vm.$emit('update:page', 2)
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, inboundCode: undefined }))
toolbar.vm.$emit('search')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(3)
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, inboundCode: 'DRAFT-CODE' }))
})
it('resets a later page and changes page size with one request per interaction', async () => {
const wrapper = mountView()
await flushPromises()
const table = wrapper.getComponent({ name: 'DataTable' })
const toolbar = wrapper.getComponent({ name: 'QueryToolbar' })
table.vm.$emit('update:page', 3)
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(2)
toolbar.vm.$emit('reset')
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(3)
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1 }))
table.vm.$emit('update:pageSize', 50)
table.vm.$emit('update:page', 1)
await flushPromises()
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(4)
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 50 }))
})
})
@@ -19,7 +19,7 @@ describe('UnifiedOrdersView contract', () => {
it('renders accessible current-status tabs with keyboard navigation', () => {
expect(source).toContain('role="tablist"')
expect(source).toContain('role="tab"')
expect(source).toContain(':aria-selected="filters.mainStatus === tab.value"')
expect(source).toContain(':aria-selected="selectedStatus === tab.value"')
expect(source).toContain("event.key === 'ArrowRight'")
expect(source).toContain("event.key === 'ArrowLeft'")
expect(source).toContain("event.key === 'Home'")
@@ -38,8 +38,8 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('listAllPlatformWarehouses')
expect(source).toContain("{ status: 'ACTIVE' }")
expect(source).toContain("warehouse.warehouseType === 'DOMESTIC'")
expect(source).toContain("loaded: optionalBoolean(filters.value.loaded)")
expect(source).toContain("fullContainer: optionalBoolean(filters.value.fullContainer)")
expect(source).toContain("loaded: optionalBoolean(activeFilters.loaded)")
expect(source).toContain("fullContainer: optionalBoolean(activeFilters.fullContainer)")
expect(source).toContain("{ value: 'UNRECORDED', label: '未录入' }")
expect(source).not.toContain("{ value: 'DRAFT', label: '草稿' }")
expect(source).not.toContain("{ value: 'CANCELLED', label: '已取消' }")
@@ -59,7 +59,7 @@ describe('UnifiedOrdersView contract', () => {
})
it('uses tabs as the only order-status filter and keeps advanced controls inside QueryToolbar', () => {
expect(source).toContain('filters.value.mainStatus || undefined')
expect(source).toContain('mainStatus: selectedStatus.value || undefined')
expect(source).toContain('<template #actions>')
expect(source).toContain(':aria-expanded="showAdvancedFilters"')
expect(source).not.toContain('class="filter-mode-actions"')
@@ -102,6 +102,14 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length"')
})
it('keeps editable query values separate from the applied list request', () => {
expect(source).toContain('const appliedFilters = ref<FilterModel>(emptyFilters())')
expect(source).toContain('const activeFilters = appliedFilters.value')
expect(source).toContain('appliedFilters.value = cloneFilters(filters.value)')
expect(source).toContain('appliedFilters.value = emptyFilters()')
expect(source).not.toContain('filters.value.mainStatus')
})
it('keeps only confirmed business actions as explicit boundaries', () => {
expect(source).toContain('nextActionBoundary')
expect(source).not.toContain('class="action-boundary"')
@@ -39,7 +39,6 @@ import {
type FilterModel = QueryModel & {
tenantId: string
mainStatus: UnifiedOrderMainStatus | ''
receivingWarehouseId: string
destinationWarehouseId: string
inboundCode: string
@@ -96,6 +95,8 @@ let detailLoadingKey = ''
let detailRequestSerial = 0
const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const appliedFilters = ref<FilterModel>(emptyFilters())
const selectedStatus = ref<UnifiedOrderMainStatus | ''>('')
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const detailTenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : '')
const detailRouteKey = computed(() => orderId.value ? `${detailTenantId.value}:${orderId.value}` : '')
@@ -240,13 +241,17 @@ const columns: DataTableColumn[] = [
function emptyFilters(): FilterModel {
return {
tenantId: '', mainStatus: '', receivingWarehouseId: '', destinationWarehouseId: '',
tenantId: '', receivingWarehouseId: '', destinationWarehouseId: '',
inboundCode: '', expectedArrivalDate: '', feeReviewStatus: '', channelName: '',
containerNo: '', cabinetNo: '', sailingDate: '', loaded: '', fullContainer: '', measurementReviewStatus: '',
fbaCode: '', createdRange: [],
}
}
function cloneFilters(source: FilterModel): FilterModel {
return { ...source, createdRange: [...source.createdRange] }
}
function trimmed(value: string) {
return value.trim() || undefined
}
@@ -256,8 +261,10 @@ function optionalBoolean(value: string) {
}
function selectStatus(status: UnifiedOrderMainStatus | '') {
filters.value = { ...filters.value, mainStatus: status }
search()
if (selectedStatus.value === status) return
selectedStatus.value = status
if (page.value === 1) void loadOrders()
else page.value = 1
}
function handleTabKeydown(event: KeyboardEvent, index: number) {
@@ -625,23 +632,24 @@ async function loadOrders() {
loading.value = true
loadError.value = null
try {
const range = filters.value.createdRange
const activeFilters = appliedFilters.value
const range = activeFilters.createdRange
const result = await listPlatformUnifiedOrders({
tenantId: filters.value.tenantId || undefined,
mainStatus: filters.value.mainStatus || undefined,
receivingWarehouseId: filters.value.receivingWarehouseId || undefined,
destinationWarehouseId: filters.value.destinationWarehouseId || undefined,
inboundCode: trimmed(filters.value.inboundCode),
expectedArrivalDate: filters.value.expectedArrivalDate || undefined,
feeReviewStatus: filters.value.feeReviewStatus || undefined,
channelName: trimmed(filters.value.channelName),
containerNo: trimmed(filters.value.containerNo),
cabinetNo: trimmed(filters.value.cabinetNo),
sailingDate: filters.value.sailingDate || undefined,
loaded: optionalBoolean(filters.value.loaded),
fullContainer: optionalBoolean(filters.value.fullContainer),
measurementReviewStatus: filters.value.measurementReviewStatus || undefined,
fbaCode: trimmed(filters.value.fbaCode),
tenantId: activeFilters.tenantId || undefined,
mainStatus: selectedStatus.value || undefined,
receivingWarehouseId: activeFilters.receivingWarehouseId || undefined,
destinationWarehouseId: activeFilters.destinationWarehouseId || undefined,
inboundCode: trimmed(activeFilters.inboundCode),
expectedArrivalDate: activeFilters.expectedArrivalDate || undefined,
feeReviewStatus: activeFilters.feeReviewStatus || undefined,
channelName: trimmed(activeFilters.channelName),
containerNo: trimmed(activeFilters.containerNo),
cabinetNo: trimmed(activeFilters.cabinetNo),
sailingDate: activeFilters.sailingDate || undefined,
loaded: optionalBoolean(activeFilters.loaded),
fullContainer: optionalBoolean(activeFilters.fullContainer),
measurementReviewStatus: activeFilters.measurementReviewStatus || undefined,
fbaCode: trimmed(activeFilters.fbaCode),
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
page: page.value,
@@ -660,6 +668,7 @@ async function loadOrders() {
}
function search() {
appliedFilters.value = cloneFilters(filters.value)
if (page.value !== 1) {
page.value = 1
return
@@ -669,6 +678,7 @@ function search() {
function reset() {
filters.value = emptyFilters()
appliedFilters.value = emptyFilters()
if (page.value === 1) void loadOrders()
else page.value = 1
}
@@ -1026,10 +1036,10 @@ onMounted(() => {
:key="tab.value || 'ALL'"
type="button"
role="tab"
:aria-selected="filters.mainStatus === tab.value"
:aria-selected="selectedStatus === tab.value"
aria-controls="unified-orders-results"
:tabindex="filters.mainStatus === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': filters.mainStatus === tab.value }]"
:tabindex="selectedStatus === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': selectedStatus === tab.value }]"
@click="selectStatus(tab.value)"
@keydown="handleTabKeydown($event, index)"
>{{ tab.label }}</button>
@@ -1,4 +1,4 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessageBox } from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -9,6 +9,7 @@ import {
getOutboundDispatch,
getPreload,
smartSplit,
voidEvidence,
} from '../api'
vi.mock('../api', async (importOriginal) => {
@@ -19,6 +20,7 @@ vi.mock('../api', async (importOriginal) => {
getOutboundDispatch: vi.fn(),
getPreload: vi.fn(),
smartSplit: vi.fn(),
voidEvidence: vi.fn(),
}
})
@@ -50,6 +52,7 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
vi.mocked(getEvidence).mockReset().mockResolvedValue({ inboundId: '2086085512165634051', evidences: [] })
vi.mocked(getPreload).mockReset().mockResolvedValue(preload('REF-OLD'))
vi.mocked(smartSplit).mockReset()
vi.mocked(voidEvidence).mockReset()
})
it('renders the formal preload table and keeps the operation column first', async () => {
@@ -116,4 +119,41 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
expect(wrapper.text()).not.toContain('完成装柜')
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
})
it('uses a validated dialog when voiding evidence', async () => {
vi.mocked(getEvidence).mockResolvedValue({
inboundId: '2086085512165634051',
evidences: [
{ id: '1', inboundId: '2086085512165634051', tenantId: '2086085512165634052', evidenceType: 'PHOTO_STEP_1', fileId: '11', fileName: 'wrong.jpg', sealNo: null, remark: null, version: 2, status: 'ACTIVE', createdAt: null, updatedAt: null },
],
})
vi.mocked(voidEvidence).mockResolvedValue({} as never)
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: '照片上传错误' } as never)
const wrapper = mountPanel(['platform:firstleg:inbound:loading:view', 'platform:firstleg:inbound:loading:edit'], 'LOADING')
await flushPromises()
const voidButton = wrapper.findAll('button').find((button) => button.text() === '作废')
expect(voidButton).toBeTruthy()
await voidButton!.trigger('click')
await flushPromises()
expect(prompt).toHaveBeenCalledWith(
'作废后照片仍会保留在审计记录中。',
'作废证据照片',
expect.objectContaining({
inputType: 'textarea',
inputPlaceholder: '请输入作废原因',
type: 'warning',
}),
)
const options = prompt.mock.calls[0]?.[2]
expect(options?.inputValidator?.(' ')).toBe('作废原因不能为空')
expect(voidEvidence).toHaveBeenCalledWith(
'2086085512165634052',
'2086085512165634051',
'1',
{ expectedVersion: 2, reason: '照片上传错误' },
)
prompt.mockRestore()
})
})
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessageBox } from 'element-plus'
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'
@@ -345,8 +346,21 @@ async function uploadAndSaveEvidence(type: EvidenceType, event: Event) {
async function markEvidenceVoided(item: Evidence) {
if (!canEvidence.value || !item.id || String(item.id).startsWith('draft-')) return
const reason = window.prompt('请输入照片作废原因')?.trim()
if (!reason) return
let reason = ''
try {
const result = await ElMessageBox.prompt('作废后照片仍会保留在审计记录中。', '作废证据照片', {
inputType: 'textarea',
inputPlaceholder: '请输入作废原因',
inputValidator: (value) => Boolean(value?.trim()) || '作废原因不能为空',
confirmButtonText: '确认作废',
cancelButtonText: '取消',
type: 'warning',
})
reason = result.value.trim()
} catch (cause) {
if (cause === 'cancel' || cause === 'close') return
throw cause
}
const result = await run(`evidence-void-${item.id}`, () => voidEvidence(props.tenantId, props.inboundId, item.id, {
expectedVersion: item.version,
reason,