Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test
This commit is contained in:
@@ -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('系统管理')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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']))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -71,6 +71,21 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(wrapper.text()).toContain('ORD-1')
|
||||
})
|
||||
|
||||
it('rounds capacity percentages and hides column settings in embedded tables', async () => {
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [{ ...container, capacityCbm: 100, maxWeightKg: 100000 }], page: 1, size: 100, total: 1, pages: 1 })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
items: [{ ...plan.items[0], volumeCbm: 0.187, grossWeightKg: 13.6 }],
|
||||
})
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('0.17%')
|
||||
expect(wrapper.text()).toContain('0.01%')
|
||||
expect(wrapper.text()).not.toContain('0.169999999')
|
||||
expect(wrapper.findAll('button[aria-label="列设置"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps unsupported prototype filters out of the real workbench', async () => {
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -66,6 +66,8 @@ const volumeLimit = computed(() => Number(selectedContainer.value?.capacityCbm |
|
||||
const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg || 0) * 1.1)
|
||||
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
|
||||
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
|
||||
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
|
||||
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
|
||||
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderKeyword', type: 'input', label: '订单或货物', placeholder: '订单号、入库单、客户、货物', width: 250 },
|
||||
@@ -123,8 +125,9 @@ const itemColumns: DataTableColumn[] = [
|
||||
]
|
||||
|
||||
function formatNumber(value: number, digits = 3) {
|
||||
return Number.isFinite(value) ? value.toFixed(digits) : '0.000'
|
||||
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0'
|
||||
}
|
||||
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
||||
function candidateRow(source: object) { return source as SmartLoadingCandidate }
|
||||
function itemRow(source: object) { return source as SmartLoadingItem }
|
||||
function statusLabel(status: ContainerPlanStatus) {
|
||||
@@ -360,6 +363,7 @@ onMounted(load)
|
||||
:columns="candidateColumns"
|
||||
:loading="loading || selectionLoading || mutating"
|
||||
preference-key="global-smart-loading-candidates"
|
||||
:show-column-settings="false"
|
||||
:empty-text="selectedContainer ? '当前仓库暂无可装货物' : '暂无可用柜次'"
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
@@ -428,10 +432,10 @@ onMounted(load)
|
||||
</el-empty>
|
||||
<template v-else-if="plan">
|
||||
<div class="capacity-grid">
|
||||
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(volumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
|
||||
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(weightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
|
||||
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
|
||||
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
|
||||
</div>
|
||||
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" preference-key="global-smart-loading-items" empty-text="当前柜次暂无已装货物">
|
||||
<DataTable :data="plan.items" :columns="itemColumns" :loading="selectionLoading || mutating" preference-key="global-smart-loading-items" :show-column-settings="false" empty-text="当前柜次暂无已装货物">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" link type="primary" :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存</el-button><el-button v-if="canDelete && editable" link type="danger" @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
||||
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
||||
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
|
||||
|
||||
@@ -47,6 +47,8 @@ const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1
|
||||
const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.1)
|
||||
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
|
||||
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
|
||||
const displayedVolumePercent = computed(() => percentage(volumePercent.value))
|
||||
const displayedWeightPercent = computed(() => percentage(weightPercent.value))
|
||||
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||||
@@ -70,7 +72,8 @@ const candidateColumns: DataTableColumn[] = [
|
||||
{ key: 'route', label: '目的仓匹配', minWidth: 170, slotName: 'route' },
|
||||
]
|
||||
|
||||
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toFixed(digits) : '0.000' }
|
||||
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: digits }) : '0' }
|
||||
function percentage(value: number) { return Number.isFinite(value) ? Number(Math.max(value, 0).toFixed(2)) : 0 }
|
||||
function candidateRow(source: object) { return source as SmartLoadingCandidate }
|
||||
function itemRow(source: object) { return source as SmartLoadingItem }
|
||||
function routeMatches(source: object) {
|
||||
@@ -221,10 +224,10 @@ onMounted(load)
|
||||
</section>
|
||||
<template v-else>
|
||||
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div><div><span>目的仓</span><strong>{{ container.destinationWarehouseName || container.destinationWarehouseCode || '不限' }}</strong></div><div><span>计划状态</span><strong>{{ plan?.status === 'LOADING' ? '拣货中' : plan ? '预装柜中' : '待装柜' }}</strong></div></section>
|
||||
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(volumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(weightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
|
||||
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
|
||||
<el-empty v-if="noDraft" description="该货柜尚未创建排柜计划"><el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建排柜计划</el-button></el-empty>
|
||||
<template v-else-if="plan">
|
||||
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading || mutating" preference-key="smart-loading-items" empty-text="暂无已分配货物">
|
||||
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading || mutating" preference-key="smart-loading-items" :show-column-settings="false" empty-text="暂无已分配货物">
|
||||
<template #toolbar-start><div><h2>装柜方案</h2><p>计划 {{ plan.planNo }} · 版本 {{ plan.version }};智能建议必须经人工核对。</p></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" link type="primary" :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)"><Save :size="15" />保存箱数</el-button><el-button v-if="canDelete && editable" link type="danger" @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
|
||||
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
||||
@@ -233,7 +236,7 @@ onMounted(load)
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||
<template #destination="{ row }">{{ row.destinationWarehouseName || row.destinationWarehouseCode || '—' }}</template>
|
||||
</DataTable>
|
||||
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" preference-key="smart-loading-candidates" empty-text="当前仓库暂无可排柜货物">
|
||||
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" preference-key="smart-loading-candidates" :show-column-settings="false" empty-text="当前仓库暂无可排柜货物">
|
||||
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于仓库、目的仓、可用箱数及 110% 容量上限校验。</p></div></template>
|
||||
<template #actions="{ row }"><el-button v-if="canAssign && editable" link type="primary" :disabled="!routeMatches(row) || assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1" @click="openAssign(row)"><Plus :size="15" />分配</el-button></template>
|
||||
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
|
||||
import { Pencil, RefreshCw, Save } from '@lucide/vue'
|
||||
import { Pencil, Plus, RefreshCw, Save, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { getCustomerOverseasStorageFees, updateCustomerOverseasStorageFees, type OverseasStorageFeeConfig, type OverseasStorageFeeTier } from './overseasStorageFee'
|
||||
import { addFeeTier, removeFeeTier, validateFeeTiers } from './overseasStorageFeeTiers'
|
||||
|
||||
const props = defineProps<{ customerId: string; customerName: string }>()
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -48,19 +49,12 @@ async function closeEditor() {
|
||||
visible.value = false
|
||||
}
|
||||
function validate() {
|
||||
if (storageTiers.value.length !== 5 || returnTiers.value.length !== 4) { ElMessage.error('仓租必须为5档,退货仓储费必须为4档'); return false }
|
||||
for (const [label, tiers] of [['仓租', storageTiers.value], ['退货仓储费', returnTiers.value] ] as const) {
|
||||
let expected = 1
|
||||
for (const [index, tier] of tiers.entries()) {
|
||||
if (!Number.isInteger(tier.startDay) || tier.startDay !== expected || tier.startDay < 1) { ElMessage.error(`${label}第${index + 1}档天数必须连续且从第1天开始`); return false }
|
||||
if (!Number.isFinite(tier.unitPrice) || tier.unitPrice < 0) { ElMessage.error(`${label}第${index + 1}档价格不能小于0`); return false }
|
||||
if (index === tiers.length - 1) { if (tier.endDay !== null) { ElMessage.error(`${label}最后一档必须为“以上”`); return false } }
|
||||
else if (!Number.isInteger(tier.endDay) || tier.endDay! < tier.startDay) { ElMessage.error(`${label}第${index + 1}档结束天数无效`); return false }
|
||||
if (tier.endDay !== null) expected = tier.endDay + 1
|
||||
}
|
||||
}
|
||||
const message = validateFeeTiers(form.tiers)
|
||||
if (message) { ElMessage.error(message); return false }
|
||||
return true
|
||||
}
|
||||
function addTier(feeType: OverseasStorageFeeTier['feeType']) { const message = addFeeTier(form.tiers, feeType); if (message) ElMessage.error(message) }
|
||||
function removeTier(tier: OverseasStorageFeeTier) { const message = removeFeeTier(form.tiers, tier); if (message) ElMessage.error(message) }
|
||||
async function save() {
|
||||
if (!config.value || !validate()) return
|
||||
saving.value = true
|
||||
@@ -85,13 +79,17 @@ onMounted(load)
|
||||
</template>
|
||||
<div v-else class="fee-empty">当前客户暂无海外仓费用配置</div>
|
||||
<AppDialog v-model="visible" title="编辑海外仓费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" @confirm="save">
|
||||
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }}</h3><table><thead><tr><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" /></td></tr></tbody></table></div></div></el-form>
|
||||
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><div class="edit-group-heading"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')"><Plus :size="15" />新增阶梯</el-button></div><table><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><td><el-button link type="danger" :disabled="group.length <= 1" @click="removeTier(tier)"><Trash2 :size="15" />删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" /></td></tr></tbody></table></div></div></el-form>
|
||||
<template #footer><div class="dialog-actions"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存费用</el-button></div></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overseas-fee-panel { margin-top: 18px; border: 1px solid var(--cc-border); background: #fff; }.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:18px; border-bottom:1px solid var(--cc-border); }.panel-heading h2,.fee-groups h3,.edit-groups h3 { margin:0; color:var(--cc-text); font-size:16px; }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); font-size:12px; }.fee-meta { display:flex; flex-wrap:wrap; gap:24px; padding:12px 18px; color:var(--cc-muted); font-size:12px; background:#f8fafc; }.fee-meta strong { color:var(--cc-text); }.fee-groups,.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px; padding:18px; }.fee-groups table,.edit-groups table { width:100%; border-collapse:collapse; font-size:12px; }.fee-groups th,.fee-groups td,.edit-groups th,.edit-groups td { padding:9px 10px; border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th,.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups :deep(.el-input-number) { width:130px; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:12px; }.fee-loading { min-height:220px; }.fee-empty { min-height:160px; display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; justify-content:flex-end; gap:8px; }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups,.edit-groups { grid-template-columns:1fr; padding:12px; }.edit-groups { overflow:auto; }.edit-groups > div { min-width:620px; }.panel-heading :deep(.el-button) { min-height:44px; } }
|
||||
.overseas-fee-panel { margin-top: 18px; border: 1px solid var(--cc-border); background: #fff; }.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:18px; border-bottom:1px solid var(--cc-border); }.panel-heading h2,.fee-groups h3,.edit-groups h3 { margin:0; color:var(--cc-text); font-size:16px; }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); font-size:12px; }.fee-meta { display:flex; flex-wrap:wrap; gap:24px; padding:12px 18px; color:var(--cc-muted); font-size:12px; background:#f8fafc; }.fee-meta strong { color:var(--cc-text); }.fee-groups,.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px; padding:18px; }.fee-groups > div,.edit-groups > div { min-width:0; }.fee-groups table,.edit-groups table { width:100%; border-collapse:collapse; font-size:12px; }.edit-groups table { table-layout:fixed; }.fee-groups th,.fee-groups td,.edit-groups th,.edit-groups td { padding:9px 10px; border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th,.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups th:first-child,.edit-groups td:first-child { width:76px; }.edit-groups :deep(.el-input-number) { width:100%; min-width:0; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:12px; }.fee-loading { min-height:220px; }.fee-empty { min-height:160px; display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; justify-content:flex-end; gap:8px; }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.edit-group-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
|
||||
.edit-group-heading h3 { min-width:0; }
|
||||
.edit-group-heading :deep(.el-button) { flex:0 0 auto; }
|
||||
@media (max-width:900px) { .edit-groups { grid-template-columns:1fr; } }
|
||||
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups,.edit-groups { grid-template-columns:1fr; padding:12px; }.edit-groups > div { overflow-x:auto; }.edit-groups table { min-width:560px; }.panel-heading :deep(.el-button) { min-height:44px; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addFeeTier, removeFeeTier, validateFeeTiers } from './overseasStorageFeeTiers'
|
||||
import type { OverseasFeeType, OverseasStorageFeeTier } from './overseasStorageFee'
|
||||
|
||||
function tier(id: string, feeType: OverseasFeeType, startDay: number, endDay: number | null): OverseasStorageFeeTier {
|
||||
return { id, feeType, billingUnit: feeType === 'STORAGE' ? 'CBM' : 'PALLET', startDay, endDay, unitPrice: 1, sortNo: 1 }
|
||||
}
|
||||
|
||||
describe('overseas storage fee tier editing', () => {
|
||||
it('adds a new open-ended tier and closes the previous tail', () => {
|
||||
const tiers = [
|
||||
tier('s1', 'STORAGE', 1, 7), tier('s2', 'STORAGE', 8, 30), tier('s3', 'STORAGE', 31, 60),
|
||||
tier('s4', 'STORAGE', 61, 90), tier('s5', 'STORAGE', 91, null),
|
||||
tier('r1', 'RETURN_STORAGE', 1, null),
|
||||
]
|
||||
expect(addFeeTier(tiers, 'STORAGE')).toBeNull()
|
||||
const storageTiers = tiers.filter((item) => item.feeType === 'STORAGE')
|
||||
expect(storageTiers).toHaveLength(6)
|
||||
expect(storageTiers.slice(-2).map(({ startDay, endDay }) => ({ startDay, endDay })))
|
||||
.toEqual([{ startDay: 91, endDay: 91 }, { startDay: 92, endDay: null }])
|
||||
expect(validateFeeTiers(tiers)).toBeNull()
|
||||
})
|
||||
|
||||
it('removes a middle tier and extends the previous range', () => {
|
||||
const tiers = [
|
||||
tier('s1', 'STORAGE', 1, 7), tier('s2', 'STORAGE', 8, 30), tier('s3', 'STORAGE', 31, 60),
|
||||
tier('s4', 'STORAGE', 61, 90), tier('s5', 'STORAGE', 91, null),
|
||||
tier('r1', 'RETURN_STORAGE', 1, null),
|
||||
]
|
||||
expect(removeFeeTier(tiers, tiers[2]!)).toBeNull()
|
||||
const storageTiers = tiers.filter((item) => item.feeType === 'STORAGE')
|
||||
expect(storageTiers).toHaveLength(4)
|
||||
expect(storageTiers.slice(1, 3).map(({ startDay, endDay }) => ({ startDay, endDay })))
|
||||
.toEqual([{ startDay: 8, endDay: 60 }, { startDay: 61, endDay: 90 }])
|
||||
expect(validateFeeTiers(tiers)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not delete the only tier of a fee type', () => {
|
||||
const tiers = [tier('s1', 'STORAGE', 1, null), tier('r1', 'RETURN_STORAGE', 1, null)]
|
||||
expect(removeFeeTier(tiers, tiers[0]!)).toBe('每种费用至少需要保留1档')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { OverseasFeeType, OverseasStorageFeeTier } from './overseasStorageFee'
|
||||
|
||||
let temporaryTierId = 0
|
||||
|
||||
export function tiersByType(tiers: OverseasStorageFeeTier[], feeType: OverseasFeeType) {
|
||||
return tiers.filter((tier) => tier.feeType === feeType)
|
||||
}
|
||||
|
||||
export function addFeeTier(tiers: OverseasStorageFeeTier[], feeType: OverseasFeeType) {
|
||||
if (tiers.length >= 100) return '阶梯费用明细最多为100组'
|
||||
const group = tiersByType(tiers, feeType)
|
||||
const last = group.at(-1)
|
||||
if (!last) return '每种费用至少需要保留1档'
|
||||
if (last.startDay >= 36500) return '当前尾档已从第36500天开始,无法继续新增'
|
||||
|
||||
const startDay = last.startDay + 1
|
||||
last.endDay = startDay - 1
|
||||
const newTier: OverseasStorageFeeTier = {
|
||||
...last,
|
||||
id: `new-${feeType}-${++temporaryTierId}`,
|
||||
startDay,
|
||||
endDay: null,
|
||||
sortNo: last.sortNo + 1,
|
||||
}
|
||||
const lastIndex = tiers.indexOf(last)
|
||||
tiers.splice(lastIndex + 1, 0, newTier)
|
||||
return null
|
||||
}
|
||||
|
||||
export function removeFeeTier(tiers: OverseasStorageFeeTier[], tier: OverseasStorageFeeTier) {
|
||||
const group = tiersByType(tiers, tier.feeType)
|
||||
if (group.length <= 1) return '每种费用至少需要保留1档'
|
||||
|
||||
const groupIndex = group.indexOf(tier)
|
||||
const previous = group[groupIndex - 1]
|
||||
const next = group[groupIndex + 1]
|
||||
if (previous) previous.endDay = tier.endDay
|
||||
else if (next) next.startDay = 1
|
||||
tiers.splice(tiers.indexOf(tier), 1)
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateFeeTiers(tiers: OverseasStorageFeeTier[]) {
|
||||
if (tiers.length < 2 || tiers.length > 100) return '仓租和退货仓储费必须各保留至少1档,合计不超过100档'
|
||||
for (const [feeType, label] of [['STORAGE', '仓租'], ['RETURN_STORAGE', '退货仓储费']] as const) {
|
||||
const group = tiersByType(tiers, feeType)
|
||||
if (group.length === 0) return `${label}至少需要保留1档`
|
||||
let expected = 1
|
||||
for (const [index, tier] of group.entries()) {
|
||||
if (!Number.isInteger(tier.startDay) || tier.startDay !== expected || tier.startDay < 1) return `${label}第${index + 1}档天数必须连续且从第1天开始`
|
||||
if (!Number.isFinite(tier.unitPrice) || tier.unitPrice < 0) return `${label}第${index + 1}档价格不能小于0`
|
||||
if (index === group.length - 1) {
|
||||
if (tier.endDay !== null) return `${label}最后一档必须为“以上”`
|
||||
} else if (!Number.isInteger(tier.endDay) || tier.endDay! < tier.startDay) {
|
||||
return `${label}第${index + 1}档结束天数无效`
|
||||
}
|
||||
if (tier.endDay !== null) expected = tier.endDay + 1
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -108,7 +108,7 @@ describe('CustomerDetailView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.textContent?.trim())).toEqual([
|
||||
'基本信息', '业务概况', '运单记录', '财务账单', '操作日志', '计费规则',
|
||||
'基本信息', '业务概况', '运单记录', '计费规则',
|
||||
])
|
||||
expect(document.body.textContent).toContain('企业用户1人')
|
||||
expect(document.body.textContent).toContain('1 人正常使用')
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowLeft, BarChart3, Box, Building2, CircleDollarSign, ClipboardList, Eye,
|
||||
FileCheck2, FileText, History, Pencil, Power, ReceiptText,
|
||||
ArrowLeft, BarChart3, Box, Building2, ClipboardList, Eye,
|
||||
FileCheck2, FileText, Pencil, Power,
|
||||
Scale, Ship, UserRound, UsersRound,
|
||||
} from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -38,7 +38,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
|
||||
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
|
||||
|
||||
type DetailTab = 'basic' | 'business' | 'orders' | 'finance' | 'logs' | 'billing'
|
||||
type DetailTab = 'basic' | 'business' | 'orders' | 'billing'
|
||||
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
|
||||
|
||||
const route = useRoute()
|
||||
@@ -74,8 +74,6 @@ const tabs: Array<{ key: DetailTab; label: string; icon: typeof ClipboardList }>
|
||||
{ key: 'basic', label: '基本信息', icon: ClipboardList },
|
||||
{ key: 'business', label: '业务概况', icon: BarChart3 },
|
||||
{ key: 'orders', label: '运单记录', icon: Box },
|
||||
{ key: 'finance', label: '财务账单', icon: CircleDollarSign },
|
||||
{ key: 'logs', label: '操作日志', icon: History },
|
||||
{ key: 'billing', label: '计费规则', icon: Scale },
|
||||
]
|
||||
|
||||
@@ -385,7 +383,7 @@ watch(orderPage, (page, previousPage) => {
|
||||
</dl>
|
||||
</section>
|
||||
<section class="detail-panel user-panel">
|
||||
<div class="panel-title"><div><h2>企业用户</h2><p>当前客户租户下的登录账号</p></div><UsersRound :size="19" /></div>
|
||||
<div class="panel-title"><div><h2>企业用户</h2><p>当前客户的登录账号</p></div><UsersRound :size="19" /></div>
|
||||
<div v-if="enterpriseMembers.length" class="user-table-wrap">
|
||||
<table><thead><tr><th>账号</th><th>成员类型</th><th>联系方式</th><th>角色</th><th>状态</th></tr></thead>
|
||||
<tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)">
|
||||
@@ -427,8 +425,6 @@ watch(orderPage, (page, previousPage) => {
|
||||
<div v-else class="module-empty"><Ship :size="30" /><strong>暂无运单查看权限</strong><p>请联系管理员申请头程订单查看权限。</p></div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'finance'" class="detail-panel module-panel"><div class="panel-title"><div><h2>财务账单</h2><p>客户应收、回款与对账记录</p></div><ReceiptText :size="19" /></div><div class="module-empty"><ReceiptText :size="30" /><strong>财务账单尚未接入</strong><p>当前阶段没有可用的客户账单 API,页面不会使用原型中的演示金额。</p></div></section>
|
||||
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><div class="panel-title"><div><h2>操作日志</h2><p>客户档案与状态变更记录</p></div><History :size="19" /></div><div class="module-empty"><History :size="30" /><strong>操作日志尚未接入</strong><p>等待审计服务提供按租户查询的只读接口后展示真实操作记录。</p></div></section>
|
||||
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||
</div>
|
||||
</DetailPageLayout>
|
||||
@@ -455,7 +451,7 @@ watch(orderPage, (page, previousPage) => {
|
||||
.overview-strip p,.overview-strip span { margin:0; color:var(--cc-muted); font-size:12px; }
|
||||
.overview-strip strong { display:block; margin:5px 0 4px; color:#172033; font-size:26px; line-height:1; }
|
||||
.overview-strip small { margin-left:5px; color:#475467; font-size:12px; font-weight:600; }
|
||||
.detail-tabs { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); overflow-x:auto; border-radius:14px 14px 0 0; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.06); }
|
||||
.detail-tabs { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); overflow-x:auto; border-radius:14px 14px 0 0; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.06); }
|
||||
.detail-tabs button { position:relative; display:flex; min-width:128px; min-height:64px; align-items:center; justify-content:center; gap:8px; padding:0 14px; border:0; background:transparent; color:#667085; font-weight:650; cursor:pointer; }
|
||||
.detail-tabs button::after { position:absolute; right:0; bottom:0; left:0; height:3px; background:transparent; content:""; }
|
||||
.detail-tabs button:hover { background:#fafbff; color:#344054; }.detail-tabs button.active { background:#f2f3ff; color:var(--cc-primary); }.detail-tabs button.active::after { background:var(--cc-primary); }
|
||||
@@ -484,7 +480,7 @@ table { width:100%; min-width:720px; border-collapse:collapse; font-size:12px; }
|
||||
.business-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); overflow:hidden; border:1px solid var(--cc-border); border-radius:12px; }.business-summary > div { display:grid; min-height:130px; place-items:center; align-content:center; gap:8px; border-right:1px solid var(--cc-border); }.business-summary > div:last-child { border-right:0; }.business-summary svg { color:var(--cc-primary); }.business-summary span { color:var(--cc-muted); font-size:11px; }.business-summary strong { color:#25324a; font-size:20px; }
|
||||
.contract-note { margin-top:18px; padding:16px 18px; border-radius:10px; background:#f8fafc; }.contract-note strong { color:#344054; font-size:12px; }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; line-height:1.7; }
|
||||
.qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:#f8fafc; }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:#fff; }
|
||||
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.detail-tabs { grid-template-columns:repeat(6,150px); }.qualification-panel { order:3; }.user-panel { order:4; } }
|
||||
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.detail-tabs { grid-template-columns:repeat(4,150px); }.qualification-panel { order:3; }.user-panel { order:4; } }
|
||||
@media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.overview-strip { grid-template-columns:1fr; }.overview-strip article { border-right:0; border-bottom:1px solid var(--cc-border); }.overview-strip article:last-child { border-bottom:0; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; }.business-summary { grid-template-columns:1fr; }.business-summary > div { min-height:100px; border-right:0; border-bottom:1px solid var(--cc-border); }.business-summary > div:last-child { border-bottom:0; } }
|
||||
@media (max-width:420px) { .company-mark { display:none; }.header-actions { grid-template-columns:1fr; }.header-actions :deep(.el-tag) { grid-column:auto; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } }
|
||||
</style>
|
||||
|
||||
@@ -120,7 +120,7 @@ const qualificationPreviewKind = ref<'image' | 'pdf' | ''>('')
|
||||
|
||||
const filters = ref<CustomerFilters>({ keyword: '', status: '' })
|
||||
const queryFields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '客户关键词', placeholder: '公司名称、客户ID或联系人' },
|
||||
{ key: 'keyword', type: 'input', label: '客户关键词', placeholder: '公司名称、客户编号或联系人' },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
|
||||
{ label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' },
|
||||
{ label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' },
|
||||
@@ -188,7 +188,7 @@ const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
|
||||
|
||||
const customerColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', minWidth: 280, slotName: 'actions' },
|
||||
{ key: 'tenantCode', prop: 'tenantCode', label: '客户ID', minWidth: 170 },
|
||||
{ key: 'tenantCode', prop: 'tenantCode', label: '客户编号', minWidth: 170 },
|
||||
{ key: 'company', label: '公司名称', minWidth: 230, slotName: 'company' },
|
||||
{ key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', minWidth: 205, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
|
||||
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) },
|
||||
@@ -1062,7 +1062,7 @@ onMounted(() => { void loadCustomers() })
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
|
||||
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户ID:{{ passwordTenant.tenantCode }}</span></div>
|
||||
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
|
||||
<el-alert v-if="passwordError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ passwordError.message }}</span><ApiErrorMeta :details="passwordError" /></div></template></el-alert>
|
||||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top">
|
||||
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
|
||||
@@ -1074,7 +1074,7 @@ onMounted(() => { void loadCustomers() })
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="impersonationDialogOpen" title="跳转登录客户工作台" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting">
|
||||
<div v-if="impersonationTenant" class="decision-summary"><div><strong>{{ impersonationTenant.tenantName }}</strong><span>将以企业管理员身份打开客户工作台</span></div><span>客户ID:{{ impersonationTenant.tenantCode }}</span></div>
|
||||
<div v-if="impersonationTenant" class="decision-summary"><div><strong>{{ impersonationTenant.tenantName }}</strong><span>将以企业管理员身份打开客户工作台</span></div><span>客户编号:{{ impersonationTenant.tenantCode }}</span></div>
|
||||
<el-alert type="warning" :closable="false" show-icon>本操作会生成一次性、短时有效的客户会话,并记录代登录原因;不会替换当前 Admin 会话。</el-alert>
|
||||
<el-alert v-if="impersonationError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ impersonationError.message }}</span><ApiErrorMeta :details="impersonationError" /></div></template></el-alert>
|
||||
<el-form ref="impersonationFormRef" :model="impersonationForm" :rules="impersonationRules" label-position="top"><el-form-item label="代登录原因" prop="reason" required><el-input v-model="impersonationForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写 5-500 字的业务原因" /></el-form-item></el-form>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Pencil } from '@lucide/vue'
|
||||
import { Pencil, Plus, Trash2 } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
@@ -11,6 +11,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listOverseasStorageFees, updateCustomerOverseasStorageFees, type OverseasStorageFeeConfig, type OverseasStorageFeeTier } from '../overseasStorageFee'
|
||||
import { addFeeTier, removeFeeTier, validateFeeTiers } from '../overseasStorageFeeTiers'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
@@ -27,12 +28,11 @@ const form = reactive({ currency: 'USD', version: 0, tiers: [] as OverseasStorag
|
||||
|
||||
const storageTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'STORAGE'))
|
||||
const returnTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE'))
|
||||
const filters = reactive({ keyword: '' })
|
||||
const filters = ref({ keyword: '' })
|
||||
const queryFields = [{ key: 'keyword', label: '客户', type: 'input' as const, placeholder: '搜索客户名称' }]
|
||||
const columns = [
|
||||
{ key: 'actions', slotName: 'actions', label: '操作', width: 100, fixed: 'left' as const },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 180 },
|
||||
{ key: 'customerId', prop: 'customerId', label: '客户ID', minWidth: 180 },
|
||||
{ key: 'currency', prop: 'currency', label: '币种', width: 90 },
|
||||
{ key: 'storage', slotName: 'storage', label: '仓租(CBM/天)', minWidth: 300 },
|
||||
{ key: 'returnStorage', slotName: 'returnStorage', label: '退货仓储费(托/天)', minWidth: 300 },
|
||||
@@ -47,10 +47,14 @@ async function load() {
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '海外仓阶梯费用加载失败,请稍后重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function search() { keyword.value = filters.keyword; page.value = 1; void load() }
|
||||
function search() { keyword.value = filters.value.keyword; page.value = 1; void load() }
|
||||
function openEdit(row: OverseasStorageFeeConfig) { editing.value = row; form.currency = row.currency || 'USD'; form.version = row.version; form.tiers = clone(row.tiers); dialogOpen.value = true }
|
||||
function addTier(feeType: OverseasStorageFeeTier['feeType']) { const message = addFeeTier(form.tiers, feeType); if (message) ElMessage.error(message) }
|
||||
function removeTier(tier: OverseasStorageFeeTier) { const message = removeFeeTier(form.tiers, tier); if (message) ElMessage.error(message) }
|
||||
async function save() {
|
||||
if (!editing.value || storageTiers.value.length !== 5 || returnTiers.value.length !== 4) { ElMessage.error('费用配置必须包含仓租5档和退货仓储费4档'); return }
|
||||
if (!editing.value) return
|
||||
const validationMessage = validateFeeTiers(form.tiers)
|
||||
if (validationMessage) { ElMessage.error(validationMessage); return }
|
||||
saving.value = true
|
||||
try { await updateCustomerOverseasStorageFees(editing.value.customerId, { customerId: editing.value.customerId, customerName: editing.value.customerName, currency: form.currency, version: form.version, tiers: form.tiers.map(({ feeType, billingUnit, startDay, endDay, unitPrice }) => ({ feeType, billingUnit, startDay, endDay, unitPrice })) }); dialogOpen.value = false; ElMessage.success('海外仓费用已保存'); await load() }
|
||||
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '保存失败,请刷新后重试').message) }
|
||||
@@ -75,7 +79,7 @@ onMounted(() => { void load() })
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
||||
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }}</h3><el-table :data="group" size="small"><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :controls="false" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :controls="false" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" /></template></el-table-column></el-table></div></div></el-form>
|
||||
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><div class="group-heading"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(index === 0 ? 'STORAGE' : 'RETURN_STORAGE')"><Plus :size="15" />新增阶梯</el-button></div><el-table :data="group" size="small"><el-table-column label="操作" width="88" fixed="left"><template #default="{ row }"><el-button link type="danger" :disabled="group.length <= 1" @click="removeTier(row as OverseasStorageFeeTier)"><Trash2 :size="15" />删除</el-button></template></el-table-column><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :max="36500" :controls="false" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" /></template></el-table-column></el-table></div></div></el-form>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</AdminPage>
|
||||
@@ -87,7 +91,14 @@ onMounted(() => { void load() })
|
||||
.tier-item { display:flex; align-items:center; justify-content:space-between; gap:6px; min-height:24px; padding:2px 6px; border-left:2px solid #409eff; background:#f6f9fd; }
|
||||
.tier-item span { color:var(--cc-muted); font-size:12px; white-space:nowrap; }
|
||||
.tier-item strong { color:var(--cc-text); font-size:12px; font-weight:650; white-space:nowrap; }
|
||||
.groups { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
|
||||
.groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:20px; }
|
||||
.groups > div { min-width:0; }
|
||||
.groups :deep(.el-table) { width:100%; }
|
||||
.groups :deep(.el-input-number) { width:100%; min-width:0; }
|
||||
.groups h3 { margin:0 0 10px; font-size:15px; }
|
||||
@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .groups,.tier-list { grid-template-columns:1fr; } }
|
||||
.group-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:10px; }
|
||||
.group-heading h3 { min-width:0; margin:0; }
|
||||
.group-heading :deep(.el-button) { flex:0 0 auto; }
|
||||
@media (max-width:900px) { .groups { grid-template-columns:1fr; } }
|
||||
@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .tier-list { grid-template-columns:1fr; } .groups > div { overflow-x:auto; } .groups :deep(.el-table) { min-width:560px; } }
|
||||
</style>
|
||||
|
||||
@@ -2,4 +2,23 @@ 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 detail, history and agent submission', () => { expect(source).toContain('getCustomsCase'); expect(source).toContain('submitCustomsCase'); expect(source).toContain('detail.history') })
|
||||
it('localizes backend statuses and timeline actions throughout the page', () => {
|
||||
expect(source).toContain('customsCaseStatusOptions')
|
||||
expect(source).toContain(':status="customsStatusLabel(row.status)"')
|
||||
expect(source).toContain(':status="customsStatusLabel(detail.customsCase.status)"')
|
||||
expect(source).toContain('customsHistoryActionLabel(entry.action)')
|
||||
expect(source).toContain('customsStatusLabel(entry.fromStatus)')
|
||||
expect(source).toContain("detail.value?.customsCase.status === 'READY_TO_SUBMIT'")
|
||||
expect(source).not.toContain("['DRAFT', 'COLLECTING', 'READY', 'SUBMITTED', 'RELEASED', 'REJECTED']")
|
||||
})
|
||||
it('does not expose internal id inputs while resource query APIs are unavailable', () => {
|
||||
expect(source).not.toContain('createCustomsCase')
|
||||
expect(source).not.toContain('新增报关')
|
||||
expect(source).not.toContain('资料清单版本 ID')
|
||||
expect(source).not.toContain("key: 'orderId', type: 'input'")
|
||||
expect(source).not.toContain("key: 'inboundId', type: 'input'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, Plus, Send } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Eye, Send } from '@lucide/vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -10,25 +10,22 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { customsCaseStatusOptions, customsHistoryActionLabel, customsStatusLabel } from '../status'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createCustomsCase, getCustomsCase, listCustomsCases, submitCustomsCase } from '../api/platformCustoms'
|
||||
import type { CustomsCaseCreateRequest, CustomsCaseDetail, CustomsCaseResource } from '../types'
|
||||
import { getCustomsCase, listCustomsCases, submitCustomsCase } from '../api/platformCustoms'
|
||||
import type { CustomsCaseDetail, CustomsCaseResource } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore(); const rows = ref<CustomsCaseResource[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const filters = reactive<QueryModel>({ orderId: '', inboundId: '', status: '' }); const fields: QueryField[] = [{ key: 'orderId', type: 'input', label: '订单 ID', width: 170 }, { key: 'inboundId', type: 'input', label: '入库单 ID', width: 170 }, { key: 'status', type: 'select', label: '状态', options: ['DRAFT', 'COLLECTING', 'READY', 'SUBMITTED', 'RELEASED', 'REJECTED'].map((value) => ({ label: value, value })) }]
|
||||
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { prop: 'orderId', label: '订单 ID', minWidth: 155 }, { prop: 'inboundId', label: '入库单 ID', minWidth: 155 }, { prop: 'checklistCode', label: '资料清单', minWidth: 125 }, { prop: 'checklistVersionNo', label: '版本', width: 90 }, { prop: 'status', label: '状态', width: 110, slotName: 'status' }, { prop: 'updatedAt', label: '更新时间', minWidth: 160 }]
|
||||
async function load() { loading.value = true; error.value = null; try { const result = await listCustomsCases({ page: page.value, size: pageSize.value, orderId: String(filters.orderId || '') || undefined, inboundId: String(filters.inboundId || '') || undefined, status: String(filters.status || '') || undefined }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
|
||||
const filters = ref<QueryModel>({ status: '' }); const fields: QueryField[] = [{ key: 'status', type: 'select', label: '状态', options: customsCaseStatusOptions.map((option) => ({ ...option })) }]
|
||||
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 175, slotName: 'actions', hideable: false, movable: false }, { prop: 'customsNo', label: '报关编号', minWidth: 145 }, { prop: 'orderId', label: '订单标识', minWidth: 155 }, { prop: 'inboundId', label: '入库单标识', 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, status: String(filters.value.status || '') || undefined }); rows.value = result.records; total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
|
||||
function search() { if (page.value === 1) void load(); else page.value = 1 }
|
||||
const createOpen = ref(false); const createSaving = ref(false); const createError = ref<ApiErrorDetails | null>(null); const formRef = ref<FormInstance>(); const createForm = reactive<CustomsCaseCreateRequest>({ customerId: '', orderId: '', inboundId: '', containerBookingId: null, checklistVersionId: '', remark: '', expectedVersion: 0 }); const rules: FormRules = { customerId: [{ required: true, message: '请输入客户 ID', trigger: 'blur' }], orderId: [{ required: true, message: '请输入订单 ID', trigger: 'blur' }], inboundId: [{ required: true, message: '请输入入库单 ID', trigger: 'blur' }], checklistVersionId: [{ required: true, message: '请输入资料清单版本 ID', trigger: 'blur' }] }
|
||||
function openCreate() { Object.assign(createForm, { customerId: '', orderId: '', inboundId: '', containerBookingId: null, checklistVersionId: '', remark: '', expectedVersion: 0 }); createError.value = null; createOpen.value = true }
|
||||
async function create() { if (!await formRef.value?.validate().catch(() => false)) return; createSaving.value = true; createError.value = null; try { await createCustomsCase({ ...createForm }); ElMessage.success('报关协同已创建'); createOpen.value = false; await load() } catch (cause) { createError.value = getApiErrorDetails(cause) } finally { createSaving.value = false } }
|
||||
const detailOpen = ref(false); const detail = ref<CustomsCaseDetail | null>(null); const detailLoading = ref(false); const actionError = ref<ApiErrorDetails | null>(null); const submitForm = reactive({ customsAgentName: '', externalReference: '', note: '' }); const canSubmit = computed(() => detail.value && access.can('platform:firstleg:customs:submit') && !['SUBMITTED', 'RELEASED'].includes(detail.value.customsCase.status))
|
||||
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?.customsCase.status === 'READY_TO_SUBMIT' && access.can('platform:firstleg:customs:submit'))
|
||||
async function openDetail(id: string) { detailOpen.value = true; detailLoading.value = true; actionError.value = null; try { detail.value = await getCustomsCase(id) } catch (cause) { actionError.value = getApiErrorDetails(cause) } finally { detailLoading.value = false } }
|
||||
async function submit() { if (!detail.value || !submitForm.customsAgentName.trim()) return; actionError.value = null; try { await submitCustomsCase(detail.value.customsCase.id, { ...submitForm, expectedVersion: detail.value.customsCase.version }); ElMessage.success('已登记线下提交批次'); detail.value = await getCustomsCase(detail.value.customsCase.id) } catch (cause) { actionError.value = getApiErrorDetails(cause); if (actionError.value.code === 'A0409') detail.value = await getCustomsCase(detail.value.customsCase.id) } }
|
||||
watch([page, pageSize], () => void load()); onMounted(load)
|
||||
</script>
|
||||
<template><AdminPage as="div"><ListPageLayout aria-label="报关协同列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="报关协同筛选" :fields="fields" @search="search" /></template><template v-if="error" #state><LoadErrorState title="报关协同加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" preference-key="customs-cases" empty-text="暂无报关协同记录"><template #toolbar-actions><el-button v-if="access.can('platform:firstleg:customs:manage')" type="primary" @click="openCreate"><Plus :size="16" />新增报关</el-button></template><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:customs:view')" link type="primary" @click="openDetail(row.id)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="row.status" /></template></DataTable></section></ListPageLayout>
|
||||
<AppDialog v-model="createOpen" title="新增报关协同" width="720px" :confirm-loading="createSaving" @confirm="create"><el-alert v-if="createError" type="error" :closable="false"><ApiErrorMeta :details="createError" /></el-alert><el-form ref="formRef" :model="createForm" :rules="rules" label-position="top"><el-row :gutter="16"><el-col :xs="24" :sm="12"><el-form-item label="客户 ID" prop="customerId"><el-input v-model="createForm.customerId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="订单 ID" prop="orderId"><el-input v-model="createForm.orderId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="入库单 ID" prop="inboundId"><el-input v-model="createForm.inboundId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="柜次 ID"><el-input v-model="createForm.containerBookingId" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="资料清单版本 ID" prop="checklistVersionId"><el-input v-model="createForm.checklistVersionId" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" /></el-form-item></el-col></el-row></el-form></AppDialog>
|
||||
<AppDialog v-model="detailOpen" title="报关协同详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError && !detail" title="详情加载失败" :message="actionError.message" :loading="detailLoading" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-if="detail"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-descriptions :column="2" border><el-descriptions-item label="报关编号">{{ detail.customsCase.customsNo }}</el-descriptions-item><el-descriptions-item label="状态"><StatusBadge :status="detail.customsCase.status" /></el-descriptions-item><el-descriptions-item label="资料清单">{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}</el-descriptions-item><el-descriptions-item label="资料/回执">{{ detail.documents.length }} / {{ detail.receipts.length }}</el-descriptions-item></el-descriptions><el-form v-if="canSubmit" label-position="top" class="submit-form"><el-row :gutter="12"><el-col :xs="24" :sm="12"><el-form-item label="报关代理"><el-input v-model="submitForm.customsAgentName" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" /></el-form-item></el-col></el-row><el-button type="primary" @click="submit"><Send :size="16" />登记线下提交</el-button></el-form><h3>状态历史</h3><el-timeline><el-timeline-item v-for="entry in detail.history" :key="entry.id" :timestamp="entry.createdAt">{{ entry.action }}:{{ entry.fromStatus || '—' }} → {{ entry.toStatus || '—' }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
|
||||
<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 #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="customsStatusLabel(row.status)" /></template></DataTable></section></ListPageLayout>
|
||||
<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="customsStatusLabel(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">{{ customsHistoryActionLabel(entry.action) }}:{{ customsStatusLabel(entry.fromStatus) }} → {{ customsStatusLabel(entry.toStatus) }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
|
||||
<style scoped>.submit-form{margin-top:18px}.submit-form+.el-button{margin-bottom:12px}h3{margin:20px 0 12px}small{display:block;color:var(--cc-muted)}</style>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { customsCaseStatusOptions, customsHistoryActionLabel, customsStatusLabel } from './status'
|
||||
|
||||
describe('customs status labels', () => {
|
||||
it('matches the backend customs case state machine', () => {
|
||||
expect(customsCaseStatusOptions).toEqual([
|
||||
{ value: 'DOCUMENTS_PENDING', label: '待补资料' },
|
||||
{ value: 'REVIEWING', label: '审核中' },
|
||||
{ value: 'SUPPLEMENT_REQUIRED', label: '需补件' },
|
||||
{ value: 'READY_TO_SUBMIT', label: '待提交代理' },
|
||||
{ value: 'RECEIPT_PENDING', label: '待回执' },
|
||||
{ value: 'RECEIPT_AVAILABLE', label: '已有回执' },
|
||||
{ value: 'CLEARANCE_COMPLETED', label: '已放行' },
|
||||
{ value: 'CANCELLED', label: '已取消' },
|
||||
])
|
||||
})
|
||||
|
||||
it('localizes status transitions and does not expose backend codes', () => {
|
||||
expect(customsStatusLabel('READY_TO_SUBMIT')).toBe('待提交代理')
|
||||
expect(customsStatusLabel(null)).toBe('—')
|
||||
expect(customsStatusLabel('FUTURE_STATUS')).toBe('未知状态')
|
||||
expect(customsHistoryActionLabel('REVIEW_DOCUMENT_RETURN')).toBe('退回补件')
|
||||
expect(customsHistoryActionLabel('SUBMIT_AGENT_BATCH')).toBe('提交报关代理')
|
||||
expect(customsHistoryActionLabel('FUTURE_ACTION')).toBe('状态更新')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
export const customsCaseStatusOptions = [
|
||||
{ value: 'DOCUMENTS_PENDING', label: '待补资料' },
|
||||
{ value: 'REVIEWING', label: '审核中' },
|
||||
{ value: 'SUPPLEMENT_REQUIRED', label: '需补件' },
|
||||
{ value: 'READY_TO_SUBMIT', label: '待提交代理' },
|
||||
{ value: 'RECEIPT_PENDING', label: '待回执' },
|
||||
{ value: 'RECEIPT_AVAILABLE', label: '已有回执' },
|
||||
{ value: 'CLEARANCE_COMPLETED', label: '已放行' },
|
||||
{ value: 'CANCELLED', label: '已取消' },
|
||||
] as const
|
||||
|
||||
const statusLabels = Object.fromEntries(customsCaseStatusOptions.map((option) => [option.value, option.label])) as Record<string, string>
|
||||
|
||||
const historyActionLabels: Record<string, string> = {
|
||||
CREATE: '创建报关协同',
|
||||
CREATE_CASE: '创建报关协同',
|
||||
UPLOAD_DOCUMENT: '上传报关资料',
|
||||
REPLACE_DOCUMENT: '替换报关资料',
|
||||
REVIEW_DOCUMENT_APPROVE: '资料审核通过',
|
||||
REVIEW_DOCUMENT_RETURN: '退回补件',
|
||||
SUBMIT_AGENT_BATCH: '提交报关代理',
|
||||
UPLOAD_RECEIPT: '上传报关回执',
|
||||
CANCEL: '取消报关协同',
|
||||
CANCEL_CASE: '取消报关协同',
|
||||
}
|
||||
|
||||
export function customsStatusLabel(status: string | null | undefined) {
|
||||
if (!status) return '—'
|
||||
return statusLabels[status] || '未知状态'
|
||||
}
|
||||
|
||||
export function customsHistoryActionLabel(action: string | null | undefined) {
|
||||
if (!action) return '状态更新'
|
||||
return historyActionLabels[action] || '状态更新'
|
||||
}
|
||||
@@ -1,48 +1,107 @@
|
||||
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, getFinanceFeeSource, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, 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 the formal G1 account list path and fixed page DTO', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } })
|
||||
|
||||
const result = await listFinanceAccounts({ page: 2, size: 20, keyword: '客户A', status: 'ACTIVE' })
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/accounts', { params: { page: 2, pageSize: 20, keyword: '客户A', status: 'ACTIVE' } })
|
||||
expect(result).toEqual({ records: [{ customerId: '10001', customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], total: 21, pages: 2 })
|
||||
})
|
||||
|
||||
it('uses the formal G1 account ledger path and fixed page DTO', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款' }], page: { page: 1, pageSize: 50, total: 1 } })
|
||||
|
||||
const result = await listFinanceAccountLedger('10001', { page: 1, size: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' })
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/accounts/10001/ledger', { params: { page: 1, pageSize: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' } })
|
||||
expect(result.records[0]).toEqual({ ledgerId: '81', occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: '91', documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款' })
|
||||
})
|
||||
|
||||
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('derives the numeric fee source id without treating manual costs as reviewable sources', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [
|
||||
{ poolItemId: 'RECEIVABLE:42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, feeItemName: '运费', amount: 100, status: 'AVAILABLE' },
|
||||
{ poolItemId: 'PAYABLE:43', direction: 'PAYABLE', sourceType: 'MANUAL_COST', sourceId: 43, sourceVersion: 1, feeItemName: '拖车费', amount: 200, status: 'POSTED' },
|
||||
] })
|
||||
|
||||
const result = await listFinanceFeePool('10001')
|
||||
|
||||
expect(result.map((item) => item.feeSourceId)).toEqual(['42', null])
|
||||
})
|
||||
|
||||
it('uses the formal G2 detail, review and settlement-inclusion contracts', async () => {
|
||||
const detail = { feeSourceId: 42, sourceType: 'INBOUND_FEE', sourceFeeId: 81, sourceVersion: 3, direction: 'RECEIVABLE', customerId: 10001, customerName: '客户A', inboundId: 91, inboundCode: 'IB-91', businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 4, operationLogs: [{ occurredAt: '2026-08-11T12:00:00', action: 'FEE_SOURCE_REVIEW_APPROVE', operatorName: '财务A', result: 'SUCCESS' }] }
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g2', traceId: 'trace-g2', data: detail })
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
const result = await getFinanceFeeSource('42')
|
||||
await reviewFinanceFeeSource('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 })
|
||||
await updateFinanceFeeSourceSettlementInclusion('42', { included: true, reason: '本期结算', expectedVersion: 4 })
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42')
|
||||
expect(post).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42/review', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
expect(put).toHaveBeenCalledWith('/platform/finance/v1/fee-sources/42/settlement-inclusion', { included: true, reason: '本期结算', expectedVersion: 4 }, { headers: { 'Idempotency-Key': expect.any(String) } })
|
||||
expect(result).toMatchObject({ feeSourceId: '42', sourceId: '81', customerId: '10001', inboundId: '91', reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4, 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 +115,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 +159,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) } })
|
||||
|
||||
@@ -1,49 +1,173 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceCostResource, FinancePaymentMethod, FinanceReceiptResource, FinanceVoucherResource } from '../types'
|
||||
import type { FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinanceFeeSourceDetail, FinanceLedgerEntry, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceSettlementDetail, FinanceStatementReadModel, FinanceVoucherResource } from '../types'
|
||||
|
||||
const basePath = '/platform/finance/v1'
|
||||
export const financePhase1Capabilities = {
|
||||
accountList: true,
|
||||
accountLedger: true,
|
||||
feeSourceDetail: true,
|
||||
feeSourceReview: true,
|
||||
feeSourceSettlementInclusion: true,
|
||||
reconciliationCreate: false,
|
||||
reconciliationActions: false,
|
||||
receivableSettlementCreate: false,
|
||||
payableSettlementCreate: false,
|
||||
settlementDetail: false,
|
||||
settlementPayment: false,
|
||||
} as const
|
||||
function idempotencyKey(operation: string) { return `finance-${operation}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
type RawResource = Record<string, unknown>
|
||||
interface PageResult<T> { records: T[]; total: number; serverPaged: boolean }
|
||||
type RawPage = RawResource[] | { records?: RawResource[]; total?: number }
|
||||
export class FinanceCapabilityBlockedError extends Error {
|
||||
readonly code = 'FINANCE_NOT_IMPLEMENTED'
|
||||
constructor(operation: string) { super(`接口尚未提供,暂不可操作:${operation}`); this.name = 'FinanceCapabilityBlockedError' }
|
||||
}
|
||||
|
||||
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) => {
|
||||
const poolItemId = stringValue(item.poolItemId)
|
||||
const feeSourceId = stringValue(item.sourceType) === 'MANUAL_COST' ? null : (poolItemId.match(/^[A-Z_]+:(\d+)$/)?.[1] ?? null)
|
||||
return { poolItemId, feeSourceId, 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 listFinanceAccounts(params: { page?: number; size?: number; keyword?: string; status?: string } = {}): Promise<{ records: FinanceAccountSummary[]; total: number; pages: number }> {
|
||||
const query = { page: params.page ?? 1, pageSize: params.size ?? 20, keyword: params.keyword || undefined, status: params.status || undefined }
|
||||
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/accounts`, { params: query })
|
||||
const data = unwrapApiResponse(response)
|
||||
const records = data.map((item) => ({ customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName), contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable), pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status), lastChangedAt: stringValue(item.lastChangedAt) }))
|
||||
const total = numberValue(response.page?.total ?? records.length)
|
||||
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
|
||||
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
|
||||
}
|
||||
|
||||
export async function listFinanceAccountLedger(customerId: string, params: { page?: number; size?: number; businessType?: string; startDate?: string; endDate?: string } = {}): Promise<{ records: FinanceLedgerEntry[]; total: number; pages: number }> {
|
||||
const query = { page: params.page ?? 1, pageSize: params.size ?? 20, businessType: params.businessType || undefined, startDate: params.startDate || undefined, endDate: params.endDate || undefined }
|
||||
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/accounts/${customerId}/ledger`, { params: query })
|
||||
const data = unwrapApiResponse(response)
|
||||
const records = data.map((item) => ({ ledgerId: stringValue(item.ledgerId), occurredAt: stringValue(item.occurredAt), businessType: stringValue(item.businessType), documentType: stringValue(item.documentType), documentId: stringValue(item.documentId), documentNo: stringValue(item.documentNo), receivableChange: numberValue(item.receivableChange), payableChange: numberValue(item.payableChange), outstandingReceivableAfter: numberValue(item.outstandingReceivableAfter), operatorName: stringValue(item.operatorName), remark: stringValue(item.remark) }))
|
||||
const total = numberValue(response.page?.total ?? records.length)
|
||||
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
|
||||
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
|
||||
}
|
||||
|
||||
export async function getFinanceFeeSource(feeSourceId: string): Promise<FinanceFeeSourceDetail> {
|
||||
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}`)
|
||||
return normalizeFeeSourceDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
function normalizeFeeSourceDetail(item: RawResource): FinanceFeeSourceDetail {
|
||||
const nullableString = (value: unknown) => value == null ? null : stringValue(value)
|
||||
const nullableNumber = (value: unknown) => value == null ? null : numberValue(value)
|
||||
return {
|
||||
poolItemId: stringValue(item.feeSourceId), feeSourceId: stringValue(item.feeSourceId),
|
||||
sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceFeeId), sourceFeeId: stringValue(item.sourceFeeId),
|
||||
sourceVersion: numberValue(item.sourceVersion), direction: stringValue(item.direction) as FinanceFeeSourceDetail['direction'],
|
||||
customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), supplierId: nullableString(item.supplierId), supplierName: stringValue(item.supplierName),
|
||||
orderId: nullableString(item.orderId), inboundId: nullableString(item.inboundId), inboundCode: stringValue(item.inboundCode),
|
||||
containerId: nullableString(item.containerId), containerNo: stringValue(item.containerNo), businessDate: stringValue(item.businessDate),
|
||||
feeItemName: stringValue(item.feeItemName), quantity: nullableNumber(item.quantity), unitPrice: nullableNumber(item.unitPrice), amount: numberValue(item.amount), currency: 'CNY',
|
||||
reviewStatus: stringValue(item.reviewStatus) as FinanceFeeSourceDetail['reviewStatus'], settlementIncluded: Boolean(item.settlementIncluded),
|
||||
sourceStatus: stringValue(item.sourceStatus), status: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated),
|
||||
reviewReason: stringValue(item.reviewReason) || undefined, settlementReason: stringValue(item.settlementReason) || undefined,
|
||||
sourceHash: stringValue(item.sourceHash) || undefined, operationVersion: numberValue(item.operationVersion), createdAt: stringValue(item.businessDate),
|
||||
operationLogs: (Array.isArray(item.operationLogs) ? item.operationLogs : []).map((entry) => { const row = entry as RawResource; return { occurredAt: stringValue(row.occurredAt), action: stringValue(row.action), operatorName: stringValue(row.operatorName), result: stringValue(row.result), remark: stringValue(row.remark) || undefined } }),
|
||||
}
|
||||
}
|
||||
|
||||
export async function reviewFinanceFeeSource(feeSourceId: string, payload: { action: 'APPROVE' | 'REJECT'; reason?: string; expectedVersion: number }): Promise<FinanceFeeSourceDetail> {
|
||||
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}/review`, payload, mutationHeaders('fee-source-review'))
|
||||
return normalizeFeeSourceDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function updateFinanceFeeSourceSettlementInclusion(feeSourceId: string, payload: { included: boolean; reason: string; expectedVersion: number }): Promise<FinanceFeeSourceDetail> {
|
||||
const response = await http.put<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}/settlement-inclusion`, payload, mutationHeaders('fee-source-settlement'))
|
||||
return normalizeFeeSourceDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function createReconciliationStatement(payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('createReconciliationStatement') }
|
||||
export async function actionReconciliationStatement(statementId: string, payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('actionReconciliationStatement') }
|
||||
export async function createReceivableSettlement(payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('createReceivableSettlement') }
|
||||
export async function createPayableSettlement(payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('createPayableSettlement') }
|
||||
export async function getFinanceSettlement(settlementId: string): Promise<FinanceSettlementDetail> { throw new FinanceCapabilityBlockedError('getSettlement') }
|
||||
export async function createFinanceSettlementPayment(settlementId: string, payload: Record<string, unknown>) { throw new FinanceCapabilityBlockedError('createSettlementPayment') }
|
||||
|
||||
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 +180,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 +210,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 +229,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,83 @@
|
||||
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')
|
||||
})
|
||||
|
||||
it('connects G1 customer accounts to the formal list and ledger operations', () => {
|
||||
const page = source('src/modules/finance/pages/FinanceCustomerAccountsView.vue')
|
||||
expect(api).toContain('`${basePath}/accounts`')
|
||||
expect(api).toContain('`${basePath}/accounts/${customerId}/ledger`')
|
||||
expect(page).toContain('listFinanceAccounts')
|
||||
expect(page).toContain('listFinanceAccountLedger')
|
||||
expect(page).not.toContain('getFinanceCustomerAccount')
|
||||
expect(page).not.toContain('接口尚未提供')
|
||||
expect(page.indexOf("key: 'actions'")).toBeLessThan(page.indexOf("key: 'customerName'"))
|
||||
for (const state of ['客户账户加载失败', '暂无客户账户', '无权访问客户账户', '账户流水加载失败']) expect(page).toContain(state)
|
||||
})
|
||||
|
||||
it('connects G2 fee sources to real detail, review and settlement operations', () => {
|
||||
const page = source('src/modules/finance/pages/FinanceCustomerArapView.vue')
|
||||
for (const marker of [
|
||||
'`${basePath}/fee-sources/${feeSourceId}`',
|
||||
'`${basePath}/fee-sources/${feeSourceId}/review`',
|
||||
'`${basePath}/fee-sources/${feeSourceId}/settlement-inclusion`',
|
||||
]) expect(api).toContain(marker)
|
||||
for (const operation of ['getFinanceFeeSource', 'reviewFinanceFeeSource', 'updateFinanceFeeSourceSettlementInclusion']) expect(page).toContain(operation)
|
||||
for (const permission of ['platform:finance:fee-source:view', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement']) expect(page).toContain(permission)
|
||||
expect(page).toContain('expectedVersion: detail.value.operationVersion')
|
||||
const columns = page.slice(page.indexOf('const columns:'))
|
||||
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("key: 'direction'"))
|
||||
expect(page).toContain('<ApiErrorMeta :details="detailError"')
|
||||
expect(page).toContain('<ApiErrorMeta :details="reviewError"')
|
||||
expect(page).toContain('<ApiErrorMeta :details="inclusionError"')
|
||||
expect(page).not.toContain('接口尚未提供')
|
||||
expect(page).not.toContain('disabled title=')
|
||||
})
|
||||
})
|
||||
|
||||
function basePathMarker(endpoint: string) {
|
||||
return '${basePath}/' + endpoint
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
<script setup lang="ts">import FinanceManualPage from './FinanceManualPage.vue'</script>
|
||||
<template><FinanceManualPage mode="bills" /></template>
|
||||
@@ -1,2 +0,0 @@
|
||||
<script setup lang="ts">import FinanceManualPage from './FinanceManualPage.vue'</script>
|
||||
<template><FinanceManualPage mode="costs" /></template>
|
||||
@@ -0,0 +1,132 @@
|
||||
<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 { 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 { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
|
||||
import { financeBusinessTypeLabel, financeDocumentTypeLabel } from '../status'
|
||||
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const filters = ref<QueryModel>({ keyword: '', status: '' })
|
||||
const rows = ref<FinanceAccountSummary[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const drawerOpen = ref(false)
|
||||
const editing = ref(false)
|
||||
const saving = ref(false)
|
||||
const selected = ref<FinanceAccountSummary | null>(null)
|
||||
const ledger = ref<FinanceLedgerEntry[]>([])
|
||||
const ledgerLoading = ref(false)
|
||||
const ledgerError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
|
||||
const creditDays = ref(30)
|
||||
|
||||
const canView = computed(() => access.can('platform:finance:account:view'))
|
||||
const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view'))
|
||||
const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update'))
|
||||
const queryFields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 260, placeholder: '客户编号/客户名称/联系人/手机号', props: { 'aria-label': '关键词' } },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }] },
|
||||
]
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180 },
|
||||
{ key: 'customerCode', label: '客户编号', width: 150 },
|
||||
{ key: 'contactName', label: '联系人', width: 120 },
|
||||
{ key: 'contactPhone', label: '联系电话', width: 140 },
|
||||
{ key: 'outstandingReceivable', label: '待收(CNY)', width: 130 },
|
||||
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
|
||||
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
|
||||
{ key: 'outstandingPayable', label: '待付(CNY)', width: 130 },
|
||||
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
|
||||
{ key: 'lastChangedAt', label: '最近变动', width: 170 },
|
||||
]
|
||||
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
if (!canView.value) return
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await listFinanceAccounts({ page: page.value, size: pageSize.value, keyword: String(filters.value.keyword || '').trim(), status: String(filters.value.status || '') })
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '客户账户加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
async function openAccount(row: Record<string, unknown>, edit: boolean) {
|
||||
selected.value = row as unknown as FinanceAccountSummary
|
||||
editing.value = edit
|
||||
drawerOpen.value = true
|
||||
ledger.value = []
|
||||
ledgerError.value = null
|
||||
selectedTerms.value = null
|
||||
if (edit) {
|
||||
if (!canViewTerms.value) return
|
||||
try { selectedTerms.value = await getFinanceCustomerTerms(selected.value.customerId); creditDays.value = selectedTerms.value.creditDays }
|
||||
catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期加载失败,请重试') }
|
||||
return
|
||||
}
|
||||
ledgerLoading.value = true
|
||||
try {
|
||||
const result = await listFinanceAccountLedger(selected.value.customerId, { page: 1, size: 200 })
|
||||
ledger.value = result.records
|
||||
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户流水加载失败,请重试') }
|
||||
finally { ledgerLoading.value = false }
|
||||
}
|
||||
async function retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, editing.value) }
|
||||
async function saveTerms() {
|
||||
if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return }
|
||||
saving.value = true
|
||||
ledgerError.value = null
|
||||
try { selectedTerms.value = await updateFinanceCustomerTerms(selected.value.customerId, { customerName: selected.value.customerName, creditDays: creditDays.value }); ElMessage.success('客户账期已保存') }
|
||||
catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期保存失败,请重试'); ElMessage.error(ledgerError.value.message) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
function money(value: unknown) { return Number(value || 0).toFixed(2) }
|
||||
onMounted(() => void load())
|
||||
</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="search" /></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="客户账户加载失败" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<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="canUpdateTerms" 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(760px, 94vw)" :loading="ledgerLoading" :confirm-loading="saving">
|
||||
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
|
||||
<template v-else-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.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
|
||||
<FormSection v-if="!editing" title="账户流水" description="展示应收、应付及每次变动后的未收金额。"><div v-for="entry in ledger" :key="entry.ledgerId" class="ledger-entry"><div><strong>{{ entry.documentNo || `单据 ${entry.documentId}` }}</strong><span>{{ financeBusinessTypeLabel(entry.businessType) }} · {{ financeDocumentTypeLabel(entry.documentType) }}</span></div><span>{{ entry.occurredAt }}</span><div class="ledger-money"><b>应收 {{ money(entry.receivableChange) }}</b><b>应付 {{ money(entry.payableChange) }}</b><span>变动后未收 {{ money(entry.outstandingReceivableAfter) }} CNY</span></div><small>{{ entry.operatorName }}{{ entry.remark ? ` · ${entry.remark}` : '' }}</small></div><el-empty v-if="!ledger.length" description="暂无账户流水" /></FormSection>
|
||||
<FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><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 && canUpdateTerms" 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)}.ledger-entry{display:grid;grid-template-columns:minmax(160px,1fr) auto minmax(210px,auto);gap:8px 16px;padding:12px 0;border-bottom:1px solid var(--el-border-color-lighter)}.ledger-entry>div:first-child,.ledger-money{display:grid;gap:3px}.ledger-entry span,.ledger-entry small{color:var(--cc-muted)}.ledger-entry small{grid-column:1/-1}.ledger-money{text-align:right}.ledger-money b{white-space:nowrap}@media(max-width:720px){.drawer-footer .el-button{min-height:44px}.ledger-entry{grid-template-columns:1fr 1fr}.ledger-money{grid-column:1/-1;text-align:left}:deep(.account-detail .el-descriptions__body .el-descriptions__table){table-layout:fixed}}@media(max-width:480px){.drawer-footer .el-button{flex:1 1 120px}.ledger-entry{grid-template-columns:1fr}:deep(.account-detail .el-descriptions__cell){display:block;width:100%}}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import type { FinanceFeeSourceDetail } from '../types'
|
||||
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
|
||||
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
listFinanceFeePool: vi.fn(),
|
||||
getFinanceFeeSource: vi.fn(),
|
||||
reviewFinanceFeeSource: vi.fn(),
|
||||
updateFinanceFeeSourceSettlementInclusion: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
const detail: FinanceFeeSourceDetail = {
|
||||
poolItemId: '42', feeSourceId: '42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceFeeId: '81', sourceVersion: 3,
|
||||
customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '', orderId: null, inboundId: '91', inboundCode: 'IB-91', containerId: null, containerNo: '',
|
||||
businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING', settlementIncluded: false,
|
||||
sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: false, operationVersion: 3, createdAt: '2026-08-11', operationLogs: [],
|
||||
}
|
||||
|
||||
async function mountView(permissions: string[]) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(FinanceCustomerArapView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function loadRows(wrapper: Awaited<ReturnType<typeof mountView>>) {
|
||||
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void> }
|
||||
await vm.load({ customerId: '10001', direction: '', status: '', keyword: '' })
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('FinanceCustomerArapView G2 operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
document.body.innerHTML = ''
|
||||
const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listFinanceFeePool).mockReset().mockResolvedValue([{ poolItemId: 'RECEIVABLE:42', feeSourceId: '42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 3, orderId: null, inboundId: '91', containerId: null, feeItemName: '运费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' }])
|
||||
vi.mocked(getFinanceFeeSource).mockReset().mockResolvedValue(detail)
|
||||
vi.mocked(reviewFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', operationVersion: 4 })
|
||||
vi.mocked(updateFinanceFeeSourceSettlementInclusion).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4 })
|
||||
})
|
||||
|
||||
it('does not expose or call G2 operations without their permissions', async () => {
|
||||
const wrapper = await mountView(['platform:finance:bill:view'])
|
||||
await loadRows(wrapper)
|
||||
|
||||
const buttonLabels = wrapper.findAll('button').map((button) => button.text())
|
||||
expect(buttonLabels).not.toContain('详情')
|
||||
expect(buttonLabels).not.toContain('审核')
|
||||
expect(buttonLabels).not.toContain('结算')
|
||||
expect(getFinanceFeeSource).not.toHaveBeenCalled()
|
||||
expect(reviewFinanceFeeSource).not.toHaveBeenCalled()
|
||||
expect(updateFinanceFeeSourceSettlementInclusion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads detail and submits review and settlement changes with the returned version', async () => {
|
||||
const wrapper = await mountView(['platform:finance:bill:view', 'platform:finance:fee-source:view', 'platform:finance:fee-source:review', 'platform:finance:fee-source:settlement'])
|
||||
await loadRows(wrapper)
|
||||
const vm = wrapper.vm as unknown as {
|
||||
openDetail(row: Record<string, unknown>): Promise<void>
|
||||
openReview(row: Record<string, unknown>): Promise<void>
|
||||
submitReview(): Promise<void>
|
||||
openInclusion(row: Record<string, unknown>): Promise<void>
|
||||
submitInclusion(): Promise<void>
|
||||
reviewForm: { action: 'APPROVE' | 'REJECT'; reason: string }
|
||||
inclusionForm: { included: boolean; reason: string }
|
||||
}
|
||||
const row = { id: 'RECEIVABLE:42', feeSourceId: '42' }
|
||||
|
||||
await vm.openDetail(row); await flushPromises()
|
||||
expect(getFinanceFeeSource).toHaveBeenCalledWith('42')
|
||||
expect(document.body.textContent).toContain('IB-91')
|
||||
expect(document.body.textContent).toContain('100.00 CNY')
|
||||
|
||||
await vm.openReview(row); vm.reviewForm.action = 'APPROVE'; vm.reviewForm.reason = '核对无误'; await vm.submitReview()
|
||||
expect(reviewFinanceFeeSource).toHaveBeenCalledWith('42', { action: 'APPROVE', reason: '核对无误', expectedVersion: 3 })
|
||||
|
||||
await vm.openInclusion(row); vm.inclusionForm.included = true; vm.inclusionForm.reason = '本期结算'; await vm.submitInclusion()
|
||||
expect(updateFinanceFeeSourceSettlementInclusion).toHaveBeenCalledWith('42', { included: true, reason: '本期结算', expectedVersion: 3 })
|
||||
})
|
||||
|
||||
it('renders request and trace ids when detail loading fails', async () => {
|
||||
vi.mocked(getFinanceFeeSource).mockRejectedValueOnce(new ApiResponseError('费用来源不可用', 'FIN-FEE-404', 404, undefined, 'req-g2-error', 'trace-g2-error'))
|
||||
const wrapper = await mountView(['platform:finance:bill:view', 'platform:finance:fee-source:view'])
|
||||
await loadRows(wrapper)
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: Record<string, unknown>): Promise<void> }
|
||||
|
||||
await vm.openDetail({ id: 'RECEIVABLE:42', feeSourceId: '42' }); await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('费用来源不可用')
|
||||
expect(document.body.textContent).toContain('req-g2-error')
|
||||
expect(document.body.textContent).toContain('trace-g2-error')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Eye, FileCheck2, Link2 } 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 AppDialog from '@/shared/components/AppDialog.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 { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
|
||||
import type { FinanceFeeSourceDetail } from '../types'
|
||||
|
||||
interface ArapRow {
|
||||
id: string
|
||||
feeSourceId: string | null
|
||||
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 = ref<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 detail = ref<FinanceFeeSourceDetail | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const reviewOpen = ref(false)
|
||||
const reviewSaving = ref(false)
|
||||
const reviewError = ref<ApiErrorDetails | null>(null)
|
||||
const reviewForm = reactive<{ action: 'APPROVE' | 'REJECT'; reason: string }>({ action: 'APPROVE', reason: '' })
|
||||
const inclusionOpen = ref(false)
|
||||
const inclusionSaving = ref(false)
|
||||
const inclusionError = ref<ApiErrorDetails | null>(null)
|
||||
const inclusionForm = reactive({ included: true, reason: '' })
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const canView = computed(() => access.can('platform:finance:bill:view'))
|
||||
const canViewSource = computed(() => access.can('platform:finance:fee-source:view'))
|
||||
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
|
||||
const canSettleSource = computed(() => access.can('platform:finance:fee-source:settlement'))
|
||||
const stateError = computed(() => customersError.value || loadError.value)
|
||||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户', width: 260, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customersLoading.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '全部', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: financeArapStatusOptions },
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '单号/费用/来源ID', props: { 'aria-label': '关键词' } },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 260, 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.value.keyword || '').trim().toLowerCase(); const direction = String(filters.value.direction || ''); const status = String(filters.value.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.value) {
|
||||
Object.assign(filters.value, model); page.value = 1; allRows.value = []; loadError.value = null
|
||||
const customerId = String(filters.value.customerId || ''); if (!customerId) return
|
||||
const customerName = customers.value.find((item) => String(item.id) === customerId)?.tenantName || `客户 ${customerId}`
|
||||
loading.value = true
|
||||
try {
|
||||
const direction = String(filters.value.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
|
||||
const items = await listFinanceFeePool(customerId, direction || undefined)
|
||||
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, 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 }
|
||||
}
|
||||
async function loadDetail(row: ArapRow) {
|
||||
detailLoading.value = true; detailError.value = null; detail.value = null
|
||||
if (!row.feeSourceId) { detailError.value = { message: '该手工成本没有可审核的费用来源详情' }; detailLoading.value = false; return null }
|
||||
try { detail.value = await getFinanceFeeSource(row.feeSourceId); return detail.value }
|
||||
catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试'); return null }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
async function openDetail(row: Record<string, unknown>) {
|
||||
selected.value = row as unknown as ArapRow; drawerOpen.value = true
|
||||
await loadDetail(selected.value)
|
||||
}
|
||||
async function openReview(row: Record<string, unknown>) {
|
||||
const target = row as unknown as ArapRow; selected.value = target
|
||||
const loaded = await loadDetail(target)
|
||||
if (!loaded) { drawerOpen.value = true; return }
|
||||
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; reviewOpen.value = true
|
||||
}
|
||||
async function submitReview() {
|
||||
if (!detail.value) return
|
||||
if (reviewForm.action === 'REJECT' && !reviewForm.reason.trim()) { ElMessage.warning('请填写驳回原因'); return }
|
||||
reviewSaving.value = true; reviewError.value = null
|
||||
try {
|
||||
detail.value = await reviewFinanceFeeSource(detail.value.feeSourceId, { action: reviewForm.action, reason: reviewForm.reason.trim() || undefined, expectedVersion: detail.value.operationVersion })
|
||||
reviewOpen.value = false; ElMessage.success(reviewForm.action === 'APPROVE' ? '费用来源已审核通过' : '费用来源已驳回'); await load()
|
||||
} catch (error) { reviewError.value = getApiErrorDetails(error, '费用来源审核失败,请重试') }
|
||||
finally { reviewSaving.value = false }
|
||||
}
|
||||
async function openInclusion(row: Record<string, unknown>) {
|
||||
const target = row as unknown as ArapRow; selected.value = target
|
||||
const loaded = await loadDetail(target)
|
||||
if (!loaded) { drawerOpen.value = true; return }
|
||||
Object.assign(inclusionForm, { included: !loaded.settlementIncluded, reason: '' }); inclusionError.value = null; inclusionOpen.value = true
|
||||
}
|
||||
async function submitInclusion() {
|
||||
if (!detail.value) return
|
||||
if (!inclusionForm.reason.trim()) { ElMessage.warning('请填写调整原因'); return }
|
||||
inclusionSaving.value = true; inclusionError.value = null
|
||||
try {
|
||||
detail.value = await updateFinanceFeeSourceSettlementInclusion(detail.value.feeSourceId, { included: inclusionForm.included, reason: inclusionForm.reason.trim(), expectedVersion: detail.value.operationVersion })
|
||||
inclusionOpen.value = false; ElMessage.success(inclusionForm.included ? '已纳入结算' : '已排除结算'); await load()
|
||||
} catch (error) { inclusionError.value = getApiErrorDetails(error, '参与结算状态更新失败,请重试') }
|
||||
finally { inclusionSaving.value = false }
|
||||
}
|
||||
function statusLabel(status: string) { return financeStatusLabel(status) }
|
||||
function money(value: unknown) { return Number(value || 0).toFixed(2) }
|
||||
|
||||
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 }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" link type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" @click="openInclusion(row)"><Link2 :size="15" />结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
|
||||
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</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(760px, 94vw)" :loading="detailLoading">
|
||||
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<el-descriptions :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName || selected?.customerName }}</el-descriptions-item><el-descriptions-item label="来源状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ financeSourceTypeLabel(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="来源版本">v{{ detail.sourceVersion }}</el-descriptions-item><el-descriptions-item label="入库单">{{ detail.inboundCode || detail.inboundId || '-' }}</el-descriptions-item><el-descriptions-item label="柜次">{{ detail.containerNo || detail.containerId || '-' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
|
||||
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="log.occurredAt">{{ financeOperationActionLabel(log.action) }} · {{ log.operatorName }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
|
||||
</template>
|
||||
<template #footer><el-button @click="drawerOpen = false">关闭</el-button></template>
|
||||
</AppDrawer>
|
||||
<AppDialog v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<el-alert v-if="reviewError" type="error" :closable="false"><ApiErrorMeta :details="reviewError" /></el-alert>
|
||||
<el-form label-position="top"><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="inclusionOpen" title="参与结算设置" confirm-text="保存设置" :confirm-loading="inclusionSaving" @confirm="submitInclusion">
|
||||
<el-alert v-if="inclusionError" type="error" :closable="false"><ApiErrorMeta :details="inclusionError" /></el-alert>
|
||||
<el-form label-position="top"><el-form-item label="结算状态"><el-switch v-model="inclusionForm.included" active-text="参与结算" inactive-text="排除结算" /></el-form-item><el-form-item label="调整原因"><el-input v-model="inclusionForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请说明参与或排除结算的原因" show-word-limit /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.finance-arap-page { min-width: 0; }
|
||||
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.row-actions .el-button { margin-left: 0; }
|
||||
.muted-action { color: var(--cc-muted); }
|
||||
.arap-detail { margin-bottom: 16px; }
|
||||
.operation-log h3 { margin: 0 0 14px; font-size: 15px; }
|
||||
.operation-log small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
||||
@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>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, 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 { getFinanceBill, listFinanceStatements } from '../api/finance'
|
||||
import { financeStatusLabel, financeStatusTone } from '../status'
|
||||
import type { FinanceBillDetail, FinanceStatementReadModel } from '../types'
|
||||
const access = useAccessPreviewStore(); const customers = ref<PlatformTenantResource[]>([]); const filters = ref<QueryModel>({ customerId: '' }); const rows = ref<FinanceStatementReadModel[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const customerError = ref<ApiErrorDetails | null>(null); const drawerOpen = ref(false); const detail = ref<FinanceBillDetail | null>(null); const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const canView = computed(() => access.can('platform:finance:bill:view')); const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) }]); const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'billNo', label: '对账单号', minWidth: 180 }, { key: 'customerName', label: '客户', minWidth: 160 }, { key: 'billDate', label: '账单日期', width: 130 }, { key: 'dueDate', label: '到期日', width: 130 }, { key: 'totalAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
|
||||
async function loadCustomers() { customerError.value = null; try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } }
|
||||
async function load(model: QueryModel = filters.value) { filters.value = { ...model }; rows.value = []; error.value = null; if (!filters.value.customerId) return; loading.value = true; try { rows.value = await listFinanceStatements(String(filters.value.customerId)) } catch (e) { error.value = getApiErrorDetails(e, '客户对账单加载失败,请重试') } finally { loading.value = false } }
|
||||
async function openDetail(row: FinanceStatementReadModel) { drawerOpen.value = true; detail.value = null; detailError.value = null; try { detail.value = await getFinanceBill(row.id) } catch (e) { detailError.value = getApiErrorDetails(e, '对账单详情加载失败,请重试') } }
|
||||
onMounted(() => void loadCustomers())
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-statements-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="fields" preference-key="finance-statements-query" @search="load" /></template><template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template><section v-if="!customerError && !error" class="surface-panel table-panel"><DataTable :data="rows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="请选择客户查询对账单"><template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template><template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template><template #pagination-left>币种:CNY</template></DataTable></section></ListPageLayout><AppDrawer v-model="drawerOpen" title="客户对账单详情" size="min(760px, 94vw)"><LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="false"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item></el-descriptions></template></AppDrawer></AdminPage>
|
||||
</template>
|
||||
<style scoped>.finance-statements-page{min-width:0}</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, 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 { listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance'
|
||||
import { financeStatusLabel, financeStatusTone } from '../status'
|
||||
import type { FinancePayableSettlement, FinanceReceivableSettlement } from '../types'
|
||||
|
||||
const props = defineProps<{ kind: 'receivable' | 'payable' }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const filters = ref<QueryModel>({ customerId: '' })
|
||||
const rows = ref<Array<FinanceReceivableSettlement | FinancePayableSettlement>>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const customerError = ref<ApiErrorDetails | null>(null)
|
||||
const drawerOpen = ref(false)
|
||||
const selected = ref<FinanceReceivableSettlement | FinancePayableSettlement | null>(null)
|
||||
const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:view' : 'platform:finance:settlement-ap:view')
|
||||
const canView = computed(() => access.can(viewPermission.value))
|
||||
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
|
||||
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '请选择客户', props: { filterable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
|
||||
const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
|
||||
? [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
|
||||
: [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }])
|
||||
async function loadCustomers() { customerError.value = null; try { const result = await listPlatformTenants({ page: 1, size: 200 }); customers.value = result.records } catch (e) { customerError.value = getApiErrorDetails(e, '客户列表加载失败') } }
|
||||
async function load(model: QueryModel = filters.value) { filters.value = { ...model }; rows.value = []; error.value = null; if (!filters.value.customerId) return; loading.value = true; try { rows.value = props.kind === 'receivable' ? await listFinanceReceivableSettlements(String(filters.value.customerId)) : await listFinancePayableSettlements(String(filters.value.customerId)) } catch (e) { error.value = getApiErrorDetails(e, `${title.value}加载失败,请重试`) } finally { loading.value = false } }
|
||||
function openDetail(row: FinanceReceivableSettlement | FinancePayableSettlement) { selected.value = row; drawerOpen.value = true }
|
||||
function totalAmount(row: FinanceReceivableSettlement | FinancePayableSettlement) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
|
||||
function customerName(customerId: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户信息暂不可用' }
|
||||
onMounted(() => void loadCustomers())
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage as="div" class="finance-settlement-page" spacing="normal">
|
||||
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
|
||||
<ListPageLayout v-else :aria-label="title">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" preference-key="finance-settlement-query" @search="load" /></template>
|
||||
<template v-if="customerError || error" #state><LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
|
||||
<section v-if="!customerError && !error" class="surface-panel table-panel">
|
||||
<DataTable :data="rows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="请选择客户查询结算单">
|
||||
<template #actions="{ row }"><el-button link type="primary" @click="openDetail(row as unknown as FinanceReceivableSettlement | FinancePayableSettlement)"><Eye :size="15" />详情</el-button></template>
|
||||
<template #customer="{ row }">{{ customerName(String(row.customerId)) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
|
||||
<template #pagination-left>币种:CNY</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<AppDrawer v-model="drawerOpen" :title="`${title}详情`" size="min(680px, 94vw)">
|
||||
<el-descriptions v-if="selected" :column="2" border><el-descriptions-item label="结算单号">{{ selected.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(selected.customerId) }}</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '应收金额' : '应付金额'">{{ totalAmount(selected) }} CNY</el-descriptions-item><el-descriptions-item :label="props.kind === 'receivable' ? '未收金额' : '未付金额'">{{ selected.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selected.status) }}</el-descriptions-item></el-descriptions>
|
||||
</AppDrawer>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>.finance-settlement-page{min-width:0}@media(max-width:720px){:deep(.el-drawer__footer .el-button){min-height:44px}}</style>
|
||||
@@ -25,20 +25,68 @@ 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('renders list and detail statuses through the finance translation contract', () => {
|
||||
expect(source).toContain('financeBillStatusOptions')
|
||||
expect(source).toContain('financeStatusLabel(selectedBill.status)')
|
||||
expect(source).toContain('financeStatusLabel(revision.status)')
|
||||
expect(source).toContain('financeStatusLabel(selectedLedgerRow.status)')
|
||||
expect(source).not.toContain('{{ selectedBill.status }}')
|
||||
})
|
||||
|
||||
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('账单分配金额不能超过该账单的未收金额')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,27 @@ 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'
|
||||
import { financeBillStatusOptions, financeStatusLabel, financeStatusTone } from '../status'
|
||||
|
||||
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[]>([])
|
||||
@@ -27,24 +44,49 @@ const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const serverPaged = ref(false)
|
||||
const filters = reactive<QueryModel>({ keyword: '', customerId: '', status: '' })
|
||||
const filters = ref<QueryModel>({ keyword: '', customerId: '', status: '' })
|
||||
const drawerOpen = ref(false)
|
||||
const drawerMode = ref<DrawerMode>('create')
|
||||
const drawerLoading = ref(false)
|
||||
const drawerError = ref<ApiErrorDetails | null>(null)
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedBill = ref<FinanceBillDetail | null>(null)
|
||||
const 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,67 +95,264 @@ 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' } },
|
||||
...(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' }] }] : []),
|
||||
{ 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: financeBillStatusOptions }] : []),
|
||||
])
|
||||
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 }
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
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 || '')
|
||||
const keyword = String(filters.value.keyword || '').trim().toLowerCase()
|
||||
const status = String(filters.value.status || '')
|
||||
allRows.value = source.filter((row) => (!keyword || Object.values(row).some((value) => String(value ?? '').toLowerCase().includes(keyword))) && (!status || row.status === status))
|
||||
total.value = keyword || status ? allRows.value.length : responseTotal
|
||||
} catch (error) { allRows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, `${title.value}加载失败,请检查 Finance 接口后重试`) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function applyFilters(model: QueryModel) { Object.assign(filters, model); page.value = 1; void load() }
|
||||
function applyFilters(model: QueryModel) { Object.assign(filters.value, 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 +363,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 +404,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 +423,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 +461,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 +469,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 +485,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="financeStatusTone(row.status)">{{ financeStatusLabel(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>
|
||||
@@ -246,64 +499,81 @@ onMounted(() => void load())
|
||||
<template v-if="props.mode === 'bills' && drawerMode === 'detail' && selectedBill">
|
||||
<el-descriptions :column="2" border class="bill-summary">
|
||||
<el-descriptions-item label="账单编号">{{ selectedBill.billNo }}</el-descriptions-item><el-descriptions-item label="当前版本">v{{ selectedBill.version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户">{{ selectedBill.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ selectedBill.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户">{{ selectedBill.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(selectedBill.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账单日期">{{ selectedBill.billDate }}</el-descriptions-item><el-descriptions-item label="到期日">{{ selectedBill.dueDate }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账单金额">{{ selectedBill.totalAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收金额">{{ selectedBill.outstandingAmount }} CNY</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<FormSection title="费用明细" description="当前账单版本中的真实费用明细。">
|
||||
<div v-for="(line, index) in selectedBill.lines" :key="`${line.sourceFeeId}-${index}`" class="detail-line"><strong>{{ line.feeItemName }}</strong><span>来源ID:{{ line.sourceFeeId }}</span><b>{{ line.amount }} CNY</b></div>
|
||||
<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>状态:{{ financeStatusLabel(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="状态">{{ financeStatusLabel(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="状态">{{ financeStatusLabel(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,再创建人工财务台账。">
|
||||
<FormSection v-if="props.mode !== 'bills'" title="凭证图片" description="上传真实收付款凭证后再保存财务记录。">
|
||||
<label class="file-picker" :class="{ 'is-disabled': saving }"><FileUp :size="16" />{{ uploadedVoucher ? `已上传:${uploadedVoucher.fileName}` : file?.name || '选择凭证图片' }}<input type="file" accept="image/*" :disabled="saving" @change="selectFile" /></label>
|
||||
<p v-if="voucherError" class="voucher-error" role="alert">{{ voucherError }}</p>
|
||||
<p v-else-if="uploadedVoucher" class="voucher-success">凭证 fileId:{{ uploadedVoucher.fileId }},创建失败时可直接重试。</p>
|
||||
<p v-else-if="uploadedVoucher" class="voucher-success">凭证已上传,创建失败时可直接重试。</p>
|
||||
</FormSection>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'detail' ? '关闭' : '取消' }}</el-button><el-button v-if="drawerMode !== 'detail'" type="primary" :loading="saving" @click="submit"><Save :size="16" />{{ props.mode === 'bills' ? drawerMode === 'adjust' ? '保存调整' : '保存' : saving ? '上传并保存中' : '上传并保存' }}</el-button></div></template>
|
||||
@@ -323,13 +593,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>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const settlement = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8')
|
||||
const statements = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
|
||||
const arap = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
|
||||
const manual = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceManualPage.vue'), 'utf8')
|
||||
|
||||
describe('finance presentation governance', () => {
|
||||
it('shows customer names instead of internal customer IDs in formal settlements', () => {
|
||||
expect(settlement).toContain('customerName(selected.customerId)')
|
||||
expect(settlement).toContain('activeCustomers.value.map')
|
||||
expect(settlement).not.toContain('{{ selected.customerId }}')
|
||||
})
|
||||
|
||||
it('translates source codes and provides reason placeholders', () => {
|
||||
expect(arap).toContain('financeSourceTypeLabel(detail.sourceType)')
|
||||
expect(arap).toContain('financeOperationActionLabel(log.action)')
|
||||
expect(arap).toContain('financeOperationResultLabel(log.result)')
|
||||
expect(arap).toContain('financeDirectionLabel(row.direction)')
|
||||
expect(arap).toContain('financeDirectionLabel(detail.direction)')
|
||||
expect(arap).not.toContain("detail.direction === 'RECEIVABLE' ? '应收' : '应付'")
|
||||
expect(arap).toContain('请填写驳回原因')
|
||||
expect(arap).toContain('请说明参与或排除结算的原因')
|
||||
})
|
||||
|
||||
it('does not expose the uploaded voucher file ID in the form', () => {
|
||||
expect(manual).toContain('凭证已上传,创建失败时可直接重试')
|
||||
expect(manual).not.toContain('凭证 fileId:')
|
||||
})
|
||||
|
||||
it('hides unavailable generation controls instead of rendering disabled buttons', () => {
|
||||
expect(settlement).not.toContain('生成{{ title }}')
|
||||
expect(settlement).not.toContain('接口尚未提供,暂不可操作')
|
||||
expect(statements).not.toContain('生成对账单')
|
||||
expect(statements).not.toContain('disabled title="接口尚未提供')
|
||||
})
|
||||
})
|
||||
@@ -1,2 +0,0 @@
|
||||
<script setup lang="ts">import FinanceManualPage from './FinanceManualPage.vue'</script>
|
||||
<template><FinanceManualPage mode="receipts" /></template>
|
||||
@@ -0,0 +1,2 @@
|
||||
<script setup lang="ts">import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'</script>
|
||||
<template><FinanceFormalSettlementPage kind="payable" /></template>
|
||||
@@ -0,0 +1,2 @@
|
||||
<script setup lang="ts">import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'</script>
|
||||
<template><FinanceFormalSettlementPage kind="receivable" /></template>
|
||||
@@ -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[]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { financeBusinessTypeLabel, financeDirectionLabel, financeDocumentTypeLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from './status'
|
||||
|
||||
describe('finance status presentation', () => {
|
||||
it.each([
|
||||
['DRAFT', '草稿'],
|
||||
['PENDING_REVIEW', '待审核'],
|
||||
['ISSUED', '已出账'],
|
||||
['REJECTED', '已驳回'],
|
||||
['PARTIALLY_PAID', '部分结清'],
|
||||
['PAID', '已结清'],
|
||||
['VOIDED', '已作废'],
|
||||
['AVAILABLE', '待结算'],
|
||||
['LOCKED', '结算锁定'],
|
||||
['BILLED', '已入账'],
|
||||
['RECORDED', '已登记'],
|
||||
['CONFIRMED', '已确认'],
|
||||
['ACTIVE', '正常'],
|
||||
['DISABLED', '停用'],
|
||||
])('maps %s to %s', (status, label) => {
|
||||
expect(financeStatusLabel(status)).toBe(label)
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', 'NEW_BACKEND_STATUS', 1])('never exposes unknown status %s', (status) => {
|
||||
expect(financeStatusLabel(status)).toBe('未知状态')
|
||||
expect(financeStatusTone(status)).toBe('info')
|
||||
})
|
||||
|
||||
it('translates source and ledger codes without exposing unknown values', () => {
|
||||
expect(financeSourceTypeLabel('CONTAINER_COST')).toBe('整柜成本')
|
||||
expect(financeBusinessTypeLabel('RECEIPT')).toBe('收款')
|
||||
expect(financeDocumentTypeLabel('SETTLEMENT')).toBe('结算单')
|
||||
expect(financeOperationActionLabel('APPROVE')).toBe('审核通过')
|
||||
expect(financeOperationResultLabel('SUCCESS')).toBe('操作成功')
|
||||
expect(financeDirectionLabel('RECEIVABLE')).toBe('应收')
|
||||
expect(financeDirectionLabel('PAYABLE')).toBe('应付')
|
||||
expect(financeDirectionLabel('NEW_DIRECTION')).toBe('未知方向')
|
||||
expect(financeSourceTypeLabel('NEW_SOURCE')).toBe('其他来源')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
export type FinanceStatusTone = 'success' | 'warning' | 'info' | 'danger' | 'primary'
|
||||
|
||||
interface FinanceStatusDefinition {
|
||||
label: string
|
||||
tone: FinanceStatusTone
|
||||
}
|
||||
|
||||
const financeStatuses: Record<string, FinanceStatusDefinition> = {
|
||||
ACTIVE: { label: '正常', tone: 'success' },
|
||||
DISABLED: { label: '停用', tone: 'info' },
|
||||
INACTIVE: { label: '停用', tone: 'info' },
|
||||
DRAFT: { label: '草稿', tone: 'info' },
|
||||
PENDING: { label: '待审核', tone: 'warning' },
|
||||
PENDING_REVIEW: { label: '待审核', tone: 'warning' },
|
||||
APPROVED: { label: '已审核', tone: 'success' },
|
||||
ISSUED: { label: '已出账', tone: 'primary' },
|
||||
REJECTED: { label: '已驳回', tone: 'danger' },
|
||||
UNPAID: { label: '未支付', tone: 'warning' },
|
||||
PARTIALLY_PAID: { label: '部分结清', tone: 'warning' },
|
||||
PAID: { label: '已结清', tone: 'success' },
|
||||
VOIDED: { label: '已作废', tone: 'info' },
|
||||
AVAILABLE: { label: '待结算', tone: 'warning' },
|
||||
LOCKED: { label: '结算锁定', tone: 'warning' },
|
||||
BILLED: { label: '已入账', tone: 'success' },
|
||||
RECORDED: { label: '已登记', tone: 'primary' },
|
||||
CONFIRMED: { label: '已确认', tone: 'success' },
|
||||
}
|
||||
|
||||
export const financeBillStatusOptions = [
|
||||
{ label: '草稿', value: 'DRAFT' },
|
||||
{ label: '待审核', value: 'PENDING_REVIEW' },
|
||||
{ label: '已出账', value: 'ISSUED' },
|
||||
{ label: '已驳回', value: 'REJECTED' },
|
||||
{ label: '部分结清', value: 'PARTIALLY_PAID' },
|
||||
{ label: '已结清', value: 'PAID' },
|
||||
{ label: '已作废', value: 'VOIDED' },
|
||||
]
|
||||
|
||||
export const financeArapStatusOptions = [
|
||||
...financeBillStatusOptions,
|
||||
{ label: '待结算', value: 'AVAILABLE' },
|
||||
{ label: '结算锁定', value: 'LOCKED' },
|
||||
{ label: '已入账', value: 'BILLED' },
|
||||
{ label: '已登记', value: 'RECORDED' },
|
||||
{ label: '已确认', value: 'CONFIRMED' },
|
||||
]
|
||||
|
||||
function normalizeStatus(status: unknown) {
|
||||
return typeof status === 'string' ? status.trim().toUpperCase() : ''
|
||||
}
|
||||
|
||||
export function financeStatusLabel(status: unknown) {
|
||||
return financeStatuses[normalizeStatus(status)]?.label || '未知状态'
|
||||
}
|
||||
|
||||
export function financeStatusTone(status: unknown): FinanceStatusTone {
|
||||
return financeStatuses[normalizeStatus(status)]?.tone || 'info'
|
||||
}
|
||||
|
||||
const financeSourceTypeLabels: Record<string, string> = {
|
||||
INBOUND_FEE: '入库费用', ORDER_FEE: '订单费用', CONTAINER_COST: '整柜成本', MANUAL_COST: '人工成本', STORAGE_FEE: '仓储费用',
|
||||
}
|
||||
const financeBusinessTypeLabels: Record<string, string> = {
|
||||
BILL: '对账单', RECEIPT: '收款', COST: '成本', ADJUSTMENT: '账务调整', SETTLEMENT: '结算',
|
||||
}
|
||||
const financeDocumentTypeLabels: Record<string, string> = {
|
||||
BILL: '对账单', RECEIPT: '收款单', COST: '成本单', SETTLEMENT: '结算单', REVISION: '调整记录',
|
||||
}
|
||||
const financeOperationActionLabels: Record<string, string> = {
|
||||
CREATE: '创建', APPROVE: '审核通过', REJECT: '审核驳回', INCLUDE: '参与结算', EXCLUDE: '排除结算', UPDATE: '更新',
|
||||
}
|
||||
const financeOperationResultLabels: Record<string, string> = {
|
||||
SUCCESS: '操作成功', FAILED: '操作失败', PENDING: '待处理', PROCESSING: '处理中',
|
||||
}
|
||||
const financeDirectionLabels: Record<string, string> = {
|
||||
RECEIVABLE: '应收', PAYABLE: '应付',
|
||||
}
|
||||
|
||||
function codeLabel(value: unknown, labels: Record<string, string>, unknownLabel: string) {
|
||||
const code = normalizeStatus(value)
|
||||
if (!code) return '—'
|
||||
return labels[code] || unknownLabel
|
||||
}
|
||||
|
||||
export const financeSourceTypeLabel = (value: unknown) => codeLabel(value, financeSourceTypeLabels, '其他来源')
|
||||
export const financeBusinessTypeLabel = (value: unknown) => codeLabel(value, financeBusinessTypeLabels, '其他业务')
|
||||
export const financeDocumentTypeLabel = (value: unknown) => codeLabel(value, financeDocumentTypeLabels, '其他单据')
|
||||
export const financeOperationActionLabel = (value: unknown) => codeLabel(value, financeOperationActionLabels, '其他操作')
|
||||
export const financeOperationResultLabel = (value: unknown) => codeLabel(value, financeOperationResultLabels, '结果未知')
|
||||
export const financeDirectionLabel = (value: unknown) => codeLabel(value, financeDirectionLabels, '未知方向')
|
||||
@@ -1,6 +1,11 @@
|
||||
export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
|
||||
export type FinanceCostType = 'SINGLE_ORDER' | 'CONTAINER_SHARED'
|
||||
export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER'
|
||||
export type FinanceFeeDirection = 'RECEIVABLE' | 'PAYABLE'
|
||||
export type FinanceReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
export type FinanceReconciliationStatus = 'DRAFT' | 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'ISSUED' | 'VOIDED'
|
||||
export type FinanceSettlementStatus = 'ISSUED' | 'VOIDED'
|
||||
export type FinancePaymentStatus = 'UNPAID' | 'PARTIALLY_PAID' | 'PAID'
|
||||
|
||||
export interface FinanceVoucherResource {
|
||||
fileId: string
|
||||
@@ -10,6 +15,167 @@ 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
|
||||
feeSourceId: string | null
|
||||
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 FinanceAccountSummary {
|
||||
customerId: string
|
||||
customerCode: string
|
||||
customerName: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
outstandingReceivable: number
|
||||
pendingBilling: number
|
||||
receivedInPeriod: number
|
||||
outstandingPayable: number
|
||||
status: string
|
||||
lastChangedAt: string
|
||||
}
|
||||
|
||||
export interface FinanceLedgerEntry {
|
||||
ledgerId: string
|
||||
occurredAt: string
|
||||
businessType: string
|
||||
documentType: string
|
||||
documentId: string
|
||||
documentNo: string
|
||||
receivableChange: number
|
||||
payableChange: number
|
||||
outstandingReceivableAfter: number
|
||||
operatorName: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface FinanceFeeSourceDetail extends FinanceFeePoolItem {
|
||||
feeSourceId: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
supplierId: string | null
|
||||
supplierName: string
|
||||
sourceFeeId: string
|
||||
inboundCode: string
|
||||
containerNo: string
|
||||
businessDate: string
|
||||
quantity: number | null
|
||||
unitPrice: number | null
|
||||
reviewStatus: FinanceReviewStatus
|
||||
settlementIncluded: boolean
|
||||
sourceStatus: string
|
||||
statementGenerated: boolean
|
||||
reviewReason?: string
|
||||
settlementReason?: string
|
||||
sourceHash?: string
|
||||
operationLogs?: Array<{ occurredAt: string; action: string; operatorName: string; result: string; remark?: string }>
|
||||
operationVersion: number
|
||||
}
|
||||
|
||||
export interface FinanceSettlementDetail {
|
||||
id: string
|
||||
settlementNo: string
|
||||
customerId: string
|
||||
supplierId?: string
|
||||
direction: 'RECEIVABLE' | 'PAYABLE'
|
||||
amount: number
|
||||
paidAmount: number
|
||||
outstandingAmount: number
|
||||
currency: 'CNY'
|
||||
status: string
|
||||
version: number
|
||||
lines: FinanceFeeSourceDetail[]
|
||||
transactions: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; voucherFileIds: string[]; remark?: string }>
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; occurredAt: string }>
|
||||
}
|
||||
|
||||
export interface FinanceBillResource {
|
||||
id: string
|
||||
billNo: string
|
||||
@@ -39,6 +205,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
|
||||
|
||||
@@ -73,4 +73,10 @@ describe('PlatformFirstLegOrdersPanel review queue', () => {
|
||||
expect(source).toContain('approvePlatformFirstLegOrder(tenantId, String(order.id))')
|
||||
expect(source).not.toContain('loadedTenantId.value')
|
||||
})
|
||||
|
||||
it('does not expose unknown backend status or history action codes', () => {
|
||||
expect(source).toContain("?.label || '未知状态'")
|
||||
expect(source).toContain('item.reason || historyActionLabel(item.action)')
|
||||
expect(source).not.toContain('item.reason || item.action')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,7 +141,15 @@ const statusOptions: Array<{ value: PlatformFirstLegOrderStatus; label: string }
|
||||
]
|
||||
|
||||
function statusLabel(status: PlatformFirstLegOrderStatus) {
|
||||
return statusOptions.find((item) => item.value === status)?.label || status
|
||||
return statusOptions.find((item) => item.value === status)?.label || '未知状态'
|
||||
}
|
||||
|
||||
function historyActionLabel(action: string | null | undefined) {
|
||||
return ({
|
||||
CREATE: '创建订单', CREATED: '创建订单', SUBMIT: '提交订单', SUBMITTED: '提交订单',
|
||||
APPROVE: '审核通过', APPROVED: '审核通过', REJECT: '审核驳回', REJECTED: '审核驳回',
|
||||
CANCEL: '取消订单', CANCELLED: '取消订单', STATUS_UPDATE: '状态更新',
|
||||
} as Record<string, string>)[action || ''] || '状态已更新。'
|
||||
}
|
||||
|
||||
function transportLabel(order: PlatformFirstLegOrderResource) {
|
||||
@@ -535,7 +543,7 @@ onMounted(() => void loadTenants())
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>状态历史</h3>
|
||||
<el-timeline><el-timeline-item v-for="item in detail.statusHistory" :key="item.id" :timestamp="`${formattedDate(item.occurredAt)} · ${item.operatorName || '系统'}`" placement="top"><strong>{{ statusLabel(item.toStatus) }}</strong><p>{{ item.reason || item.action || '状态已更新。' }}</p></el-timeline-item></el-timeline>
|
||||
<el-timeline><el-timeline-item v-for="item in detail.statusHistory" :key="item.id" :timestamp="`${formattedDate(item.occurredAt)} · ${item.operatorName || '系统'}`" placement="top"><strong>{{ statusLabel(item.toStatus) }}</strong><p>{{ item.reason || historyActionLabel(item.action) }}</p></el-timeline-item></el-timeline>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else-if="!detailLoading" description="订单详情暂不可用"><el-button type="primary" @click="closeDetailPage"><ArrowLeft :size="16" />返回订单列表</el-button></el-empty>
|
||||
@@ -617,7 +625,6 @@ onMounted(() => void loadTenants())
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="expressDialogOpen" title="维护履约快递" width="min(520px, calc(100vw - 28px))">
|
||||
<el-alert title="当前后端契约只接收快递公司名称和快递单号;快递主数据关联待后端补充。" type="info" :closable="false" show-icon />
|
||||
<el-form ref="expressFormRef" :model="expressForm" :rules="expressRules" label-position="top" class="delivery-form"><el-form-item label="快递公司" prop="expressCompany" required><el-input v-model.trim="expressForm.expressCompany" maxlength="64" placeholder="例如 顺丰速运" /></el-form-item><el-form-item label="快递单号" prop="expressTrackingNo" required><el-input v-model.trim="expressForm.expressTrackingNo" maxlength="128" placeholder="请输入完整快递单号" /></el-form-item></el-form>
|
||||
<template #footer><el-button :disabled="expressSaving" @click="expressDialogOpen = false">取消</el-button><el-button type="primary" :loading="expressSaving" @click="saveExpress">保存快递信息</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { InboundMeasurementPanel } from '../measurement'
|
||||
import { WeightOcrPanel } from '../weight'
|
||||
import { MeasurementReviewPanel } from '../review'
|
||||
import { InboundFeePanel } from '../fee'
|
||||
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId: string
|
||||
@@ -35,20 +36,7 @@ let disposed = false
|
||||
|
||||
const title = computed(() => detail.value?.summary.inboundCode || props.inboundId)
|
||||
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||
const statusLabel = computed(() => {
|
||||
const status = detail.value?.summary.processStatus || ''
|
||||
return ({
|
||||
WAIT_MEASURE: '待测量',
|
||||
MEASURING: '测量中',
|
||||
MEASURE_PENDING: '待审核',
|
||||
MEASURE_REJECTED: '测量已驳回',
|
||||
WAIT_PRICING: '待计费',
|
||||
FEE_CONFIRMING: '费用确认中',
|
||||
INBOUNDING: '入库处理中',
|
||||
INBOUNDED: '已入库',
|
||||
VOIDED: '已作废',
|
||||
} as Record<string, string>)[status] || status || '未知状态'
|
||||
})
|
||||
const statusLabel = computed(() => inboundStatusLabel(detail.value?.summary.processStatus, inboundProcessStatusLabels, '未知状态'))
|
||||
|
||||
async function loadDetail() {
|
||||
const serial = ++requestSerial
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { InboundFileSummary, InboundInternalNote, InboundPalletLocation, InboundStatusHistory } from './types'
|
||||
import { inboundHistoryActionLabel, inboundProcessStatusLabels, inboundStatusLabel } from '../status'
|
||||
|
||||
defineProps<{
|
||||
palletLocations: InboundPalletLocation[]
|
||||
@@ -43,7 +44,7 @@ function date(value: string | null) {
|
||||
</section>
|
||||
<section class="history-section">
|
||||
<div class="section-heading"><h3>状态历史</h3><span>{{ statusHistory.length }} 条</span></div>
|
||||
<ol v-if="statusHistory.length" class="timeline"><li v-for="(history, index) in statusHistory" :key="`${history.occurredAt}-${index}`"><span class="timeline-dot" /><div><strong>{{ value(history.action) }}</strong><span>{{ value(history.fromStatus) }} → {{ value(history.toStatus) }} · {{ value(history.operatorName || history.operatorId) }}</span><small>{{ date(history.occurredAt) }}<template v-if="history.reason"> · {{ history.reason }}</template></small></div></li></ol>
|
||||
<ol v-if="statusHistory.length" class="timeline"><li v-for="(history, index) in statusHistory" :key="`${history.occurredAt}-${index}`"><span class="timeline-dot" /><div><strong>{{ inboundHistoryActionLabel(history.action) }}</strong><span>{{ inboundStatusLabel(history.fromStatus, inboundProcessStatusLabels) }} → {{ inboundStatusLabel(history.toStatus, inboundProcessStatusLabels) }} · {{ value(history.operatorName || history.operatorId) }}</span><small>{{ date(history.occurredAt) }}<template v-if="history.reason"> · {{ history.reason }}</template></small></div></li></ol>
|
||||
<p v-else class="muted-empty">暂无状态历史</p>
|
||||
</section>
|
||||
<section v-if="publicRemark" class="history-section remark-section"><div class="section-heading"><h3>公开备注</h3></div><p>{{ publicRemark }}</p></section>
|
||||
|
||||
@@ -23,4 +23,14 @@ describe('InboundSummaryPanel', () => {
|
||||
expect(source).not.toContain('接收审核')
|
||||
expect(source).not.toContain('客户确认费用')
|
||||
})
|
||||
|
||||
it('renders aggregate statuses through the inbound translation contract', () => {
|
||||
expect(source).toContain('inboundOrderStatusLabels')
|
||||
expect(source).toContain('inboundLoadingStatusLabels')
|
||||
expect(source).toContain('inboundReviewStatusLabels')
|
||||
expect(source).not.toContain('{{ value(summary.orderStatusSnapshot) }}')
|
||||
expect(source).toContain('firstLegBusinessTypeLabel(summary.businessType)')
|
||||
expect(source).toContain('firstLegTaxModeLabel(summary.taxMode)')
|
||||
expect(source).toContain('firstLegDestinationTypeLabel(summary.destinationType)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { Box, MapPin, Scale, Warehouse } from '@lucide/vue'
|
||||
import { firstLegBusinessTypeLabel, firstLegContainerModeLabel, firstLegDestinationTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
|
||||
import type { InboundOrderSummary } from './types'
|
||||
import { inboundFeeStatusLabels, inboundLoadingStatusLabels, inboundNotificationStatusLabels, inboundOrderStatusLabels, inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
||||
|
||||
withDefaults(defineProps<{ summary: InboundOrderSummary; publicRemark?: string | null }>(), { publicRemark: null })
|
||||
|
||||
const processLabels: Record<string, string> = {
|
||||
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回',
|
||||
WAIT_PRICING: '待计费', FEE_CONFIRMING: '待发送费用', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废',
|
||||
}
|
||||
|
||||
function processLabel(status: string) { return processLabels[status] || status || '未知状态' }
|
||||
function processLabel(status: string) { return inboundStatusLabel(status, inboundProcessStatusLabels, '未知状态') }
|
||||
function processType(status: string) {
|
||||
if (status === 'INBOUNDED') return 'success'
|
||||
if (status === 'MEASURE_REJECTED' || status === 'VOIDED') return 'danger'
|
||||
@@ -24,10 +21,7 @@ function date(value: string | null) {
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function yesNo(value: boolean | null) { return value === null ? '—' : value ? '是' : '否' }
|
||||
const measureReviewLabels: Record<string, string> = { PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' }
|
||||
const feeLabels: Record<string, string> = { UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废' }
|
||||
const notificationLabels: Record<string, string> = { NOT_CREATED: '未创建', PENDING: '待发送', SUCCESS: '发送成功', FAILED: '发送失败' }
|
||||
function label(value: string | null, labels: Record<string, string>) { return value ? labels[value] || value : '—' }
|
||||
function label(value: string | null, labels: Record<string, string>) { return inboundStatusLabel(value, labels) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,13 +40,13 @@ function label(value: string | null, labels: Record<string, string>) { return va
|
||||
<h4 id="inbound-basic-heading">基本信息</h4>
|
||||
<dl class="summary-grid">
|
||||
<div><dt>头程订单号</dt><dd>{{ value(summary.orderNo) }}</dd></div>
|
||||
<div><dt>订单状态</dt><dd>{{ value(summary.orderStatusSnapshot) }}</dd></div>
|
||||
<div><dt>订单状态</dt><dd>{{ label(summary.orderStatusSnapshot, inboundOrderStatusLabels) }}</dd></div>
|
||||
<div><dt>客户</dt><dd>{{ value(summary.customerName) }}</dd></div>
|
||||
<div><dt>公司</dt><dd>{{ value(summary.companyName) }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ value(summary.businessType) }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ firstLegBusinessTypeLabel(summary.businessType) }}</dd></div>
|
||||
<div><dt>渠道</dt><dd>{{ value(summary.channelName || summary.channelCode) }}</dd></div>
|
||||
<div><dt>买单 / 退税</dt><dd>{{ value(summary.taxMode) }}</dd></div>
|
||||
<div><dt>是否整柜</dt><dd>{{ value(summary.containerMode) }}</dd></div>
|
||||
<div><dt>买单 / 退税</dt><dd>{{ firstLegTaxModeLabel(summary.taxMode) }}</dd></div>
|
||||
<div><dt>是否整柜</dt><dd>{{ firstLegContainerModeLabel(summary.containerMode) }}</dd></div>
|
||||
<div><dt>商检验</dt><dd>{{ yesNo(summary.customsInspection) }}</dd></div>
|
||||
<div><dt>备注</dt><dd>{{ value(publicRemark) }}</dd></div>
|
||||
<div><dt>创建时间</dt><dd>{{ date(summary.createdAt) }}</dd></div>
|
||||
@@ -65,11 +59,11 @@ function label(value: string | null, labels: Record<string, string>) { return va
|
||||
<div><dt><Warehouse :size="14" />收货仓</dt><dd>{{ value(summary.warehouseName) }}</dd></div>
|
||||
<div><dt>交货仓</dt><dd>{{ value(summary.deliveryWarehouseName) }}</dd></div>
|
||||
<div><dt><MapPin :size="14" />目的仓</dt><dd>{{ value(summary.destinationName || summary.destinationCode) }}</dd></div>
|
||||
<div><dt>目的仓类型</dt><dd>{{ value(summary.destinationType) }}</dd></div>
|
||||
<div><dt>目的仓类型</dt><dd>{{ firstLegDestinationTypeLabel(summary.destinationType) }}</dd></div>
|
||||
<div><dt>集装箱号</dt><dd>{{ value(summary.containerNo) }}</dd></div>
|
||||
<div><dt>柜号</dt><dd>{{ value(summary.cabinetNo) }}</dd></div>
|
||||
<div><dt>船名 / 航次</dt><dd>{{ value(summary.vesselName) }} / {{ value(summary.voyageNo) }}</dd></div>
|
||||
<div><dt>配载状态</dt><dd>{{ value(summary.loadingStatus) }}</dd></div>
|
||||
<div><dt>配载状态</dt><dd>{{ label(summary.loadingStatus, inboundLoadingStatusLabels) }}</dd></div>
|
||||
<div><dt>预计到达</dt><dd>{{ date(summary.expectedArrivalAt) }}</dd></div>
|
||||
<div><dt>开船 / 到港</dt><dd>{{ date(summary.sailingAt) }} / {{ date(summary.portArrivalAt) }}</dd></div>
|
||||
<div><dt>快递公司</dt><dd>{{ value(summary.expressCompanyName || summary.expressCompanyCode) }}</dd></div>
|
||||
@@ -87,11 +81,11 @@ function label(value: string | null, labels: Record<string, string>) { return va
|
||||
<div><dt>总材积重</dt><dd>{{ number(summary.volumeWeightKg) }} kg</dd></div>
|
||||
<div><dt>计费毛重</dt><dd>{{ number(summary.chargeableWeightKg) }} kg</dd></div>
|
||||
<div><dt>计费体积</dt><dd>{{ number(summary.chargeableVolumeCbm) }} CBM</dd></div>
|
||||
<div><dt>测量审核</dt><dd>{{ label(summary.measureReviewStatus, measureReviewLabels) }}</dd></div>
|
||||
<div><dt>费用状态</dt><dd>{{ label(summary.feeStatus, feeLabels) }}</dd></div>
|
||||
<div><dt>费用审核状态</dt><dd>{{ value(summary.feeReviewStatus) }}</dd></div>
|
||||
<div><dt>测量审核</dt><dd>{{ label(summary.measureReviewStatus, inboundReviewStatusLabels) }}</dd></div>
|
||||
<div><dt>费用状态</dt><dd>{{ label(summary.feeStatus, inboundFeeStatusLabels) }}</dd></div>
|
||||
<div><dt>费用审核状态</dt><dd>{{ label(summary.feeReviewStatus, inboundReviewStatusLabels) }}</dd></div>
|
||||
<div><dt>测量完成时间</dt><dd>{{ date(summary.measureCompletedAt) }}</dd></div>
|
||||
<div><dt>通知状态</dt><dd>{{ label(summary.notificationStatus, notificationLabels) }}</dd></div>
|
||||
<div><dt>通知状态</dt><dd>{{ label(summary.notificationStatus, inboundNotificationStatusLabels) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('InboundFeePanel', () => {
|
||||
expect(document.body.textContent).toContain('附加费 ¥100.00')
|
||||
expect(document.body.textContent).toContain('133.6 KG')
|
||||
expect(document.body.textContent).toContain('按重量计费')
|
||||
expect(document.body.textContent).toContain('FBA 海运')
|
||||
expect(document.body.textContent).toContain('¥8.50 / KG')
|
||||
expect(document.body.textContent).toContain('RULE-FBA-KG · V3')
|
||||
expect(document.body.textContent).toContain('RATE-LAX · V5')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { CircleDollarSign, PencilLine, RefreshCw } from '@lucide/vue'
|
||||
import { firstLegBusinessTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -74,7 +75,7 @@ function feeStatusInfo(status?: string) {
|
||||
SENT: { label: '已发布', type: 'success' },
|
||||
SUPERSEDED: { label: '历史版本', type: 'info' },
|
||||
}
|
||||
return map[status || ''] || { label: status || '未知状态', type: 'info' as const }
|
||||
return map[status || ''] || { label: '未知状态', type: 'info' as const }
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined, digits = 3) {
|
||||
@@ -104,7 +105,7 @@ function formatDateTime(value: string | null | undefined) {
|
||||
}
|
||||
|
||||
function taxModeLabel(value: string) {
|
||||
return value === 'TAX_INCLUDED' ? '包税' : value === 'TAX_EXCLUDED' ? '不包税' : value
|
||||
return firstLegTaxModeLabel(value)
|
||||
}
|
||||
|
||||
function roundingModeLabel(value: string) {
|
||||
@@ -112,7 +113,7 @@ function roundingModeLabel(value: string) {
|
||||
UP: '远离零方向', DOWN: '向零方向', CEILING: '向上', FLOOR: '向下',
|
||||
HALF_UP: '四舍五入', HALF_DOWN: '五舍六入', HALF_EVEN: '银行家舍入',
|
||||
}
|
||||
return labels[value] || value
|
||||
return labels[value] || '未知规则'
|
||||
}
|
||||
|
||||
async function loadFee(options: { preserveAdjustment?: boolean } = {}) {
|
||||
@@ -314,7 +315,7 @@ defineExpose({ reload: loadFee })
|
||||
<div class="section-title"><div><h4 id="rule-title">客户计费规则</h4><p>{{ snapshot.ruleCode }} · V{{ snapshot.chargeRuleVersion }}</p></div></div>
|
||||
<dl>
|
||||
<div><dt>客户</dt><dd>{{ snapshot.customerName }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ snapshot.businessType }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ firstLegBusinessTypeLabel(snapshot.businessType) }}</dd></div>
|
||||
<div><dt>计费口径</dt><dd>{{ chargeModeLabel }}</dd></div>
|
||||
<div><dt>舍入规则</dt><dd>保留 {{ snapshot.roundingScale }} 位,{{ roundingModeLabel(snapshot.roundingMode) }}</dd></div>
|
||||
</dl>
|
||||
|
||||
@@ -8,6 +8,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { inboundProcessStatusLabels, inboundStatusLabel } from '../status'
|
||||
import { downloadInboundMeasurementImage, getInboundMeasurement, submitInboundMeasurement, updateConfirmedPackageCount } from './api'
|
||||
import type {
|
||||
InboundMeasurementDetail,
|
||||
@@ -314,10 +315,7 @@ async function previewImage(fileId: string | null) {
|
||||
}
|
||||
|
||||
function processStatusLabel(status: string | undefined) {
|
||||
return ({
|
||||
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '审核驳回',
|
||||
WAIT_PRICING: '待计费', FEE_CONFIRMING: '费用确认中', INBOUNDING: '入库中', INBOUNDED: '已入库', VOIDED: '已作废',
|
||||
} as Record<string, string>)[status || ''] || status || '—'
|
||||
return inboundStatusLabel(status, inboundProcessStatusLabels)
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -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 }))
|
||||
})
|
||||
|
||||
|
||||
@@ -13,12 +13,14 @@ 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 { firstLegBusinessTypeLabel, firstLegContainerModeLabel, firstLegDestinationTypeLabel, firstLegTaxModeLabel } from '@/shared/utils/firstLegBusinessLabels'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { downloadInboundCartonLabels, getPlatformInbound, listPlatformInbounds } from '../core/api'
|
||||
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
|
||||
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
|
||||
import type { InboundListTab, InboundOrderDetail, InboundOrderSummary } from '../core/types'
|
||||
import { inboundFeeStatusLabels, inboundLoadingStatusLabels, inboundOrderStatusLabels, inboundProcessStatusLabels, inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
customerId: string
|
||||
@@ -51,7 +53,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)
|
||||
@@ -98,7 +102,7 @@ const allQueryFields = computed<QueryField[]>(() => [
|
||||
{ label: '待计费', value: 'WAIT_PRICING' }, { label: '费用处理中', value: 'FEE_CONFIRMING' },
|
||||
{ label: '入库处理中', value: 'INBOUNDING' }, { label: '已入库', value: 'INBOUNDED' }, { label: '已作废', value: 'VOIDED' },
|
||||
] },
|
||||
{ key: 'orderStatus', type: 'input', width: 200, label: '订单状态码', placeholder: '订单状态码' },
|
||||
{ key: 'orderStatus', type: 'select', width: 180, label: '订单状态', placeholder: '全部订单状态', options: Object.entries(inboundOrderStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
{ key: 'measureReviewStatus', type: 'select', width: 160, label: '测量审核状态', placeholder: '测量审核状态', options: [
|
||||
{ label: '待审核', value: 'PENDING' }, { label: '审核通过', value: 'APPROVED' }, { label: '审核驳回', value: 'REJECTED' },
|
||||
] },
|
||||
@@ -106,7 +110,7 @@ const allQueryFields = computed<QueryField[]>(() => [
|
||||
{ label: '未计费', value: 'UNPRICED' }, { label: '已试算', value: 'CALCULATED' },
|
||||
{ label: '已确认', value: 'CONFIRMED' }, { label: '已发送', value: 'SENT' }, { label: '已作废', value: 'VOIDED' },
|
||||
] },
|
||||
{ key: 'feeReviewStatus', type: 'input', width: 200, label: '费用审核状态码', placeholder: '费用审核状态码' },
|
||||
{ key: 'feeReviewStatus', type: 'select', width: 180, label: '费用审核状态', placeholder: '全部审核状态', options: Object.entries(inboundReviewStatusLabels).map(([value, label]) => ({ value, label })) },
|
||||
{ key: 'customerName', type: 'input', width: 260, label: '客户名称', placeholder: '客户名称' },
|
||||
{ key: 'destinationCode', type: 'input', width: 220, label: '目的仓编码', placeholder: '目的仓编码' },
|
||||
{ key: 'holdFlag', type: 'select', width: 160, label: '挂起状态', placeholder: '挂起状态', options: [{ label: '已挂起', value: 'true' }, { label: '未挂起', value: 'false' }] },
|
||||
@@ -159,7 +163,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 +250,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 +283,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
|
||||
}
|
||||
@@ -343,7 +350,7 @@ function formatDate(value: string | null) {
|
||||
}
|
||||
function text(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function statusLabel(status: string) {
|
||||
return ({ WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回', WAIT_PRICING: '待计费', FEE_CONFIRMING: '待发送费用', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废' } as Record<string, string>)[status] || status || '未知状态'
|
||||
return inboundStatusLabel(status, inboundProcessStatusLabels, '未知状态')
|
||||
}
|
||||
function statusType(status: string) {
|
||||
if (status === 'INBOUNDED') return 'success'
|
||||
@@ -351,13 +358,10 @@ function statusType(status: string) {
|
||||
if (status === 'MEASURING' || status === 'INBOUNDING') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
function codeLabel(status: string | null | undefined, labels: Record<string, string>) { return status ? labels[status] || status : '—' }
|
||||
const measureReviewLabels: Record<string, string> = { PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' }
|
||||
const feeStatusLabels: Record<string, string> = { UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废' }
|
||||
const loadingStatusLabels: Record<string, string> = { NOT_LOADED: '未配载', LOADING: '配载中', LOADED: '已配载' }
|
||||
const taxModeLabels: Record<string, string> = { TAX_INCLUDED: '包税', TAX_EXCLUDED: '不包税', DDP: '包税', DDU: '不包税' }
|
||||
const containerModeLabels: Record<string, string> = { FCL: '整柜', LCL: '拼箱' }
|
||||
|
||||
function codeLabel(status: string | null | undefined, labels: Record<string, string>) { return inboundStatusLabel(status, labels) }
|
||||
const measureReviewLabels = inboundReviewStatusLabels
|
||||
const feeStatusLabels = inboundFeeStatusLabels
|
||||
const loadingStatusLabels = inboundLoadingStatusLabels
|
||||
function resetFilters() {
|
||||
selectedCustomerId.value = ''
|
||||
showAdvancedFilters.value = false
|
||||
@@ -383,19 +387,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() })
|
||||
@@ -452,10 +452,10 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
||||
<template #pagination-left>当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link :loading="printingInboundId === (row as InboundOrderSummary).id" :disabled="Boolean(printingInboundId)" @click="printCartonLabels(row as InboundOrderSummary)"><Printer :size="15" />打印箱唛</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div></template>
|
||||
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
|
||||
<template #orderStatus="{ row }"><div class="status-stack"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag><span>订单:{{ text((row as InboundOrderSummary).orderStatusSnapshot) }}</span></div></template>
|
||||
<template #orderStatus="{ row }"><div class="status-stack"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag><span>订单:{{ codeLabel((row as InboundOrderSummary).orderStatusSnapshot, inboundOrderStatusLabels) }}</span></div></template>
|
||||
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
|
||||
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
|
||||
<template #feeStatus="{ row }"><div class="status-stack"><span>{{ codeLabel((row as InboundOrderSummary).feeStatus, feeStatusLabels) }}</span><small v-if="(row as InboundOrderSummary).feeReviewStatus">审核:{{ text((row as InboundOrderSummary).feeReviewStatus) }}</small></div></template>
|
||||
<template #feeStatus="{ row }"><div class="status-stack"><span>{{ codeLabel((row as InboundOrderSummary).feeStatus, feeStatusLabels) }}</span><small v-if="(row as InboundOrderSummary).feeReviewStatus">审核:{{ codeLabel((row as InboundOrderSummary).feeReviewStatus, inboundReviewStatusLabels) }}</small></div></template>
|
||||
<template #customerName="{ row }">{{ text((row as InboundOrderSummary).customerName) }}</template>
|
||||
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
|
||||
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
|
||||
@@ -465,10 +465,10 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
||||
<template #containerNo="{ row }">{{ text((row as InboundOrderSummary).containerNo) }}</template>
|
||||
<template #cabinetNo="{ row }">{{ text((row as InboundOrderSummary).cabinetNo) }}</template>
|
||||
<template #companyName="{ row }">{{ text((row as InboundOrderSummary).companyName) }}</template>
|
||||
<template #businessType="{ row }">{{ text((row as InboundOrderSummary).businessType) }}</template>
|
||||
<template #businessType="{ row }">{{ firstLegBusinessTypeLabel((row as InboundOrderSummary).businessType) }}</template>
|
||||
<template #channelName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).channelName) }}</strong><span>{{ text((row as InboundOrderSummary).channelCode) }}</span></div></template>
|
||||
<template #taxMode="{ row }">{{ codeLabel((row as InboundOrderSummary).taxMode, taxModeLabels) }}</template>
|
||||
<template #containerMode="{ row }">{{ codeLabel((row as InboundOrderSummary).containerMode, containerModeLabels) }}</template>
|
||||
<template #taxMode="{ row }">{{ firstLegTaxModeLabel((row as InboundOrderSummary).taxMode) }}</template>
|
||||
<template #containerMode="{ row }">{{ firstLegContainerModeLabel((row as InboundOrderSummary).containerMode) }}</template>
|
||||
<template #customsInspection="{ row }">{{ (row as InboundOrderSummary).customsInspection === null ? '—' : (row as InboundOrderSummary).customsInspection ? '是' : '否' }}</template>
|
||||
<template #sailingAt="{ row }">{{ formatDate((row as InboundOrderSummary).sailingAt) }}</template>
|
||||
<template #portArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).portArrivalAt) }}</template>
|
||||
@@ -481,7 +481,7 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
||||
<template #locationCodes="{ row }">{{ text((row as InboundOrderSummary).locationCodes) }}</template>
|
||||
<template #volumeWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).volumeWeightKg) }}</template>
|
||||
<template #actualNetWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualNetWeightKg) }}</template>
|
||||
<template #destinationType="{ row }">{{ text((row as InboundOrderSummary).destinationType) }}</template>
|
||||
<template #destinationType="{ row }">{{ firstLegDestinationTypeLabel((row as InboundOrderSummary).destinationType) }}</template>
|
||||
<template #destinationName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).destinationName) }}</strong><span>{{ text((row as InboundOrderSummary).destinationCode) }}</span></div></template>
|
||||
<template #measureCompletedAt="{ row }">{{ formatDate((row as InboundOrderSummary).measureCompletedAt) }}</template>
|
||||
<template #inboundedAt="{ row }">{{ formatDate((row as InboundOrderSummary).inboundedAt) }}</template>
|
||||
|
||||
@@ -46,6 +46,8 @@ describe('MeasurementReviewPanel', () => {
|
||||
|
||||
expect(wrapper.text()).toContain('原单申报')
|
||||
expect(wrapper.text()).toContain('设备实测')
|
||||
expect(wrapper.text()).toContain('设备采集')
|
||||
expect(wrapper.text()).not.toContain('DEVICE')
|
||||
expect(wrapper.text()).toContain('称重 OCR')
|
||||
expect(wrapper.text()).toContain('实测长 × 宽 × 高(cm)')
|
||||
expect(wrapper.text()).toContain('周长超过渠道限制')
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { inboundReviewStatusLabels, inboundStatusLabel, weightRecognitionStatusLabels } from '../status'
|
||||
import {
|
||||
approveMeasurement,
|
||||
getMeasurementReviewDetail,
|
||||
@@ -49,12 +50,7 @@ const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
|
||||
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
|
||||
const confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.finalWeightKg !== null).length ?? 0)
|
||||
const reviewStatusLabel = computed(() => {
|
||||
if (isPending.value) return '待审核'
|
||||
if (isRejected.value) return '已驳回'
|
||||
if (detail.value?.measureReviewStatus === 'APPROVED') return '已通过'
|
||||
return detail.value?.measureReviewStatus || '未提交'
|
||||
})
|
||||
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
|
||||
|
||||
const measurementColumns: DataTableColumn[] = [
|
||||
{ key: 'boxCode', label: '扫描箱号', minWidth: 190, slotName: 'boxCode' },
|
||||
@@ -83,7 +79,16 @@ function formatDate(value: string | null | undefined) {
|
||||
function resultLabel(value: string | null | undefined) {
|
||||
if (value === 'APPROVED') return '审核通过'
|
||||
if (value === 'REJECTED') return '审核驳回'
|
||||
return value || '未知结果'
|
||||
return '未知结果'
|
||||
}
|
||||
|
||||
function recognitionStatusLabel(value: string | null | undefined) {
|
||||
return inboundStatusLabel(value, weightRecognitionStatusLabels, '待确认')
|
||||
}
|
||||
|
||||
function measurementSourceLabel(value: string | null | undefined) {
|
||||
if (!value) return '-'
|
||||
return ({ DEVICE: '设备采集', THIRD_PARTY: '第三方设备', MANUAL: '人工录入' } as Record<string, string>)[value] || '其他来源'
|
||||
}
|
||||
|
||||
function isConflict(error: unknown) {
|
||||
@@ -247,7 +252,7 @@ onMounted(() => {
|
||||
<strong>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</strong>
|
||||
<dl>
|
||||
<div><dt>已确认图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }}</dd></div>
|
||||
<div><dt>测量来源</dt><dd>{{ detail.sourceType || '-' }}</dd></div>
|
||||
<div><dt>测量来源</dt><dd>{{ measurementSourceLabel(detail.sourceType) }}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
@@ -281,7 +286,7 @@ onMounted(() => {
|
||||
<div class="section-heading"><div><h3>称重图片识别结果</h3><p>原始 OCR 值不可覆盖,只有已确认且有效的结果会计入实际重量。</p></div><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
|
||||
<div class="weight-list">
|
||||
<div v-for="item in detail.weightResults" :key="item.id" class="weight-row">
|
||||
<div><strong>{{ item.fileId || '未关联文件' }}</strong><span>{{ item.ocrProvider || 'OCR' }} · {{ item.recognitionStatus || '待确认' }}</span><p v-if="item.ocrRawText">OCR 原文:{{ item.ocrRawText }}</p><p v-if="item.adjustmentReason">调整原因:{{ item.adjustmentReason }}</p></div>
|
||||
<div><strong>{{ item.fileId || '未关联文件' }}</strong><span>{{ item.ocrProvider || 'OCR' }} · {{ recognitionStatusLabel(item.recognitionStatus) }}</span><p v-if="item.ocrRawText">OCR 原文:{{ item.ocrRawText }}</p><p v-if="item.adjustmentReason">调整原因:{{ item.adjustmentReason }}</p></div>
|
||||
<div><span>原始</span><strong>{{ formatNumber(item.ocrWeightValue, ` ${item.ocrWeightUnit || ''}`) }}</strong></div>
|
||||
<div><span>调整后</span><strong>{{ formatNumber(item.adjustedWeightValue, ` ${item.adjustedWeightUnit || ''}`) }}</strong></div>
|
||||
<div><span>最终</span><strong>{{ formatNumber(item.finalWeightKg, ' kg') }}</strong></div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { inboundHistoryActionLabel, inboundOrderStatusLabels, inboundProcessStatusLabels, inboundStatusLabel } from './status'
|
||||
|
||||
describe('inbound status presentation', () => {
|
||||
it('translates process and order statuses', () => {
|
||||
expect(inboundStatusLabel('MEASURE_PENDING', inboundProcessStatusLabels)).toBe('待测量审核')
|
||||
expect(inboundStatusLabel('WAIT_SPLIT', inboundOrderStatusLabels)).toBe('待分柜')
|
||||
})
|
||||
|
||||
it('does not expose unknown status and action codes', () => {
|
||||
expect(inboundStatusLabel('NEW_STATUS', inboundProcessStatusLabels)).toBe('未知状态')
|
||||
expect(inboundHistoryActionLabel('NEW_ACTION')).toBe('状态更新')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
export const inboundProcessStatusLabels: Record<string, string> = {
|
||||
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待测量审核', MEASURE_REJECTED: '测量已驳回',
|
||||
WAIT_PRICING: '待计费', FEE_CONFIRMING: '费用处理中', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废',
|
||||
}
|
||||
|
||||
export const inboundOrderStatusLabels: Record<string, string> = {
|
||||
DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', REJECTED: '已驳回',
|
||||
PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', PENDING_REVIEW: '待审核',
|
||||
WAIT_MEASURE: '待测量', INBOUNDED: '已入库', WAIT_SPLIT: '待分柜', LOADING: '装柜中',
|
||||
LOADED: '装柜完成', DELIVERED: '派送完成',
|
||||
}
|
||||
|
||||
export const inboundReviewStatusLabels: Record<string, string> = {
|
||||
NOT_SUBMITTED: '未提交', UNRECORDED: '未录入', PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回', CONFIRMED: '已确认',
|
||||
}
|
||||
|
||||
export const inboundFeeStatusLabels: Record<string, string> = {
|
||||
UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废',
|
||||
}
|
||||
|
||||
export const inboundLoadingStatusLabels: Record<string, string> = {
|
||||
NOT_LOADED: '未配载', WAIT_LOADING: '待配载', EDITING: '预装柜中', LOADING: '配载中', LOADED: '已配载', COMPLETED: '装柜完成',
|
||||
}
|
||||
|
||||
export const inboundNotificationStatusLabels: Record<string, string> = {
|
||||
NOT_CREATED: '未创建', PENDING: '待发送', SUCCESS: '发送成功', FAILED: '发送失败',
|
||||
}
|
||||
|
||||
export const weightRecognitionStatusLabels: Record<string, string> = {
|
||||
PENDING: '待识别', PENDING_CONFIRM: '待确认', NEEDS_MANUAL: '需人工录入', CONFIRMED: '已确认',
|
||||
}
|
||||
|
||||
const inboundHistoryActionLabels: Record<string, string> = {
|
||||
CREATE: '创建入库单', STATUS_UPDATE: '状态更新', MEASURE_SUBMIT: '提交测量', MEASURE_APPROVE: '测量审核通过',
|
||||
MEASURE_REJECT: '测量审核驳回', FEE_CALCULATE: '费用试算', FEE_CONFIRM: '费用确认',
|
||||
INBOUND_BEGIN: '开始入库', INBOUND_COMPLETE: '完成入库', VOID: '作废入库单',
|
||||
}
|
||||
|
||||
export function inboundStatusLabel(value: string | null | undefined, labels: Record<string, string>, emptyLabel = '—') {
|
||||
if (!value) return emptyLabel
|
||||
return labels[value] || '未知状态'
|
||||
}
|
||||
|
||||
export function inboundHistoryActionLabel(value: string | null | undefined) {
|
||||
if (!value) return '状态更新'
|
||||
return inboundHistoryActionLabels[value] || '状态更新'
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../api/weightOcr'
|
||||
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||
import WeightImageThumbnail from './WeightImageThumbnail.vue'
|
||||
import { inboundProcessStatusLabels, inboundStatusLabel, weightRecognitionStatusLabels } from '../../status'
|
||||
|
||||
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
@@ -73,11 +74,7 @@ function formatConfidence(value: number | null) {
|
||||
}
|
||||
|
||||
function recognitionLabel(status: string) {
|
||||
return ({
|
||||
PENDING_CONFIRM: '待确认',
|
||||
NEEDS_MANUAL: '需人工录入',
|
||||
CONFIRMED: '已确认',
|
||||
} as Record<string, string>)[status] || status
|
||||
return inboundStatusLabel(status, weightRecognitionStatusLabels, '未知状态')
|
||||
}
|
||||
|
||||
function requiresEvidenceReview(row: WeightResultResource) {
|
||||
@@ -384,7 +381,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
|
||||
<template v-else>
|
||||
<el-alert v-if="workspace && !editable" type="info" :closable="false" show-icon class="weight-state-note">
|
||||
当前入库状态为 {{ workspace.processStatus }},称重数据仅供查看。
|
||||
当前入库状态为 {{ inboundStatusLabel(workspace.processStatus, inboundProcessStatusLabels, '未知状态') }},称重数据仅供查看。
|
||||
</el-alert>
|
||||
|
||||
<LoadErrorState
|
||||
|
||||
@@ -7,6 +7,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { loadingListStatusLabel } from '../status'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import type { LoadingDetail, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
|
||||
|
||||
@@ -50,10 +51,6 @@ const title = computed(() => {
|
||||
return `${props.mode === 'preload' ? '预装清单' : '装柜清单'} · ${number}`
|
||||
})
|
||||
|
||||
function statusLabel(status?: string | null) {
|
||||
return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' } as Record<string, string>)[status || ''] || status || '—'
|
||||
}
|
||||
|
||||
function actionLabel(action: string) {
|
||||
return ({
|
||||
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
|
||||
@@ -92,7 +89,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
|
||||
<div><span>集装箱号</span><strong>{{ summary.containerNo || '—' }}</strong></div>
|
||||
<div><span>柜号</span><strong>{{ summary.internalContainerNo || '—' }}</strong></div>
|
||||
<div><span>状态</span><StatusBadge :status="statusLabel(summary.status)" /></div>
|
||||
<div><span>状态</span><StatusBadge :status="loadingListStatusLabel(summary.status)" /></div>
|
||||
<template v-if="preloadSummary">
|
||||
<div><span>船名 / 航次</span><strong>{{ preloadSummary.vesselName || '—' }} / {{ preloadSummary.voyageNo || '—' }}</strong></div>
|
||||
<div><span>箱型</span><strong>{{ preloadSummary.containerType || '—' }}</strong></div>
|
||||
@@ -132,7 +129,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<el-timeline-item v-for="history in detail.histories" :key="history.historyId" :timestamp="formatDateTime(history.occurredAt)" placement="top">
|
||||
<div class="history-entry">
|
||||
<strong>{{ actionLabel(history.actionType) }}</strong>
|
||||
<span v-if="history.fromStatus || history.toStatus">{{ statusLabel(history.fromStatus) }} → {{ statusLabel(history.toStatus) }}</span>
|
||||
<span v-if="history.fromStatus || history.toStatus">{{ loadingListStatusLabel(history.fromStatus) }} → {{ loadingListStatusLabel(history.toStatus) }}</span>
|
||||
<p>{{ history.operatorName || history.operatorId }}<template v-if="history.operationReason"> · {{ history.operationReason }}</template></p>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
|
||||
@@ -40,11 +40,8 @@ describe('LoadingListsView', () => {
|
||||
})
|
||||
|
||||
it('renders business labels instead of raw loading-plan status codes', () => {
|
||||
expect(source).toContain("LOADING: '拣货中'")
|
||||
expect(source).toContain("COMPLETED: '装柜完成'")
|
||||
expect(source).toContain(':status="statusLabel(row.status)"')
|
||||
expect(detailSource).toContain("EDITING: '预装柜中'")
|
||||
expect(detailSource).toContain(':status="statusLabel(summary.status)"')
|
||||
expect(source).toContain(':status="loadingListStatusLabel(row.status)"')
|
||||
expect(detailSource).toContain(':status="loadingListStatusLabel(summary.status)"')
|
||||
expect(detailSource).not.toContain(':status="summary.status"')
|
||||
expect(source).not.toContain('仅装柜中允许修改')
|
||||
})
|
||||
@@ -70,8 +67,8 @@ describe('LoadingListsView', () => {
|
||||
})
|
||||
|
||||
it('passes translated status text directly to the badge', () => {
|
||||
expect(source).toContain('<StatusBadge :status="statusLabel(row.status)" />')
|
||||
expect(source).toContain('<StatusBadge :status="loadingListStatusLabel(row.status)" />')
|
||||
expect(source).not.toContain('<StatusBadge :status="row.status" />')
|
||||
expect(detailSource).toContain('<StatusBadge :status="statusLabel(summary.status)" />')
|
||||
expect(detailSource).toContain('<StatusBadge :status="loadingListStatusLabel(summary.status)" />')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { loadingListStatusLabel } from '../status'
|
||||
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
|
||||
import {
|
||||
getConfirmedLoadingList,
|
||||
@@ -110,10 +111,6 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: digits })
|
||||
}
|
||||
|
||||
function statusLabel(status?: string | null) {
|
||||
return ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' } as Record<string, string>)[status || ''] || status || '—'
|
||||
}
|
||||
|
||||
function queryId(value: unknown) {
|
||||
return typeof value === 'string' && /^[1-9]\d*$/.test(value) ? value : ''
|
||||
}
|
||||
@@ -310,7 +307,7 @@ onMounted(() => {
|
||||
</template>
|
||||
<template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || item.accountId).join('、') || '—' }}</template>
|
||||
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
|
||||
<template #status="{ row }"><StatusBadge :status="loadingListStatusLabel(row.status)" /></template>
|
||||
<template #grossWeight="{ row }">{{ formatNumber(row.totalGrossWeightKg) }}</template>
|
||||
<template #netWeight="{ row }">{{ formatNumber((row as ConfirmedSummary).totalNetWeightKg) }}</template>
|
||||
<template #volume="{ row }">{{ formatNumber(row.totalVolumeCbm) }}</template>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './api/platformLoadingLists'
|
||||
export { loadingListRoutes } from './routes'
|
||||
export { loadingListStatusLabel } from './status'
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { loadingListStatusLabel } from './status'
|
||||
|
||||
describe('loading list status labels', () => {
|
||||
it('translates known states and hides unknown backend enums', () => {
|
||||
expect(loadingListStatusLabel('EDITING')).toBe('预装柜中')
|
||||
expect(loadingListStatusLabel('COMPLETED')).toBe('装柜完成')
|
||||
expect(loadingListStatusLabel('NEW_LOADING_STATE')).toBe('未知状态')
|
||||
expect(loadingListStatusLabel(null)).toBe('—')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
const loadingListStatusLabels: Record<string, string> = {
|
||||
WAIT_LOADING: '待装柜',
|
||||
EDITING: '预装柜中',
|
||||
LOADING: '拣货中',
|
||||
COMPLETED: '装柜完成',
|
||||
}
|
||||
|
||||
export function loadingListStatusLabel(status: string | null | undefined) {
|
||||
if (!status) return '—'
|
||||
return loadingListStatusLabels[status] || '未知状态'
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export { oceanBookingRoutes } from './routes'
|
||||
export { listOceanBookings } from './api/platformOceanBookings'
|
||||
export * from './status'
|
||||
export * from './types'
|
||||
|
||||
@@ -4,30 +4,34 @@ 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('renders the translated status once in the detail heading', () => {
|
||||
expect(source).toContain('<StatusBadge :status="statusLabel(detail.status)" />')
|
||||
expect(source).not.toContain('<StatusBadge :status="detail.status" />')
|
||||
})
|
||||
|
||||
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,
|
||||
@@ -27,11 +29,8 @@ import type {
|
||||
OceanBookingSaveRequest,
|
||||
OceanBookingStatus,
|
||||
} from '../types'
|
||||
import { oceanBookingStatusLabel } from '../status'
|
||||
|
||||
const statusLabels: Record<OceanBookingStatus, string> = {
|
||||
DRAFT: '草稿', SUBMITTED: '已提交', CONFIRMED: '已确认', RELEASED: '已放舱',
|
||||
FAILED: '订舱失败', CHANGE_PENDING: '变更中', CANCELLED: '已取消',
|
||||
}
|
||||
const partyTypes = [
|
||||
{ value: 'SHIPPER', label: '发货人' },
|
||||
{ value: 'CONSIGNEE', label: '收货人' },
|
||||
@@ -67,6 +66,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 +87,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
|
||||
@@ -93,7 +100,37 @@ const changeDialogOpen = ref(false)
|
||||
const changeForm = reactive({ vesselName: '', voyageNo: '', estimatedSailingAt: null as string | null, estimatedArrivalAt: null as string | null, reason: '' })
|
||||
|
||||
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
|
||||
return status ? (statusLabels[status as OceanBookingStatus] || status) : '未设置'
|
||||
return oceanBookingStatusLabel(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
|
||||
@@ -185,7 +222,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>
|
||||
@@ -195,7 +232,7 @@ onMounted(load)
|
||||
<div class="detail-heading">
|
||||
<el-button @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱资料'" :description="detail ? `${detail.customerName || '—'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '填写并保存订舱资料'">
|
||||
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="detail.status" /><span>{{ statusLabel(detail.status) }}</span></div></template>
|
||||
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
|
||||
</PageToolbar>
|
||||
</div>
|
||||
</template>
|
||||
@@ -211,26 +248,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 +301,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,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps list actions in the table toolbar without a duplicate page title', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).not.toContain('<template #toolbar-start>'); expect(source).not.toContain('PageToolbar'); expect(source).not.toContain('<template #actions><el-button') }); it('shows the two frequent row actions directly without a more menu', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }) })
|
||||
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }); it('keeps list actions in the table toolbar without a duplicate page title', () => { expect(source).toContain('<template #toolbar-actions>'); expect(source).not.toContain('<template #toolbar-start>'); expect(source).not.toContain('PageToolbar'); expect(source).not.toContain('<template #actions><el-button') }); it('shows the two frequent row actions directly without a more menu', () => { expect(source).toContain('<Eye :size="15" />查看'); expect(source).toContain('<Pencil :size="15" />编辑'); expect(source).not.toContain('MoreHorizontal') }); it('renders backend booking statuses through the domain translation contract', () => { expect(source).toContain('options: oceanBookingStatusOptions'); expect(source).toContain(':status="oceanBookingStatusLabel(row.status)"'); expect(source).not.toContain(':status="row.status"') }) })
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listOceanBookings, type OceanBookingResource } from '../api/platformOceanBookings'
|
||||
import type { OceanBookingQuery, OceanBookingStatus } from '../types'
|
||||
import { oceanBookingStatusLabel, oceanBookingStatusOptions } from '../status'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -23,13 +24,9 @@ const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const query = reactive<QueryModel>({ keyword: '', status: '' })
|
||||
const statuses: Array<{ label: string; value: string }> = [
|
||||
{ label: '草稿', value: 'DRAFT' }, { label: '已提交', value: 'SUBMITTED' }, { label: '已确认', value: 'CONFIRMED' },
|
||||
{ label: '已放舱', value: 'RELEASED' }, { label: '失败', value: 'FAILED' }, { label: '变更中', value: 'CHANGE_PENDING' }, { label: '已取消', value: 'CANCELLED' },
|
||||
]
|
||||
const fields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/订单号/客户', width: 220 },
|
||||
{ key: 'status', type: 'select', label: '状态', options: statuses },
|
||||
{ key: 'status', type: 'select', label: '状态', options: oceanBookingStatusOptions },
|
||||
]
|
||||
const columns = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 170, slotName: 'actions', hideable: false, movable: false },
|
||||
@@ -54,7 +51,7 @@ watch([page, pageSize], () => void load()); onMounted(load)
|
||||
<template #toolbar-actions><el-button v-if="access.can('platform:firstleg:ocean-booking:create')" type="primary" @click="open()"><Plus :size="16" />新增订舱</el-button></template>
|
||||
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:ocean-booking:view')" link type="primary" @click="open(row.id)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:ocean-booking:edit')" link @click="open(row.id)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
|
||||
<template #status="{ row }"><StatusBadge :status="row.status" /></template>
|
||||
<template #status="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.status)" /></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { oceanBookingStatusLabel, oceanBookingStatusOptions } from './status'
|
||||
|
||||
describe('ocean booking status labels', () => {
|
||||
it('translates every backend status for selectors and badges', () => {
|
||||
expect(oceanBookingStatusOptions).toEqual([
|
||||
{ value: 'DRAFT', label: '草稿' },
|
||||
{ value: 'SUBMITTED', label: '已提交' },
|
||||
{ value: 'CONFIRMED', label: '已确认' },
|
||||
{ value: 'RELEASED', label: '已放舱' },
|
||||
{ value: 'FAILED', label: '订舱失败' },
|
||||
{ value: 'CHANGE_PENDING', label: '变更中' },
|
||||
{ value: 'CANCELLED', label: '已取消' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not leak unknown backend enum values to users', () => {
|
||||
expect(oceanBookingStatusLabel('NEW_BACKEND_STATUS')).toBe('未知状态')
|
||||
expect(oceanBookingStatusLabel(null)).toBe('未设置')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { OceanBookingStatus } from './types'
|
||||
|
||||
export const oceanBookingStatusLabels: Record<OceanBookingStatus, string> = {
|
||||
DRAFT: '草稿',
|
||||
SUBMITTED: '已提交',
|
||||
CONFIRMED: '已确认',
|
||||
RELEASED: '已放舱',
|
||||
FAILED: '订舱失败',
|
||||
CHANGE_PENDING: '变更中',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
export const oceanBookingStatusOptions = Object.entries(oceanBookingStatusLabels).map(([value, label]) => ({
|
||||
value: value as OceanBookingStatus,
|
||||
label,
|
||||
}))
|
||||
|
||||
export function oceanBookingStatusLabel(status: string | null | undefined, emptyLabel = '未设置') {
|
||||
if (!status) return emptyLabel
|
||||
return oceanBookingStatusLabels[status as OceanBookingStatus] || '未知状态'
|
||||
}
|
||||
@@ -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,148 @@
|
||||
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', oceanBookingStatus: 'RELEASED', truckAppointmentStatus: 'COMPLETED', pickTaskStatus: 'AWAITING_REVIEW', dispatchStatus: 'READY_FOR_DISPATCH', 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')
|
||||
expect(wrapper.text()).toContain('已放舱')
|
||||
expect(wrapper.text()).toContain('已完成')
|
||||
expect(wrapper.text()).toContain('待复核')
|
||||
expect(wrapper.text()).toContain('待确认出仓')
|
||||
expect(wrapper.text()).not.toContain('READY_FOR_DISPATCH')
|
||||
})
|
||||
|
||||
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("row.dispatchStatus === 'READY_FOR_DISPATCH'")
|
||||
expect(source).toContain("row.dispatchStatus === 'WAITING_FOR_LOADING'")
|
||||
expect(source).toContain('timelineModuleLabel(entry.module)')
|
||||
expect(source).toContain('timelineActionLabel(entry.action)')
|
||||
expect(source).toContain('timelineStatusLabel(entry.module, entry.fromStatus)')
|
||||
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,198 @@ 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'
|
||||
import { oceanBookingStatusLabel, outboundDispatchStatusLabel, pickTaskStatusLabel, timelineActionLabel, timelineModuleLabel, timelineStatusLabel, truckStatusLabel } from '../statusLabels'
|
||||
|
||||
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 === 'READY_FOR_DISPATCH'" 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 === 'WAITING_FOR_LOADING'" link type="danger" @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></template>
|
||||
<template #booking="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.oceanBookingStatus)" /></template><template #truck="{ row }"><StatusBadge :status="truckStatusLabel(row.truckAppointmentStatus)" /></template><template #pick="{ row }"><StatusBadge :status="pickTaskStatusLabel(row.pickTaskStatus)" /></template><template #status="{ row }"><StatusBadge :status="outboundDispatchStatusLabel(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="outboundDispatchStatusLabel(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">{{ timelineModuleLabel(entry.module) }} · {{ timelineActionLabel(entry.action) }}:{{ timelineStatusLabel(entry.module, entry.fromStatus) }} → {{ timelineStatusLabel(entry.module, 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>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { oceanBookingStatusLabel, outboundDispatchStatusLabel, pickTaskStatusLabel, timelineActionLabel, timelineModuleLabel, timelineStatusLabel, truckStatusLabel } from './statusLabels'
|
||||
|
||||
describe('outbound dispatch status labels', () => {
|
||||
it('maps list statuses to business Chinese', () => {
|
||||
expect(outboundDispatchStatusLabel('READY_FOR_DISPATCH')).toBe('待确认出仓')
|
||||
expect(outboundDispatchStatusLabel('WAITING_FOR_LOADING')).toBe('等待装柜')
|
||||
expect(oceanBookingStatusLabel('RELEASED')).toBe('已放舱')
|
||||
expect(truckStatusLabel('PICKUP_IN_PROGRESS')).toBe('提柜中')
|
||||
expect(pickTaskStatusLabel('PENDING')).toBe('待拣货')
|
||||
expect(pickTaskStatusLabel('IN_PROGRESS')).toBe('拣货中')
|
||||
expect(pickTaskStatusLabel('AWAITING_REVIEW')).toBe('待复核')
|
||||
expect(pickTaskStatusLabel('COMPLETED')).toBe('已完成')
|
||||
})
|
||||
|
||||
it('localizes timeline modules, actions and module-specific states', () => {
|
||||
expect(timelineModuleLabel('OUTBOUND_DISPATCH')).toBe('国内出仓')
|
||||
expect(timelineActionLabel('CONFIRM_DISPATCH')).toBe('确认出仓')
|
||||
expect(timelineStatusLabel('OUTBOUND_DISPATCH', 'READY_FOR_DISPATCH')).toBe('待确认出仓')
|
||||
expect(timelineStatusLabel('OCEAN_BOOKING', 'RELEASED')).toBe('已放舱')
|
||||
expect(timelineStatusLabel('TRUCK_APPOINTMENT', 'COMPLETED')).toBe('已完成')
|
||||
expect(timelineStatusLabel('INBOUND', 'INBOUNDED')).toBe('已入库')
|
||||
})
|
||||
|
||||
it('does not expose unknown backend enum values', () => {
|
||||
expect(outboundDispatchStatusLabel('NEW_BACKEND_STATE')).toBe('未知状态')
|
||||
expect(timelineModuleLabel('NEW_MODULE')).toBe('其他业务流程')
|
||||
expect(timelineActionLabel('NEW_ACTION')).toBe('状态更新')
|
||||
expect(timelineStatusLabel('NEW_MODULE', 'NEW_STATE')).toBe('未知状态')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { truckAppointmentStatusLabels } from '@/modules/trucking'
|
||||
import { oceanBookingStatusLabel as sharedOceanBookingStatusLabel } from '@/modules/ocean-bookings'
|
||||
|
||||
const dispatchStatusLabels: Record<string, string> = {
|
||||
READY_FOR_DISPATCH: '待确认出仓',
|
||||
WAITING_FOR_LOADING: '等待装柜',
|
||||
}
|
||||
|
||||
const pickTaskStatusLabels: Record<string, string> = {
|
||||
PENDING: '待拣货',
|
||||
IN_PROGRESS: '拣货中',
|
||||
AWAITING_REVIEW: '待复核',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
const inboundStatusLabels: Record<string, string> = {
|
||||
WAIT_MEASURE: '待测量',
|
||||
MEASURING: '测量中',
|
||||
MEASURE_PENDING: '待测量审核',
|
||||
MEASURE_REJECTED: '测量已驳回',
|
||||
WAIT_PRICING: '待计费',
|
||||
FEE_CONFIRMING: '费用处理中',
|
||||
INBOUNDING: '入库处理中',
|
||||
INBOUNDED: '已入库',
|
||||
VOIDED: '已作废',
|
||||
}
|
||||
|
||||
const loadingPlanStatusLabels: Record<string, string> = {
|
||||
WAIT_LOADING: '等待装柜',
|
||||
EDITING: '编辑中',
|
||||
PRELOADED: '已预装',
|
||||
CONFIRMED: '已确认',
|
||||
LOADING: '装柜中',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
const moduleLabels: Record<string, string> = {
|
||||
INBOUND: '入库作业',
|
||||
LOADING_PLAN: '装柜计划',
|
||||
PDA_PICK: 'PDA 拣货',
|
||||
LOADING_EVIDENCE: '装柜证据',
|
||||
OCEAN_BOOKING: '海运订舱',
|
||||
TRUCK_APPOINTMENT: '拖车预约',
|
||||
OUTBOUND_DISPATCH: '国内出仓',
|
||||
}
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
CREATE: '创建',
|
||||
UPDATE: '更新',
|
||||
SUBMIT: '提交',
|
||||
APPROVE: '审核通过',
|
||||
REJECT: '审核驳回',
|
||||
CONFIRM: '确认',
|
||||
RELEASE: '放舱',
|
||||
FAIL: '标记失败',
|
||||
CANCEL: '取消',
|
||||
CHANGE_REQUEST: '申请改船',
|
||||
REQUEST_CHANGE: '申请改船',
|
||||
CONFIRM_CHANGE: '确认改船',
|
||||
FAIL_CHANGE: '驳回改船',
|
||||
CONFIRM_DISPATCH: '确认出仓',
|
||||
REVERSE_DISPATCH: '撤销出仓',
|
||||
UPSERT_EVIDENCE: '登记装柜证据',
|
||||
VOID_EVIDENCE: '作废装柜证据',
|
||||
START: '开始作业',
|
||||
COMPLETE: '完成作业',
|
||||
}
|
||||
|
||||
function mapped(value: string | null | undefined, labels: Record<string, string>, empty: string) {
|
||||
if (!value) return empty
|
||||
return labels[value] || '未知状态'
|
||||
}
|
||||
|
||||
export function outboundDispatchStatusLabel(value: string | null | undefined) {
|
||||
return mapped(value, dispatchStatusLabels, '待确认出仓')
|
||||
}
|
||||
|
||||
export function oceanBookingStatusLabel(value: string | null | undefined) {
|
||||
return sharedOceanBookingStatusLabel(value, '未关联')
|
||||
}
|
||||
|
||||
export function truckStatusLabel(value: string | null | undefined) {
|
||||
return mapped(value, truckAppointmentStatusLabels as Record<string, string>, '未关联')
|
||||
}
|
||||
|
||||
export function pickTaskStatusLabel(value: string | null | undefined) {
|
||||
return mapped(value, pickTaskStatusLabels, '未关联')
|
||||
}
|
||||
|
||||
export function timelineModuleLabel(value: string | null | undefined) {
|
||||
if (!value) return '业务流程'
|
||||
return moduleLabels[value] || '其他业务流程'
|
||||
}
|
||||
|
||||
export function timelineActionLabel(value: string | null | undefined) {
|
||||
if (!value) return '状态更新'
|
||||
return actionLabels[value] || '状态更新'
|
||||
}
|
||||
|
||||
export function timelineStatusLabel(module: string | null | undefined, value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
if (module === 'OUTBOUND_DISPATCH') return outboundDispatchStatusLabel(value)
|
||||
if (module === 'OCEAN_BOOKING') return oceanBookingStatusLabel(value)
|
||||
if (module === 'TRUCK_APPOINTMENT') return truckStatusLabel(value)
|
||||
if (module === 'PDA_PICK') return pickTaskStatusLabel(value)
|
||||
if (module === 'INBOUND') return mapped(value, inboundStatusLabels, '—')
|
||||
if (module === 'LOADING_PLAN') return mapped(value, loadingPlanStatusLabels, '—')
|
||||
return '未知状态'
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/components/TruckAttachmentDialog.vue'), 'utf8')
|
||||
|
||||
describe('TruckAttachmentDialog interaction governance', () => {
|
||||
it('shows existing attachments without exposing file-service internals', () => {
|
||||
expect(source).toContain('预约附件')
|
||||
expect(source).toContain('attachmentTypeLabel(item.attachmentType)')
|
||||
expect(source).not.toContain('文件 ID')
|
||||
expect(source).not.toContain('item.objectKey')
|
||||
})
|
||||
|
||||
it('hides the unavailable manual reference flow but keeps deletion', () => {
|
||||
expect(source).not.toContain('referenceTruckAppointmentAttachment')
|
||||
expect(source).not.toContain('输入文件服务 ID')
|
||||
expect(source).toContain('deleteTruckAppointmentAttachment')
|
||||
expect(source).toContain('<Trash2 :size="15" />删除')
|
||||
})
|
||||
})
|
||||
@@ -1,28 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { deleteTruckAppointmentAttachment, referenceTruckAppointmentAttachment } from '../api/platformTrucking'
|
||||
import { Trash2 } from '@lucide/vue'
|
||||
import { deleteTruckAppointmentAttachment } from '../api/platformTrucking'
|
||||
import type { TruckAttachmentReference } from '../types'
|
||||
const props = defineProps<{ modelValue: boolean; appointmentId?: string; attachments: TruckAttachmentReference[]; canManage?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [boolean]; changed: [] }>()
|
||||
const fileId = ref(''); const fileName = ref(''); const busy = ref(false)
|
||||
async function add() { if (!props.appointmentId || !/^\d+$/.test(fileId.value)) return; busy.value = true; try { await referenceTruckAppointmentAttachment(props.appointmentId, { fileId: fileId.value, fileName: fileName.value || null }); fileId.value = ''; fileName.value = ''; emit('changed'); ElMessage.success('附件引用已添加') } catch { ElMessage.error('附件引用失败') } finally { busy.value = false } }
|
||||
const busy = ref(false)
|
||||
const attachmentTypeLabels: Record<string, string> = { DISPATCH_ORDER: '派车单', DRIVER_DOCUMENT: '司机证件', VEHICLE_DOCUMENT: '车辆证件', OTHER: '其他附件' }
|
||||
function attachmentTypeLabel(value: string | null | undefined) { return value ? attachmentTypeLabels[value] || '业务附件' : '业务附件' }
|
||||
async function remove(item: TruckAttachmentReference) { if (!props.appointmentId || !item.id) return; await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { type: 'warning' }); busy.value = true; try { await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch { ElMessage.error('删除失败') } finally { busy.value = false } }
|
||||
</script>
|
||||
<template>
|
||||
<el-dialog :model-value="props.modelValue" title="附件引用" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
|
||||
<el-alert type="info" :closable="false" title="仅允许引用文件服务中属于当前预约和客户范围的文件,后端会校验归属与业务类型。" />
|
||||
<el-form v-if="props.appointmentId && props.canManage" inline @submit.prevent="add"><el-form-item label="文件 ID"><el-input v-model="fileId" placeholder="输入文件服务 ID" /></el-form-item><el-form-item label="文件名"><el-input v-model="fileName" placeholder="可选" /></el-form-item><el-button type="primary" :loading="busy" @click="add">添加引用</el-button></el-form>
|
||||
<el-empty v-if="attachments.length === 0" description="暂无真实附件引用" :image-size="72" />
|
||||
<el-dialog :model-value="props.modelValue" title="预约附件" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
|
||||
<el-empty v-if="attachments.length === 0" description="暂无预约附件" :image-size="72" />
|
||||
<div v-for="item in attachments" :key="item.fileId" class="attachment-row">
|
||||
<div><strong>{{ item.fileName || '未命名文件' }}</strong><small>文件 ID:{{ item.fileId }} · {{ item.attachmentType || '业务附件' }}</small></div>
|
||||
<code v-if="item.objectKey">{{ item.objectKey }}</code>
|
||||
<el-button v-if="props.appointmentId && props.canManage && item.id" text type="danger" :loading="busy" @click="remove(item)">删除</el-button>
|
||||
<div><strong>{{ item.fileName || '未命名文件' }}</strong><small>{{ attachmentTypeLabel(item.attachmentType) }}{{ item.uploadedAt ? ` · ${item.uploadedAt}` : '' }}</small></div>
|
||||
<el-button v-if="props.appointmentId && props.canManage && item.id" text type="danger" :loading="busy" @click="remove(item)"><Trash2 :size="15" />删除</el-button>
|
||||
</div>
|
||||
<template #footer><el-button type="primary" @click="emit('update:modelValue', false)">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.attachment-row { display:flex; justify-content:space-between; gap:12px; margin:14px 0; padding:12px; border:1px solid var(--el-border-color-lighter); border-radius:6px; overflow-wrap:anywhere; }.attachment-row small { display:block; margin-top:4px; color:var(--el-text-color-secondary); }.attachment-row code { color:var(--el-text-color-secondary); font-size:12px; }
|
||||
.attachment-row { display:flex; justify-content:space-between; gap:12px; margin:14px 0; padding:12px; border:1px solid var(--el-border-color-lighter); border-radius:6px; overflow-wrap:anywhere; }.attachment-row small { display:block; margin-top:4px; color:var(--el-text-color-secondary); }
|
||||
@media (max-width: 560px) { .attachment-row { flex-direction:column; } }
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
|
||||
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
|
||||
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
|
||||
import { truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
|
||||
import { truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAppointmentStatusHistory } from '../types'
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0 })
|
||||
const history = ref<TruckAppointmentStatusHistory[]>([])
|
||||
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && !['COMPLETED','CANCELLED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
|
||||
@@ -41,9 +41,9 @@ onMounted(load)
|
||||
</template>
|
||||
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else-if="resource">
|
||||
<section class="surface-panel status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabels[resource.status] }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><ApiErrorMeta v-if="error" :details="error" /></section>
|
||||
<section class="surface-panel status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><ApiErrorMeta v-if="error" :details="error" /></section>
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
|
||||
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ item.action }}</strong><span>{{ item.fromStatus || '—' }} → {{ item.toStatus || '—' }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
|
||||
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
|
||||
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
|
||||
@@ -12,7 +12,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listTruckAppointments } from '../api/platformTrucking'
|
||||
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabels, type TruckAppointmentQuery, type TruckAppointmentResource, type TruckAppointmentStatus } from '../types'
|
||||
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabel, truckAppointmentStatusLabels, type TruckAppointmentQuery, type TruckAppointmentResource, type TruckAppointmentStatus } from '../types'
|
||||
|
||||
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -46,9 +46,7 @@ function appointmentActionLabel(action: '查看' | '编辑', row: TruckAppointme
|
||||
return identifier ? `${action} ${identifier}` : `${action}拖车预约详情`
|
||||
}
|
||||
function statusLabel(value: unknown) {
|
||||
return typeof value === 'string' && value in truckAppointmentStatusLabels
|
||||
? truckAppointmentStatusLabels[value as TruckAppointmentStatus]
|
||||
: String(value || '—')
|
||||
return truckAppointmentStatusLabel(typeof value === 'string' ? value : null)
|
||||
}
|
||||
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: '', containerType: String(model.containerType || '') }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { truckAppointmentActionLabel, truckAppointmentStatusLabel } from './types'
|
||||
|
||||
describe('truck appointment status labels', () => {
|
||||
it('translates status history without exposing backend enum values', () => {
|
||||
expect(truckAppointmentStatusLabel('DISPATCHED')).toBe('已派车')
|
||||
expect(truckAppointmentStatusLabel('NEW_STATUS')).toBe('未知状态')
|
||||
expect(truckAppointmentActionLabel('DISPATCH')).toBe('确认派车')
|
||||
expect(truckAppointmentActionLabel('NEW_ACTION')).toBe('状态更新')
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,30 @@ export const truckAppointmentStatusLabels: Record<TruckAppointmentStatus, string
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
export function truckAppointmentStatusLabel(status: string | null | undefined, emptyLabel = '—') {
|
||||
if (!status) return emptyLabel
|
||||
return truckAppointmentStatusLabels[status as TruckAppointmentStatus] || '未知状态'
|
||||
}
|
||||
|
||||
export const truckAppointmentActionLabels: Record<string, string> = {
|
||||
CREATE: '创建预约',
|
||||
SUBMIT: '提交派车',
|
||||
DISPATCH: '确认派车',
|
||||
PICKUP: '开始提柜',
|
||||
ARRIVE: '到达仓库',
|
||||
LOAD: '开始装柜',
|
||||
DEPART: '离开仓库',
|
||||
COMPLETE: '完成预约',
|
||||
RESCHEDULE: '预约改期',
|
||||
EXCEPTION: '标记异常',
|
||||
CANCEL: '取消预约',
|
||||
}
|
||||
|
||||
export function truckAppointmentActionLabel(action: string | null | undefined) {
|
||||
if (!action) return '状态更新'
|
||||
return truckAppointmentActionLabels[action] || '状态更新'
|
||||
}
|
||||
|
||||
export interface TruckAttachmentReference {
|
||||
id?: string
|
||||
fileId: string
|
||||
|
||||
@@ -214,6 +214,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
|
||||
@@ -389,5 +391,5 @@ export async function listPlatformUnifiedOrderFeeDetails(accountId: string, inbo
|
||||
}
|
||||
|
||||
export function unifiedOrderStatusLabel(status: UnifiedOrderMainStatus) {
|
||||
return unifiedOrderStatusOptions.find((item) => item.value === status)?.label || status
|
||||
return unifiedOrderStatusOptions.find((item) => item.value === status)?.label || '未知状态'
|
||||
}
|
||||
|
||||
@@ -20,4 +20,11 @@ describe('OrderFulfillmentTreePanel contract', () => {
|
||||
expect(source).toContain('暂无拆分分片')
|
||||
expect(source).toContain('@media (max-width: 720px)')
|
||||
})
|
||||
|
||||
it('translates fragment roles and statuses instead of exposing backend enums', () => {
|
||||
expect(source).toContain('unifiedOrderFulfillmentRoleLabel(row.splitRole)')
|
||||
expect(source).toContain('unifiedOrderFulfillmentStatusLabel(row.status, row.splitRole)')
|
||||
expect(source).not.toContain(':status="row.status')
|
||||
expect(source).not.toContain('return role ||')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getPlatformOrderFulfillmentTree, type UnifiedOrderFulfillmentTree } from '../api/orderFulfillment'
|
||||
import { unifiedOrderFulfillmentRoleLabel, unifiedOrderFulfillmentStatusLabel } from '../status'
|
||||
|
||||
const props = defineProps<{ tenantId: string; orderId: string; compact?: boolean }>()
|
||||
const tree = ref<UnifiedOrderFulfillmentTree | null>(null)
|
||||
@@ -19,12 +20,6 @@ async function load() {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function roleLabel(role: string | null) {
|
||||
if (role === 'REMAINDER' || role === 'ACTIVE_REMAINDER') return '当前余量'
|
||||
if (role === 'LOADED' || role === 'COMPLETED_FRAGMENT' || role === 'SOURCE_COMPLETED') return '已完成分片'
|
||||
return role || '分片'
|
||||
}
|
||||
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
@@ -44,11 +39,11 @@ onMounted(() => { void load() })
|
||||
<el-alert v-if="tree.historicalTwoChildModel" type="info" :closable="false" show-icon title="该记录使用历史双子单模型,仅兼容展示;新拆分不会再生成已装子单。" />
|
||||
<el-table :data="tree.fragments" row-key="orderId" empty-text="暂无拆分分片">
|
||||
<el-table-column label="分片订单" min-width="180"><template #default="{ row }"><strong>{{ row.orderNo || row.orderId }}</strong><small v-if="row.splitSequence != null"> · 第 {{ row.splitSequence }} 次</small></template></el-table-column>
|
||||
<el-table-column label="类型" width="120"><template #default="{ row }">{{ roleLabel(row.splitRole) }}</template></el-table-column>
|
||||
<el-table-column label="类型" width="120"><template #default="{ row }">{{ unifiedOrderFulfillmentRoleLabel(row.splitRole) }}</template></el-table-column>
|
||||
<el-table-column label="本单计划" width="100"><template #default="{ row }">{{ row.plannedPackageCount ?? '—' }} 箱</template></el-table-column>
|
||||
<el-table-column label="已拣" width="90"><template #default="{ row }">{{ row.pickedPackageCount ?? '—' }}</template></el-table-column>
|
||||
<el-table-column label="未拣" width="90"><template #default="{ row }">{{ row.unpickedPackageCount ?? '—' }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="130"><template #default="{ row }"><StatusBadge :status="row.status || roleLabel(row.splitRole)" /></template></el-table-column>
|
||||
<el-table-column label="状态" width="130"><template #default="{ row }"><StatusBadge :status="unifiedOrderFulfillmentStatusLabel(row.status, row.splitRole)" /></template></el-table-column>
|
||||
<el-table-column label="柜次" min-width="150"><template #default="{ row }">{{ row.containerBookingId || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
listPlatformUnifiedOrderFeeDetails,
|
||||
listPlatformUnifiedOrders,
|
||||
type UnifiedOrderFeeDetailResource,
|
||||
type UnifiedOrderResource,
|
||||
} from './api/unifiedOrders'
|
||||
|
||||
export {
|
||||
unifiedOrderFulfillmentRoleLabel,
|
||||
unifiedOrderFulfillmentStatusLabel,
|
||||
unifiedOrderHistoryActionLabel,
|
||||
unifiedOrderLoadingStatusLabel,
|
||||
unifiedOrderReviewStatusLabel,
|
||||
unifiedOrderStatusLabel,
|
||||
} from './status'
|
||||
@@ -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"')
|
||||
@@ -69,9 +69,9 @@ describe('UnifiedOrdersView contract', () => {
|
||||
|
||||
it('covers real list states and keeps operation column first and left-fixed', () => {
|
||||
expect(source).toContain("{ key: 'actions', label: '操作', width: 300, fixed: 'left'")
|
||||
expect(source).toContain("{ key: 'selection', type: 'selection', width: 52, fixed: 'left'")
|
||||
expect(source).toContain('批量审核接口待接入')
|
||||
expect(source).toContain('@selection-change="handleSelectionChange"')
|
||||
expect(source).not.toContain("{ key: 'selection', type: 'selection'")
|
||||
expect(source).not.toContain('批量审核接口待接入')
|
||||
expect(source).not.toContain('@selection-change="handleSelectionChange"')
|
||||
expect(source).toContain('title="订单列表暂时无法加载"')
|
||||
expect(source).toContain('empty-text="暂无符合条件的订单"')
|
||||
expect(source).toContain(':loading="loading"')
|
||||
@@ -79,7 +79,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('v-model:page-size="pageSize"')
|
||||
expect(source).toContain('slotName: \'orderStatus\'')
|
||||
expect(source).toContain('slotName: \'measurementReviewStatus\'')
|
||||
expect(source).toContain("NOT_SUBMITTED: '未提交'")
|
||||
expect(source).toContain('unifiedOrderReviewStatusLabel(value, \'未录入\')')
|
||||
expect(source).toContain("{ key: 'customerRemark', prop: 'customerRemark', label: '备注'")
|
||||
expect(source).toContain("{ key: 'latestInternalNote', prop: 'latestInternalNote', label: '客服内部备注'")
|
||||
expect(source.match(/<LineClampText v-if="column\.key === 'remark'"/g)).toHaveLength(3)
|
||||
@@ -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"')
|
||||
@@ -148,6 +156,10 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain(':title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
|
||||
expect(source).toContain("can('platform:order:note')")
|
||||
expect(source).toContain('await loadOrders()')
|
||||
expect(source).toContain('orderStatusLabel(detail.summary.internalOrderStatus || detail.summary.mainStatus)')
|
||||
expect(source).toContain('feeReviewLabel(detail.summary.feeReviewStatus)')
|
||||
expect(source).toContain('unifiedOrderLoadingStatusLabel(detail.summary.transport?.loadingStatus)')
|
||||
expect(source).not.toContain("value.replaceAll('_', ' ')")
|
||||
})
|
||||
|
||||
it('exposes the single-remainder fulfillment aggregate in row expansion and detail', () => {
|
||||
@@ -188,6 +200,9 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).not.toContain("partyValue(detail.order.parties, 'CONSIGNEE'")
|
||||
expect(source).toContain("cargoTotal(detail.order.cargoes, 'netWeightKg')")
|
||||
expect(source).toContain('暂无费用数据')
|
||||
expect(source).toContain('firstLegTransportModeLabel(detail.order.order?.transportMode)')
|
||||
expect(source).toContain('firstLegPickupMethodLabel(detail.order.order?.pickupMethod)')
|
||||
expect(source).toContain('firstLegDestinationTypeLabel(detail.order.order?.destinationType)')
|
||||
})
|
||||
|
||||
it('mounts downstream progress inside the existing order detail without opening unsafe writes', () => {
|
||||
|
||||
@@ -30,7 +30,22 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import {
|
||||
firstLegBusinessTypeLabel,
|
||||
firstLegContainerModeLabel,
|
||||
firstLegDestinationTypeLabel,
|
||||
firstLegPickupMethodLabel,
|
||||
firstLegTaxModeLabel,
|
||||
firstLegTradeTermLabel,
|
||||
firstLegTransportModeLabel,
|
||||
} from '@/shared/utils/firstLegBusinessLabels'
|
||||
import { UnifiedOrderWorkflowPanel } from '@/modules/unified-orders/workflows'
|
||||
import {
|
||||
unifiedOrderHistoryActionLabel,
|
||||
unifiedOrderLoadingStatusLabel,
|
||||
unifiedOrderReviewStatusLabel,
|
||||
unifiedOrderStatusLabel,
|
||||
} from '@/modules/unified-orders/status'
|
||||
import {
|
||||
downloadWeightImage,
|
||||
getInboundMeasurement,
|
||||
@@ -39,7 +54,6 @@ import {
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
tenantId: string
|
||||
mainStatus: UnifiedOrderMainStatus | ''
|
||||
receivingWarehouseId: string
|
||||
destinationWarehouseId: string
|
||||
inboundCode: string
|
||||
@@ -60,7 +74,6 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const records = ref<UnifiedOrderResource[]>([])
|
||||
const selectedOrders = ref<UnifiedOrderResource[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
const page = ref(1)
|
||||
@@ -96,6 +109,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}` : '')
|
||||
@@ -182,7 +197,6 @@ const queryFields = computed(() => showAdvancedFilters.value
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'selection', type: 'selection', width: 52, fixed: 'left', align: 'center', selectable: canBulkReview },
|
||||
{ key: 'fulfillment', type: 'expand', width: 52, fixed: 'left', slotName: 'fulfillment', hideable: false, movable: false },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'field' },
|
||||
{ key: 'internalOrderStatus', prop: 'internalOrderStatus', label: '订单状态', minWidth: 130, slotName: 'orderStatus' },
|
||||
@@ -240,13 +254,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 +274,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) {
|
||||
@@ -338,34 +358,29 @@ function displayField(row: UnifiedOrderResource, property: string | undefined) {
|
||||
if (property.endsWith('At') || property.endsWith('Date')) return formatDate(String(value))
|
||||
if (property === 'measurementReviewStatus') return measurementStatusLabel(String(value))
|
||||
if (property === 'internalOrderStatus') return orderStatusLabel(String(value))
|
||||
if (property === 'feeReviewStatus') return feeReviewLabel(String(value))
|
||||
if (property === 'loadingStatus') return unifiedOrderLoadingStatusLabel(String(value))
|
||||
if (property === 'businessType') return firstLegBusinessTypeLabel(String(value))
|
||||
if (property === 'pickupMethod') return firstLegPickupMethodLabel(String(value))
|
||||
if (property === 'taxMode') return firstLegTaxModeLabel(String(value))
|
||||
if (property === 'destinationType') return firstLegDestinationTypeLabel(String(value))
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function statusLabel(value: UnifiedOrderMainStatus | string | null) {
|
||||
return unifiedOrderStatusOptions.find((item) => item.value === value)?.label || orderStatusLabel(value) || '状态更新'
|
||||
return unifiedOrderStatusLabel(value, '状态更新')
|
||||
}
|
||||
|
||||
function historyActionLabel(value: string | null | undefined) {
|
||||
return ({
|
||||
CREATE: '创建订单', CREATED: '创建订单', SUBMITTED: '提交订单', APPROVED: '审核通过',
|
||||
ORDER_APPROVED: '订单审核通过', REJECTED: '审核驳回', MEASURE_APPROVE: '测量审核通过',
|
||||
MEASURE_REJECT: '测量审核驳回', INBOUND_BEGIN: '开始入库', INBOUND_COMPLETE: '入库完成',
|
||||
UI_FIXTURE_MEASURE_APPROVE: '测量数据审核通过', UI_FIXTURE_INBOUND_BEGIN: '开始入库',
|
||||
STATUS_UPDATE: '状态更新',
|
||||
} as Record<string, string>)[value || ''] || (value ? value.replaceAll('_', ' ') : '状态更新')
|
||||
return unifiedOrderHistoryActionLabel(value)
|
||||
}
|
||||
|
||||
function measurementStatusLabel(value: string | null | undefined) {
|
||||
return ({ NOT_SUBMITTED: '未提交', UNRECORDED: '未录入', PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' } as Record<string, string>)[value || ''] || value || '未录入'
|
||||
return unifiedOrderReviewStatusLabel(value, '未录入')
|
||||
}
|
||||
|
||||
function orderStatusLabel(value: string | null | undefined) {
|
||||
return ({
|
||||
DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', REJECTED: '已驳回',
|
||||
PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', PENDING_REVIEW: '待审核',
|
||||
WAIT_MEASURE: '待测量数据', INBOUNDED: '已入库', WAIT_SPLIT: '待分柜', LOADING: '装柜中',
|
||||
LOADED: '装柜完成', DELIVERED: '派送完成',
|
||||
} as Record<string, string>)[value || ''] || value || '—'
|
||||
return unifiedOrderStatusLabel(value)
|
||||
}
|
||||
|
||||
function cargoValue(cargo: Record<string, unknown>, key: string) {
|
||||
@@ -398,11 +413,12 @@ function feeNumber(value: number | null | undefined) {
|
||||
}
|
||||
|
||||
function feeReviewLabel(value: string | null | undefined) {
|
||||
return ({ PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回', NOT_SUBMITTED: '未提交' } as Record<string, string>)[value || ''] || value || '—'
|
||||
return unifiedOrderReviewStatusLabel(value)
|
||||
}
|
||||
|
||||
function feeDirectionLabel(value: string | null | undefined) {
|
||||
return ({ RECEIVABLE: '应收', PAYABLE: '应付' } as Record<string, string>)[value || ''] || value || '—'
|
||||
if (!value) return '—'
|
||||
return ({ RECEIVABLE: '应收', PAYABLE: '应付' } as Record<string, string>)[value] || '未知方向'
|
||||
}
|
||||
|
||||
async function loadFeeDetails() {
|
||||
@@ -508,19 +524,6 @@ function canApprove(row: UnifiedOrderResource) {
|
||||
return row.mainStatus === 'PENDING_REVIEW' && row.nextAction === 'APPROVE_ORDER' && can('platform:order:approve')
|
||||
}
|
||||
|
||||
function canBulkReview(row: UnifiedOrderResource) {
|
||||
return canApprove(row)
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: UnifiedOrderResource[]) {
|
||||
selectedOrders.value = rows
|
||||
}
|
||||
|
||||
function openBulkReviewPlaceholder() {
|
||||
if (selectedOrders.value.length === 0) return
|
||||
ElMessage.info('批量审核接口待接入,请先逐笔审核')
|
||||
}
|
||||
|
||||
function openApproval(row: UnifiedOrderResource) {
|
||||
if (!canApprove(row)) return
|
||||
approvalOrder.value = row
|
||||
@@ -625,30 +628,30 @@ 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,
|
||||
size: pageSize.value,
|
||||
})
|
||||
records.value = result.records
|
||||
selectedOrders.value = []
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
records.value = []
|
||||
@@ -660,6 +663,7 @@ async function loadOrders() {
|
||||
}
|
||||
|
||||
function search() {
|
||||
appliedFilters.value = cloneFilters(filters.value)
|
||||
if (page.value !== 1) {
|
||||
page.value = 1
|
||||
return
|
||||
@@ -669,6 +673,7 @@ function search() {
|
||||
|
||||
function reset() {
|
||||
filters.value = emptyFilters()
|
||||
appliedFilters.value = emptyFilters()
|
||||
if (page.value === 1) void loadOrders()
|
||||
else page.value = 1
|
||||
}
|
||||
@@ -838,9 +843,9 @@ onMounted(() => {
|
||||
<div class="panel-title"><div><h2>订单基本信息</h2><p>{{ detail.summary.orderNo || `订单 ${detail.summary.orderId}` }}</p></div><Box :size="19" /></div>
|
||||
<dl class="order-info-grid">
|
||||
<div><dt>公司</dt><dd>{{ customerName(detail.summary) }}</dd></div>
|
||||
<div><dt>运输方式</dt><dd>{{ detail.order.order?.transportMode || '—' }}</dd></div>
|
||||
<div><dt>运输方式</dt><dd>{{ firstLegTransportModeLabel(detail.order.order?.transportMode) }}</dd></div>
|
||||
<div><dt>当前状态</dt><dd><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /></dd></div>
|
||||
<div><dt>内部订单状态</dt><dd>{{ detail.summary.internalOrderStatus || '—' }}</dd></div>
|
||||
<div><dt>内部订单状态</dt><dd>{{ orderStatusLabel(detail.summary.internalOrderStatus || detail.summary.mainStatus) }}</dd></div>
|
||||
<div><dt>平台收货仓</dt><dd>{{ detail.summary.receivingWarehouseName || '—' }}</dd></div>
|
||||
<div><dt>收货仓地址</dt><dd>{{ detail.order.order?.receivingWarehouseAddress || '—' }}</dd></div>
|
||||
<div><dt>目的仓</dt><dd>{{ detail.summary.destinationWarehouseName || '—' }}</dd></div>
|
||||
@@ -850,13 +855,13 @@ onMounted(() => {
|
||||
<div><dt>目的仓联系人</dt><dd>{{ detail.summary.destinationContact || '—' }}</dd></div>
|
||||
<div><dt>目的仓联系人电话</dt><dd>{{ detail.summary.destinationContactPhone || '—' }}</dd></div>
|
||||
<div><dt>渠道</dt><dd>{{ detail.summary.channelName || '—' }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ detail.summary.businessType || '—' }}</dd></div>
|
||||
<div><dt>贸易条款</dt><dd>{{ detail.order.order?.tradeTerm || '—' }}</dd></div>
|
||||
<div><dt>买单 / 退税</dt><dd>{{ detail.order.order?.taxMode || '—' }}</dd></div>
|
||||
<div><dt>费用审核状态</dt><dd>{{ detail.summary.feeReviewStatus || '—' }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ firstLegBusinessTypeLabel(detail.summary.businessType) }}</dd></div>
|
||||
<div><dt>贸易条款</dt><dd>{{ firstLegTradeTermLabel(detail.order.order?.tradeTerm) }}</dd></div>
|
||||
<div><dt>买单 / 退税</dt><dd>{{ firstLegTaxModeLabel(detail.order.order?.taxMode) }}</dd></div>
|
||||
<div><dt>费用审核状态</dt><dd>{{ feeReviewLabel(detail.summary.feeReviewStatus) }}</dd></div>
|
||||
<div><dt>是否扣货</dt><dd>{{ displayBoolean(detail.summary.holdCargo) }}</dd></div>
|
||||
<div><dt>扣货理由</dt><dd>{{ detail.summary.holdReason || '—' }}</dd></div>
|
||||
<div><dt>提货方式</dt><dd>{{ detail.order.order?.pickupMethod || '—' }}</dd></div>
|
||||
<div><dt>提货方式</dt><dd>{{ firstLegPickupMethodLabel(detail.order.order?.pickupMethod) }}</dd></div>
|
||||
<div><dt>提货时间</dt><dd>{{ formatDate(detail.summary.pickupAt) }}</dd></div>
|
||||
<div><dt>到仓时间</dt><dd>{{ detail.order.order?.receivingTime || '—' }}</dd></div>
|
||||
<div><dt>货物到仓时间</dt><dd>{{ formatDate(detail.summary.expectedArrivalAt || detail.summary.inboundCompletedAt) }}</dd></div>
|
||||
@@ -867,9 +872,9 @@ onMounted(() => {
|
||||
<div><dt>船名 / 航次</dt><dd>{{ detail.summary.transport?.vesselName || '—' }} / {{ detail.summary.transport?.voyageNo || '—' }}</dd></div>
|
||||
<div><dt>开船时间</dt><dd>{{ formatDate(detail.summary.transport?.sailingAt) }}</dd></div>
|
||||
<div><dt>到港时间</dt><dd>{{ formatDate(detail.summary.transport?.portArrivalAt) }}</dd></div>
|
||||
<div><dt>配载状态</dt><dd>{{ detail.summary.transport?.loadingStatus || '—' }}</dd></div>
|
||||
<div><dt>目的仓类型</dt><dd>{{ detail.order.order?.destinationType || '—' }}</dd></div>
|
||||
<div><dt>是否整柜</dt><dd>{{ detail.order.order?.containerMode || '—' }}</dd></div>
|
||||
<div><dt>配载状态</dt><dd>{{ unifiedOrderLoadingStatusLabel(detail.summary.transport?.loadingStatus) }}</dd></div>
|
||||
<div><dt>目的仓类型</dt><dd>{{ firstLegDestinationTypeLabel(detail.order.order?.destinationType) }}</dd></div>
|
||||
<div><dt>是否整柜</dt><dd>{{ firstLegContainerModeLabel(detail.order.order?.containerMode) }}</dd></div>
|
||||
<div><dt>商检验</dt><dd>{{ displayBoolean(detail.order.order?.customsInspection) }}</dd></div>
|
||||
<div><dt>总箱数</dt><dd>{{ detail.summary.totalPackageCount ?? '—' }} 箱</dd></div>
|
||||
<div><dt>推荐托盘类型</dt><dd>{{ detail.summary.recommendedPalletType || '—' }}</dd></div>
|
||||
@@ -1026,10 +1031,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>
|
||||
@@ -1054,10 +1059,7 @@ onMounted(() => {
|
||||
|
||||
<template #default>
|
||||
<div v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleSelectionChange">
|
||||
<template #toolbar-actions>
|
||||
<el-button type="primary" plain :disabled="selectedOrders.length === 0" title="批量审核接口待接入" @click="openBulkReviewPlaceholder"><ListChecks :size="16" />批量审核<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
|
||||
</template>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表">
|
||||
<template #actions="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
|
||||
@@ -1087,7 +1089,7 @@ onMounted(() => {
|
||||
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong><span>客户 ID {{ (row as UnifiedOrderResource).tenantId }}</span></div></template>
|
||||
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
|
||||
<template #destinationWarehouseName="{ row }">{{ (row as UnifiedOrderResource).destinationWarehouseName || '—' }}</template>
|
||||
<template #transport="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).transport?.containerNo || (row as UnifiedOrderResource).transport?.loadingStatus || '—' }}</strong><span v-if="(row as UnifiedOrderResource).transport?.vesselName">{{ (row as UnifiedOrderResource).transport?.vesselName }} · {{ (row as UnifiedOrderResource).transport?.voyageNo || '—' }}</span></div></template>
|
||||
<template #transport="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).transport?.containerNo || unifiedOrderLoadingStatusLabel((row as UnifiedOrderResource).transport?.loadingStatus) }}</strong><span v-if="(row as UnifiedOrderResource).transport?.vesselName">{{ (row as UnifiedOrderResource).transport?.vesselName }} · {{ (row as UnifiedOrderResource).transport?.voyageNo || '—' }}</span></div></template>
|
||||
<template #cargo="{ row }"><span>{{ (row as UnifiedOrderResource).totalPackageCount ?? '—' }} 箱</span><span v-if="(row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== null && (row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualGrossWeightKg) }} kg</span><span v-if="(row as UnifiedOrderResource).measurement?.actualVolumeCbm !== null && (row as UnifiedOrderResource).measurement?.actualVolumeCbm !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualVolumeCbm, 3) }} m³</span></template>
|
||||
<template #expectedShipDate="{ row }">{{ formatDate((row as UnifiedOrderResource).expectedShipDate) }}</template>
|
||||
<template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
unifiedOrderFulfillmentRoleLabel,
|
||||
unifiedOrderFulfillmentStatusLabel,
|
||||
unifiedOrderHistoryActionLabel,
|
||||
unifiedOrderLoadingStatusLabel,
|
||||
unifiedOrderReviewStatusLabel,
|
||||
unifiedOrderStatusLabel,
|
||||
} from './status'
|
||||
|
||||
describe('unified order status labels', () => {
|
||||
it('maps order, review and loading statuses to domain-specific Chinese', () => {
|
||||
expect(unifiedOrderStatusLabel('WAIT_SPLIT')).toBe('待分柜')
|
||||
expect(unifiedOrderReviewStatusLabel('APPROVED')).toBe('审核通过')
|
||||
expect(unifiedOrderLoadingStatusLabel('LOADED')).toBe('已配载')
|
||||
})
|
||||
|
||||
it('never exposes unknown backend enums', () => {
|
||||
expect(unifiedOrderStatusLabel('NEW_ORDER_STATE')).toBe('未知状态')
|
||||
expect(unifiedOrderReviewStatusLabel('NEW_REVIEW_STATE')).toBe('未知状态')
|
||||
expect(unifiedOrderLoadingStatusLabel('NEW_LOADING_STATE')).toBe('未知状态')
|
||||
expect(unifiedOrderHistoryActionLabel('NEW_ACTION')).toBe('状态更新')
|
||||
expect(unifiedOrderFulfillmentRoleLabel('NEW_ROLE')).toBe('未知类型')
|
||||
expect(unifiedOrderFulfillmentStatusLabel('NEW_FRAGMENT_STATE', 'REMAINDER')).toBe('未知状态')
|
||||
})
|
||||
|
||||
it('uses the fragment role when a fulfillment status is absent', () => {
|
||||
expect(unifiedOrderFulfillmentStatusLabel(null, 'ACTIVE_REMAINDER')).toBe('当前余量')
|
||||
expect(unifiedOrderFulfillmentStatusLabel(undefined, 'COMPLETED_FRAGMENT')).toBe('已完成分片')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
const orderStatusLabels: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
SUBMITTED: '待审核',
|
||||
APPROVED: '已审核',
|
||||
ACCEPTED: '已受理',
|
||||
REJECTED: '已驳回',
|
||||
PROCESSING: '处理中',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
PENDING_REVIEW: '待审核',
|
||||
WAIT_MEASURE: '待测量数据',
|
||||
INBOUNDED: '已入库',
|
||||
WAIT_SPLIT: '待分柜',
|
||||
LOADING: '装柜中',
|
||||
LOADED: '装柜完成',
|
||||
DELIVERED: '派送完成',
|
||||
}
|
||||
|
||||
const reviewStatusLabels: Record<string, string> = {
|
||||
NOT_SUBMITTED: '未提交',
|
||||
UNRECORDED: '未录入',
|
||||
PENDING: '待审核',
|
||||
APPROVED: '审核通过',
|
||||
REJECTED: '审核驳回',
|
||||
CONFIRMED: '已确认',
|
||||
}
|
||||
|
||||
const loadingStatusLabels: Record<string, string> = {
|
||||
NOT_LOADED: '未配载',
|
||||
WAIT_LOADING: '待配载',
|
||||
EDITING: '预装柜中',
|
||||
LOADING: '配载中',
|
||||
LOADED: '已配载',
|
||||
COMPLETED: '装柜完成',
|
||||
}
|
||||
|
||||
const historyActionLabels: Record<string, string> = {
|
||||
CREATE: '创建订单',
|
||||
CREATED: '创建订单',
|
||||
SUBMITTED: '提交订单',
|
||||
APPROVED: '审核通过',
|
||||
ORDER_APPROVED: '订单审核通过',
|
||||
REJECTED: '审核驳回',
|
||||
MEASURE_APPROVE: '测量审核通过',
|
||||
MEASURE_REJECT: '测量审核驳回',
|
||||
INBOUND_BEGIN: '开始入库',
|
||||
INBOUND_COMPLETE: '入库完成',
|
||||
UI_FIXTURE_MEASURE_APPROVE: '测量数据审核通过',
|
||||
UI_FIXTURE_INBOUND_BEGIN: '开始入库',
|
||||
STATUS_UPDATE: '状态更新',
|
||||
}
|
||||
|
||||
const fulfillmentRoleLabels: Record<string, string> = {
|
||||
REMAINDER: '当前余量',
|
||||
ACTIVE_REMAINDER: '当前余量',
|
||||
LOADED: '已完成分片',
|
||||
COMPLETED_FRAGMENT: '已完成分片',
|
||||
SOURCE_COMPLETED: '已完成分片',
|
||||
}
|
||||
|
||||
function normalized(value: string | null | undefined) {
|
||||
return value?.trim().toUpperCase() || ''
|
||||
}
|
||||
|
||||
function statusLabel(value: string | null | undefined, labels: Record<string, string>, emptyLabel = '—') {
|
||||
const status = normalized(value)
|
||||
if (!status) return emptyLabel
|
||||
return labels[status] || '未知状态'
|
||||
}
|
||||
|
||||
export function unifiedOrderStatusLabel(value: string | null | undefined, emptyLabel = '—') {
|
||||
return statusLabel(value, orderStatusLabels, emptyLabel)
|
||||
}
|
||||
|
||||
export function unifiedOrderReviewStatusLabel(value: string | null | undefined, emptyLabel = '—') {
|
||||
return statusLabel(value, reviewStatusLabels, emptyLabel)
|
||||
}
|
||||
|
||||
export function unifiedOrderLoadingStatusLabel(value: string | null | undefined, emptyLabel = '—') {
|
||||
return statusLabel(value, loadingStatusLabels, emptyLabel)
|
||||
}
|
||||
|
||||
export function unifiedOrderHistoryActionLabel(value: string | null | undefined) {
|
||||
const action = normalized(value)
|
||||
if (!action) return '状态更新'
|
||||
return historyActionLabels[action] || '状态更新'
|
||||
}
|
||||
|
||||
export function unifiedOrderFulfillmentRoleLabel(value: string | null | undefined) {
|
||||
const role = normalized(value)
|
||||
if (!role) return '分片'
|
||||
return fulfillmentRoleLabels[role] || '未知类型'
|
||||
}
|
||||
|
||||
export function unifiedOrderFulfillmentStatusLabel(status: string | null | undefined, role: string | null | undefined) {
|
||||
if (!status) return unifiedOrderFulfillmentRoleLabel(role)
|
||||
return unifiedOrderStatusLabel(status)
|
||||
}
|
||||
@@ -106,6 +106,11 @@ function formatNumber(value: number, precision = 3) {
|
||||
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: precision }) : '—'
|
||||
}
|
||||
|
||||
function planStatusLabel(status?: string | null) {
|
||||
return ({ EDITING: '预装柜中', LOADING: '拣货装柜中', COMPLETED: '装柜完成' } as Record<string, string>)[status || '']
|
||||
|| (status ? '未知状态' : '尚未创建')
|
||||
}
|
||||
|
||||
function routeMatches(candidate: SmartLoadingCandidate) {
|
||||
const target = container.value?.destinationWarehouseCode
|
||||
return !target || candidate.destinationWarehouseCode === target
|
||||
@@ -291,7 +296,7 @@ onMounted(() => { void load() })
|
||||
</el-alert>
|
||||
|
||||
<div v-if="container" class="smart-loading__summary">
|
||||
<div><span>计划状态</span><strong>{{ plan?.status || '尚未创建' }}</strong></div>
|
||||
<div><span>计划状态</span><strong>{{ planStatusLabel(plan?.status) }}</strong></div>
|
||||
<div><span>已装箱数</span><strong>{{ totalPackages }}</strong></div>
|
||||
<div><span>容积</span><strong>{{ formatNumber(usedVolume) }} / {{ formatNumber(container.capacityCbm || 0) }} m³</strong><small>{{ volumeRate == null ? '容量待配置' : `${volumeRate}%` }}</small></div>
|
||||
<div><span>重量</span><strong>{{ formatNumber(usedWeight) }} / {{ formatNumber(container.maxWeightKg || 0) }} kg</strong><small>{{ weightRate == null ? '承重待配置' : `${weightRate}%` }}</small></div>
|
||||
|
||||
@@ -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'
|
||||
@@ -7,8 +7,10 @@ import UnifiedOrderWorkflowPanel from './UnifiedOrderWorkflowPanel.vue'
|
||||
import {
|
||||
getEvidence,
|
||||
getOutboundDispatch,
|
||||
getPicking,
|
||||
getPreload,
|
||||
smartSplit,
|
||||
voidEvidence,
|
||||
} from '../api'
|
||||
|
||||
vi.mock('../api', async (importOriginal) => {
|
||||
@@ -17,8 +19,10 @@ vi.mock('../api', async (importOriginal) => {
|
||||
...original,
|
||||
getEvidence: vi.fn(),
|
||||
getOutboundDispatch: vi.fn(),
|
||||
getPicking: vi.fn(),
|
||||
getPreload: vi.fn(),
|
||||
smartSplit: vi.fn(),
|
||||
voidEvidence: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -48,8 +52,10 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getOutboundDispatch).mockReset().mockResolvedValue(null as never)
|
||||
vi.mocked(getEvidence).mockReset().mockResolvedValue({ inboundId: '2086085512165634051', evidences: [] })
|
||||
vi.mocked(getPicking).mockReset().mockResolvedValue({ id: '51', inboundId: '2086085512165634051', status: 'PICKING', version: 2, expectedCount: 10, pickedCount: 4, remark: null })
|
||||
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 +122,74 @@ 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()
|
||||
})
|
||||
|
||||
it('renders booking, trucking and picking backend statuses as business Chinese', async () => {
|
||||
vi.mocked(getOutboundDispatch).mockResolvedValue({
|
||||
orderId: '1', orderNo: 'ORD-1', inboundId: '2', inboundCode: 'IN-2', containerBookingId: '3', containerNo: 'CONT-3',
|
||||
loadingPlanId: '4', loadingPlanStatus: 'LOADING', oceanBookingId: '5', oceanBookingStatus: 'RELEASED',
|
||||
truckAppointmentId: '6', truckAppointmentStatus: 'PICKUP_IN_PROGRESS', gate: { passed: false, items: [] },
|
||||
})
|
||||
const wrapper = mountPanel(['platform:firstleg:inbound:picking:view'], 'LOADING')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('已放舱')
|
||||
expect(wrapper.text()).toContain('提柜中')
|
||||
expect(wrapper.text()).toContain('拣货中')
|
||||
expect(wrapper.text()).not.toContain('RELEASED')
|
||||
expect(wrapper.text()).not.toContain('PICKUP_IN_PROGRESS')
|
||||
expect(wrapper.text()).not.toContain('PICKING')
|
||||
})
|
||||
|
||||
it('does not expose unknown backend enum values', async () => {
|
||||
vi.mocked(getOutboundDispatch).mockResolvedValue({
|
||||
orderId: '1', orderNo: 'ORD-1', inboundId: '2', inboundCode: 'IN-2', containerBookingId: null, containerNo: null,
|
||||
loadingPlanId: null, loadingPlanStatus: null, oceanBookingId: '5', oceanBookingStatus: 'NEW_BOOKING_STATE',
|
||||
truckAppointmentId: '6', truckAppointmentStatus: 'NEW_TRUCK_STATE', gate: { passed: false, items: [] },
|
||||
})
|
||||
vi.mocked(getPicking).mockResolvedValue({ id: '51', inboundId: '2', status: 'NEW_PICK_STATE', version: 2, expectedCount: null, pickedCount: null, remark: null })
|
||||
const wrapper = mountPanel(['platform:firstleg:inbound:picking:view'], 'LOADING')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('未知状态')
|
||||
expect(wrapper.text()).not.toContain('NEW_BOOKING_STATE')
|
||||
expect(wrapper.text()).not.toContain('NEW_TRUCK_STATE')
|
||||
expect(wrapper.text()).not.toContain('NEW_PICK_STATE')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -8,6 +9,8 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import SmartLoadingWorkbench from '@/modules/unified-orders/workflows/components/SmartLoadingWorkbench.vue'
|
||||
import { oceanBookingStatusLabel } from '@/modules/ocean-bookings'
|
||||
import { truckAppointmentStatusLabel } from '@/modules/trucking'
|
||||
import { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from '@/modules/unified-orders/workflows/preloadForm'
|
||||
import {
|
||||
completeLoading,
|
||||
@@ -145,7 +148,19 @@ function removeItem(index: number) {
|
||||
}
|
||||
|
||||
function preloadStatusLabel(status?: string | null) {
|
||||
return ({ PENDING: '待提交', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[status || ''] || '尚未生成'
|
||||
return workflowStatusLabel(status, { PENDING: '待提交', CONFIRMING: '确认中', CONFIRMED: '已确认' }, '尚未生成')
|
||||
}
|
||||
|
||||
function workflowStatusLabel(value: string | null | undefined, labels: Record<string, string>, emptyLabel: string) {
|
||||
if (!value) return emptyLabel
|
||||
return labels[value] || '未知状态'
|
||||
}
|
||||
|
||||
function pickingStatusLabel(status?: string | null) {
|
||||
return workflowStatusLabel(status, {
|
||||
PENDING: '待拣货', PICKING: '拣货中', IN_PROGRESS: '拣货中', AWAITING_REVIEW: '待复核',
|
||||
COMPLETED: '已完成', CANCELLED: '已取消',
|
||||
}, '暂无数据')
|
||||
}
|
||||
|
||||
function formatVolume(value: number | null) {
|
||||
@@ -345,8 +360,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,
|
||||
@@ -409,8 +437,8 @@ onMounted(() => { void refresh() })
|
||||
<el-card v-if="dispatch" shadow="never" class="workflow-card">
|
||||
<template #header><div class="card-title"><span>子流程与门禁摘要</span><el-tag :type="dispatch.gate?.passed ? 'success' : 'warning'">{{ dispatch.gate?.passed ? '已通过' : '待补齐' }}</el-tag></div></template>
|
||||
<div class="gate-grid">
|
||||
<div><span>订舱</span><strong>{{ dispatch.oceanBookingStatus || '暂无数据' }}</strong></div>
|
||||
<div><span>拖车</span><strong>{{ dispatch.truckAppointmentStatus || '暂无数据' }}</strong></div>
|
||||
<div><span>订舱</span><strong>{{ oceanBookingStatusLabel(dispatch.oceanBookingStatus, '未关联') }}</strong></div>
|
||||
<div><span>拖车</span><strong>{{ truckAppointmentStatusLabel(dispatch.truckAppointmentStatus, '未关联') }}</strong></div>
|
||||
<div><span>柜号</span><strong>{{ dispatch.containerNo || '暂无数据' }}</strong></div>
|
||||
<div v-for="item in dispatch.gate?.items || []" :key="item.code"><span>{{ gateItemName(item.code, item.name) }}</span><el-tag size="small" :type="item.passed ? 'success' : 'danger'">{{ item.passed ? '已满足' : '待补齐' }}</el-tag></div>
|
||||
</div>
|
||||
@@ -459,7 +487,7 @@ onMounted(() => { void refresh() })
|
||||
<el-card shadow="never" class="workflow-card">
|
||||
<template #header><div class="card-title"><span>拣货与装柜</span><el-tag type="warning">装柜中</el-tag></div></template>
|
||||
<el-descriptions v-if="picking" :column="2" border>
|
||||
<el-descriptions-item label="拣货状态">{{ picking.status || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="拣货状态">{{ pickingStatusLabel(picking.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="拣货版本">{{ picking.version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="应拣件数">{{ picking.expectedCount ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已拣件数">{{ picking.pickedCount ?? '—' }}</el-descriptions-item>
|
||||
|
||||
@@ -104,4 +104,40 @@ describe('API error diagnostics', () => {
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(expired).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refreshes once and replays the original request with the rotated access token', async () => {
|
||||
const refresh = vi.fn(async () => 'access-rotated')
|
||||
const expired = vi.fn()
|
||||
let orderRequests = 0
|
||||
let replayAuthorization: unknown
|
||||
registerAuthRefreshHandler(refresh)
|
||||
registerAuthSessionExpiredHandler(expired)
|
||||
http.defaults.adapter = async (config) => {
|
||||
if (config.url === '/platform/v1/orders' && orderRequests++ === 0) {
|
||||
throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, {
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
headers: {},
|
||||
config,
|
||||
data: { code: 'A0401', message: '登录状态已过期' },
|
||||
})
|
||||
}
|
||||
replayAuthorization = config.headers.Authorization
|
||||
return {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config,
|
||||
data: { code: '200', message: 'success', data: [{ id: 'order-1' }] },
|
||||
}
|
||||
}
|
||||
|
||||
const response = await http.get('/platform/v1/orders')
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(expired).not.toHaveBeenCalled()
|
||||
expect(orderRequests).toBe(2)
|
||||
expect(replayAuthorization).toBe('Bearer access-rotated')
|
||||
expect(response).toEqual({ code: '200', message: 'success', data: [{ id: 'order-1' }] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -163,4 +163,18 @@ describe('DataTable column preferences', () => {
|
||||
expect(toolbarActions.find('.data-table__utility-actions').exists()).toBe(true)
|
||||
expect(toolbarActions.find('button[aria-label="列设置"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('can hide column settings for compact tables without leaving an empty toolbar', () => {
|
||||
const wrapper = shallowMount(DataTable, {
|
||||
props: { data: [], columns: configurableColumns, preferenceKey: 'summary-table', showColumnSettings: false },
|
||||
global: {
|
||||
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
|
||||
stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub },
|
||||
directives: { loading: {} },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('button[aria-label="列设置"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.data-table__toolbar').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,11 +63,13 @@ const props = withDefaults(defineProps<{
|
||||
total?: number
|
||||
pageSizes?: number[]
|
||||
preferenceKey?: string
|
||||
showColumnSettings?: boolean
|
||||
}>(), {
|
||||
loading: false,
|
||||
emptyText: '暂无数据',
|
||||
total: undefined,
|
||||
pageSizes: () => [10, 20, 50, 100],
|
||||
showColumnSettings: true,
|
||||
})
|
||||
|
||||
const page = defineModel<number>('page', { default: 1 })
|
||||
@@ -76,7 +78,7 @@ const slots = useSlots()
|
||||
const preferenceScope = inject(UI_PREFERENCE_SCOPE, computed(() => 'anonymous'))
|
||||
const preference = ref<LayoutPreferenceSnapshot>({ version: 1, order: [], hidden: [] })
|
||||
const tableRef = ref<{ doLayout: () => void }>()
|
||||
const preferenceEnabled = computed(() => Boolean(props.preferenceKey))
|
||||
const preferenceEnabled = computed(() => Boolean(props.preferenceKey && props.showColumnSettings))
|
||||
|
||||
function columnId(column: DataTableColumn) {
|
||||
const value = column.key ?? column.columnKey ?? column.prop ?? column.property
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user